From c8bf4e0f89e506d63820d6dfac945bf405cc998e Mon Sep 17 00:00:00 2001 From: mateaix <57164338+mateaix@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:28:15 +0800 Subject: [PATCH] release: v1.7.0 --- .env.example | 19 + .gitignore | 3 + README.md | 32 +- README_zh.md | 32 +- docker-compose.yml | 24 + mateclaw-desktop/.env.example | 15 + mateclaw-desktop/.gitignore | 33 + mateclaw-desktop/CODESIGNING.md | 275 ++ mateclaw-desktop/README.md | 432 ++ mateclaw-desktop/RELEASE_NOTES_v1.0.101.md | 67 + mateclaw-desktop/branding.config.json | 9 + .../build/entitlements.mac.inherit.plist | 14 + mateclaw-desktop/build/entitlements.mac.plist | 18 + mateclaw-desktop/build/icon.icns | Bin 0 -> 247190 bytes mateclaw-desktop/build/icon.ico | Bin 0 -> 285478 bytes mateclaw-desktop/build/icon.png | Bin 0 -> 247190 bytes mateclaw-desktop/build/icon_256.png | Bin 0 -> 53511 bytes mateclaw-desktop/electron-builder.cjs | 139 + mateclaw-desktop/electron/main/config.ts | 87 + mateclaw-desktop/electron/main/index.ts | 1103 +++++ mateclaw-desktop/electron/main/localBridge.ts | 205 + .../electron/main/localToolsApproval.ts | 82 + .../electron/main/localToolsConfig.ts | 122 + .../electron/main/localToolsExecutor.ts | 194 + mateclaw-desktop/electron/preload/index.ts | 55 + mateclaw-desktop/index.html | 27 + mateclaw-desktop/package.json | 51 + mateclaw-desktop/pnpm-lock.yaml | 3942 +++++++++++++++++ .../public/logo/mateclaw_logo_s.png | Bin 0 -> 68958 bytes mateclaw-desktop/src/App.vue | 1355 ++++++ mateclaw-desktop/src/env.d.ts | 67 + mateclaw-desktop/src/main.ts | 4 + mateclaw-desktop/tsconfig.json | 25 + mateclaw-desktop/tsconfig.node.json | 12 + mateclaw-desktop/tsconfig.node.tsbuildinfo | 1 + mateclaw-desktop/vite.config.ts | 72 + .../vip/mate/plugin/api/PluginContext.java | 14 + .../java/vip/mate/plugin/api/PluginType.java | 5 +- .../api/search/PluginSearchProvider.java | 53 + .../plugin/api/search/PluginSearchQuery.java | 22 + .../plugin/api/search/PluginSearchResult.java | 24 + mateclaw-plugin-search-sample/pom.xml | 50 + .../sample/search/SimpleSearchPlugin.java | 124 + .../src/main/resources/mateclaw-plugin.json | 24 + .../vip/mate/agent/AgentGraphBuilder.java | 283 +- .../main/java/vip/mate/agent/BaseAgent.java | 10 +- .../controller/AgentBindingController.java | 9 +- .../model/AgentProviderPreference.java | 10 + .../AgentBindingMcpRemovalListener.java | 71 + .../binding/service/AgentBindingService.java | 24 +- .../vip/mate/agent/context/ChatOrigin.java | 43 +- .../context/ConversationWindowManager.java | 50 +- .../mate/agent/context/PrefixBudgetPlan.java | 41 + .../agent/context/PrefixBudgetPlanner.java | 120 + .../delegation/DelegatedUsageAccumulator.java | 95 + .../agent/delegation/SubagentRunContext.java | 75 + .../agent/graph/NodeStreamingChatHelper.java | 143 +- .../agent/graph/StateGraphReActAgent.java | 62 +- .../graph/executor/ToolExecutionExecutor.java | 14 + .../graph/executor/ToolResultStorage.java | 38 + .../agent/graph/node/FinalAnswerNode.java | 9 +- .../mate/agent/graph/node/ReasoningNode.java | 101 +- .../plan/StateGraphPlanExecuteAgent.java | 45 +- .../graph/plan/node/PlanGenerationNode.java | 69 +- .../graph/plan/node/StepExecutionNode.java | 55 +- .../graph/plan/state/PlanStateAccessor.java | 27 + .../graph/state/MateClawStateAccessor.java | 6 + .../agent/graph/state/MateClawStateKeys.java | 6 + .../approval/ApprovalWorkflowService.java | 34 + .../vip/mate/auth/service/AuthService.java | 8 +- .../mate/auth/sso/SsoAutoConfiguration.java | 33 + .../mate/auth/sso/SsoCallbackResponse.java | 43 + .../java/vip/mate/auth/sso/SsoController.java | 72 + .../java/vip/mate/auth/sso/SsoProperties.java | 51 + .../java/vip/mate/auth/sso/SsoService.java | 267 ++ .../vip/mate/auth/sso/SsoStateService.java | 233 + .../sso/model/ExternalIdentityEntity.java | 60 + .../mate/auth/sso/model/SsoStateEntity.java | 37 + .../auth/sso/provider/FeishuSsoProvider.java | 230 + .../mate/auth/sso/provider/SsoProvider.java | 34 + .../sso/provider/SsoProviderRegistry.java | 50 + .../mate/auth/sso/provider/SsoUserInfo.java | 22 + .../repository/ExternalIdentityMapper.java | 14 + .../auth/sso/repository/SsoStateMapper.java | 14 + .../channel/ChannelChatOriginFactory.java | 3 +- .../java/vip/mate/channel/ChannelManager.java | 15 +- .../mate/channel/ChannelMessageRouter.java | 50 +- .../channel/feishu/FeishuChannelAdapter.java | 84 +- .../tool_guard/ToolGuardCardHandler.java | 75 + .../tool_guard/ToolGuardCardKindFactory.java | 15 +- .../vip/mate/channel/web/ChatController.java | 162 +- .../channel/webchat/WebChatController.java | 338 +- .../channel/webchat/WebChatFileService.java | 31 +- .../channel/wecom/WeComChannelAdapter.java | 32 +- .../tool_guard/ToolGuardCardHandler.java | 69 +- .../tool_guard/ToolGuardCardKindFactory.java | 11 +- .../channel/weixin/WeixinChannelAdapter.java | 24 +- .../main/java/vip/mate/cli/ExportCommand.java | 80 + .../main/java/vip/mate/cli/MateClawCli.java | 178 + .../vip/mate/common/net/SsrfAllowlist.java | 138 + .../vip/mate/common/net/SsrfProperties.java | 28 + .../java/vip/mate/config/JwtAuthFilter.java | 2 + .../vip/mate/config/LoginRateLimitFilter.java | 22 +- .../java/vip/mate/config/OpenApiConfig.java | 99 + .../mate/config/PrefixBudgetProperties.java | 69 + .../java/vip/mate/config/SecurityConfig.java | 63 +- .../java/vip/mate/config/WebMvcConfig.java | 6 + .../java/vip/mate/config/WebSocketConfig.java | 10 + .../java/vip/mate/hook/HookActionFactory.java | 3 + .../java/vip/mate/hook/action/HttpAction.java | 14 +- .../vip/mate/kbopen/auth/KbApiKeyContext.java | 39 + .../mate/kbopen/auth/KbApiKeyRateLimiter.java | 46 + .../vip/mate/kbopen/auth/KbApiKeyService.java | 240 + .../mate/kbopen/auth/KbOpenApiAuthFilter.java | 142 + .../mate/kbopen/auth/KbScopeInterceptor.java | 97 + .../vip/mate/kbopen/auth/RequireKbScope.java | 29 + .../vip/mate/kbopen/auth/TokenHashUtil.java | 58 + .../auth/model/KbApiKeyBindingEntity.java | 33 + .../kbopen/auth/model/KbApiKeyEntity.java | 64 + .../repository/KbApiKeyBindingMapper.java | 9 + .../auth/repository/KbApiKeyMapper.java | 9 + .../controller/KbApiKeyAdminController.java | 137 + .../controller/KbOpenApiController.java | 331 ++ .../controller/KbOpenResearchController.java | 230 + .../vip/mate/kbopen/dto/KbOpenApiDtos.java | 142 + .../research/KbResearchSessionRegistry.java | 188 + .../mate/kbopen/service/KbOpenApiService.java | 298 ++ .../mate/llm/cache/CacheUsageExtractor.java | 73 +- .../llm/probe/ContextLimitErrorParser.java | 64 + .../llm/probe/ContextProbeProperties.java | 25 + .../vip/mate/llm/probe/LocalContextProbe.java | 41 + .../vip/mate/llm/probe/LocalEndpoints.java | 85 + .../llm/probe/ModelContextWindowResolver.java | 125 + .../mate/llm/probe/OllamaContextProbe.java | 141 + .../probe/OpenAiCompatibleContextProbe.java | 130 + .../llm/routing/AgentBindingResolver.java | 8 +- .../mate/llm/routing/ProviderModelRef.java | 18 + .../vip/mate/llm/routing/ProviderRouter.java | 45 +- .../llm/service/ModelCapabilityService.java | 9 +- .../memory/search/SessionSearchService.java | 10 +- .../mate/memory/search/SessionSearchTool.java | 23 +- .../memory/service/MemoryRecallService.java | 19 + .../memory/service/MemoryRecallTracker.java | 12 +- .../vip/mate/memory/spi/MemoryManager.java | 54 +- .../notification/NotificationController.java | 8 + .../controller/OperationalDataController.java | 112 + .../model/ExportInProgressException.java | 10 + .../mate/operational/model/ExportTask.java | 72 + .../service/OperationalDataExportService.java | 1352 ++++++ .../planning/service/PlanningService.java | 16 +- .../java/vip/mate/plugin/LoadedPlugin.java | 3 + .../vip/mate/plugin/PluginContextImpl.java | 36 +- .../java/vip/mate/plugin/PluginManager.java | 81 +- .../plugin/bridge/PluginSearchBridge.java | 112 + .../vip/mate/plugin/model/PluginInfo.java | 3 + .../skill/controller/SkillController.java | 9 + .../controller/SkillInstallController.java | 13 +- .../mate/skill/installer/SkillHubClient.java | 12 +- .../mate/skill/installer/ZipSkillFetcher.java | 66 +- .../lifecycle/SkillConsolidationService.java | 255 ++ .../mate/skill/lifecycle/SkillCuratorJob.java | 23 + .../skill/lifecycle/SkillCuratorReport.java | 19 + .../lifecycle/SkillCuratorReportStore.java | 14 + .../lifecycle/SkillLifecycleProperties.java | 19 + .../SkillReflectionAutoConfiguration.java | 14 + .../reflection/SkillReflectionProperties.java | 49 + .../reflection/SkillReflectionService.java | 352 ++ .../vip/mate/skill/service/SkillService.java | 2 + .../controller/SystemSettingController.java | 8 + .../model/SearchProviderCatalogEntry.java | 24 + .../model/SearchProviderCatalogResponse.java | 17 + .../system/service/SystemSettingService.java | 64 +- .../mate/tool/browser/BrowserLauncher.java | 122 +- .../mate/tool/browser/BrowserProperties.java | 62 +- .../mate/tool/browser/UrlSafetyChecker.java | 133 +- .../vip/mate/tool/builtin/BrowserUseTool.java | 163 +- .../mate/tool/builtin/ChatUploadResolver.java | 74 +- .../mate/tool/builtin/CodeExecuteTool.java | 28 +- .../mate/tool/builtin/DelegateAgentTool.java | 238 +- .../mate/tool/builtin/DelegationContext.java | 79 +- .../mate/tool/builtin/MateClawDocService.java | 78 +- .../mate/tool/builtin/SessionListTool.java | 144 + .../mate/tool/builtin/SessionSendTool.java | 142 + .../mate/tool/builtin/ShellExecuteTool.java | 16 +- .../mate/tool/builtin/SkillManageTool.java | 79 +- .../DefaultToolDisclosureService.java | 84 +- .../disclosure/ToolDisclosureService.java | 32 + .../disclosure/ToolUsageRecencyTracker.java | 33 + .../tool/document/GeneratedFileCache.java | 45 + .../document/WorkspaceArtifactSurfacer.java | 121 + .../mate/tool/guard/WorkspacePathGuard.java | 82 +- .../guardian/WorkspaceBoundaryGuardian.java | 49 +- .../mate/tool/image/ImageFileDownloader.java | 10 +- .../mate/tool/image/ImageReferenceLoader.java | 40 +- .../tool/local/DesktopBridgeController.java | 44 + .../tool/local/DesktopBridgeException.java | 32 + .../DesktopBridgeHandshakeInterceptor.java | 86 + .../tool/local/DesktopBridgeRegistry.java | 181 + .../local/DesktopBridgeWebSocketHandler.java | 117 + .../vip/mate/tool/local/LocalFileTools.java | 132 + .../vip/mate/tool/local/LocalShellTool.java | 63 + .../tool/local/LocalToolBridgeService.java | 145 + .../vip/mate/tool/local/LocalToolFormat.java | 40 + .../tool/mcp/event/McpServerRemovedEvent.java | 20 + .../IdentityForwardingToolCallback.java | 112 + .../tool/mcp/runtime/McpClientManager.java | 38 +- .../runtime/McpIdentityForwardProperties.java | 151 + .../runtime/McpIdentityForwardService.java | 255 ++ .../tool/mcp/service/McpServerService.java | 4 + .../tool/model3d/Model3dFileDownloader.java | 8 +- .../tool/music/MusicGenerationService.java | 6 +- .../vip/mate/tool/search/SearchCache.java | 2 +- .../tool/search/SearchProviderRegistry.java | 89 +- .../vip/mate/tool/search/SearchQuery.java | 2 +- .../mate/tool/video/VideoFileDownloader.java | 8 +- .../main/java/vip/mate/tts/TtsService.java | 6 +- .../wiki/controller/WikiAdminController.java | 39 + .../mate/wiki/controller/WikiController.java | 3 + .../wiki/controller/WikiEntityController.java | 39 +- .../controller/WikiRelationController.java | 117 +- .../vip/mate/wiki/dto/WikiFailureItem.java | 23 + .../java/vip/mate/wiki/job/WikiKbConfig.java | 10 + .../vip/mate/wiki/job/WikiKbConfigParser.java | 3 + .../GlobalDefaultStepModelStrategy.java | 12 +- .../job/strategy/KbDefaultModelStrategy.java | 14 +- .../job/strategy/WikiLightModelStrategy.java | 92 + .../wiki/model/WikiRawMaterialEntity.java | 36 +- .../mate/wiki/profile/WikiPageTypeDef.java | 28 + .../repository/WikiRawMaterialMapper.java | 32 + .../mate/wiki/service/WikiContextService.java | 27 +- .../mate/wiki/service/WikiPageService.java | 2 +- .../wiki/service/WikiProcessingService.java | 164 +- .../wiki/service/WikiRawMaterialService.java | 114 +- .../wiki/service/WikiResearchService.java | 40 + .../service/WikiTransformationExecutor.java | 10 +- .../service/WikiTransformationService.java | 18 +- .../vip/mate/wiki/sse/WikiProgressBus.java | 2 + .../java/vip/mate/wiki/tool/WikiTool.java | 32 + .../mode/AwaitApprovalStepAdapter.java | 71 + .../conversation/ConversationService.java | 87 +- .../conversation/TokenUsageService.java | 12 + .../conversation/model/MessageEntity.java | 9 + .../workspace/conversation/vo/MessageVO.java | 12 + .../conversation/vo/TokenUsageSummaryVO.java | 9 + .../config/ChatUploadAutoConfiguration.java | 48 + .../core/config/ChatUploadProperties.java | 35 + .../service/ChatUploadLocationResolver.java | 239 + .../main/resources/application-kingbase.yml | 8 + .../src/main/resources/application-mysql.yml | 8 + .../main/resources/application-postgres.yml | 8 + .../src/main/resources/application.yml | 56 + .../src/main/resources/db/data-en.sql | 10 + .../main/resources/db/data-kingbase-en.sql | 10 + .../main/resources/db/data-kingbase-zh.sql | 10 + .../src/main/resources/db/data-mysql-en.sql | 10 + .../src/main/resources/db/data-mysql-zh.sql | 10 + .../src/main/resources/db/data-zh.sql | 10 + .../h2/V157__register_session_list_tool.sql | 10 + .../h2/V158__register_session_send_tool.sql | 9 + .../h2/V159__sso_external_identity.sql | 79 + .../h2/V160__register_local_tools.sql | 17 + .../V161__agent_provider_preference_model.sql | 21 + .../h2/V162__wiki_raw_material_error_code.sql | 9 + .../h2/V163__wiki_raw_material_warning.sql | 10 + .../db/migration/h2/V164__kb_open_api_key.sql | 30 + ...iki_transformation_starter_pack_global.sql | 14 + .../h2/V166__message_usage_detail.sql | 6 + .../V157__register_session_list_tool.sql | 10 + .../V158__register_session_send_tool.sql | 9 + .../kingbase/V159__sso_external_identity.sql | 37 + .../kingbase/V160__register_local_tools.sql | 17 + .../V161__agent_provider_preference_model.sql | 21 + .../V162__wiki_raw_material_error_code.sql | 17 + .../V163__wiki_raw_material_warning.sql | 27 + .../kingbase/V164__kb_open_api_key.sql | 30 + ...iki_transformation_starter_pack_global.sql | 14 + .../kingbase/V166__message_usage_detail.sql | 6 + .../V157__register_session_list_tool.sql | 10 + .../V158__register_session_send_tool.sql | 9 + .../mysql/V159__sso_external_identity.sql | 39 + .../mysql/V160__register_local_tools.sql | 17 + .../V161__agent_provider_preference_model.sql | 46 + .../V162__wiki_raw_material_error_code.sql | 12 + .../mysql/V163__wiki_raw_material_warning.sql | 16 + .../migration/mysql/V164__kb_open_api_key.sql | 36 + ...iki_transformation_starter_pack_global.sql | 14 + .../mysql/V166__message_usage_detail.sql | 15 + .../src/main/resources/docs/en/api.md | 327 ++ .../src/main/resources/docs/en/chat.md | 17 +- .../src/main/resources/docs/en/desktop.md | 22 + .../main/resources/docs/en/docker-deploy.md | 2 +- .../src/main/resources/docs/en/faq.md | 6 +- .../src/main/resources/docs/en/index.md | 2 +- .../src/main/resources/docs/en/mcp.md | 142 + .../src/main/resources/docs/en/models.md | 10 +- .../src/main/resources/docs/en/openapi.md | 80 + .../resources/docs/en/operational-export.md | 77 + .../src/main/resources/docs/en/quickstart.md | 4 +- .../src/main/resources/docs/en/releases.md | 1 + .../src/main/resources/docs/en/roadmap.md | 193 +- .../src/main/resources/docs/en/security.md | 47 +- .../src/main/resources/docs/en/skills.md | 4 +- .../src/main/resources/docs/en/user-guide.md | 2 +- .../src/main/resources/docs/en/webchat.md | 29 +- .../main/resources/docs/en/wecom-tuning.md | 2 +- .../src/main/resources/docs/en/wiki.md | 52 +- .../src/main/resources/docs/en/workflow.md | 6 + .../src/main/resources/docs/en/workspaces.md | 2 +- .../src/main/resources/docs/zh/api.md | 327 ++ .../src/main/resources/docs/zh/chat.md | 17 +- .../src/main/resources/docs/zh/desktop.md | 22 + .../main/resources/docs/zh/docker-deploy.md | 2 +- .../src/main/resources/docs/zh/faq.md | 6 +- .../src/main/resources/docs/zh/index.md | 2 +- .../src/main/resources/docs/zh/mcp.md | 113 + .../src/main/resources/docs/zh/models.md | 10 +- .../src/main/resources/docs/zh/openapi.md | 80 + .../resources/docs/zh/operational-export.md | 77 + .../src/main/resources/docs/zh/quickstart.md | 4 +- .../src/main/resources/docs/zh/releases.md | 1 + .../src/main/resources/docs/zh/roadmap.md | 191 +- .../src/main/resources/docs/zh/security.md | 46 +- .../src/main/resources/docs/zh/skills.md | 4 +- .../src/main/resources/docs/zh/user-guide.md | 2 +- .../src/main/resources/docs/zh/webchat.md | 29 +- .../main/resources/docs/zh/wecom-tuning.md | 2 +- .../src/main/resources/docs/zh/wiki.md | 52 +- .../src/main/resources/docs/zh/workflow.md | 6 + .../src/main/resources/docs/zh/workspaces.md | 2 +- .../prompts/memory/summarize-system.txt | 2 +- .../prompts/skill/consolidate-system.txt | 19 + .../prompts/skill/consolidate-user.txt | 6 + .../prompts/skill/reflect-system.txt | 21 + .../resources/prompts/skill/reflect-user.txt | 10 + .../vip/mate/ApplicationContextSmokeTest.java | 25 + .../AgentGraphBuilderPreferenceTest.java | 104 +- .../AgentBindingMcpRemovalE2ETest.java | 90 + .../AgentBindingMcpRemovalListenerTest.java | 47 + .../context/ChatOriginSenderFieldsTest.java | 4 +- .../mate/agent/context/ChatOriginTest.java | 4 +- ...dowManagerSpillMarkerPreservationTest.java | 9 +- .../InformativeClearedPlaceholderTest.java | 46 + .../context/PrefixBudgetPlannerTest.java | 145 + .../RuntimeContextInjectorModelTest.java | 2 +- .../RuntimeContextInjectorSenderTest.java | 6 +- .../delegation/SubagentRunContextTest.java | 94 + ...deStreamingChatHelperToolCallArgsTest.java | 44 + .../node/ReasoningNodeOutputClampTest.java | 56 + .../node/ReasoningNodePtlPromptTest.java | 7 +- .../node/PlanGenerationDisplayGoalTest.java | 76 + .../ApprovalReplayContinuityTest.java | 2 +- .../WorkflowApprovalResumeBridgeTest.java | 168 + .../mate/auth/sso/SsoStateServiceTest.java | 194 + .../channel/ChannelManagerReconcileTest.java | 3 +- .../cards/FeishuCardDispatcherTest.java | 2 + .../web/ChatControllerUploadPathTest.java | 57 + .../WebChatApprovalInteractionTest.java | 223 + .../webchat/WebChatFileServiceTest.java | 6 +- .../tool_guard/ToolGuardCardHandlerTest.java | 68 +- .../mate/common/net/SsrfAllowlistTest.java | 56 + .../mate/config/OpenApiExposedAccessTest.java | 37 + .../config/OpenApiLockedDownAccessTest.java | 59 + .../cron/service/CronJobRunnerPromptTest.java | 2 +- .../action/HttpActionSsrfAllowlistTest.java | 53 + .../kbopen/auth/KbApiKeyRateLimiterTest.java | 66 + .../mate/kbopen/auth/KbApiKeyServiceTest.java | 234 + .../kbopen/auth/KbOpenApiAuthFilterTest.java | 127 + .../controller/KbOpenApiControllerTest.java | 102 + .../KbResearchSessionRegistryTest.java | 259 ++ .../llm/cache/CacheUsageExtractorTest.java | 82 + .../probe/ContextLimitErrorParserTest.java | 62 + .../probe/ModelContextWindowResolverTest.java | 135 + .../probe/OllamaContextProbeParseTest.java | 52 + ...OpenAiCompatibleContextProbeParseTest.java | 70 + .../ProviderRouterSelectPrimaryTest.java | 113 +- .../service/ModelCapabilityServiceTest.java | 27 +- .../mate/memory/MemoryManagerBudgetTest.java | 96 + .../search/SessionSearchIsolationTest.java | 98 + .../MemoryRecallFilenameTruncationTest.java | 111 + .../plugin/PluginContextImplSearchTest.java | 103 + .../plugin/PluginManagerSearchLookupTest.java | 112 + .../plugin/PluginManagerUpdateConfigTest.java | 184 + .../plugin/bridge/PluginSearchBridgeTest.java | 167 + .../skill/installer/ZipSkillFetcherTest.java | 34 + .../SkillConsolidationServiceTest.java | 179 + .../skill/lifecycle/SkillCuratorJobTest.java | 4 +- .../SkillReflectionServiceTest.java | 173 + .../service/SystemSettingBoolApiTest.java | 7 +- .../SystemSettingServiceCatalogTest.java | 150 + .../browser/BrowserLauncherManualProbe.java | 3 +- .../tool/browser/BrowserPropertiesTest.java | 92 + .../tool/browser/UrlSafetyCheckerTest.java | 266 ++ .../tool/builtin/CodeExecuteToolArgsTest.java | 2 +- .../builtin/CodeExecuteToolArtifactTest.java | 51 + .../DelegateAgentToolDenyListTest.java | 3 +- .../tool/builtin/DelegateAgentToolTest.java | 176 +- ...elegateAsyncTaskOutputAttributionTest.java | 5 +- .../tool/builtin/DelegateAsyncToolTest.java | 5 +- .../builtin/DelegateEventSequenceTest.java | 29 +- .../tool/builtin/SessionListToolTest.java | 136 + .../tool/builtin/SessionSendToolTest.java | 147 + .../builtin/SkillManageToolWriteFileTest.java | 115 + .../disclosure/ToolDisclosureServiceTest.java | 104 +- .../GeneratedFileCacheLinkifyTest.java | 98 + .../WorkspaceArtifactSurfacerTest.java | 92 + .../guard/WorkspacePathGuardSandboxTest.java | 56 + .../guard/WorkspacePathGuardShellTest.java | 11 + .../WorkspaceBoundaryGuardianTest.java | 73 +- .../tool/image/ImageFileDownloaderTest.java | 3 +- .../tool/image/ImageReferenceLoaderTest.java | 3 +- .../IdentityForwardingToolCallbackTest.java | 72 + .../runtime/McpClientManagerSnapshotTest.java | 3 +- .../McpIdentityForwardServiceTest.java | 308 ++ .../SearchProviderRegistryPluginTest.java | 224 + .../WikiEntityControllerIdorTest.java | 102 + .../WikiRelationControllerIdorTest.java | 299 ++ .../strategy/WikiLightModelStrategyTest.java | 77 + .../WikiPageTypeDefStageInstructionsTest.java | 56 + .../WikiRawMaterialFailuresMapperE2ETest.java | 95 + .../service/WikiContextServiceBudgetTest.java | 87 + .../WikiProcessingServiceErrorCodeTest.java | 139 + .../WikiProcessingServiceLazyTest.java | 6 +- .../WikiRawMaterialFailureStateTest.java | 100 + ...ransformationStarterPackGlobalE2ETest.java | 131 + .../runtime/AwaitApprovalNotifyTest.java | 157 + ...sationServiceCleanAttachmentFilesTest.java | 15 + .../ChatUploadLocationResolverTest.java | 203 + ...ChatUploadLocationResolverTestSupport.java | 47 + mateclaw-ui/package.json | 2 +- mateclaw-ui/src/api/index.ts | 73 +- .../src/components/agents/PlanBoard.vue | 97 +- .../src/components/agents/PlanDetailPanel.vue | 19 +- .../src/components/chat/ContentSegment.vue | 11 +- .../components/chat/DelegationNodeView.vue | 12 + .../src/components/chat/MessageBubble.vue | 297 +- .../src/components/chat/MessageList.vue | 184 +- .../src/components/chat/RunOverviewPanel.vue | 427 ++ .../dashboard/OperationalExport.vue | 452 ++ .../useSearchProviderCatalog.test.ts | 99 + mateclaw-ui/src/composables/chat/useChat.ts | 88 +- .../src/composables/chat/useMessages.ts | 1 - .../src/composables/chat/useStickToBottom.ts | 17 +- mateclaw-ui/src/composables/chat/useStream.ts | 2 +- mateclaw-ui/src/composables/chat/useTyping.ts | 2 +- .../src/composables/useNotificationCenter.ts | 3 + .../composables/useSearchProviderCatalog.ts | 84 + mateclaw-ui/src/i18n/locales/en-US.ts | 125 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 125 +- mateclaw-ui/src/stores/useGoalStore.ts | 10 + mateclaw-ui/src/stores/useWikiStore.ts | 8 + mateclaw-ui/src/types/index.ts | 37 + mateclaw-ui/src/types/tokenUsage.ts | 3 + .../__tests__/generatedFileLinks.test.ts | 56 + mateclaw-ui/src/utils/generatedFileLinks.ts | 44 + mateclaw-ui/src/views/Agents.vue | 95 +- mateclaw-ui/src/views/ChatConsole.vue | 79 +- mateclaw-ui/src/views/Dashboard.vue | 113 +- mateclaw-ui/src/views/Docs/index.vue | 227 +- mateclaw-ui/src/views/Login.vue | 246 +- mateclaw-ui/src/views/Plugins.vue | 175 +- .../Models/modals/ManageModelsModal.vue | 15 +- .../src/views/Settings/SkillCurator/index.vue | 34 +- .../src/views/Settings/System/index.vue | 280 +- mateclaw-ui/src/views/TokenUsage.vue | 12 + mateclaw-ui/src/views/Tools.vue | 8 +- .../views/Wiki/components/GraphNodeSearch.vue | 222 + .../Wiki/components/RawMaterialPanel.vue | 67 +- .../Wiki/components/WikiEntityGraphView.vue | 40 +- .../Wiki/components/WikiFailureCenter.vue | 97 + .../views/Wiki/components/WikiGraphView.vue | 51 +- .../views/Wiki/components/WikiWorkspace.vue | 11 +- mateclaw-ui/src/views/Wiki/index.vue | 17 +- mateclaw-ui/src/views/layout/MainLayout.vue | 9 +- pom.xml | 3 +- 474 files changed, 37224 insertions(+), 1283 deletions(-) create mode 100644 mateclaw-desktop/.env.example create mode 100644 mateclaw-desktop/.gitignore create mode 100644 mateclaw-desktop/CODESIGNING.md create mode 100644 mateclaw-desktop/README.md create mode 100644 mateclaw-desktop/RELEASE_NOTES_v1.0.101.md create mode 100644 mateclaw-desktop/branding.config.json create mode 100644 mateclaw-desktop/build/entitlements.mac.inherit.plist create mode 100644 mateclaw-desktop/build/entitlements.mac.plist create mode 100644 mateclaw-desktop/build/icon.icns create mode 100644 mateclaw-desktop/build/icon.ico create mode 100644 mateclaw-desktop/build/icon.png create mode 100644 mateclaw-desktop/build/icon_256.png create mode 100644 mateclaw-desktop/electron-builder.cjs create mode 100644 mateclaw-desktop/electron/main/config.ts create mode 100644 mateclaw-desktop/electron/main/index.ts create mode 100644 mateclaw-desktop/electron/main/localBridge.ts create mode 100644 mateclaw-desktop/electron/main/localToolsApproval.ts create mode 100644 mateclaw-desktop/electron/main/localToolsConfig.ts create mode 100644 mateclaw-desktop/electron/main/localToolsExecutor.ts create mode 100644 mateclaw-desktop/electron/preload/index.ts create mode 100644 mateclaw-desktop/index.html create mode 100644 mateclaw-desktop/package.json create mode 100644 mateclaw-desktop/pnpm-lock.yaml create mode 100644 mateclaw-desktop/public/logo/mateclaw_logo_s.png create mode 100644 mateclaw-desktop/src/App.vue create mode 100644 mateclaw-desktop/src/env.d.ts create mode 100644 mateclaw-desktop/src/main.ts create mode 100644 mateclaw-desktop/tsconfig.json create mode 100644 mateclaw-desktop/tsconfig.node.json create mode 100644 mateclaw-desktop/tsconfig.node.tsbuildinfo create mode 100644 mateclaw-desktop/vite.config.ts create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java create mode 100644 mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java create mode 100644 mateclaw-plugin-search-sample/pom.xml create mode 100644 mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java create mode 100644 mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListener.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlan.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/SsoProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/model/ExternalIdentityEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/model/SsoStateEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/provider/FeishuSsoProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProviderRegistry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoUserInfo.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/repository/ExternalIdentityMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/sso/repository/SsoStateMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java create mode 100644 mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java create mode 100644 mateclaw-server/src/main/java/vip/mate/common/net/SsrfAllowlist.java create mode 100644 mateclaw-server/src/main/java/vip/mate/common/net/SsrfProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java create mode 100644 mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyContext.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyRateLimiter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbOpenApiAuthFilter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbScopeInterceptor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/RequireKbScope.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/TokenHashUtil.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyBindingEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyBindingMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbApiKeyAdminController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenApiController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenResearchController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/dto/KbOpenApiDtos.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/research/KbResearchSessionRegistry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/kbopen/service/KbOpenApiService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/ContextLimitErrorParser.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/LocalContextProbe.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/LocalEndpoints.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/OllamaContextProbe.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/OpenAiCompatibleContextProbe.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderModelRef.java create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionAutoConfiguration.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogEntry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogResponse.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolUsageRecencyTracker.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeHandshakeInterceptor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeRegistry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeWebSocketHandler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/LocalFileTools.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/LocalShellTool.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolBridgeService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolFormat.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerRemovedEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallback.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadAutoConfiguration.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V157__register_session_list_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V159__sso_external_identity.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V160__register_local_tools.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V161__agent_provider_preference_model.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V164__kb_open_api_key.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V165__wiki_transformation_starter_pack_global.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V166__message_usage_detail.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V157__register_session_list_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V158__register_session_send_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V159__sso_external_identity.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V160__register_local_tools.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V161__agent_provider_preference_model.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V164__kb_open_api_key.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V165__wiki_transformation_starter_pack_global.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V166__message_usage_detail.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V157__register_session_list_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V158__register_session_send_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V159__sso_external_identity.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V160__register_local_tools.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V161__agent_provider_preference_model.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V164__kb_open_api_key.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V165__wiki_transformation_starter_pack_global.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V166__message_usage_detail.sql create mode 100644 mateclaw-server/src/main/resources/docs/en/openapi.md create mode 100644 mateclaw-server/src/main/resources/docs/en/operational-export.md create mode 100644 mateclaw-server/src/main/resources/docs/zh/openapi.md create mode 100644 mateclaw-server/src/main/resources/docs/zh/operational-export.md create mode 100644 mateclaw-server/src/main/resources/prompts/skill/consolidate-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/skill/consolidate-user.txt create mode 100644 mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt create mode 100644 mateclaw-server/src/test/java/vip/mate/ApplicationContextSmokeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalE2ETest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListenerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/InformativeClearedPlaceholderTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputClampTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/approval/WorkflowApprovalResumeBridgeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/common/net/SsrfAllowlistTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/config/OpenApiExposedAccessTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionSsrfAllowlistTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyRateLimiterTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbOpenApiAuthFilterTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/kbopen/controller/KbOpenApiControllerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/kbopen/research/KbResearchSessionRegistryTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/probe/ContextLimitErrorParserTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/probe/OllamaContextProbeParseTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/probe/OpenAiCompatibleContextProbeParseTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerBudgetTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/service/MemoryRecallFilenameTruncationTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerSearchLookupTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserPropertiesTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallbackTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpIdentityForwardServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiEntityControllerIdorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiRelationControllerIdorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeDefStageInstructionsTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceBudgetTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiTransformationStarterPackGlobalE2ETest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workflow/runtime/AwaitApprovalNotifyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTestSupport.java create mode 100644 mateclaw-ui/src/components/chat/RunOverviewPanel.vue create mode 100644 mateclaw-ui/src/components/dashboard/OperationalExport.vue create mode 100644 mateclaw-ui/src/composables/__tests__/useSearchProviderCatalog.test.ts create mode 100644 mateclaw-ui/src/composables/useSearchProviderCatalog.ts create mode 100644 mateclaw-ui/src/utils/__tests__/generatedFileLinks.test.ts create mode 100644 mateclaw-ui/src/utils/generatedFileLinks.ts create mode 100644 mateclaw-ui/src/views/Wiki/components/GraphNodeSearch.vue create mode 100644 mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue diff --git a/.env.example b/.env.example index 3983a0dd..1213459f 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,11 @@ MATECLAW_CORS_ALLOWED_ORIGINS= # 留空时回退到当前请求的 host,再退回相对路径。反代后部署建议显式设置。 MATECLAW_PUBLIC_BASE_URL= +# 是否公开 Swagger UI / OpenAPI 文档(/swagger-ui.html、/v3/api-docs)。 +# 生产数据库 profile(mysql/kingbase/postgres)默认 false —— 匿名无法浏览全部 +# 端点结构,需全局管理员(ROLE_ADMIN)。仅在内网/预发临时调试时设为 true。 +MATECLAW_OPENAPI_EXPOSE_UI= + # SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。 # openssl rand -hex 32 SEARXNG_SECRET= @@ -55,6 +60,20 @@ MATECLAW_BROWSER_CDP_URL= MATECLAW_BROWSER_CHROME_PATH= MATECLAW_BROWSER_CHANNEL= +# ==================== 局域网 部署放开(可选,默认 false 严格模式) ==================== +# 浏览器 SSRF 防护:放行本地回环和私有 IP(127.0.0.1 / 10.x / 192.168.x / +# 172.16-31.x / IPv6 fc00::/7 等),公网部署务必保持 false,否则 SSRF 防护失效 +PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=false +# 浏览器忽略 HTTPS 证书错误(自签证书 / IP 直连 HTTPS 场景) +# 公网部署务必保持 false,否则中间人攻击可绕过证书校验 +PLAYWRIGHT_IGNORE_HTTPS_ERRORS=false +# Playwright 单次操作超时(秒),慢链路 / 大页面可调高 +PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS=30 +# Playwright 导航超时(秒),慢网络可调高 +PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS=30 +# snapshot 文本截断长度,超出会返回 truncated:true 提示 LLM 用 selector 缩小范围 +PLAYWRIGHT_SNAPSHOT_MAX_LENGTH=20000 + # ==================== OpenAI OAuth(Docker,可选) ==================== # # OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code, diff --git a/.gitignore b/.gitignore index f93f1342..5e5ab1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,9 @@ CLAUDE.md # Codex CLI local artifacts .codex/ +# Codebase memory (local agent index / graph artifact; do not commit) +.codebase-memory/ + # Sync tooling local state (generated each run; report is intentionally tracked) scripts/.*-sync-state.json diff --git a/README.md b/README.md index 3d338c90..524afd5c 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@

Agent Harness · Spring Boot inside · One JAR to ship

-[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw) +[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/mateaix/mateclaw) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) [![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip) [![Java Version](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/) -[![Last Commit](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw) +[![Last Commit](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw) [![License](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE) [[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)] @@ -161,7 +161,7 @@ docker compose up -d # http://localhost:18080 ### Desktop -Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21. No Java install needed. +Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). Bundles JRE 21. No Java install needed. --- @@ -193,7 +193,7 @@ mateclaw/ └── .env.example ``` -Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed. +Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/releases) with a bundled JRE 21 — no Java install needed. ## Tech stack @@ -217,25 +217,29 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc ## Roadmap +**v1.7.0 (shipped 2026-07-04)** — a *productionization pass*: once it's in real collaboration, close every loop you can't see, gather, reach, fit, or connect: + +- **All three approval paths close the loop** — workflow `await_approval` actually pushes to channels and resolves → resumes, the WebChat (API-key) channel can approve/deny and replay, and Feishu/WeCom card clicks resolve workflow approvals directly +- **Long tasks are visible** — an always-on Run Overview rail + a per-turn token breakdown (cache hit/miss/write + reasoning split) + sub-agent cost rolled up + one-click generated-file download +- **Fits the real model window** — local-model context-window probing, a unified token budget for prefix injection, small-context degradation, and tool-schema budget gating — no more "guess 32K" pre-flight rejections or silent truncation +- **Opens up** — a knowledge-base + Deep Research open API (API-key + rate limit + SSE), a pluggable search Provider SPI, and MCP identity forwarding (carry the authenticated user's identity into a STDIO MCP) +- **Reaches further** — desktop local-embedded / remote-centralized dual mode (with `mateclaw-desktop` source opened) + a LAN deployment mode for controlled intranet access +- **One-click operational data export** — Dashboard 9-sheet Excel + a CLI for offline export + +Full story in the [v1.7.0 release notes](https://claw.mate.vip/docs/en/releases/1.7.0). + +**v1.6.0 (shipped 2026-06-22)** — make the autonomous employee *fast, sharp-eyed, and embeddable*: two-stage skill loading + prefix compression (faster first token) · `execute_code` native sandboxed code execution · vision that persists across turns + `image_analyze` · embeddable/headless webchat with per-`endUserId` memory · a Wiki you actually read (reading split from management · unified Sources tab · clickable `[[wikilinks]]`) · steadier under load (self-healing MCP · tool-call recovery · evidence-gated plans). Full story in the [v1.6.0 release notes](https://claw.mate.vip/docs/en/releases/1.6.0). + **v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0). **v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0). **v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0). -**v1.6.0 (in progress)** — make the autonomous employee *fast, sharp-eyed, and embeddable*: - -- **Faster first token** — two-stage skill loading (base skills resident, scenario skills retrieved on demand by a relevance scorer) plus prefix compression, cutting the cold-start payload that used to blow past a million characters -- **Native code execution** — `execute_code` lets an employee write and run sandboxed code to compute, transform data, and assemble multi-format reports, all JVM-side -- **Vision that persists** — images stay in context across turns; `image_analyze` re-reads an attachment on demand, so "zoom into that chart" follow-ups work without re-uploading -- **Embeddable & headless** — the webchat widget becomes a Web/API surface with multi-session support and per-end-user identity (`endUserId`), isolating memory per end user -- **A Wiki you actually read** — reading split from management, a unified Sources tab with per-KB auto-sync, and clickable cross-KB `[[wikilinks]]` -- **Steadier under load** — self-healing MCP connections · tool-call recovery on interleaved-thinking models · evidence-gated plan execution - ## Contributing ```bash -git clone https://github.com/matevip/mateclaw.git +git clone https://github.com/mateaix/mateclaw.git cd mateclaw cd mateclaw-server && mvn clean compile cd ../mateclaw-ui && pnpm install && pnpm dev diff --git a/README_zh.md b/README_zh.md index dae024a8..a23506d5 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,14 +10,14 @@

Agent Harness · Spring Boot 内核 · 一个 JAR 交付

-[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw) +[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/mateaix/mateclaw) [![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) [![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip) [![Java 版本](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/) -[![最后提交](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw) +[![最后提交](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw) [![许可证](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE) [[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)] @@ -161,7 +161,7 @@ docker compose up -d # http://localhost:18080 ### 桌面端 -从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。 +从 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。 --- @@ -193,7 +193,7 @@ mateclaw/ └── .env.example ``` -桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。 +桌面端安装包通过 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。 ## 技术栈 @@ -217,25 +217,29 @@ mateclaw/ ## 路线图 +**v1.7.0(2026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上: + +- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChat(API-Key)渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批 +- **长任务看得见** — 常驻「运行总览」侧栏 + 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载 +- **装得下真实模型窗口** — 本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文降级、工具 schema 预算门——不再被"猜个 32K"坑到预检拒绝或悄悄截断 +- **开放出去** — 知识库 / Deep Research 开放 API(API-Key + 限流 + SSE)、插件化搜索 Provider SPI、MCP 身份透传(把认证用户身份带给 STDIO MCP) +- **够得着更远** — 桌面端本地内嵌 / 远程集中部署双模式(`mateclaw-desktop` 源码开放)+ 局域网部署模式放开受控内网访问 +- **运营数据一键导出** — Dashboard 9 表 Excel + CLI 命令行离线导出 + +完整故事见 [v1.7.0 release notes](https://claw.mate.vip/docs/zh/releases/1.7.0)。 + +**v1.6.0(2026-06-22 发布)** — 让自驱的数字员工*更快、更会看、更易嵌入*:技能两段式载入 + prefix 压缩(首字节更快)· `execute_code` 原生沙箱代码执行 · 图片跨轮次留存 + `image_analyze` · 可嵌入/无头 webchat 按 `endUserId` 隔离记忆 · 真正可读的 Wiki(阅读与管理分离 · 统一 Sources 标签 · 可点击 `[[wikilinks]]`)· 高负载更稳(MCP 自愈 · 工具调用恢复 · 计划证据闸门)。完整故事见 [v1.6.0 release notes](https://claw.mate.vip/docs/zh/releases/1.6.0)。 + **v1.5.0(2026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。 **v1.4.0(2026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBAC(Owner / Admin / Member / Viewer)· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。 **v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。 -**v1.6.0(开发中)** — 让自驱的数字员工*更快、更会看、更易嵌入*: - -- **首字节更快** — 技能两段式载入(基础技能常驻,场景技能由相关性评分器按需检索)+ prefix 压缩,砍掉过去单请求动辄上百万字符的冷启动负载 -- **原生代码执行** — `execute_code` 让员工自己写、自己跑沙箱代码,完成计算、数据加工与多格式报告生成,全程在 JVM 内 -- **能记住图的视觉** — 图片跨轮次保留在上下文里;`image_analyze` 按需重新解析某张附件,"放大看那张图表"这类追问无需重新上传 -- **可嵌入、可无头** — webchat 组件升级为 Web/API 接入面,支持多会话与按终端用户身份(`endUserId`)隔离记忆 -- **真正可读的 Wiki** — 阅读与管理分离、统一的 Sources 标签页(按知识库自动同步)、可点击的跨库 `[[wikilinks]]` -- **高负载更稳** — MCP 连接自愈 · interleaved-thinking 模型的工具调用恢复 · 计划执行的证据闸门 - ## 参与贡献 ```bash -git clone https://github.com/matevip/mateclaw.git +git clone https://github.com/mateaix/mateclaw.git cd mateclaw cd mateclaw-server && mvn clean compile cd ../mateclaw-ui && pnpm install && pnpm dev diff --git a/docker-compose.yml b/docker-compose.yml index 36fe8052..66fbab91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,30 @@ services: MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-} MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-} MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-} + # SSRF / TLS relaxations for isolated LAN / on-prem deployments. + # Both default to false (strict mode, public-internet safe). + # The .env file uses the PLAYWRIGHT_* prefix (component-oriented naming, + # not product-oriented) — here we translate to the MATECLAW_BROWSER_* + # container env that Spring Boot relaxed-binding maps to BrowserProperties. + # - PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=true: allow loopback / private / link-local + # addresses through the browser SSRF guard. Cloud-metadata endpoints stay + # blocked. Turn on when the agent must drive http://192.168.x.x:port style + # internal services and has no path to the public internet. + # - PLAYWRIGHT_IGNORE_HTTPS_ERRORS=true: ignore HTTPS certificate errors. + # Auto-enables --ignore-certificate-errors at the Chromium command line + # when ALLOW_PRIVATE_NETWORK is also true (so CDP-attached external + # browsers benefit too). Leave false on internet-facing deployments. + MATECLAW_BROWSER_ALLOW_PRIVATE_NETWORK: ${PLAYWRIGHT_ALLOW_PRIVATE_NETWORK:-false} + MATECLAW_BROWSER_IGNORE_HTTPS_ERRORS: ${PLAYWRIGHT_IGNORE_HTTPS_ERRORS:-false} + # Playwright action / navigation timeouts (seconds). Increase for slow + # LAN or large-page scenarios. Defaults match Playwright's own (30s). + MATECLAW_BROWSER_DEFAULT_TIMEOUT_SECONDS: ${PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS:-30} + MATECLAW_BROWSER_DEFAULT_NAVIGATION_TIMEOUT_SECONDS: ${PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS:-30} + # Hard cap on the textual snapshot returned by action=snapshot. Content + # beyond this length is dropped with a truncated:true flag and a hint to + # retry with selector. Results > framework spill threshold (~8000 chars) + # are further spilt to disk by ToolResultStorage. + MATECLAW_BROWSER_SNAPSHOT_MAX_LENGTH: ${PLAYWRIGHT_SNAPSHOT_MAX_LENGTH:-20000} # OAuth 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 DEVICE_CODE。 # 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。 MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-} diff --git a/mateclaw-desktop/.env.example b/mateclaw-desktop/.env.example new file mode 100644 index 00000000..5ba7a3ec --- /dev/null +++ b/mateclaw-desktop/.env.example @@ -0,0 +1,15 @@ +# macOS 代码签名与公证 +# 本地构建推荐不设 CSC_LINK,让 electron-builder 自动从钥匙串发现证书 +# CSC_LINK=/path/to/developer_id_application.p12 # CI/CD 专用 +# CSC_KEY_PASSWORD= # CI/CD 专用 + +APPLE_ID=your@apple.id +APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx +APPLE_TEAM_ID=XXXXXXXXXX + +# GitHub Releases 发布 +GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Windows 代码签名(可选) +# WIN_CSC_LINK=/path/to/windows-cert.pfx +# WIN_CSC_KEY_PASSWORD= diff --git a/mateclaw-desktop/.gitignore b/mateclaw-desktop/.gitignore new file mode 100644 index 00000000..2f1d662c --- /dev/null +++ b/mateclaw-desktop/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dist-electron/ +release/ + +# Resources (downloaded/generated, not committed) +resources/jre/ +resources/app.jar + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Runtime data (H2 database created during dev testing) +data/ + +# Environment +.env +.env.local diff --git a/mateclaw-desktop/CODESIGNING.md b/mateclaw-desktop/CODESIGNING.md new file mode 100644 index 00000000..ced54225 --- /dev/null +++ b/mateclaw-desktop/CODESIGNING.md @@ -0,0 +1,275 @@ +# macOS 代码签名证书操作指南 + +本文档详细说明如何创建、导出和配置 macOS **Developer ID Application** 证书,用于 MateClaw Desktop 的签名与公证。 + +--- + +## 前置条件 + +- [Apple Developer Program](https://developer.apple.com/programs/) 会员($99/年) +- macOS 系统(需要钥匙串访问生成密钥对) + +## Step 1: 撤销旧证书(如有) + +如果本地证书已过期或私钥丢失,需先撤销线上旧证书: + +1. 登录 https://developer.apple.com/account/resources/certificates/list +2. 找到旧的 `Developer ID Application` 证书 → 点击进入详情 +3. 点击 **Revoke** → 确认撤销 +4. 回到本地 **钥匙串访问** → 删除过期证书(右键 → 删除) + +## Step 2: 生成 CSR(证书签名请求) + +CSR 会在本地生成密钥对(私钥留在钥匙串,公钥随 CSR 提交给 Apple)。 + +1. 打开 **钥匙串访问** +2. 菜单栏 → 钥匙串访问 → **证书助理** → **从证书颁发机构请求证书…** +3. 填写: + - **用户电子邮件地址**:你的 Apple ID 邮箱 + - **常用名称**:与开发者账号一致(如 `ZHANFU XU`) + - **CA 电子邮件地址**:留空 + - **请求是**:选择 **存储到磁盘** +4. 保存 `CertificateSigningRequest.certSigningRequest` 到桌面 + +## Step 3: 创建 Developer ID Application 证书 + +1. 访问 https://developer.apple.com/account/resources/certificates/add +2. 在 **Software** 分类下,选择 **Developer ID Application** +3. 点击 **Continue** +4. 上传 Step 2 保存的 CSR 文件 +5. 点击 **Continue** → **Download** 下载 `developerID_application.cer` +6. **双击**下载的 `.cer` 文件 → 自动安装到钥匙串 + +## Step 4: 验证安装 + +```bash +security find-identity -v -p codesigning | grep "Developer ID Application" +``` + +应输出类似: + +``` +"Developer ID Application: ZHANFU XU (MR97WAD978)" +``` + +在钥匙串访问 → 登录 → **我的证书**中,展开该证书应能看到关联的**私钥**(左侧三角展开)。 + +## Step 5: 导出 .p12 文件 + +`.p12` 文件包含证书 + 私钥,是 `electron-builder` 签名所需的文件。 + +1. 钥匙串访问 → 登录 → **我的证书** +2. 找到 `Developer ID Application: Your Name (TEAMID)` +3. 点左侧三角**展开**,确认包含私钥 +4. **右键证书**(不是私钥)→ **导出…** +5. 格式选择:**个人信息交换 (.p12)** +6. 保存为 `developer_id_application.p12` +7. 设置一个强密码(后续用作 `CSC_KEY_PASSWORD` 环境变量) + +> **安全提醒**:`.p12` 文件包含私钥,绝不要提交到 Git 仓库。 + +## Step 6: 创建 App 专用密码(公证用) + +Apple 公证(notarization)需要通过 Apple ID 验证身份,使用 App 专用密码代替账号密码。 + +1. 访问 https://appleid.apple.com/account/manage +2. 登录 → **登录与安全** → **App 专用密码** → **生成** +3. 标签填:`mateclaw-notarize` +4. 记录生成的密码(格式如 `xxxx-xxxx-xxxx-xxxx`) + +## Step 7: 查找 Team ID + +```bash +security find-identity -v -p codesigning | grep "Developer ID Application" +``` + +输出中括号内的 10 位字母数字即为 Team ID(如 `MR97WAD978`)。 + +## Step 8: 配置环境变量并构建 + +### 方式 A:本地钥匙串自动发现(推荐) + +证书已安装到本地钥匙串时,**不需要设置 `CSC_LINK` 和 `CSC_KEY_PASSWORD`**,electron-builder 会自动从钥匙串中发现 Developer ID Application 证书。 + +```bash +cd mateclaw-desktop + +# 只需设置公证相关变量 +export APPLE_ID="your@apple.id" +export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" +export APPLE_TEAM_ID="XXXXXXXXXX" + +# 执行签名+公证构建 +bash scripts/build-all-platforms.sh --mac-only +``` + +> **为什么推荐这种方式?** 设置 `CSC_LINK` 时,electron-builder 会创建一个临时钥匙串来导入 `.p12` 文件,这可能导致签名过程静默卡死(无报错)。直接使用本地钥匙串可以避免此问题。 + +### 方式 B:指定 .p12 文件(CI/CD 专用) + +在 CI/CD 环境或证书不在本地钥匙串时,需要通过环境变量指定 `.p12` 文件: + +```bash +cd mateclaw-desktop + +export CSC_LINK="$HOME/developer_id_application.p12" +export CSC_KEY_PASSWORD="你的p12密码" +export APPLE_ID="your@apple.id" +export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" +export APPLE_TEAM_ID="XXXXXXXXXX" + +bash scripts/build-all-platforms.sh --mac-only +``` + +> **注意**:`CSC_KEY_PASSWORD` 中如有特殊字符(`$`、`!`、`"`、`` ` ``),必须用**单引号**包裹,如 `export CSC_KEY_PASSWORD='pa$$w0rd!'`。 + +### GitHub Actions Secrets + +将 `.p12` 文件 Base64 编码后存为 GitHub Secret: + +```bash +base64 -i developer_id_application.p12 | pbcopy +# 粘贴到 GitHub Secret: MAC_CSC_LINK +``` + +| GitHub Secret | 值 | +|---|---| +| `MAC_CSC_LINK` | `.p12` 的 Base64 内容 | +| `MAC_CSC_KEY_PASSWORD` | `.p12` 密码 | +| `APPLE_ID` | Apple ID 邮箱 | +| `APPLE_APP_SPECIFIC_PASSWORD` | App 专用密码 | +| `APPLE_TEAM_ID` | 10 位 Team ID | + +## Step 9: 验证签名和公证 + +构建完成后验证: + +```bash +# 验证代码签名 +codesign --verify --deep --strict release/mac-arm64/MateClaw.app + +# 验证 Gatekeeper 公证状态 +spctl --assess --type execute --verbose release/mac-arm64/MateClaw.app +# 期望输出: accepted, source=Developer ID + +# 验证 DMG +spctl --assess --type open --context context:primary-signature release/MateClaw-*.dmg +``` + +--- + +## 故障排查 + +### 签名卡死(无报错) + +**现象**:构建停在 `signing` 行不动,`ps aux | grep codesign` 无进程或进程短暂出现后消失。 + +**原因**:设置了 `CSC_LINK` 后,electron-builder 会创建临时钥匙串导入 `.p12`,临时钥匙串的访问权限可能导致 `codesign` 静默卡死。 + +**解决**: +```bash +# 方案一(推荐):取消 CSC_LINK,使用本地钥匙串自动发现 +unset CSC_LINK +unset CSC_KEY_PASSWORD + +# 方案二:授权 codesign 访问钥匙串 +security unlock-keychain -p "你的Mac登录密码" ~/Library/Keychains/login.keychain-db +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "你的Mac登录密码" ~/Library/Keychains/login.keychain-db +``` + +### `Permission denied` (classes.jsa) + +**现象**:`codesign` 报错 `Permission denied`,通常指向 JRE 中的 `classes.jsa` 文件。 + +**原因**:下载的 Adoptium JRE 中部分文件是只读的,`codesign --force` 需要写权限。 + +**解决**:`download-jre.sh` 已在解压后自动执行 `chmod -R u+w`。如果使用旧版 JRE,手动修复: +```bash +# 删除旧 JRE 重新下载(推荐) +rm -rf resources/jre/mac-arm64 resources/jre/mac-x64 +npm run setup:jre + +# 或手动修复权限 +chmod -R u+w resources/jre/ +``` + +### `MAC verification failed` (wrong password) + +**现象**:`SecKeychainItemImport: MAC verification failed during PKCS12 import (wrong password?)` + +**原因**:`CSC_KEY_PASSWORD` 与导出 `.p12` 时设置的密码不匹配。 + +**解决**: +```bash +# 验证密码是否正确 +openssl pkcs12 -in ~/developer_id_application.p12 -nokeys -passin pass:"你的密码" + +# 如果报错 mac verify failure,重新导出 .p12: +# 钥匙串访问 → 我的证书 → 右键 Developer ID Application → 导出 → 重新设置密码 + +# 注意特殊字符需用单引号包裹 +export CSC_KEY_PASSWORD='pa$$w0rd!' +``` + +### 公证上传超时 (deadlineExceeded) + +**现象**:`HTTPClientError.deadlineExceeded`,公证上传到 Apple S3 超时。 + +**原因**:网络到 Apple 服务器不稳定,700MB+ 的应用上传容易超时。 + +**解决**:先跳过公证构建,再用 `xcrun notarytool` 手动公证(支持断点续传,超时容忍度更高): +```bash +# 1. 去掉公证变量,仅签名 +unset APPLE_ID +unset APPLE_APP_SPECIFIC_PASSWORD +unset APPLE_TEAM_ID +bash scripts/build-all-platforms.sh --mac-only + +# 2. 手动公证 +xcrun notarytool submit release/MateClaw_1.0.0_arm64.zip \ + --apple-id "your@apple.id" \ + --password "app专用密码" \ + --team-id "XXXXXXXXXX" \ + --wait + +xcrun notarytool submit release/MateClaw_1.0.0_x64.zip \ + --apple-id "your@apple.id" \ + --password "app专用密码" \ + --team-id "XXXXXXXXXX" \ + --wait + +# 3. 装订公证票据到 DMG +xcrun stapler staple release/MateClaw_1.0.0_arm64.dmg +xcrun stapler staple release/MateClaw_1.0.0_x64.dmg +``` + +--- + +## 常见问题 + +### 证书过期了怎么办? + +Developer ID Application 证书有效期 **5 年**。过期后需重复 Step 1 ~ Step 5 重新创建。Apple 会在自动轮换日期前通过邮件提醒。 + +### 导出 .p12 时没有"导出"选项? + +说明本地钥匙串中没有该证书对应的私钥。私钥只存在于当初生成 CSR 的那台 Mac 上。解决方案: +- **方案 A**:在原 Mac 上导出 `.p12`,再导入到当前 Mac +- **方案 B**:撤销旧证书,在当前 Mac 重新创建(Step 1 ~ Step 5) + +### 签名很慢正常吗? + +正常。700MB+ 的应用(含 JRE + Electron Framework)签名需要 **15~30 分钟**,公证上传+审核需要额外 **5~15 分钟**。可以用以下命令监控签名进度: +```bash +watch -n 2 'ps aux | grep codesign | grep -v grep' +# macOS 需先安装:brew install watch +``` + +### 跳过签名(开发测试用) + +```bash +export CSC_IDENTITY_AUTO_DISCOVERY=false +bash scripts/build-all-platforms.sh --mac-only +``` + +未签名的应用无法使用自动升级功能,macOS 用户需手动下载 DMG 安装。 diff --git a/mateclaw-desktop/README.md b/mateclaw-desktop/README.md new file mode 100644 index 00000000..bbdd697a --- /dev/null +++ b/mateclaw-desktop/README.md @@ -0,0 +1,432 @@ +# MateClaw Desktop + +MateClaw 的桌面客户端,基于 Electron 构建,自动集成 JRE 21 和后端服务,实现双击即用。 + +## 架构 + +``` +Electron Shell +├── Splash Screen (Vue 3) ← 启动加载界面 +├── Bundled JRE 21 ← 自带 Java 运行时 +├── mateclaw-server.jar ← Spring Boot 后端 + Vue 前端 +└── BrowserWindow → localhost:18088 +``` + +**启动流程**: Electron 启动 → 显示 Splash → 用内置 JRE 启动 JAR → 等待后端就绪 → 加载主界面 + +## 快速开始 + +### 前置要求 + +- Node.js 18+ +- pnpm (前端构建) +- Maven 3.9+ (后端构建) +- Java 21+ (仅构建时需要,运行时使用内置 JRE) + +### 开发模式 + +```bash +# 1. 安装依赖 +npm install + +# 2. 构建后端 JAR(包含前端资源) +npm run setup:jar + +# 3. 下载 JRE(当前平台) +npm run setup:jre + +# 4. 启动开发模式 +npm run dev +``` + +### 打包发布 + +```bash +# macOS (.dmg) +npm run package:mac + +# Windows (.exe) +npm run package:win + +# 全平台 +npm run package:all +``` + +输出在 `release/` 目录。 + +## 目录结构 + +``` +mateclaw-desktop/ +├── electron/main/ # Electron 主进程(Java 生命周期管理) +├── electron/preload/ # 预加载脚本(安全 IPC 桥接) +├── src/ # Splash Screen(Vue 3 加载页面) +├── build/ # 应用图标和 macOS entitlements +├── scripts/ # 构建脚本 +│ ├── download-jre.sh # 下载 Adoptium JRE 21 +│ └── build.sh # 构建前端 + 后端 JAR +└── resources/ # 运行时资源(JRE + JAR,不提交到 Git) +``` + +## 环境变量 + +桌面应用**不需要任何环境变量**就能启动——LLM 供应商 Key 在 UI 里加。 + +以下是可选的环境变量(桌面应用会继承系统环境): + +| 变量 | 必须 | 说明 | +|------|------|------| +| `SERPER_API_KEY` | ❌ | Google Serper 搜索 API(搜索工具暂未迁到 UI) | +| `TAVILY_API_KEY` | ❌ | Tavily 搜索 API | + +> 💡 DashScope / OpenAI / Anthropic / DeepSeek / Kimi / Ollama 等 LLM 供应商 Key 启动后在「设置 → 模型 → 添加供应商」里粘进去,加密存到本地 H2 数据库。 + +## 自动升级 + +应用内置 `electron-updater` 自动升级,更新产物托管在 [GitHub Releases](https://github.com/matevip/mateclaw/releases)。 + +**升级流程**:启动时检查 → Splash Screen 底部通知 → 用户点击下载 → 下载完成点击重启 → 自动停止 Java 后端 → 安装新版本 + +| 平台 | 更新包格式 | 元数据文件 | 签名要求 | +|------|-----------|-----------|---------| +| Windows | NSIS `.exe` | `latest.yml` | 可选(不签名会触发 SmartScreen) | +| macOS | `.zip` | `latest-mac.yml` | **必须签名+公证**(否则只能手动 DMG 安装) | + +## 发布操作手册 + +### 第一步:配置 GitHub Token + +`electron-builder` 使用 `github` provider,需要 GitHub Personal Access Token 来创建 Release 并上传产物。 + +1. 前往 https://github.com/settings/tokens → **Generate new token (classic)** +2. 勾选 `repo` 权限(需要完整 repo 访问才能创建 Release) +3. 生成后保存 token + +```bash +# 设置环境变量(建议写入 ~/.zshrc 或 CI Secret) +export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### 第二步:版本号管理 + +每次发布前必须更新 `package.json` 中的 `version` 字段。`electron-updater` 客户端通过对比本地版本号和 `latest.yml` 中的版本号来判断是否有更新。 + +```bash +# 编辑版本号 +cd mateclaw-desktop +vim package.json # 修改 "version": "1.0.0" → "1.1.0" +``` + +版本号遵循 [SemVer](https://semver.org/): +- 修复 bug → `1.0.0` → `1.0.1` +- 新功能 → `1.0.0` → `1.1.0` +- 破坏性变更 → `1.0.0` → `2.0.0` + +### 第三步:构建并发布 + +```bash +cd mateclaw-desktop + +# 一键构建全平台 + 自动上传到 GitHub Releases +export GH_TOKEN=ghp_xxxxxxxxxxxx +bash scripts/build-all-platforms.sh --all --publish=always +``` + +这会自动: +1. 构建后端 JAR +2. 下载各平台 JRE +3. 编译前端 +4. 打包 macOS(DMG + ZIP)和 Windows(NSIS) +5. 生成 `latest.yml` 和 `latest-mac.yml` +6. 创建 GitHub Draft Release 并上传所有产物 + +完成后前往 https://github.com/matevip/mateclaw/releases ,找到 Draft Release: +- 填写 Release Notes(更新说明) +- 点击 **Publish release** 正式发布 + +也可以仅构建特定平台: + +```bash +bash scripts/build-all-platforms.sh --mac-only --publish=always # 仅 macOS +bash scripts/build-all-platforms.sh --win-only --publish=always # 仅 Windows +``` + +### 第四步(可选):手动发布 + +如果不想用 `--publish=always` 自动上传: + +```bash +# 1. 仅构建,不上传 +bash scripts/build-all-platforms.sh --all + +# 2. 查看生成的产物 +ls -la release/ +# 产物包括: +# MateClaw_1.1.0_arm64.dmg macOS ARM64 安装包 +# MateClaw_1.1.0_x64.dmg macOS x64 安装包 +# MateClaw_1.1.0_arm64.zip macOS ARM64 更新包(升级用) +# MateClaw_1.1.0_x64.zip macOS x64 更新包(升级用) +# MateClaw_1.1.0_x64_Setup.exe Windows x64 安装包 +# MateClaw_1.1.0_arm64_Setup.exe Windows ARM64 安装包 +# MateClaw_1.1.0_*.blockmap 差分下载支持文件 +# latest.yml Windows 更新元数据 +# latest-mac.yml macOS 更新元数据 + +# 3. 在 GitHub 手动创建 Release +# Tag: v1.1.0 +# 上传 release/ 目录中的所有 .exe .zip .dmg .blockmap .yml 文件 +``` + +> **注意**:`latest.yml` 和 `latest-mac.yml` 必须上传,客户端靠它们检测新版本。 + +--- + +## macOS 代码签名与公证 + +macOS 自动升级**必须**签名+公证,否则 Gatekeeper 会阻止更新后的应用启动。未签名时 macOS 用户只能手动下载 DMG 安装。 + +> **证书创建完整指南**:首次配置或证书过期时,参见 [CODESIGNING.md](./CODESIGNING.md)(含 CSR 生成、证书创建、.p12 导出、公证配置等完整步骤)。 + +### 本地签名构建(推荐) + +证书安装到本地钥匙串后,**不需要设置 `CSC_LINK`**,electron-builder 会自动发现证书: + +```bash +# 只需设置公证相关变量 +export APPLE_ID=your@apple.id +export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx # 在 appleid.apple.com 生成 +export APPLE_TEAM_ID=XXXXXXXXXX # 10 位团队 ID + +bash scripts/build-all-platforms.sh --mac-only --publish=always +``` + +`electron-builder` 会自动完成签名 → 公证 → 装订(staple)→ 上传。 + +> **注意**:不要设置 `CSC_LINK` 环境变量,否则 electron-builder 会创建临时钥匙串,可能导致签名卡死。详见 [CODESIGNING.md](./CODESIGNING.md) 故障排查章节。 + +### CI/CD 签名构建 + +CI 环境无本地钥匙串,需通过 `CSC_LINK` 指定 `.p12` 文件(Base64 编码存入 GitHub Secret): + +```bash +export CSC_LINK=base64_encoded_p12_content +export CSC_KEY_PASSWORD=your_certificate_password +export APPLE_ID=your@apple.id +export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx +export APPLE_TEAM_ID=XXXXXXXXXX + +bash scripts/build-all-platforms.sh --mac-only --publish=always +``` + +### 公证超时处理 + +如果公证上传超时(`deadlineExceeded`),可先跳过公证构建,再用 `xcrun notarytool` 手动公证: + +```bash +# 1. 去掉公证变量,仅签名出包 +unset APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID +bash scripts/build-all-platforms.sh --mac-only + +# 2. 手动公证(支持断点续传) +xcrun notarytool submit release/MateClaw_*.zip \ + --apple-id your@apple.id \ + --password "app专用密码" \ + --team-id XXXXXXXXXX \ + --wait + +# 3. 装订公证票据 +xcrun stapler staple release/MateClaw_*.dmg +``` + +### 跳过签名(开发/测试用) + +```bash +export CSC_IDENTITY_AUTO_DISCOVERY=false +bash scripts/build-all-platforms.sh --mac-only +``` + +--- + +## Windows 代码签名(可选) + +未签名的 Windows 安装包会触发 SmartScreen 警告("Windows 已保护你的电脑"),用户可以点击"仍要运行"。签名可消除此警告。 + +### EV 代码签名证书 + +推荐使用 EV(Extended Validation)证书,可立即获得 SmartScreen 信誉,无需积累安装量。 + +证书提供商(参考): +- [DigiCert](https://www.digicert.com/signing/code-signing-certificates) — 需硬件 token +- [SSL.com](https://www.ssl.com/certificates/ev-code-signing/) — 支持云签名 +- [Certum](https://shop.certum.eu/code-signing-certificates/) — 较便宜的选项 + +### 配置 + +```bash +# PFX 文件签名 +export WIN_CSC_LINK=/path/to/windows-cert.pfx +export WIN_CSC_KEY_PASSWORD=password + +# 或使用 signtool(需要硬件 token 的 EV 证书) +# 在 electron-builder.json 的 win 节中配置: +# "signingHashAlgorithms": ["sha256"], +# "sign": "./scripts/sign.js" +``` + +--- + +## CI/CD 自动发布(GitHub Actions) + +以下为 GitHub Actions 完整示例,实现 Git tag 推送时自动构建全平台并发布: + +```yaml +# .github/workflows/release.yml +name: Release Desktop + +on: + push: + tags: + - 'v*' # 推送 v1.0.0 等 tag 时触发 + +jobs: + release-mac: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Build and publish macOS + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + cd mateclaw-desktop + npm install + bash scripts/build-all-platforms.sh --mac-only --publish=always + + release-win: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Build and publish Windows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd mateclaw-desktop + npm install + bash scripts/build-all-platforms.sh --win-only --publish=always +``` + +### 配置 CI Secrets + +在 GitHub 仓库 → Settings → Secrets and variables → Actions → New repository secret: + +| Secret 名称 | 说明 | +|-------------|------| +| `MAC_CSC_LINK` | macOS 签名证书 .p12 的 Base64 编码:`base64 -i cert.p12 \| tr -d '\n'` | +| `MAC_CSC_KEY_PASSWORD` | .p12 证书密码 | +| `APPLE_ID` | Apple ID 邮箱 | +| `APPLE_APP_SPECIFIC_PASSWORD` | App 专用密码 | +| `APPLE_TEAM_ID` | 10 位开发者团队 ID | +| `GITHUB_TOKEN` | 自动提供,无需手动配置 | + +### 发布流程(CI 方式) + +```bash +# 1. 更新版本号 +cd mateclaw-desktop +vim package.json # "version": "1.1.0" + +# 2. 提交并打 tag +git add -A && git commit -m "release: v1.1.0" +git tag v1.1.0 +git push origin main --tags + +# 3. GitHub Actions 自动构建并创建 Draft Release +# 4. 前往 GitHub Releases 确认并发布 +``` + +--- + +## 本地测试自动升级 + +### 方式一:开发模式 + dev-app-update.yml + +在开发模式下测试 updater 流程(不需要打包): + +```bash +# 1. 在 mateclaw-desktop/ 根目录创建 dev-app-update.yml +cat > dev-app-update.yml << 'EOF' +provider: generic +url: http://localhost:8080/ +EOF + +# 2. 构建一个"新版本"的产物 +# 先把 package.json 的 version 改为更高版本(如 9.9.9) +# 然后构建: +npm run build +npx electron-builder --mac --publish=never # 或 --win +# 构建完成后把 version 改回原值 + +# 3. 启动本地文件服务器 +cd release && python3 -m http.server 8080 + +# 4. 另一个终端启动开发模式 +cd mateclaw-desktop && npm run dev +# updater 会从 localhost:8080 检查更新并发现"新版本" +``` + +> 开发模式下 `quitAndInstall()` 不会真正安装,但可验证检查→发现→下载的完整流程。 + +### 方式二:打包后端到端测试(推荐) + +```bash +# 1. 打包 v1.0.0 并安装到系统 +# 2. 修改 package.json version 为 v1.1.0 +# 3. 重新构建,产物上传到 GitHub Release(或本地服务器) +# 4. 启动已安装的 v1.0.0,观察完整升级流程: +# 检查更新 → 发现 v1.1.0 → 下载 → 重启安装 +``` + +--- + +## 发布检查单 + +- [ ] `package.json` 版本号已更新 +- [ ] 后端 JAR 已构建(`npm run setup:jar`) +- [ ] 各平台 JRE 已下载 +- [ ] `npm run build` 编译通过 +- [ ] `GH_TOKEN` 环境变量已设置 +- [ ] macOS 签名证书环境变量已设置(若需要签名) +- [ ] `bash scripts/build-all-platforms.sh --all --publish=always` 执行成功 +- [ ] GitHub Draft Release 已确认发布 +- [ ] 在旧版本应用上验证升级通知正常 + +## 技术栈 + +- **Electron** - 桌面应用框架 +- **Vite + Vue 3** - Splash Screen 构建 +- **electron-builder** + **electron-updater** - 跨平台打包与自动升级 +- **Adoptium JRE 21** - 内置 Java 运行时 diff --git a/mateclaw-desktop/RELEASE_NOTES_v1.0.101.md b/mateclaw-desktop/RELEASE_NOTES_v1.0.101.md new file mode 100644 index 00000000..ccce4a3f --- /dev/null +++ b/mateclaw-desktop/RELEASE_NOTES_v1.0.101.md @@ -0,0 +1,67 @@ +# MateClaw v1.0.101 + +## What's New + +### Mobile Responsive UI +- Sidebar transforms to slide-in drawer with hamburger menu on mobile (<=768px) +- Conversation panel becomes a toggleable overlay on mobile +- Welcome screen centers properly with auto text wrapping, single-column suggestion cards +- Chat header auto-simplifies: icon-only agent badge, adaptive model selector +- Reduced padding/gaps across all chat components for mobile screens + +### Drag & Drop File Upload +- Drag-and-drop files and folders directly into the chat area +- Electron: directory references via local path; Web: recursive file collection and upload + +### Multi-Agent Collaboration +- `DelegateAgentTool` for agent-to-agent task delegation + +### LLM Context Awareness +- Current datetime automatically injected into LLM context for time-aware responses + +### MCP Server +- Pre-configured GitHub MCP Server in seed data (ready to use out of the box) + +### Ollama Auto-Discovery +- Auto-detect local Ollama instance on startup +- Pre-configured 6 popular local models (Qwen3, Llama, DeepSeek, Gemma, Phi, Mistral) +- Local providers sorted first in model management UI + +### Model Management Enhancements +- Provider list grouped by Local / Cloud with section headers +- Zhipu AI models updated to GLM-5 series (GLM-5-Turbo / GLM-5V-Turbo / GLM-5 / GLM-5.1) +- 20+ model providers supported + +### API Docs +- Replaced Knife4j with SpringDoc OpenAPI 2.8.16 (`/swagger-ui.html`) + +## Bug Fixes + +- **Security**: Fixed SPA frontend route refresh returning 401 +- **i18n**: Window title dynamically set from language pack instead of hardcoded +- **i18n**: Fixed 5 hardcoded Chinese strings in approval bar +- **i18n**: Fixed hardcoded time formatting (locale-aware now) +- **Guard**: Aligned tool guard rule names with runtime `@Tool` method names +- **LLM**: Fixed Zhipu connection test 404 +- **UI**: Fixed suggestion cards grid misalignment with longer text +- **Upload**: File upload size limit raised to 100MB + +## Download + +| Platform | File | Note | +|----------|------|------| +| macOS Apple Silicon | `MateClaw_1.0.101_arm64.dmg` | M1 / M2 / M3 / M4 / M5 | +| macOS Intel | `MateClaw_1.0.101_x64.dmg` | Intel Mac | +| Windows | `MateClaw_1.0.101_Setup.exe` | Windows 10/11 (x64+arm64) | +| Windows x64 | `MateClaw_1.0.101_x64_Setup.exe` | Windows 10/11 x64 | +| Windows ARM64 | `MateClaw_1.0.101_arm64_Setup.exe` | Windows ARM64 | + +> zip / blockmap / yml files are for auto-update support. + +## Links + +- GitHub: https://github.com/matevip/mateclaw +- Gitee: https://gitee.com/matevip_admin/mateclaw +- Documentation: https://mateclaw.com + +**Full Changelog**: https://github.com/matevip/mateclaw/compare/v1.0.0...v1.0.101 diff --git a/mateclaw-desktop/branding.config.json b/mateclaw-desktop/branding.config.json new file mode 100644 index 00000000..f966e865 --- /dev/null +++ b/mateclaw-desktop/branding.config.json @@ -0,0 +1,9 @@ +{ + "name": "MateClaw", + "tagline": "AI Personal Assistant", + "team": "MateClaw Team", + "copyright": "Copyright © 2026 MateClaw Team", + "appId": "vip.mate.mateclaw", + "githubUrl": "https://github.com/matevip/mateclaw", + "logoFile": "mateclaw_logo_s.png" +} diff --git a/mateclaw-desktop/build/entitlements.mac.inherit.plist b/mateclaw-desktop/build/entitlements.mac.inherit.plist new file mode 100644 index 00000000..faf2370e --- /dev/null +++ b/mateclaw-desktop/build/entitlements.mac.inherit.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.inherit + + + diff --git a/mateclaw-desktop/build/entitlements.mac.plist b/mateclaw-desktop/build/entitlements.mac.plist new file mode 100644 index 00000000..e7b78d29 --- /dev/null +++ b/mateclaw-desktop/build/entitlements.mac.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + + + diff --git a/mateclaw-desktop/build/icon.icns b/mateclaw-desktop/build/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..d40845323013dc6f30436d42d12d8a0ebd82eaf4 GIT binary patch literal 247190 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�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%0DKnJ>+G8(?oE@ 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

GSJ34tY$XRB(gC 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


qVv}G=)Zq##EjkV-wdglGW=iHM5+B>u8(Hh z7jBsMxiY2j7$^m7z`I(@C;-DUitlB z7mf}7-WfN-%=APl%^#HyDnAvo9c(KJgJ@u&*+1cPs3G;-Ai){VTt*DRAih8JKkupd zslpcI_JH@;z4$QR1R|Sls-wHA?PEnp7f1(-BQn}zhZeU&V~W1Eq4?G4ov?N4k;Ua! zR`y>#5)6*g?<%snEoU8SdD`hyfp1dJ!m- zNGr&_ggt5FxgiqOCJ6J{`oqg)5*OK!$yiwd{jQ|ZMw>{ks~NVlo853O?k`e78nYGN z8de*c#rowFzUeWgtyl{Y)j2hFi+@2&7h>XWjE+CqW!C-^;PMong$q5w3cxi%WZ8)LK98= zpm>uw&xzf6bWy5U+r<8bvrz2eQEXftRrl*weh)IETyoEsUr9HZzPs1D=(aeK!#x}7 zrEu-#Q#wlvj=<5$!#_aJ<^zKvf3^JZ%*pCYH%Pi_XaF#7Xoo5SeUcK;ZFRQL+ieCV zE?FzREX)zCwM1{eFBg)5fMncnRm8dycEL6YO*>~M#Q2lfr=LIoY%?a$ddU|YlzaZmyU#$Yj~|F;&hOYN)p zRA)LYX$m?T9d=zz2R$xWZ>qmd-8KwUf8`H#b$RP99oo?3)sr??N1R=re4|y2dpfpZf2T}h;el~XcisfgU!Ryn={jF&`41Hv(blu*Z2rQ68AOE7oxo=QvrYy z7u;g}kfbRNS}d&}vwCHayPB~2*K=$AE=SUJ3HrReyL;1PHtcly6C=0& ze_@Ey5SE>8!qMNd=dIU*nzyzw&sD9!Tapit*8{CjsgBpK9?R^aegt}$dK};^CbvLq zn+CVuh^mv?2^Vo`oj;a%5mIZ{MDh};l?ln@oMLc_3(9B4gODOiE8w5H<7@NwF#UBLfv$#$+k>X=C67u_*7>&l8c)#n$!|6{Z;Ohg zjLfu+85SOz2;_44(q8bQ9{Q=OmuiS|hEuZKL0J|rp=QSgfr1`G(BUE3C!PM0b z9Ft7hs~%DCNDd-F{j6XI*ycMNRTipltnd{l!UO7jXKHDNVX6P279~xDTAXcpnv3or zvOxSB93Z8qpKHhh`v*nqT+avPOsp~O?3Q^x<73Z!y7S9_Rzc%I!HgKch}&o6N6yf{ zWq28pczqVAQ7OfE%aGpKzD3hm4w9!Ar?^S5w3Rt+Fc4#Uj&r#(HH$w()S)d1!2tTM zoy<#ffJHE>fT}gyL0}{t=J^qgN@eEN(0{lopOqfhH;AoLzpBt`rVx?ml^@n8MgTD@M@ z*~WzWWa#%27bKSkEIYR=*7oVTpTkm(QA^6gA7a}h=;qBzh%Zg1rL-nn!=GW29tQq?A95;iC^WrVtNa!? zMtC1|*e;m+Rh4z|NP48_`+G12%6fX-l@``|uEkEB6-p)cJ-VbhH6r8*Yx7{;%AdP5 z3wq13_c~4dPXW^9gybRyD#Lpo4>kAu5;g@o-bkXGdDuS_v7Cv~IZrpQa(|B1U+psm z1KyrdG)ZT8le6`@(lf6d869&g*70s~WXPFPtrSW&WuBqO36a69g}}DK?#%g@O$C?x zPX4Cf*E#xts_q3VgaKB3{4&M)C(>U(!=Zf07e13m?4RvhY>d~*kAq@ zui+rdDgP&vo|YB0Ij0N1k7HO8?a#lEkIbJ7gE3<*D~jbk*PcHA*^B(Qp2RTzdD|Aa z-xBb_(S1MjTC@HZ&GO>X;d4=M;C=joAc@V{-hotD{4g+2!Yb2E)~YmC!noPQp~HtP z!$bjM=`Ceok)^{wN04@gUg6sE`I-CvmAPdn2mK+weH9L3ai7s8i58`!qp6fI$S@h zoKt{>cQlyeAGvr&?=?Tdu=x=TIz$>}THJ{`ivpUbi*XR@gcqjsvJg;I^8 zwCLeqSD)?ZkD$Y)FGbiuN*@jAtWg1@@B0LpXRQOF^+b(E+L^r+>tjzvQxe)Re%xg2 zfHl>k&RknWrxKqeshwM`z@K{AM{Cj6d5`if2w;JBso+y-DC>u%SJrESyIu^A&IaF$ z5&k&%9|HYe)H{w8^*(Qxgq-dm9}W1P7)V}@Z;q6dA|mXfwKM-~myQ14TWPhvCY@C{ zehf{0G#Y)h0Xok;{hqbbU2z-`9@p=|2{dgr(~7yu8X9-VvHJk3anixwaOSzXT86`4 zS~5#>YpR&lR7Tca5xs)_ru=pskTaYbqijTkvv#&SrQEBXvHlHg%b-2Wlr7{ zVI^hA9aaW5fh2UuZ6sNdjPX+;?w4}h@25uM7Wvd{Q47`%jw#VPeZS}`gjrjxBcWKg zku){yyC8WzrZ2#ZG*tz{UkW!XdhgsK?BT&P@MKB-H>4xT5f??I$@($(p{x{$72b0& zC43&>UX48we8d+=x zTWqRaR!j<0H5g_bbg{?gPHFw>g@cK?g;FJY^Ha!x397hm$ z-w1a-ipP99xp1xrx|w%$q$C9yNmiE+5ygYuMvf{lCxNCxr2c>wicb?(8g zFY)Qhxa7~_E`^^3xn6I<-?Tv7J|(&fi4r9TOMxA4={q~*=EfuRG%HR>emY^8q81B) z+?c;hc%0b3MG)>JEK5>~`0KGm`A5PN=>N8ERYO0*Pj1qU!d#e{3|0Zz&jl)2GXBb>Y$%HtG0O#(FM#!`NF$hZ++Q_Mu4+aeYHHBL(pbkHFf^e znco-v^!qp3gd%}9;u=(uifAg05^8cJt;MK7)eG)60&Y9M;{CIdCnt|uK>@fRCnW0v zW29KNkHGE3>c?`+M?lBN_J+r2gz)}Po?~nQ!>54aH=4lMWn5q+UGt$gxy=E=Em#yi zs5lo|0CNM{d$Nc^HBDUSfxmWf%I@w&8XF8aa@q~@{ zFH25U<0TaH?&wu%{ zp<2~Uj0?7x#0+9Rbm;SBz{Fhu+_5XjpOWm*86rw8ddaC=~S75Sn}Bl2GSKmk~K9)A;+H2vuWU3Gvoln#CS`0{Cr%=lN1LC!Ng#m$=~MA!AO32Rva@*VF7>)Hj0gT#=vxSJncz& zDRh5vbN*75Q*5V?{|9_c5F}`pGg(i4^t}v7Bz3>unGsznDGn{MY48l z!TCrw`a+G$B%uZQRk3`=H}f?#S5i!66(0hPLvGT0g z)BhTp0P(L7*jye&^`VlEkHLh`{e<^@&%BFAkk&`8BfRiy%}4o{!6kTy+qH_Gz2;Sw zTcb)r2XfJ+*+g_9p1_=pj!dcZ8x&sIh|vNAcCek6>|gkoSACtTXFF5^OSe$p6ALe; zW}bFSqc`t7$GlIxJcmpF_cJ?hr$Mm|=^A3Qn@*SPQ|Za$?jj}z2*c(b3ir? z+;34Jf`$Z>rB$J$gc;i#^fac(n&VWBG%hk3p5eX@v1i8HDI^A#J<9|H+zeT+J|6Dp z)1^MSG~nnwp}1&-I9(;}F{gFsmcOcT_-bPyBAJGSN($G(jANAc9xttr{1n=)%}i{$ zc(71#o-(c)`aopxy-Y4inp6nVQM(&x3N+3vH|}>zdCzxs&8n^Vf$kS?3`6Z=3Dn56 z%cv$^y^mX&Q0ne)#^QmM?&ixVIbZG*tHBoi@%u*q{rT_hSu7Tqr{!EPbzQ{_IK3G) zjMsExdHQGoSz8w+w4Vo9rPmasj>2QWb$dY>s7Uzb*6?L1&3^q!oR~1r;UC{B^On1d zh3jRJR*NHn=GrUD(i!Y&#pe1IKcG(i|L->UmHLote74qoP{Q&QU-Oi6`3}r`^Ss@9 zS?dv4f0$dunwvMlUyJY$~u?GnZq1d0%!9@@GI z_{Pm<$#8EDyKcTd!QjeeWw{WG#c0Kl_;gtFyx~EO@{rWhRiuh}hET2P@qRpjqLiM< z3dp9P8TWOEhQUH_*?A>REQ#W=B}d2Eg&g=}%nSDYirNL+y1pi8YWTUzStEG#%w9H5 z9tDz)4LK7&nb6o}kNNy-mk$D?26HoQF{@NC3%@pLP<02? z80FcyuIsk@wc`^D2KdHnbM5KIeHl@L?0&?7TZ5LOCHkgWrpse^I;KNscn~oDO`Ws{JklnVL(ww zc+l4kC6+O8w`e#*Y z(?{wNy%A`^+0!!0H5Ul0@Y&P{+1<}%#PCNc8PA+mj_w%i#saiw)%5A0kv)I)u?ADcUvxgUJ6Z*Ht@{1B(1xyR|Z^Y6c$fhzvGc-aX4`(lQbdhZu% zIxk8BALM%Ow1ixa#Fc!GL0rCv9C?;n)do%d?I%@QW!!cET6o1Pb*|?qHM?=WZ^c1U zV}qPBa=}2cV?sOTQtTA875J9HV{8&no#3>EqZg1?Kwa0(fsyg0?^*W6)2Rh3UjNIy z;(w@Mx#?_dHo1ThaN{SX7nFcJrvx0gw(Tp$E7T!u>0OxaH-|Ub4umZEF{IV+08>m- zwasstwW;;k;! z)Iue}E`>G7n7T-W*+1T3rj+8!Q||96U=j-Tt58Up zijrx&@4j_>KYp0oK@o*7w?NaOy#nxQ>6~$-TOie!59$rwXTP@|@8W;(f$OCf>N26= zD@m$Ndwufv%qzE*u9m*6$H-{S&a`UHHbR~IJe;*cL~CNNd%0oe&Yh|yMFB#H9DZ52 zog@##b(0=qHZjzJNqVe~6n(N!-}2t(-1t^W(lHXBzG#&=gIV(u2c@R;tdiw^a?j8MLs-tU*tXWcKg{WTXv?-4;U>Zh-=+NZl zpm;2RoG5rK#e{sxp9<&G#)Vw8DLjS*pxpWZhl&m|&VQU11w}P}37dH|ehPG#lIbb0H(_KPbLa&QJ}qvJyg4duTbsq-6rka zcLnV^M}r;=W;a3s%JT#%CWTaJluQtDBwve>GzNN5Zhh62Wy^%K>dNfg1vX|tEkE>P zM0DkFU~krtAn=p!iU(O7YL4$7fL5H43cJRki7DyWa{Z~8^)dK_Jdqp zi3o)&?h=-D6k)`L-iDs-m1>bj#s~@x=cbUxKy@$ig#?Fn@$%>s*XPC2w8&H~?@&Jl z*L@VTWq3OkSL;vf)J|mQ3K1HL7ZCwXR$ekm<%UZ#$&D&TJujO8ea2uoHlyRMRvp@M z4RkvM@#18aFfHf@RpZ)<5T|2?bG4MAo#925D#j2MpXbgqeuwwn>lwt|%QCd)zeFi% zDJB1mB3t&K(;A1xKGW#jZR~n3Z1P#vsjkV>kH7`EA7whv(c_#yuGuu~?U8#lLy=rj zI2sQgeB0vP$WHKNsjw}`+N3gg7vF$6iY2hWD!OTn=5m-@x>_sG+5IllIiY=>hqD^4 z{j9H{*=GFYvmel?W_`YW+&^k=FjP#%#Bh{391{DsTo<_T!wPiO2CM@iIPULVkyGWL*lx-=quzx}R3mAQ%GOniBW2XPc?j$ zh4*9;$=UiNLaWPr0#y!q0`$WKcTs~e^bm%CqDwL1S4=DrP)=Z@!?9bRDAjt>EA==} zwPh{i1Drc=T(fD?i@%74zpT$6OSWpzT-h~LgWKU1T3ztrAj@?t~Tg!=0yr)b{+(vk1PB#WFu`1^Y8jE-@~~ zykF~h16J>^0r`cMXLcDKwz+kICf5xG24kFE$eF^B@vLD%(C}twEQX_Yn?sJ)oV;A= z-jACS;b>Z&?OszGXha>O&WCp+u7Llp&CLIysJ5J{j$`2?z@0^w!U`a}9?`Iofe6LJ(E@(SN=vC+307q??qa|@LJrr$V7=|&27WTk zsIa!Yy~WU*{;wG?-l*uc0^20jW)m-E0*s^69JhkoP(GOrJYmJ2brSi`I9xl zWMRwpl!+QW=?AAZ(Ag~}j9g*S;m(08KxJiQdnX;wto+(s&wTpK3eJR-d=KkQJydU; z7Z##_S_sV;+4sxgv$Q0>pW39SRq?|rR|T?Blgr>_c8Bj>+BOQ3DX2!{wjj z4C?#rOAfCnBLYTk&i7Ogm2{o=W3BQf9WG7J9B;G2GgtkRe+bD-z6xArtHmkkW%PcT z*!uY9fdf)r54_%l2Or)Zf@+-wb=ti)^OU1dA3byk%gT zSFlD>XeXpIC*RH4@&8iEi_6gRysJRevGEpN*#0i+`frCsm)G;wj6ds z2sLRTuWi|6mp7EXdMKGSO3NlF(_<8H8=8Tb7&4w@$K!$Co?;QMKB3 z&8+=GX(G@4H%aE(S2)~7AWCMY1EtjGuNLOrHhStc7C9&%HOX?`&eA@;)0dX2K-^(L zDc#GGZ=^{y zGRpeQ#kQiF-iE60qXe6dCsTyPRhP^J@W1|J?Eqh?|CTPIP zZxb6ECJ#vBEv^+#&K3ENJD*j4G@qG-pA0)n43_~84KWWLf>7b;Q<6Ber#B(Pl@i3^ zV99{H)!D@0WM*3-6dPPm(fkOMD4uJE$O+@ZELP`<1civ0FpMbhYGSJZD`xYJi}Qsb0w%mwPO#K)1m5Qr3{zJiKS}aTomJ+QFc;JuK<4>rRJIQh=<=!3lRIkUvYx_r5*9jfVM26QIy9}P8VK!oBz)tzNMo{&dHE91f z*M&G6)ZMn}@nEH-=Gp(B_F1TZ6#r8|9i*>kiPg>TLppU?K2Ix7L0=qPOg-NG--NYK zGghue_`w5RqXHwP!x95m2L)XchN#kY%V`Ya?hdvK6X=GB*-ANIL`!3I3&YjKHWFH8 z2`{$Gt^$?PAe0BGVO*QV@h4^@zd^|?sFHpSJE|{17?%x` zENXXUX~cWb5AZd$rthLpZ0iU*!ldMV#U5p{3Iz#~{~n3sjNMALGND$v7rd4~|1}r} zndOHkCvAzMpLlE<7nV9a8Tq0PGbl{VWGXj^ z+!%tx&J11$``?wOW+pg8GlR@uf9*bD^d2?YydTxLO+7N)!ZRV-{xlrV6!4Z@+aLmg z^h6r8DUsx{PaFh9o0~Zbh0Ylt{KQTzpVn#d{g~QR=>f%pyBNdyzi39@*4ur4@urhH&H08RfL!-?O2D_Zg zcy%WD$K=nJ^6Ig?$+&!e+-XtKN>J}wJlWWI*>p?^L%Onf_ZZQ+LlNR}SNjl*_YL2i zh=ZpOk;1z*AQ^~9*fVJlJxBZ(PnOv0yVJt^;JMBze!@f}HoEmfwYxyqR~){Yl>O!1 zoBB2CFX?qO)!dHn4%thUjJBB=;sXrk&<1TqUAP+kHyaMts*f>nDIvNG*68H6XGVtq zTMJ=GPAp1RV9vtKSJN;BH(mW( zT->DO>v+)qyuoM5yMQJ^P%{1dqQr%npdB1X3?-GY)wen{lzMFby$o?Z0nrvk zd)79i!(lgXvg{EM{$5ur8hphUAj(BJtBl^_9evvrX;E0~Fwp6?d; zTJ7`7v*mWNg)2h%->z6NH2L`P&+f8m8L%6@k7rM&I**MwI?j^(V_{jSZ09b%%=xhP_g1 zq?mz$=?6)}lXNu30vK#gLGM9qrlS6c;kN-dcK&7B{>WEFARfs@l!yqbVS^pJN$4m( zB;3U5qR#;!y6Df{N_cz7__*Ju&7xR~`qSkREoB*$K4Yo`6F|g!s*&~nzP_ndPk?b{ z{Mx1*MBar>wz#+KI7ikLoI5PF$ZI~Jt}Zl#o0r{JBA|f3##D!(Jo<6mVv1TJJF7Vr z9Ws{3@;mcSOiUyM>Q_SJMpA|>b!C#~*0-T6@2ejEx=gOh<^PR}XCL-pw6W))*Q8Z# z<#uqt&AJr1nUuABBp-e_?jFpW zS)h(*ul%`~SK^FIV4L)lO&5G595z)n;_)`b$&VNQI6zG7-@Xv#vp_4dq zjO20)@T~1InesoZezW!7wNySxMHBy!h7oB`lK=8^M*du4{wz8_m-s+PZt9GouD6#M zUMD7GLlO;|Hw5o3yRx`<>FHv;g$8A?Bla7G!*VMkk>d+iynbgNHVLn~=ZD6o^Fj3* z%CF3SXcp{#K;_`834k|F<7oi2258lTq3fnJ+xc=*=DSB%`DN5yUZAbG6v(R64~Xln zTdlQ{+C4L1a*Om0m?$5glV>P&LjY>ZESgIxm`s7K9?{cxtx^8Qnlx>y&-hn}K`{j% zdhQf--B995Un?z1cx8A^xm>$UArrMix{qm+w-#&W9N<#QM9~mxWHB{TmhFrWyNQ^Z z9r(pFD2+WtF_u*_-W$UHqIf{?;D#4Z>P@&#yM{TFn7=qQh-?cxEb*Xr7lxFIA#)-; zQ$U$?8w%leNDRhTdm!GQS0}5P~VV&FO7td~((Xk3!>5 zVP>WvvRyp)vc4E$uSuD_+)@n>Fm9XIQGwM;T`axfNYHKI}l~0jHJoG%b;H zR(l|@Y$9$1?-%?9h^iGAqs?0Up?CWW>kh?52bAm5akfuog*^DL4F?P~ z-$`+bqj2hkKTV}(5Z$j|8n7&qJfNr-%JCnESa*?iA-2z;=uzrjgs zX0cc|yf#Dfj`eTMXnBzCCN`%ovpr1nXt!X;kB+Ec@le0~*6Z|P6@?8V5Vo(sX2ZHk zPuyfSCG`6Pff4aRZucBmknwc}aZC?5U&d1%Q8`gUml9Y1l0gsS@y&D-lN3V>82u6= zg(9~NtpUjY#58(!WpX?ZoXOkF(#zh-jHq*Ewb91!C%34k-UXSOJFBF0CT z4^5CDs9{Cocz$#nPwi2-RtTConZ(CS*1(X#fDuGOE9Kk7?b$YV%KnWE@qF??^_eXa>-krA``?uvzQI~hYTzmJSPQ?EXS!VKC{ej&~M zE>79OYA_~|FRGK&ZhGPQT>WbXfkXtt|4l7Uwa3<|Xehu6aeQ|-hwMODnn*sxXaJS! zQ8M^rdO~uZ5FTltk>GR+E!M9_P3{YTjR~T`>Ar)ME~&UFsHqyG5<{+SP8*ZPCk}r4 zksVBTF1n>6DKZjw@(j`PV(W!J$=fm3YP4fwCgWpRmUP~lHFK-e16BEzWWR)0D0sc=yyqWY_UZKJ6$G(8vNG;M1^I)q5iKg zH{?EJKf}@CIvIp`9$5B&fqMYV${0)jn5d8|=*M3SuSRx%`zt0`=LJ%Sv>-z{{hfr~ zwn&D`REvkO|MrIUl1G?vIBdxnmKRE>vk+A=Cijef?Wme?ea^Y_%wA?2s zutX}P44qg0#>Klf-jnz-?O81zutDsO66>-`G3lPS*hl#|l|p}|&Ikfkw_n~qt@Qub z6PC7UvkV?hq}n7yWzhh5o@_6;wBH{lAM9PfHQUW8^~-7nw+}=v25>0oc7%^qe<^Xm zV-~APLb$rc;yCi73oT&yZ4bo+n8C*SG^5IpgsPy`*G~}ibE#NFcHkJ2MGgVT=3fBr zQM{Bx99{{-SH17bCn=9$M(2gx+tScVjMo>Yv-0_ykltz`%*61HPk;@<+Th{Ebcx1 zP|kL5cqaSyqA?+N`uA3DH&32ZwDwzJrAGd1Xzf{pp0`D%)+x)(Hcsrebg2{yT8SD`sR(YU>&XYk)c; z=PPZ{x`k43?Cx+FzHK-5ZB(gQEG+sLiTDfEJ*XeFHT^9m=Xk+6CH&-NexRa=&u7x0 zB2M@6lrK}sVqFS~>`J)t>tZTS`VC}|q201u;D_KZ&V#W$3>oMsf4IMHALxwv3NIEmXs3WcI+{0JiEI*f=(3VMG8GT#-C zw1_ET>FUfNfr~E!1O3z15HD5Aj1j(QXTk+b0X1J8KQr)N$8g$sMC#De!CQY1DnM|8 zjX4GP$oG3UVpU4JcE9(ZT7r93-E0IXzCRlu&haPl_R)C0MNi~qmvFjltoIGfJ}`wH zKvcDsc=BRKFF6`E*9u-t+AuAPfLN2MtfM8{h&0>@tYD>k{48bEO|Lp&El0=6=*JwT z8hBv=SR=d@{-Lv46$6Uhjwkw$y%wG%6KnU|iEK=nzIOidzn((q@&BTX zqDnBzu%{CUd?i^2*|(ax9TCZN9nb>v>vYPZ6u(DKb0Y)@poK;CuCh~GHQTQHwMeJ9>>k#L)=A*by2x2*bI$|_83@MprqC{Ea=8_kG0D;`Ug0wq{Rmzo?$l7DK`H0_4y zccCAMfzQRiz3I;A1uFD{8?N=U8Vz&T-k8X!d-`86t~X4j7&>3zj4@IQ&Wxv!u_$6) z+gfW%;@uhA!`d=Z-UT*3h;BOleco&WBT$_++z96Xw0{mb0d8{!?yEtlB@+{EHf`{D ztD7Op5ksyg&{`!MdM+E&#&$=m7jQD}8Xm+=| zIq2)*u}FS&D5H_8AA8;6is&Luc}B}l2y$1-*Pvx;ygi4rZ}*}X+nF+jp)VLbg`b87 zqf)uCGe42xlDXQ?`7A8ONKTyyKK=MXl1O8|uh%zL6%pUHi6A!VfoI}M&&8HgKxRgQ zY|F2xtj9L_7nhr2U`|5BegdhjNq-?>*1Bj&PZLf^+zL6s>CNqOUF{4=JQorpmZ|pF zYT47=yU)jctHH+SsV!RD$N=>I{}MG03vi+qZ^0#U%&0>Qa6h?!-ddd-4XQty^qi!Z zh<>Zeo43TyNIe<+(=zRzW~TOJ21qX9hKeungb|2|g1ck*yQ}LA?SJtz7@O*|!kt{h z$j?BQm|7^@(SzW$bO@c4WEs}k+&i(~#F#_(5URX{3&ms#%D%-7ObbAI>*qlO^nQ#K zGyLgjdUVS~xb|tixf$98;R82VC~Pc2lXtxF_#oy>!e}CQyeDwG>&HdrS+%i=+FV4!xyT+|Ou4+2k^;Ct zizxyE5~)dF|H2j!rPx!?ab2WNo=*b0Ecl|&02K`(%w%EXx$p03EOTD2Pp_A$Bbw_^ zPC6onq6%VjedNzftUZ$9mbn4UDt3W*eVF@fHt;)?FZ4Kg2j}xRA?fF{QQf$T6$* zHOF;YL_q6A_qWTPlje^Qo zQ7V%6+tUykAB#Q1ZU^vp&w;<28*lsyhjCKcdSJuklAHPC^2E|n09vrzx^}*lssrv^ z*+{93Z-p-i;L56XL|>OGacd!{gC+9BUYcEQlNigo@dR94J; zBc*H~R5eO604I29q5_;0(r}8hF>qgH!F)FCqLFG>&FWvcH!9bK6xGj(sZVlc?drH%4zaK&=(JFmL{&43=ac7XFH2trFmZhxD;)XUwN2Roh?}utAeu|R)@W~( zFrLj8u9V^^S9_ms6C~kabALl`x|JgW{lZX7YAX1?KN6@o%Uin0QqW!{T>WM!oMPEp zzt*AiD=6V-F=;g0S)gbLvz0+DdMi}@Einclu^{G2D`1zjj0VTf|2f_KNmz|9((%m* zjrC&9?f&ezru4^6g4b?7arvUcR9p5FF|C*23?pp7z|UsOzvVw)-=+yZ4qhGwslpm- zO%ZRTe$f09&AQDB@njHFk;%%a{&NkiagJODwlcPDjTNZmPid6d&7)_(abdG=Q|XM1 znk$2dT*`Y|F;(&}am?7Q_Eu}({!Dq^&DvymJMV?}fHKIdARw&C5a?uM8#;)pc%Lq4 zKR5xJQqk*8SGkY3m%W@y_W6`8*=eHRXn$bHCW?>^+hi_!%66cMRiPL)6l+(Cs047A z&YlurqEgEC3jt08h3Che&n{VY;^FkoHT!>CxGTC2i%$p%N_|V28ms#;G4-YWs3uaG zgWSKwtWtKR3M;=$^$cwur%vIJ($EwYbJ@t#0u6W$oju>xZE zZ(HZ;G}=7;@|^pPy%e+2ya|(iYzEc&iMh=c`7}U6TTD^(PDyE(-vN?#N&-YAC8DP& zBebDh7?*kMiK)Yl4FEFAXoEVgd`!+1|1)k00bM^`(CH=P9;*p^c6+mkBaOtXLvOq zS1-&44Jn`3pYO{&woG{!4FX!z0>#M9o~$x9<~m&;OgZ0IsI=c69FEc)Anm}WJQfPM z9WpaB?+2~-@k$K1IeH4dVXV|S1p&9D+}0z*)23Tn)EV~G(IJXn%`(fua$ z3vo}Y9@fV_wdPiM;jTo3yMUEoEMmg~^pg@Qiu|sf8Xb+ql66sFz|ykMO&&H1K6G`{RO8qHn^ zEosl~7wc?3Z!$Ckae`D(LCy)zqYGPS;du(ZtDe^kF&)t)_Dlsv0 z`)grMc}IWHVL3(`!FmO*Qcj6028)+3uLkKb+r>%v#8S}?G~<-uUGy@zD@BtWr@~jr z*glyCfWl;oO=`RF?^#YRyNoudS5FdIzuDcugFJ~4#)yZ5wj$_6Q3zRP9lEj$K| zdo?N_Pu$o|y=7Ytn^ZS7*Ydef=`u~J{bf0lr?Zd@GQ81btOdGURZASeM;v-cq&Z+Y zjwg#Kl?){g>d`svny*V$vFj)_Y?*O6Sc;lTbsCl*_v z!#04ki>jxNSLdsQ&*GsqZ6ba)=eH4;VPymIZ~aQQ6=fywE`15V$#7|8ChuROC!*Mt zbCqUD=PLI{r5GU{9DmB+_u4S|S*R9MH7EZpUunn>VO$QRS#`k8o7vmEa)0nnU6zAx zt!B$d%vj7P^iN$sSY^b*O#Xxvu<^q@Sfv>VYt(|TNX(po;yKWaMBUWKkkRpN^p%S}8_+7+9f2X^TC2+`K+$%)!=7JcW{ zx-puM<8k~kaE>Ap`b~_N@pMQ{^0}%sccyOB$$(C?eiq!N{g?3Wgs;k8Ra~w{(NZ^M zJLSer2`ezt9fk~0O4~+f{)#&4|jC4s7s%?1+BU!je&@0-+k`t z25SI@Nu`KHAUdyk?sFQ$Gn?dA%((C7k>&v4#Os(kqOV_kYfFc0bV)qzRM#uC@q@J% z%TgbkPn!(If0~3}ec7Buy6kF!EB7S-98X;yfkKI|mEodaNozrmD|)PVjK|K?=IyDt zsD2CBtKxlqLYu74T))=@1<^bq06w&EsrsskN$<1!F%lVxHjCHjD~}rUIs3KJRJFnt zB)MX?kphuhB%Vxk*tKD$5kbN5r;}9EQEy?@UQwoUlaZxeMHhi9|!y1siO9mmsO ze5LE)p9s$}sjTJ=pls0??f6r_+rZ_l#1*k|-Y{CIEAB=bvAcd~odJ5gx5)PN-s97G zn|(QA=jfAW+6di3gxF?>2A>c?u-Rge1CLCG)`q8HJK<)X>r*GD8C9{d$)eSK-pcqv z*(8;aa-(HD#V6q3&t^Cno!E4GjL2+BpwlVI+d77Dj$;H2+O&~G8Z-_f=|{x`H^~wt zW=t&>lv&SG!;Y}qYScRU^*iH}g?uG+^OTYg+#ED)m51=8}^y){LzWs=fd>M5e<=A8i@^nZLW3o_$K?yk*JIoaC#}b z<@U{~3%dda62e-Dsip5DQIFnf%PSmh>!UTm$KHkSNmp|NQJqZzYXkAIC!>`{q1vsn zGzs3;+$A?S^0BH&mt-AgVv?qnsp5Q8w(8sv@!J{KpQAFG1a@hc(E|U2V`I;5^TuX>s5p^ z`$v9BsT2}(*wD(hWh8jgCI$?&8yVN3xqTKjvs?E%@40^oA6p>zaX5tc!S4KQ45 zU26f|{@E~HZF4iT_mTWfZ+=Ues-R(0R^sYvBMk0LH+=&b!%#nW2(N+lEq`I$ zv}u9x)2DhhRCvm{8ruT*8^!ha>lKNedj%aGFFMgcRb9J{ezp8t*g?J6i?FMs;5djb}50d+32slbct%8eWshi9hI4@ zFk!(N*bgc%%z0}0VLn+V1}Mn^rMFZT7Q7eeimdH_>NG9G=ATeb%NT*&)f^`{8vJlR z#!6y+V@<6j9&J*^b;4Z7YjN;3BAJ~7XL)2mmXqbA8v0U{4Gu%=s`TE>k|`fEgC~{F zw}nb79ZrV_eyClrDMUemO+lw*c6k%E?P$`*$J)y2Zl*>aTZDFQi7{a&u}%ZK?L>-W zYE~CU>&8r5`8%=HHYO!w%q1fz*nlTqp1a{RQ<^3XJSD2`6(QXsuY+7EmqU7ggM4Wv zK7)MHnswKpb#sz}_+8=|x%8I82}hH~uAH})&+WyL1*N$6`^#OKC#;#^l&5Yh5Q)9# zlwYOT9K_|;We!ZAt*uEjaPDXTCFLCOP`f>90J zk^!=QP}c;qu{VUG@rvuzR9Fz=2@4CpCv7XFAp{h z_BwJ^x@qJlu&Rj&bdl(O3mUL+$bYkMQb@p%OHI5`9gq(%i`hzw{F6+EnJwMym?z%% zs|}6FtSjfaOdU(_v+39PJP`g2jnf4U$@fH8nVC4Nf9gN z3taV=%tOjnb8Uz58+e13Jvm+iW)WV!9!7tzRy1{`EOaCWoq2fL%&g4_i$wKTmia;9 zRGlr4$tF`t3TJ0$IPV_9WIPbvM5s_m)|3I$+^)9wj~sk8w|?aCNj^?3=Uo{LnjU@1 z_4y*tM11{c3BT9MhG%~0fJqS^)rm0}5~VukhvvN`FLJX3p2Y4NTg4PCM&RVmY)*HM z8DAH&VSCmJbsLYXE-~=2q~*>m=%jzfln-#LO-PTkblh&_wO7BoVXgs;1gnV-xRfe^ z9pY<-Qc)@V06rIIZjb$Q^|y77F7KOtpnJ3H-V;0o#HA&=sx2*UV1K9APE}%J3;gCy zcP!e~RQMHr`i1G)soF|KBi#lf^P?P+^NxufGFk6EclENlZK#{9j7Pae3%mKt zr0g`|8zQs#aPi09%Z>9$O1%z?d0z5Crh7!-pK3tBru!#h)e#M2w<2#QB#8y{%-KrT ze0*SANM^-&$2wstgXh)krH&5>35VZ)<0-)DD?~RfGWgWRtD=cRhoU04JomT#T!;XF z@5kos+)Y{+lWs)JS0^$9>h#NLHhN0`n*UX7eTX}5#nf*jPSbWv%y}t{Q!ffTG;2`i z#gz0mCw>%Pemj z7X}9Fh&hw&S8x(DMPKCwr1j9teYb#z@KwP@pljA@24wOQt@86R`^b2`J&2-x>~4fF zRMYRfb5#x1xH)ODDW51iIj1a@7OHU!l%2|%t`TL@l)81esztfrkFu57V|)v~-<7UD zPF0>^L?=iMEd=uwm!2!HYo2ZBmCqn1>cEFpimPKA7~n+JRgZ5ho8JrzQp2ydD@bK~ z0AG>;u67Pb<&0UI+;d%xzC&P-iiHMNIS%UzKfJ4K$*3(anfJ;35S&eqtJuVlv!XZ2 zb<~H;RqJDKV&gW}sfjX0bTy_D7sAu%-Mu;qA=U{E?L_x=QEx8l__CsfANlxm2KU!9 zx(ytyhL+=+jK;6Zy!!00>Dgz_o?~;eNktQbu8;4mIPB8et<#{3)yG7iHd|85KoXEc za19iME0%A!|5_#i>{4gq!HI?nDr3j%^yuy@>N!bQ>1GugLz3Nw<#`~TKkXEo!DyMI z=Ln9Kcp=s}eziuh=#;vE)%U9wOtp=VEhgmkX2wu;43=15(GpwH(05u?DChzB_wvLMhd*|yh4&mIxWn0 zBFDn#LRy_RXG;`ci#N{8HmsFQ8hEVe>=SYMwiOKU9LtN{B^G_j`u78j226>u zMMwUo4TveFacAhPnK5XqZ@ItiCSD9RRuxriX=-=?ARr(#;h~T&4MAgp#{vkiE{A=+ zZ*TXYgM$-L;mC7t%gfB=TZ#8;uc3W2wnmaR#i0DatXYvb%9+$!f93||Bt@^HJLX-* z8pYzp;o_qKLH*FOd*(^Au#S}Nb`tb|_it2_?}FItfz1OyXUvMQhX#gB(yMZkq@ z;84mthYqwHEch*|&7b){ga7|8KlK00L;s&Xvu%8now}lN%HwAApSZ+?rGHfk>iYi= DTdv%V literal 0 HcmV?d00001 diff --git a/mateclaw-desktop/build/icon.ico b/mateclaw-desktop/build/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..801622c010b729f603cefbd04dec519c6dd70404 GIT binary patch literal 285478 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�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%0DKnJ>+G8(?oE@ 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

GSJ34tY$XRB(gC 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


qVv}G=)Zq##EjkV-wdglGW=iHM5+B>u8(Hh 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 DTdv%V literal 0 HcmV?d00001 diff --git a/mateclaw-desktop/build/icon_256.png b/mateclaw-desktop/build/icon_256.png new file mode 100644 index 0000000000000000000000000000000000000000..269aaf6261a648a6047fe2f811725577283e7349 GIT binary patch literal 53511 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

;o$n^MM>U-TbWP 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

cy;-4$&Iiar2D`2HO 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 literal 0 HcmV?d00001 diff --git a/mateclaw-desktop/electron-builder.cjs b/mateclaw-desktop/electron-builder.cjs new file mode 100644 index 00000000..cbc2aba6 --- /dev/null +++ b/mateclaw-desktop/electron-builder.cjs @@ -0,0 +1,139 @@ +/** + * electron-builder.cjs — Dynamic build configuration. + * + * Two packaging modes are controlled by the BUILD_MODE environment variable: + * + * BUILD_MODE=local (default) Full build: bundles the embedded JRE and + * Spring Boot JAR so the desktop app can run a + * local backend. Original behavior. + * + * BUILD_MODE=remote Lightweight build: omits the JRE/JAR + * resources (~530 MB smaller on macOS). The app + * can only connect to a remote server — the + * "local" connection option is hidden in the + * splash UI. + * + * Branding is controlled by branding.config.json or BRAND_* env vars. + * See scripts/branding.cjs for details. + * + * Usage: + * BUILD_MODE=remote npx electron-builder --mac + * npm run package:mac:remote + * BRAND_NAME=MyAI npm run package:mac:remote + */ +'use strict' + +const { loadBrandConfig } = require('./scripts/branding.cjs') + +const mode = process.env.BUILD_MODE === 'remote' ? 'remote' : 'local' +const brand = loadBrandConfig(__dirname) + +// Derive a short slug from the brand name for artifact file names. +// "MyAI" → "MyAI", "Cool App" → "Cool_App" +const brandSlug = brand.name.replace(/\s+/g, '_') + +// Parse GitHub URL for publish config (owner/repo) +let githubOwner = 'matevip' +let githubRepo = 'mateclaw' +const ghMatch = brand.githubUrl.match(/github\.com\/([^/]+)\/([^/]+)/) +if (ghMatch) { + githubOwner = ghMatch[1] + githubRepo = ghMatch[2] +} + +/** @type {import('electron-builder').Configuration} */ +const config = { + appId: brand.appId, + productName: brand.name, + copyright: brand.copyright, + directories: { output: 'release' }, + publish: [ + { + provider: 'github', + owner: githubOwner, + repo: githubRepo, + }, + ], + files: ['dist-electron', 'dist'], + afterPack: 'scripts/trim-playwright-driver.cjs', + + // extraResources: only bundle JRE + JAR in local mode. + // In remote mode this array is empty — the packaged app contains only the + // Electron + Vue shell, cutting ~530 MB from the installer. + extraResources: + mode === 'local' + ? [ + { + from: 'resources/jre/${os}-${arch}/', + to: 'jre/', + filter: ['**/*'], + }, + { + from: 'resources/app.jar', + to: 'app.jar', + }, + ] + : [], + + mac: { + category: 'public.app-category.productivity', + target: [ + { target: 'dmg', arch: ['arm64', 'x64'] }, + { target: 'zip', arch: ['arm64', 'x64'] }, + ], + icon: 'build/icon.icns', + hardenedRuntime: true, + gatekeeperAssess: false, + entitlements: 'build/entitlements.mac.plist', + entitlementsInherit: 'build/entitlements.mac.inherit.plist', + // Differentiate installers so users can tell local vs remote builds apart. + artifactName: + mode === 'remote' + ? `${brandSlug}_Remote_${'$'}{version}_${'$'}{arch}.${'$'}{ext}` + : `${brandSlug}_${'$'}{version}_${'$'}{arch}.${'$'}{ext}`, + }, + + dmg: { + contents: [ + { x: 130, y: 220 }, + { x: 410, y: 220, type: 'link', path: '/Applications' }, + ], + title: `${brand.name} ${'$'}{version}`, + }, + + win: { + target: [ + { target: 'nsis', arch: 'x64' }, + { target: 'nsis', arch: 'arm64' }, + ], + icon: 'build/icon.ico', + artifactName: + mode === 'remote' + ? `${brandSlug}_Remote_${'$'}{version}_${'$'}{arch}_Setup.${'$'}{ext}` + : `${brandSlug}_${'$'}{version}_${'$'}{arch}_Setup.${'$'}{ext}`, + }, + + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + deleteAppDataOnUninstall: false, + installerIcon: 'build/icon.ico', + uninstallerIcon: 'build/icon.ico', + installerHeaderIcon: 'build/icon.ico', + createDesktopShortcut: true, + createStartMenuShortcut: true, + }, + + linux: { + target: ['AppImage'], + icon: 'build/icon.png', + category: 'Utility', + artifactName: + mode === 'remote' + ? `${brandSlug}_Remote_${'$'}{version}.${'$'}{ext}` + : `${brandSlug}_${'$'}{version}.${'$'}{ext}`, + }, +} + +module.exports = config diff --git a/mateclaw-desktop/electron/main/config.ts b/mateclaw-desktop/electron/main/config.ts new file mode 100644 index 00000000..f67a4ecf --- /dev/null +++ b/mateclaw-desktop/electron/main/config.ts @@ -0,0 +1,87 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync, readFileSync, writeFileSync } from 'fs' + +// ─── Connection configuration ──────────────────────────────────────────────── +// Persists how the desktop shell reaches its backend: either an embedded local +// JVM ("local") or a centrally deployed remote server ("remote"). Stored as a +// small JSON file in userData so no extra dependency is required. + +export type ConnectionMode = 'local' | 'remote' + +export interface RemoteServer { + url: string + name?: string + lastUsed?: number +} + +export interface ConnectionConfig { + // null = no choice made yet (first run → show the connection chooser) + mode: ConnectionMode | null + remoteUrl: string + servers: RemoteServer[] +} + +const DEFAULT_CONFIG: ConnectionConfig = { + mode: null, + remoteUrl: '', + servers: [], +} + +function getConfigPath(): string { + return join(app.getPath('userData'), 'connection.json') +} + +export function loadConfig(): ConnectionConfig { + try { + const path = getConfigPath() + if (!existsSync(path)) return { ...DEFAULT_CONFIG } + const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial + return { + ...DEFAULT_CONFIG, + ...raw, + servers: Array.isArray(raw.servers) ? raw.servers : [], + } + } catch (err) { + console.error('[MateClaw] Failed to read connection config:', err) + return { ...DEFAULT_CONFIG } + } +} + +export function saveConfig(patch: Partial): ConnectionConfig { + const merged: ConnectionConfig = { ...loadConfig(), ...patch } + try { + writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8') + } catch (err) { + console.error('[MateClaw] Failed to write connection config:', err) + } + return merged +} + +// Normalize a user-entered server URL: trim, default to https when no scheme is +// given, and strip a trailing slash. Returns null when the input cannot form a +// valid http(s) URL. +export function normalizeServerUrl(input: string): string | null { + const trimmed = (input || '').trim() + if (!trimmed) return null + + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + try { + const url = new URL(withScheme) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + // Drop a trailing slash on the path-less root so URLs compare cleanly. + return withScheme.replace(/\/+$/, '') + } catch { + return null + } +} + +// Record a successful remote connection in the most-recently-used server list, +// de-duplicating by URL and capping the history length. +export function recordServer(url: string, name?: string): ConnectionConfig { + const cfg = loadConfig() + const now = Date.now() + const without = cfg.servers.filter((s) => s.url !== url) + const servers: RemoteServer[] = [{ url, name, lastUsed: now }, ...without].slice(0, 8) + return saveConfig({ servers }) +} diff --git a/mateclaw-desktop/electron/main/index.ts b/mateclaw-desktop/electron/main/index.ts new file mode 100644 index 00000000..dc5f4f84 --- /dev/null +++ b/mateclaw-desktop/electron/main/index.ts @@ -0,0 +1,1103 @@ +import { app, BrowserWindow, shell, ipcMain, dialog, Menu, nativeImage } from 'electron' +import { join, resolve } from 'path' +import { ChildProcess, spawn } from 'child_process' +import { existsSync, mkdirSync } from 'fs' +import http from 'http' +import https from 'https' +import net from 'net' +import { autoUpdater } from 'electron-updater' +import type { UpdateInfo, ProgressInfo } from 'electron-updater' +import { + loadConfig, + saveConfig, + normalizeServerUrl, + recordServer, + type ConnectionMode, +} from './config' +import { + loadLocalToolsConfig, + saveLocalToolsConfig, + expandPath, + type LocalToolsConfig, +} from './localToolsConfig' +import { LocalBridge } from './localBridge' + +// ─── Constants ─────────────────────────────────────────────────────────────── + +let BACKEND_PORT = 0 +let BACKEND_URL = '' +const HEALTH_CHECK_INTERVAL = 1000 // ms +const HEALTH_CHECK_TIMEOUT = 120_000 // 2 minutes max wait +const WINDOW_WIDTH = 1280 +const WINDOW_HEIGHT = 860 + +// ─── State ─────────────────────────────────────────────────────────────────── + +let mainWindow: BrowserWindow | null = null +let javaProcess: ChildProcess | null = null +let isQuitting = false +let isUpdating = false +let backendReady = false + +// Local-tool tunnel: lets a remote agent operate this machine's files/shell +// through an authenticated WebSocket back to the backend. The JWT is read from +// the renderer's localStorage (where the admin SPA stores it after login). +async function readRendererToken(): Promise { + if (!mainWindow || mainWindow.isDestroyed()) return null + try { + const token = await mainWindow.webContents.executeJavaScript( + 'window.localStorage && window.localStorage.getItem("token")' + ) + return typeof token === 'string' && token.length > 0 ? token : null + } catch { + return null + } +} + +const localBridge = new LocalBridge(() => BACKEND_URL, readRendererToken) + +// Connection state: which backend the shell is talking to. +let connectionMode: ConnectionMode | null = null +// When true, the splash shows the connection chooser even if a mode was saved +// (used by the "Switch Server" menu action). +let forceChooser = false +// Hosts whose TLS certificate the user explicitly trusted this session (covers +// enterprise self-signed certificates on remote servers). +const trustedCertHosts = new Set() + +// Reachability probes (health poll / test button) must not hard-fail on an +// untrusted certificate — that only signals reachability. The real trust +// decision still happens at BrowserWindow navigation via the certificate-error +// handler, which prompts the user before loading the page. +const insecureAgent = new https.Agent({ rejectUnauthorized: false }) + +// ─── Build Mode Detection ──────────────────────────────────────────────────── + +/** + * The desktop app ships in two variants: + * + * "local" — bundles the JRE and Spring Boot JAR in extraResources so the + * app can run an embedded backend. This is the traditional full + * build. + * + * "remote" — omits the JRE/JAR (~530 MB lighter). The app can only connect + * to a remote server. The "local" option is hidden in the splash + * connection chooser. + * + * Detection is done at runtime by checking whether the JAR exists in the + * resources directory. This avoids any build-time code injection — the same + * main-process code runs in both builds; the only difference is whether the + * JAR/JRE files are present. + */ +function detectBuildMode(): 'local' | 'remote' { + const jarPath = getJarPath() + return existsSync(jarPath) ? 'local' : 'remote' +} + +const BUILD_MODE = detectBuildMode() + +interface UpdaterState { + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + version?: string + releaseNotes?: string + progress?: { percent: number; bytesPerSecond: number; transferred: number; total: number } + error?: string +} + +let updaterState: UpdaterState = { status: 'idle' } + +// ─── Platform Detection & Resource Paths ───────────────────────────────────── + +function getResourcesPath(): string { + // In production: process.resourcesPath points to /Contents/Resources (macOS) or /resources (Windows) + // In dev: use the local resources/ directory + if (app.isPackaged) { + return process.resourcesPath + } + return resolve(__dirname, '../../resources') +} + +function getJavaExecutable(): string { + const resourcesPath = getResourcesPath() + const platform = process.platform + + // In production: extraResources copies jre//* → Resources/jre/ + // In dev: jre is at resources/jre// + const jrePath = join(resourcesPath, 'jre') + + // Candidate paths for java binary (try all known layouts) + const candidates: string[] = [] + + if (platform === 'darwin') { + // Packaged: jre/Contents/Home/bin/java + candidates.push(join(jrePath, 'Contents', 'Home', 'bin', 'java')) + // Dev: jre/mac-arm64/Contents/Home/bin/java + candidates.push(join(jrePath, 'mac-arm64', 'Contents', 'Home', 'bin', 'java')) + candidates.push(join(jrePath, 'mac-x64', 'Contents', 'Home', 'bin', 'java')) + // Fallback: flat layout + candidates.push(join(jrePath, 'bin', 'java')) + } else if (platform === 'win32') { + candidates.push(join(jrePath, 'bin', 'java.exe')) + candidates.push(join(jrePath, 'win32-x64', 'bin', 'java.exe')) + } else { + candidates.push(join(jrePath, 'bin', 'java')) + candidates.push(join(jrePath, 'linux-x64', 'bin', 'java')) + candidates.push(join(jrePath, 'linux-arm64', 'bin', 'java')) + } + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + + // Return first candidate for error reporting + return candidates[0] +} + +function getJarPath(): string { + const resourcesPath = getResourcesPath() + return join(resourcesPath, 'app.jar') +} + +function getUserDataPath(): string { + const dataPath = join(app.getPath('userData'), 'data') + if (!existsSync(dataPath)) { + mkdirSync(dataPath, { recursive: true }) + } + return app.getPath('userData') +} + +// ─── Java Backend Lifecycle ────────────────────────────────────────────────── + +function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo + server.close(() => resolve(port)) + }) + server.on('error', reject) + }) +} + +async function startJavaBackend(): Promise { + // A tunnel pinned to a prior backend URL must be dropped before the embedded + // server comes up on a fresh port; it reconnects once the backend is ready. + localBridge.stop() + // Remote builds have no bundled JRE/JAR — refuse to start the local backend + // and guide the user toward the connection chooser instead of showing a + // generic "file not found" error. + if (BUILD_MODE === 'remote') { + console.log('[MateClaw] Remote build — local backend unavailable, showing connection chooser') + sendToWindow('backend:status', 'choose') + return + } + + BACKEND_PORT = await getAvailablePort() + BACKEND_URL = `http://localhost:${BACKEND_PORT}` + console.log(`[MateClaw] Using dynamic port: ${BACKEND_PORT}`) + + const javaExec = getJavaExecutable() + const jarPath = getJarPath() + const workingDir = getUserDataPath() + + console.log(`[MateClaw] Java executable: ${javaExec}`) + console.log(`[MateClaw] JAR path: ${jarPath}`) + console.log(`[MateClaw] Working directory: ${workingDir}`) + + if (!existsSync(javaExec)) { + console.error(`[MateClaw] Java executable not found: ${javaExec}`) + dialog.showErrorBox( + 'MateClaw 启动失败', + `找不到 Java 运行时环境。\n路径: ${javaExec}\n\n请重新安装 MateClaw。` + ) + app.quit() + return + } + + if (!existsSync(jarPath)) { + console.error(`[MateClaw] JAR not found: ${jarPath}`) + dialog.showErrorBox( + 'MateClaw 启动失败', + `找不到应用程序包。\n路径: ${jarPath}\n\n请重新安装 MateClaw。` + ) + app.quit() + return + } + + // Prepare environment variables — inherit current env + override + const env = { + ...process.env, + // Ensure H2 database is stored in userData + SPRING_DATASOURCE_URL: `jdbc:h2:file:${join(workingDir, 'data', 'mateclaw')};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE`, + } + + // Spawn Java process + javaProcess = spawn(javaExec, [ + '-jar', + jarPath, + `--server.port=${BACKEND_PORT}`, + '--mateclaw.setup.await-language-selection=true', + ], { + cwd: workingDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + javaProcess.stdout?.on('data', (data: Buffer) => { + const line = data.toString().trim() + if (line) console.log(`[Java] ${line}`) + }) + + javaProcess.stderr?.on('data', (data: Buffer) => { + const line = data.toString().trim() + if (line) console.error(`[Java:ERR] ${line}`) + }) + + javaProcess.on('error', (err: Error) => { + console.error('[MateClaw] Failed to start Java process:', err) + sendToWindow('backend:crashed', `Java 进程启动失败: ${err.message}`) + }) + + javaProcess.on('exit', (code: number | null, signal: string | null) => { + console.log(`[MateClaw] Java process exited: code=${code}, signal=${signal}`) + javaProcess = null + + if (!isQuitting) { + sendToWindow('backend:crashed', `Java 进程意外退出 (code: ${code})`) + } + }) + + // Start health check polling + pollBackendReady() +} + +function pollBackendReady(): void { + const startTime = Date.now() + + sendToWindow('backend:status', 'starting') + + let resolved = false + + const check = () => { + if (isQuitting || resolved) return + + const elapsed = Date.now() - startTime + // Remote connections fail fast (server should already be up); the embedded + // JVM gets the full window to boot. + const timeout = connectionMode === 'remote' ? 15_000 : HEALTH_CHECK_TIMEOUT + if (elapsed > timeout) { + console.error('[MateClaw] Backend health check timed out') + sendToWindow('backend:status', 'timeout') + if (connectionMode !== 'remote') { + dialog.showErrorBox( + 'MateClaw 启动超时', + '后端服务启动超时,请检查日志或重启应用。' + ) + } + return + } + + const isHttps = BACKEND_URL.startsWith('https:') + const client = isHttps ? https : http + const reqOpts = isHttps ? { agent: insecureAgent } : {} + const req = client.get(`${BACKEND_URL}/`, reqOpts, (res) => { + if (resolved) return + resolved = true + + // Consume response data to free up the socket + res.resume() + + backendReady = true + console.log(`[MateClaw] Backend ready (${elapsed}ms, status: ${res.statusCode})`) + sendToWindow('backend:status', 'ready') + + // Bring up the local-tool tunnel. It waits for a renderer JWT (post-login) + // and reconnects on its own, so starting it here is safe even pre-login. + if (loadLocalToolsConfig().enabled) { + localBridge.start() + } + + // Do NOT auto-navigate — let the splash screen handle it + // after language selection / setup check completes. + }) + + req.on('error', () => { + if (resolved) return + // Server not ready yet, retry + setTimeout(check, HEALTH_CHECK_INTERVAL) + }) + + req.setTimeout(3000, () => { + req.destroy() + if (resolved) return + setTimeout(check, HEALTH_CHECK_INTERVAL) + }) + } + + check() +} + +async function stopJavaBackend(): Promise { + if (!javaProcess) return + + console.log('[MateClaw] Stopping Java backend...') + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + console.log('[MateClaw] Force killing Java process') + javaProcess?.kill('SIGKILL') + resolve() + }, 10_000) // 10s grace period + + javaProcess!.on('exit', () => { + clearTimeout(timeout) + console.log('[MateClaw] Java process stopped') + resolve() + }) + + // Try graceful shutdown first + if (process.platform === 'win32') { + // On Windows, spawn taskkill for graceful stop + spawn('taskkill', ['/pid', String(javaProcess!.pid), '/t']) + } else { + javaProcess!.kill('SIGTERM') + } + }) +} + +// ─── Connection Orchestration ──────────────────────────────────────────────── + +// Decide how to reach the backend on launch based on saved configuration. +async function bootConnection(): Promise { + if (forceChooser) { + sendToWindow('backend:status', 'choose') + return + } + + const cfg = loadConfig() + + // Remote builds cannot start a local backend. If the user previously + // saved 'local' mode (e.g. they upgraded from a full build), ignore the + // stale preference and fall through to the connection chooser. + if (BUILD_MODE === 'remote' && cfg.mode === 'local') { + connectionMode = null + sendToWindow('backend:status', 'choose') + return + } + + if (cfg.mode === 'local') { + connectionMode = 'local' + await startJavaBackend() + } else if (cfg.mode === 'remote' && cfg.remoteUrl) { + startRemoteConnection(cfg.remoteUrl) + } else { + // First run: the renderer queries getConnectionConfig() and shows the chooser. + connectionMode = null + sendToWindow('backend:status', 'choose') + } +} + +// Point the shell at a remote server and start health polling against it. +function startRemoteConnection(url: string): void { + const normalized = normalizeServerUrl(url) + if (!normalized) { + sendToWindow('backend:crashed', `无效的服务器地址: ${url}`) + return + } + connectionMode = 'remote' + backendReady = false + // Drop any tunnel pinned to the previous backend; it is re-established against + // the new URL once the backend reports ready. + localBridge.stop() + BACKEND_URL = normalized + console.log(`[MateClaw] Remote mode → ${BACKEND_URL}`) + pollBackendReady() +} + +// Probe an arbitrary server root with a short timeout. Used by the connection +// chooser's "Test" button before the user commits to a server. +function probeServer( + url: string, + timeoutMs = 6000 +): Promise<{ ok: boolean; status?: number; error?: string }> { + return new Promise((resolve) => { + const normalized = normalizeServerUrl(url) + if (!normalized) { + resolve({ ok: false, error: 'invalid-url' }) + return + } + + const isHttps = normalized.startsWith('https:') + const client = isHttps ? https : http + const reqOpts = isHttps ? { agent: insecureAgent } : {} + const req = client.get(`${normalized}/`, reqOpts, (res) => { + res.resume() + const status = res.statusCode ?? 0 + // Any non-5xx response means the server is reachable and serving. + resolve({ ok: status > 0 && status < 500, status }) + }) + + req.on('error', (err) => resolve({ ok: false, error: err.message })) + req.setTimeout(timeoutMs, () => { + req.destroy() + resolve({ ok: false, error: 'timeout' }) + }) + }) +} + +// Reload the splash and force the connection chooser (menu "Switch Server"). +function goToConnectionChooser(): void { + forceChooser = true + backendReady = false + localBridge.stop() + loadSplash() +} + +function loadSplash(): void { + if (!mainWindow || mainWindow.isDestroyed()) return + if (process.env.VITE_DEV_SERVER_URL) { + mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL) + } else { + mainWindow.loadFile(join(__dirname, '../../dist/index.html')) + } +} + +// ─── Window Management ─────────────────────────────────────────────────────── + +function createWindow(): void { + const preloadPath = join(__dirname, '../preload/index.js') + + mainWindow = new BrowserWindow({ + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + minWidth: 900, + minHeight: 600, + title: 'MateClaw', + icon: join(__dirname, '../../build/icon.png'), + webPreferences: { + preload: preloadPath, + nodeIntegration: false, + contextIsolation: true, + webSecurity: true, + }, + show: false, + backgroundColor: '#f5f5f5', + }) + + // Show when ready to prevent visual flash + mainWindow.once('ready-to-show', () => { + mainWindow?.show() + }) + + // Open DevTools in dev mode for debugging + if (!app.isPackaged) { + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + + // Log renderer console messages to main process + mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => { + const levelStr = ['DEBUG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG' + console.log(`[Renderer:${levelStr}] ${message} (${sourceId}:${line})`) + }) + + // Load splash screen first + loadSplash() + + // Open external links in system browser, but allow WeCom auth popup in-app + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + // WeCom SDK needs in-app popup for postMessage auth callback + if (url.includes('work.weixin.qq.com')) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + width: 500, + height: 620, + title: '企业微信授权', + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }, + } + } + if (url.startsWith('http')) { + shell.openExternal(url) + } + return { action: 'deny' } + }) + + mainWindow.on('closed', () => { + mainWindow = null + }) +} + +function sendToWindow(channel: string, data: unknown): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, data) + } +} + +// ─── Auto Updater ─────────────────────────────────────────────────────────── + +function setupAutoUpdater(): void { + if (!app.isPackaged) { + // In dev mode, electron-updater can still work if dev-app-update.yml exists + // at the project root. It overrides the publish config from electron-builder.json. + const devUpdateConfig = resolve(__dirname, '../../dev-app-update.yml') + if (!existsSync(devUpdateConfig)) { + console.log('[MateClaw] Skipping auto-updater in dev mode (no dev-app-update.yml)') + return + } + console.log('[MateClaw] Dev mode: using dev-app-update.yml for updater') + autoUpdater.forceDevUpdateConfig = true + } + + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + + autoUpdater.on('checking-for-update', () => { + updaterState = { status: 'checking' } + sendToWindow('updater:state', updaterState) + console.log('[MateClaw] Checking for update...') + }) + + autoUpdater.on('update-available', (info: UpdateInfo) => { + updaterState = { + status: 'available', + version: info.version, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + } + sendToWindow('updater:state', updaterState) + console.log(`[MateClaw] Update available: ${info.version}`) + }) + + autoUpdater.on('update-not-available', (info: UpdateInfo) => { + updaterState = { status: 'not-available', version: info.version } + sendToWindow('updater:state', updaterState) + console.log('[MateClaw] No update available') + }) + + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + updaterState = { + ...updaterState, + status: 'downloading', + progress: { + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, + }, + } + sendToWindow('updater:state', updaterState) + }) + + autoUpdater.on('update-downloaded', (info: UpdateInfo) => { + updaterState = { status: 'downloaded', version: info.version } + sendToWindow('updater:state', updaterState) + console.log(`[MateClaw] Update downloaded: ${info.version}`) + }) + + autoUpdater.on('error', (err: Error) => { + updaterState = { status: 'error', error: err.message } + sendToWindow('updater:state', updaterState) + console.error('[MateClaw] Auto-updater error:', err.message) + + setTimeout(() => { + if (updaterState.status === 'error') { + updaterState = { status: 'idle' } + sendToWindow('updater:state', updaterState) + } + }, 10_000) + }) + + // Check for updates after a short delay to avoid blocking startup + setTimeout(() => { + autoUpdater.checkForUpdates().catch((err) => { + console.error('[MateClaw] Update check failed:', err.message) + }) + }, 3000) +} + +// ─── IPC Handlers ──────────────────────────────────────────────────────────── + +function registerIpcHandlers(): void { + ipcMain.handle('app:get-platform', () => process.platform) + + ipcMain.handle('app:get-version', () => app.getVersion()) + + // Let the renderer know whether this is a local (full) or remote (lite) build + // so the splash can hide the "local" connection option in remote builds. + ipcMain.handle('app:get-build-mode', () => BUILD_MODE) + + ipcMain.handle('app:get-backend-url', () => BACKEND_URL) + + ipcMain.handle('app:is-backend-ready', () => backendReady) + + ipcMain.handle('app:open-external', (_event, url: string) => { + shell.openExternal(url) + }) + + ipcMain.handle('app:get-user-data-path', () => app.getPath('userData')) + + ipcMain.handle('app:restart-backend', async () => { + backendReady = false + sendToWindow('backend:status', 'restarting') + if (connectionMode === 'remote') { + // Nothing to restart locally — just re-probe the remote server. + startRemoteConnection(BACKEND_URL) + return + } + await stopJavaBackend() + await startJavaBackend() + }) + + // ── Connection management IPC ── + + ipcMain.handle('connection:get-config', () => { + const cfg = loadConfig() + return { + mode: cfg.mode, + remoteUrl: cfg.remoteUrl, + servers: cfg.servers, + // The renderer shows the chooser on first run or when "Switch Server" forced it. + forceChoose: forceChooser, + // Tell the renderer which build variant is running so it can hide the + // "local" option in remote (lite) builds. + buildMode: BUILD_MODE, + } + }) + + ipcMain.handle('connection:test', async (_event, url: string) => { + return probeServer(url) + }) + + ipcMain.handle('connection:use-local', async () => { + // Remote builds have no bundled JRE/JAR — reject the local mode request. + if (BUILD_MODE === 'remote') { + sendToWindow('backend:crashed', '此版本为轻量版(Remote),不支持本地内嵌后端。请选择连接远程服务器。') + return + } + forceChooser = false + saveConfig({ mode: 'local' }) + connectionMode = 'local' + backendReady = false + if (!javaProcess) { + await startJavaBackend() + } else { + // Already running (e.g. switched away and back) — just re-check health. + pollBackendReady() + } + }) + + ipcMain.handle('connection:use-remote', async (_event, url: string) => { + const normalized = normalizeServerUrl(url) + if (!normalized) return { ok: false, error: 'invalid-url' } + + forceChooser = false + // A local JVM is pointless in remote mode — free its resources. + if (javaProcess) { + await stopJavaBackend() + } + saveConfig({ mode: 'remote', remoteUrl: normalized }) + recordServer(normalized) + startRemoteConnection(normalized) + return { ok: true } + }) + + ipcMain.handle('connection:switch-server', () => { + goToConnectionChooser() + }) + + ipcMain.handle('app:navigate-to-app', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + console.log('[MateClaw] Navigating to main application') + mainWindow.loadURL(BACKEND_URL) + } + }) + + // ── Local tools IPC ── + + ipcMain.handle('localtools:get-config', () => ({ + ...loadLocalToolsConfig(), + connected: localBridge.isConnected(), + })) + + ipcMain.handle('localtools:set-config', (_event, patch: Partial) => { + const saved = saveLocalToolsConfig(patch) + // Honor an enable/disable toggle immediately. + if (saved.enabled && backendReady) { + localBridge.start() + } else if (!saved.enabled) { + localBridge.stop() + } + return saved + }) + + ipcMain.handle('localtools:add-dir', async () => { + const dir = await pickAllowedDirectory() + return { ...loadLocalToolsConfig(), added: dir } + }) + + ipcMain.handle('localtools:remove-dir', (_event, dir: string) => { + const cfg = loadLocalToolsConfig() + return saveLocalToolsConfig({ + allowedDirs: cfg.allowedDirs.filter((d) => d !== dir), + }) + }) + + // ── Auto Updater IPC ── + + ipcMain.handle('updater:get-state', () => updaterState) + + ipcMain.handle('updater:check', async () => { + if (!app.isPackaged) return { status: 'not-available' } as UpdaterState + try { + await autoUpdater.checkForUpdates() + } catch (err: any) { + console.error('[MateClaw] Manual update check failed:', err.message) + } + return updaterState + }) + + ipcMain.handle('updater:download', async () => { + if (updaterState.status !== 'available') return + try { + await autoUpdater.downloadUpdate() + } catch (err: any) { + console.error('[MateClaw] Download failed:', err.message) + } + }) + + ipcMain.handle('updater:install', async () => { + if (updaterState.status !== 'downloaded') return + + console.log('[MateClaw] Installing update, stopping backend first...') + isUpdating = true + + try { + await stopJavaBackend() + } catch (err) { + console.error('[MateClaw] Error stopping backend before update:', err) + } + + autoUpdater.quitAndInstall(false, true) + }) +} + +// ─── App Lifecycle ─────────────────────────────────────────────────────────── + +// Prevent multiple instances +const gotTheLock = app.requestSingleInstanceLock() +if (!gotTheLock) { + app.quit() +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }) +} + +// ─── Application Menu ──────────────────────────────────────────────────────── + +function showAboutDialog(): void { + const iconPath = join(__dirname, '../../build/icon.png') + const icon = existsSync(iconPath) ? nativeImage.createFromPath(iconPath) : undefined + + dialog.showMessageBox({ + type: 'info', + title: 'About MateClaw', + message: 'MateClaw', + detail: [ + `Version: ${app.getVersion()}`, + '', + 'Your intelligent AI assistant powered by Spring AI Alibaba.', + '', + `Copyright © 2026 MateClaw Team`, + ].join('\n'), + buttons: ['OK'], + icon, + }) +} + +// Add a directory to the local-tools whitelist via a native folder picker. +// Returns the added path, or null if the user cancelled. +async function pickAllowedDirectory(): Promise { + const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const result = parent + ? await dialog.showOpenDialog(parent, { properties: ['openDirectory', 'createDirectory'] }) + : await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] }) + if (result.canceled || result.filePaths.length === 0) return null + + const dir = result.filePaths[0] + const cfg = loadLocalToolsConfig() + if (!cfg.allowedDirs.includes(dir)) { + saveLocalToolsConfig({ allowedDirs: [...cfg.allowedDirs, dir] }) + } + return dir +} + +// Native overview of the local-tools settings, with quick actions to add a +// directory or toggle the feature. Keeps management self-contained in the +// desktop shell without requiring a renderer settings page. +async function showLocalToolsSettings(): Promise { + const cfg = loadLocalToolsConfig() + const dirs = cfg.allowedDirs.length > 0 + ? cfg.allowedDirs.map((d) => ` • ${d}`).join('\n') + : ` (未配置 — ${cfg.failClosed ? '默认拒绝所有本地访问' : '默认允许全部本地访问'})` + const detail = [ + `状态: ${cfg.enabled ? '已启用' : '已停用'}`, + `隧道: ${localBridge.isConnected() ? '已连接' : '未连接'}`, + '', + '允许访问的目录:', + dirs, + ].join('\n') + + const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const opts = { + type: 'info' as const, + title: '本地工具设置', + message: '本地文件/命令工具', + detail, + buttons: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'], + defaultId: 0, + cancelId: 0, + noLink: true, + } + const res = parent + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts) + + if (res.response === 1) { + await pickAllowedDirectory() + } else if (res.response === 2) { + const saved = saveLocalToolsConfig({ enabled: !cfg.enabled }) + if (saved.enabled && backendReady) localBridge.start() + else if (!saved.enabled) localBridge.stop() + } +} + +async function menuCheckForUpdates(): Promise { + if (!app.isPackaged) { + dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' }) + return + } + + try { + const result = await autoUpdater.checkForUpdates() + if (!result || !result.updateInfo || result.updateInfo.version === app.getVersion()) { + dialog.showMessageBox({ + type: 'info', + title: 'Check for Updates', + message: 'You are up to date!', + detail: `MateClaw ${app.getVersion()} is the latest version.`, + }) + } + // If update is available, the existing updater:state IPC events will notify the renderer + } catch (err: any) { + dialog.showMessageBox({ + type: 'error', + title: 'Update Error', + message: 'Failed to check for updates', + detail: err.message || 'Please check your network connection and try again.', + }) + } +} + +function setupApplicationMenu(): void { + const isMac = process.platform === 'darwin' + + const template: Electron.MenuItemConstructorOptions[] = [] + + // ── macOS App Menu ── + if (isMac) { + template.push({ + label: app.name, + submenu: [ + { label: `About ${app.name}`, click: showAboutDialog }, + { label: 'Check for Updates...', click: menuCheckForUpdates }, + { type: 'separator' }, + { label: 'Switch Server…', click: goToConnectionChooser }, + { label: '本地工具设置…', click: () => { void showLocalToolsSettings() } }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }) + } + + // ── File Menu (Windows/Linux only) ── + if (!isMac) { + template.push({ + label: 'File', + submenu: [ + { label: 'Switch Server…', click: goToConnectionChooser }, + { label: '本地工具设置…', click: () => { void showLocalToolsSettings() } }, + { type: 'separator' }, + { role: 'quit', label: 'Exit' }, + ], + }) + } + + // ── Edit Menu ── + template.push({ + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }) + + // ── View Menu ── + template.push({ + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }) + + // ── Window Menu (macOS) ── + if (isMac) { + template.push({ + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + { type: 'separator' }, + { role: 'front' }, + ], + }) + } + + // ── Help Menu ── + template.push({ + label: 'Help', + submenu: [ + ...(!isMac ? [ + { label: 'Check for Updates...', click: menuCheckForUpdates }, + { type: 'separator' as const }, + ] : []), + { + label: 'GitHub Repository', + click: () => shell.openExternal('https://github.com/matevip/mateclaw'), + }, + { + label: 'Report Issue', + click: () => shell.openExternal('https://github.com/matevip/mateclaw/issues'), + }, + ...(!isMac ? [ + { type: 'separator' as const }, + { label: `About ${app.name}`, click: showAboutDialog }, + ] : []), + ], + }) + + const menu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(menu) +} + +// Allow the user to accept a self-signed / untrusted certificate for the remote +// server they explicitly chose to connect to (common on enterprise intranets). +app.on('certificate-error', (event, _webContents, url, _error, _certificate, callback) => { + let host = '' + try { + host = new URL(url).host + } catch { + callback(false) + return + } + + if (trustedCertHosts.has(host)) { + event.preventDefault() + callback(true) + return + } + + // Only prompt for the server the user is actively connecting to. + if (connectionMode !== 'remote' || !BACKEND_URL.includes(host)) { + callback(false) + return + } + + const choice = dialog.showMessageBoxSync({ + type: 'warning', + title: '证书不受信任', + message: `服务器 ${host} 使用了不受信任的证书`, + detail: '该服务器的 TLS 证书无法验证(可能是自签名证书)。仅在你信任此服务器时继续。', + buttons: ['取消', '信任并继续'], + defaultId: 0, + cancelId: 0, + }) + + if (choice === 1) { + trustedCertHosts.add(host) + event.preventDefault() + callback(true) + } else { + callback(false) + } +}) + +app.whenReady().then(() => { + setupApplicationMenu() + registerIpcHandlers() + createWindow() + bootConnection() + setupAutoUpdater() +}) + +app.on('window-all-closed', () => { + // On macOS, apps typically stay active until Cmd+Q + if (process.platform !== 'darwin') { + app.quit() + } +}) + +app.on('activate', () => { + // On macOS, re-create window when dock icon is clicked + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + if (!backendReady) { + bootConnection() + } + } +}) + +app.on('before-quit', async (event) => { + if (isQuitting) return + + // During update install, backend is already stopped by updater:install handler + if (isUpdating) { + isQuitting = true + return + } + + isQuitting = true + event.preventDefault() + + localBridge.stop() + try { + await stopJavaBackend() + } catch (err) { + console.error('[MateClaw] Error stopping backend:', err) + } finally { + app.exit(0) + } +}) diff --git a/mateclaw-desktop/electron/main/localBridge.ts b/mateclaw-desktop/electron/main/localBridge.ts new file mode 100644 index 00000000..61da7461 --- /dev/null +++ b/mateclaw-desktop/electron/main/localBridge.ts @@ -0,0 +1,205 @@ +import WebSocket from 'ws' +import { + readFile, + writeFile, + editFile, + listDir, + statPath, + executeShell, + LocalToolError, +} from './localToolsExecutor' +import { requestApproval, clearApprovalCache } from './localToolsApproval' + +// ─── Desktop → server local-tool tunnel (client side) ──────────────────────── +// Opens a WebSocket to the backend's /api/v1/desktop/ws endpoint, advertises the +// local tool capabilities, and services "call" frames the server forwards when a +// cloud agent invokes a local_* tool. File/shell work runs through the executor +// (whitelist-enforced) and approval (native dialog) modules. Reconnects with +// backoff while the desktop is meant to be online. + +const PROTOCOL_VERSION = 1 +const CAPABILITIES = ['read', 'list', 'stat', 'write', 'edit', 'shell'] +const RECONNECT_MIN_MS = 2000 +const RECONNECT_MAX_MS = 30_000 + +type TokenProvider = () => Promise +type UrlProvider = () => string + +export class LocalBridge { + private ws: WebSocket | null = null + private shouldRun = false + private reconnectDelay = RECONNECT_MIN_MS + private reconnectTimer: NodeJS.Timeout | null = null + + constructor( + private readonly getBackendUrl: UrlProvider, + private readonly getToken: TokenProvider + ) {} + + // Begin maintaining a connection. Safe to call repeatedly. + start(): void { + if (this.shouldRun) return + this.shouldRun = true + void this.connect() + } + + // Tear down the tunnel and stop reconnecting (e.g. on logout or app quit). + stop(): void { + this.shouldRun = false + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + clearApprovalCache() + if (this.ws) { + try { + this.ws.close() + } catch { + /* ignore */ + } + this.ws = null + } + } + + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN + } + + private buildWsUrl(token: string): string | null { + const base = this.getBackendUrl() + if (!base) return null + const wsBase = base.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + return `${wsBase}/api/v1/desktop/ws?token=${encodeURIComponent(token)}` + } + + private async connect(): Promise { + if (!this.shouldRun) return + + const token = await this.getToken() + if (!token) { + // Not logged in yet — retry shortly without escalating backoff. + this.scheduleReconnect(RECONNECT_MIN_MS) + return + } + const url = this.buildWsUrl(token) + if (!url) { + this.scheduleReconnect(RECONNECT_MIN_MS) + return + } + + console.log('[LocalBridge] Connecting tunnel…') + // rejectUnauthorized:false mirrors the app's handling of enterprise + // self-signed certificates for remote servers the user chose to trust. + const ws = new WebSocket(url, { rejectUnauthorized: false }) + this.ws = ws + + ws.on('open', () => { + console.log('[LocalBridge] Tunnel connected') + this.reconnectDelay = RECONNECT_MIN_MS + this.send({ + type: 'hello', + protocolVersion: PROTOCOL_VERSION, + capabilities: CAPABILITIES, + platform: process.platform, + }) + }) + + ws.on('message', (raw: WebSocket.RawData) => { + void this.onMessage(raw.toString()) + }) + + ws.on('close', () => { + console.log('[LocalBridge] Tunnel closed') + this.ws = null + if (this.shouldRun) this.scheduleReconnect(this.reconnectDelay) + }) + + ws.on('error', (err: Error) => { + console.warn('[LocalBridge] Tunnel error:', err.message) + // 'close' fires after 'error'; reconnect is scheduled there. + }) + } + + private scheduleReconnect(delay: number): void { + if (!this.shouldRun || this.reconnectTimer) return + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS) + void this.connect() + }, delay) + } + + private send(obj: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)) + } + } + + private async onMessage(text: string): Promise { + let frame: any + try { + frame = JSON.parse(text) + } catch { + return + } + + if (frame.type === 'hello-ack') { + if (frame.ok === false) console.warn('[LocalBridge] Handshake rejected:', frame.error) + return + } + if (frame.type === 'pong') return + if (frame.type !== 'call') return + + const { id, method, params } = frame + try { + const data = await this.dispatch(method, params || {}) + this.send({ type: 'result', id, ok: true, data }) + } catch (e) { + const code = e instanceof LocalToolError ? e.code : 'ERROR' + const error = e instanceof Error ? e.message : String(e) + this.send({ type: 'result', id, ok: false, code, error }) + } + } + + private async dispatch(method: string, params: any): Promise { + switch (method) { + case 'read_file': + return readFile(params.filePath, params.startLine, params.endLine) + case 'list_dir': + return listDir(params.dirPath) + case 'stat': + return statPath(params.path) + case 'write_file': { + await this.approveOrThrow('write_file', params.filePath, + `文件: ${params.filePath}\n\n内容预览:\n${preview(params.content)}`) + return writeFile(params.filePath, params.content) + } + case 'edit_file': { + await this.approveOrThrow('edit_file', params.filePath, + `文件: ${params.filePath}\n\n替换:\n- ${preview(params.oldText, 200)}\n+ ${preview(params.newText, 200)}`) + return editFile(params.filePath, params.oldText, params.newText, !!params.replaceAll) + } + case 'execute_shell': { + await this.approveOrThrow('execute_shell', params.command, + `命令:\n${params.command}`) + return executeShell(params.command, params.timeoutSeconds || 60) + } + default: + throw new LocalToolError('UNKNOWN_METHOD', `Unknown method: ${method}`) + } + } + + private async approveOrThrow( + kind: 'write_file' | 'edit_file' | 'execute_shell', + subject: string, + detail: string + ): Promise { + const { approved } = await requestApproval({ kind, subject, detail }) + if (!approved) throw new LocalToolError('DENIED', 'User denied') + } +} + +function preview(text: string | undefined, max = 500): string { + const s = text ?? '' + return s.length > max ? `${s.slice(0, max)}\n…(${s.length - max} more chars)` : s +} diff --git a/mateclaw-desktop/electron/main/localToolsApproval.ts b/mateclaw-desktop/electron/main/localToolsApproval.ts new file mode 100644 index 00000000..5f378fe8 --- /dev/null +++ b/mateclaw-desktop/electron/main/localToolsApproval.ts @@ -0,0 +1,82 @@ +import { dialog, BrowserWindow } from 'electron' + +// ─── Local tool approval ───────────────────────────────────────────────────── +// High-risk local operations (file write/edit, shell execution) prompt the user +// with a native dialog showing the full operation context before they run. The +// user may tick "don't ask again this session" to temporarily allow matching +// operations — same path for file ops, same command prefix for shell — until the +// app restarts (the cache is in-memory only). + +// Cache of approvals the user chose to remember this session. +const sessionAllow = new Set() + +export type ApprovalKind = 'write_file' | 'edit_file' | 'execute_shell' + +// The cache key scopes "remember": file ops by exact path, shell by command +// prefix (first word + first 40 chars) so re-running the same kind of command +// doesn't re-prompt, but a different command still does. +function cacheKey(kind: ApprovalKind, subject: string): string { + if (kind === 'execute_shell') { + const head = subject.trim().split(/\s+/)[0] || '' + return `shell:${head}:${subject.trim().slice(0, 40)}` + } + return `${kind}:${subject}` +} + +interface ApprovalRequest { + kind: ApprovalKind + // The path (file ops) or command (shell) this approval is scoped to. + subject: string + // Human-readable detail shown in the dialog body. + detail: string +} + +export interface ApprovalResult { + approved: boolean +} + +function titleFor(kind: ApprovalKind): string { + switch (kind) { + case 'write_file': + return '允许写入本地文件?' + case 'edit_file': + return '允许修改本地文件?' + case 'execute_shell': + return '允许执行本地命令?' + } +} + +export async function requestApproval(req: ApprovalRequest): Promise { + const key = cacheKey(req.kind, req.subject) + if (sessionAllow.has(key)) return { approved: true } + + const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] + const options = { + type: 'warning' as const, + title: titleFor(req.kind), + message: titleFor(req.kind), + detail: `${req.detail}\n\n该操作由远程 Agent 发起,将在你的本机执行。`, + buttons: ['拒绝', '允许'], + defaultId: 0, + cancelId: 0, + checkboxLabel: '本次会话不再询问相同操作', + checkboxChecked: false, + noLink: true, + } + + const result = parent + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options) + + const approved = result.response === 1 + if (approved && result.checkboxChecked) { + sessionAllow.add(key) + } + return { approved } +} + +// Drop all remembered approvals — called when the desktop disconnects/logs out so +// a new session starts from a clean slate. +export function clearApprovalCache(): void { + sessionAllow.clear() +} diff --git a/mateclaw-desktop/electron/main/localToolsConfig.ts b/mateclaw-desktop/electron/main/localToolsConfig.ts new file mode 100644 index 00000000..f4d4c274 --- /dev/null +++ b/mateclaw-desktop/electron/main/localToolsConfig.ts @@ -0,0 +1,122 @@ +import { app } from 'electron' +import { join, resolve, relative, isAbsolute } from 'path' +import { homedir } from 'os' +import { existsSync, readFileSync, writeFileSync } from 'fs' + +// ─── Local tools configuration ─────────────────────────────────────────────── +// Governs the desktop's local file/shell tool proxy: whether it is enabled, the +// directory whitelist every local file operation is constrained to, and the +// default policy when no whitelist is configured. Stored as its own JSON file in +// userData so it is independent of the connection config. + +export interface LocalToolsConfig { + // Master switch. When false the desktop advertises no local-tool capabilities + // and rejects any forwarded call. + enabled: boolean + // Absolute (or ~-prefixed) directories the agent may touch. Every local file + // operation must resolve to a path inside one of these. + allowedDirs: string[] + // Policy when allowedDirs is empty: + // true (fail-closed, default) → deny all local file access + // false (fail-open) → allow the entire local filesystem + failClosed: boolean +} + +const DEFAULT_CONFIG: LocalToolsConfig = { + enabled: true, + allowedDirs: [], + failClosed: true, +} + +function getConfigPath(): string { + return join(app.getPath('userData'), 'local-tools.json') +} + +export function loadLocalToolsConfig(): LocalToolsConfig { + try { + const path = getConfigPath() + if (!existsSync(path)) return { ...DEFAULT_CONFIG } + const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial + return { + ...DEFAULT_CONFIG, + ...raw, + allowedDirs: Array.isArray(raw.allowedDirs) ? raw.allowedDirs : [], + } + } catch (err) { + console.error('[MateClaw] Failed to read local-tools config:', err) + return { ...DEFAULT_CONFIG } + } +} + +export function saveLocalToolsConfig(patch: Partial): LocalToolsConfig { + const merged: LocalToolsConfig = { ...loadLocalToolsConfig(), ...patch } + try { + writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8') + } catch (err) { + console.error('[MateClaw] Failed to write local-tools config:', err) + } + return merged +} + +// Expand a leading ~ to the user's home directory and resolve to an absolute, +// normalized path. Returns null for empty input. +export function expandPath(input: string): string | null { + const trimmed = (input || '').trim() + if (!trimmed) return null + const expanded = trimmed === '~' || trimmed.startsWith('~/') + ? join(homedir(), trimmed.slice(1)) + : trimmed + return resolve(expanded) +} + +// Whether `target` is contained by `dir` (or equal to it). Both are resolved +// absolute paths. Uses path.relative so it is symlink-name-agnostic but does not +// follow symlinks — the whitelist is enforced on the lexical path the agent asked +// for, which is the path the user approved. +function isInside(dir: string, target: string): boolean { + const rel = relative(dir, target) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +export interface PathCheck { + allowed: boolean + // Resolved absolute path (when input was parseable), for use by the caller. + resolved: string | null + // Machine-readable reason when not allowed. + reason?: 'disabled' | 'unparseable' | 'whitelist' +} + +// Decide whether a local file operation on `inputPath` is permitted by the +// current configuration. This is the single chokepoint every file tool calls. +export function checkPath(inputPath: string): PathCheck { + const cfg = loadLocalToolsConfig() + if (!cfg.enabled) return { allowed: false, resolved: null, reason: 'disabled' } + + const target = expandPath(inputPath) + if (!target) return { allowed: false, resolved: null, reason: 'unparseable' } + + if (cfg.allowedDirs.length === 0) { + return { allowed: !cfg.failClosed, resolved: target, reason: cfg.failClosed ? 'whitelist' : undefined } + } + + for (const dir of cfg.allowedDirs) { + const base = expandPath(dir) + if (base && isInside(base, target)) { + return { allowed: true, resolved: target } + } + } + return { allowed: false, resolved: target, reason: 'whitelist' } +} + +// The working directory to run a shell command in: the first configured +// whitelist directory, falling back to the user's home. Shell commands are not +// path-checked (they are arbitrary), so they are gated by approval + timeout and +// pinned to a sensible cwd rather than wherever the app launched. +export function shellWorkingDir(): string { + const cfg = loadLocalToolsConfig() + for (const dir of cfg.allowedDirs) { + const base = expandPath(dir) + if (base && existsSync(base)) return base + } + return homedir() +} diff --git a/mateclaw-desktop/electron/main/localToolsExecutor.ts b/mateclaw-desktop/electron/main/localToolsExecutor.ts new file mode 100644 index 00000000..e96dd56a --- /dev/null +++ b/mateclaw-desktop/electron/main/localToolsExecutor.ts @@ -0,0 +1,194 @@ +import { spawn } from 'child_process' +import { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + existsSync, +} from 'fs' +import { dirname } from 'path' +import { checkPath, shellWorkingDir } from './localToolsConfig' + +// ─── Local tool executor ───────────────────────────────────────────────────── +// Runs the actual file/shell operations on the user's machine. Every file +// operation is constrained to the directory whitelist via checkPath(); shell +// commands are gated by approval (handled by the caller) and a hard timeout. +// Output limits mirror the server-side tools: ~30KB for file reads, ~10KB each +// for shell stdout/stderr. + +const MAX_FILE_BYTES = 30 * 1024 +const MAX_SHELL_BYTES = 10_000 +const IS_WINDOWS = process.platform === 'win32' + +export class LocalToolError extends Error { + constructor(public code: string, message: string) { + super(message) + } +} + +function requireAllowed(inputPath: string): string { + const check = checkPath(inputPath) + if (!check.allowed) { + if (check.reason === 'disabled') { + throw new LocalToolError('DISABLED', 'Local tools are disabled in the desktop app') + } + if (check.reason === 'unparseable') { + throw new LocalToolError('BAD_PATH', `Invalid path: ${inputPath}`) + } + throw new LocalToolError( + 'WHITELIST', + `Path is outside the allowed local directories: ${inputPath}` + ) + } + return check.resolved as string +} + +export function readFile(filePath: string, startLine?: number, endLine?: number): unknown { + const path = requireAllowed(filePath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`) + if (statSync(path).isDirectory()) { + throw new LocalToolError('IS_DIR', `Path is a directory: ${filePath}`) + } + + const raw = readFileSync(path, 'utf-8') + const allLines = raw.split('\n') + const totalLines = allLines.length + + const start = startLine && startLine > 0 ? startLine : 1 + const end = endLine && endLine > 0 ? Math.min(endLine, totalLines) : totalLines + if (start > totalLines) { + throw new LocalToolError('RANGE', `startLine ${start} exceeds total lines ${totalLines}`) + } + + let content = '' + let readLines = 0 + let truncated = false + for (let i = start - 1; i < end; i++) { + const line = `${String(i + 1).padStart(6)}\t${allLines[i]}\n` + if (Buffer.byteLength(content + line, 'utf-8') > MAX_FILE_BYTES) { + truncated = true + break + } + content += line + readLines++ + } + + return { filePath: path, totalLines, startLine: start, readLines, content, truncated } +} + +export function writeFile(filePath: string, content: string): unknown { + const path = requireAllowed(filePath) + const existed = existsSync(path) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content ?? '', 'utf-8') + return { + filePath: path, + bytesWritten: Buffer.byteLength(content ?? '', 'utf-8'), + created: !existed, + overwritten: existed, + } +} + +export function editFile( + filePath: string, + oldText: string, + newText: string, + replaceAll: boolean +): unknown { + const path = requireAllowed(filePath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`) + + const original = readFileSync(path, 'utf-8') + if (!original.includes(oldText)) { + throw new LocalToolError('NO_MATCH', 'oldText not found in file') + } + + let replacements = 0 + let updated: string + if (replaceAll) { + updated = original.split(oldText).join(newText) + replacements = original.split(oldText).length - 1 + } else { + updated = original.replace(oldText, newText) + replacements = 1 + } + writeFileSync(path, updated, 'utf-8') + return { filePath: path, replacements, replaceAll: !!replaceAll } +} + +export function listDir(dirPath: string): unknown { + const path = requireAllowed(dirPath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Directory not found: ${dirPath}`) + if (!statSync(path).isDirectory()) { + throw new LocalToolError('NOT_DIR', `Path is not a directory: ${dirPath}`) + } + const entries = readdirSync(path, { withFileTypes: true }).map((e) => ({ + name: e.name, + type: e.isDirectory() ? 'dir' : 'file', + })) + return { dirPath: path, entries } +} + +export function statPath(targetPath: string): unknown { + const path = requireAllowed(targetPath) + if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Path not found: ${targetPath}`) + const st = statSync(path) + return { + path, + size: st.size, + isDirectory: st.isDirectory(), + modifiedTime: st.mtime.toISOString(), + } +} + +function truncateUtf8(buf: Buffer, maxBytes: number): { text: string; truncated: boolean } { + if (buf.length <= maxBytes) return { text: buf.toString('utf-8'), truncated: false } + return { + text: buf.subarray(0, maxBytes).toString('utf-8') + + `\n... [output truncated, exceeds ${maxBytes} byte limit]`, + truncated: true, + } +} + +export function executeShell(command: string, timeoutSeconds: number): Promise { + const cwd = shellWorkingDir() + const timeoutMs = Math.min(Math.max(timeoutSeconds, 1), 300) * 1000 + + // cmd.exe on Windows, /bin/sh on macOS/Linux — mirrors the server tool. + const child = IS_WINDOWS + ? spawn('cmd.exe', ['/D', '/S', '/C', command], { cwd }) + : spawn('/bin/sh', ['-c', command], { cwd }) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + child.stdout.on('data', (d: Buffer) => stdoutChunks.push(d)) + child.stderr.on('data', (d: Buffer) => stderrChunks.push(d)) + + return new Promise((resolvePromise) => { + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, timeoutMs) + + const finish = (exitCode: number) => { + clearTimeout(timer) + const out = truncateUtf8(Buffer.concat(stdoutChunks), MAX_SHELL_BYTES) + const err = truncateUtf8(Buffer.concat(stderrChunks), MAX_SHELL_BYTES) + resolvePromise({ + command, + exitCode, + stdout: out.text, + stderr: err.text, + timedOut, + }) + } + + child.on('error', (e) => { + clearTimeout(timer) + resolvePromise({ command, exitCode: -1, stdout: '', stderr: String(e), timedOut: false }) + }) + child.on('close', (code) => finish(code == null ? -1 : code)) + }) +} diff --git a/mateclaw-desktop/electron/preload/index.ts b/mateclaw-desktop/electron/preload/index.ts new file mode 100644 index 00000000..f66b5bc2 --- /dev/null +++ b/mateclaw-desktop/electron/preload/index.ts @@ -0,0 +1,55 @@ +import { contextBridge, ipcRenderer } from 'electron' + +// Expose safe APIs to the renderer process (splash screen) +contextBridge.exposeInMainWorld('mateClawAPI', { + // Platform info + getPlatform: () => ipcRenderer.invoke('app:get-platform'), + getVersion: () => ipcRenderer.invoke('app:get-version'), + getBuildMode: () => ipcRenderer.invoke('app:get-build-mode'), + getBackendUrl: () => ipcRenderer.invoke('app:get-backend-url'), + isBackendReady: () => ipcRenderer.invoke('app:is-backend-ready'), + getUserDataPath: () => ipcRenderer.invoke('app:get-user-data-path'), + + // Actions + openExternal: (url: string) => ipcRenderer.invoke('app:open-external', url), + restartBackend: () => ipcRenderer.invoke('app:restart-backend'), + navigateToApp: () => ipcRenderer.invoke('app:navigate-to-app'), + + // Connection management + getConnectionConfig: () => ipcRenderer.invoke('connection:get-config'), + testConnection: (url: string) => ipcRenderer.invoke('connection:test', url), + useLocalConnection: () => ipcRenderer.invoke('connection:use-local'), + useRemoteConnection: (url: string) => ipcRenderer.invoke('connection:use-remote', url), + switchServer: () => ipcRenderer.invoke('connection:switch-server'), + + // Backend status events + onBackendStatus: (callback: (status: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, status: string) => callback(status) + ipcRenderer.on('backend:status', handler) + return () => ipcRenderer.removeListener('backend:status', handler) + }, + + onBackendCrashed: (callback: (message: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, message: string) => callback(message) + ipcRenderer.on('backend:crashed', handler) + return () => ipcRenderer.removeListener('backend:crashed', handler) + }, + + // Local tools (file/shell proxy) management + getLocalToolsConfig: () => ipcRenderer.invoke('localtools:get-config'), + setLocalToolsConfig: (patch: unknown) => ipcRenderer.invoke('localtools:set-config', patch), + addLocalToolsDir: () => ipcRenderer.invoke('localtools:add-dir'), + removeLocalToolsDir: (dir: string) => ipcRenderer.invoke('localtools:remove-dir', dir), + + // Auto-updater + getUpdaterState: () => ipcRenderer.invoke('updater:get-state'), + checkForUpdates: () => ipcRenderer.invoke('updater:check'), + downloadUpdate: () => ipcRenderer.invoke('updater:download'), + installUpdate: () => ipcRenderer.invoke('updater:install'), + + onUpdaterState: (callback: (state: any) => void) => { + const handler = (_event: Electron.IpcRendererEvent, state: any) => callback(state) + ipcRenderer.on('updater:state', handler) + return () => ipcRenderer.removeListener('updater:state', handler) + }, +}) diff --git a/mateclaw-desktop/index.html b/mateclaw-desktop/index.html new file mode 100644 index 00000000..6003ca89 --- /dev/null +++ b/mateclaw-desktop/index.html @@ -0,0 +1,27 @@ + + + + + + MateClaw + + + +

+ + + diff --git a/mateclaw-desktop/package.json b/mateclaw-desktop/package.json new file mode 100644 index 00000000..77eb1c22 --- /dev/null +++ b/mateclaw-desktop/package.json @@ -0,0 +1,51 @@ +{ + "name": "mateclaw-desktop", + "version": "1.7.0", + "description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba", + "author": "MateClaw Team", + "license": "Apache-2.0", + "main": "dist-electron/main/index.js", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "setup:jre": "bash scripts/download-jre.sh", + "setup:jar": "bash scripts/build.sh", + "setup": "npm run setup:jar && npm run setup:jre", + "setup:all-platforms": "bash scripts/build-all-platforms.sh --all", + "package:mac": "npm run build && electron-builder --mac", + "package:mac:local": "npm run build && cross-env BUILD_MODE=local electron-builder --mac", + "package:mac:remote": "npm run build && cross-env BUILD_MODE=remote electron-builder --mac", + "package:win": "npm run build && electron-builder --win", + "package:win:local": "npm run build && cross-env BUILD_MODE=local electron-builder --win", + "package:win:remote": "npm run build && cross-env BUILD_MODE=remote electron-builder --win", + "package:all": "bash scripts/build-all-platforms.sh --all", + "package:all:local": "bash scripts/build-all-platforms.sh --local", + "package:all:remote": "bash scripts/build-all-platforms.sh --remote", + "publish:github": "bash scripts/publish-github.sh", + "publish:github:draft": "bash scripts/publish-github.sh --draft" + }, + "dependencies": { + "electron-updater": "^6.3.9", + "vue": "^3.5.13", + "ws": "^8" + }, + "devDependencies": { + "@types/ws": "^8.18.1", + "@vitejs/plugin-vue": "^5.2.1", + "cross-env": "^10.1.0", + "electron": "^33.3.1", + "electron-builder": "^25.1.8", + "typescript": "^5.7.3", + "vite": "^6.0.7", + "vite-plugin-electron": "^0.28.8", + "vite-plugin-electron-renderer": "^0.14.6", + "vue-tsc": "^2.2.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "electron", + "esbuild" + ] + } +} diff --git a/mateclaw-desktop/pnpm-lock.yaml b/mateclaw-desktop/pnpm-lock.yaml new file mode 100644 index 00000000..2cb47e2f --- /dev/null +++ b/mateclaw-desktop/pnpm-lock.yaml @@ -0,0 +1,3942 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + electron-updater: + specifier: ^6.3.9 + version: 6.8.9 + vue: + specifier: ^3.5.13 + version: 3.5.31(typescript@5.9.3) + ws: + specifier: ^8 + version: 8.21.0 + devDependencies: + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@6.4.3(@types/node@25.5.0))(vue@3.5.31(typescript@5.9.3)) + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + electron: + specifier: ^33.3.1 + version: 33.4.11 + electron-builder: + specifier: ^25.1.8 + version: 25.1.8(electron-builder-squirrel-windows@25.1.8) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@25.5.0) + vite-plugin-electron: + specifier: ^0.28.8 + version: 0.28.8(vite-plugin-electron-renderer@0.14.7) + vite-plugin-electron-renderer: + specifier: ^0.14.6 + version: 0.14.7 + vue-tsc: + specifier: ^2.2.0 + version: 2.2.12(typescript@5.9.3) + +packages: + + 7zip-bin@5.2.0: + resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@develar/schema-utils@2.6.5': + resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} + engines: {node: '>= 8.9.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.1': + resolution: {integrity: sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@3.6.1': + resolution: {integrity: sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==} + engines: {node: '>=12.13.0'} + hasBin: true + + '@electron/universal@2.0.1': + resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==} + engines: {node: '>=16.4'} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@npmcli/fs@2.1.2': + resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + '@npmcli/move-file@2.0.1': + resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This functionality has been moved to @npmcli/fs + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/verror@1.10.11': + resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.31': + resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==} + + '@vue/compiler-dom@3.5.31': + resolution: {integrity: sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==} + + '@vue/compiler-sfc@3.5.31': + resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==} + + '@vue/compiler-ssr@3.5.31': + resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.31': + resolution: {integrity: sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==} + + '@vue/runtime-core@3.5.31': + resolution: {integrity: sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==} + + '@vue/runtime-dom@3.5.31': + resolution: {integrity: sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==} + + '@vue/server-renderer@3.5.31': + resolution: {integrity: sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==} + peerDependencies: + vue: 3.5.31 + + '@vue/shared@3.5.31': + resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==} + + '@xmldom/xmldom@0.8.12': + resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} + engines: {node: '>=10.0.0'} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + app-builder-bin@5.0.0-alpha.10: + resolution: {integrity: sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==} + + app-builder-lib@25.1.8: + resolution: {integrity: sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 25.1.8 + electron-builder-squirrel-windows: 25.1.8 + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird-lst@1.0.9: + resolution: {integrity: sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builder-util-runtime@9.2.10: + resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==} + engines: {node: '>=12.0.0'} + + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@25.1.7: + resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} + + cacache@16.1.3: + resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-file-ts@0.2.8-rc1: + resolution: {integrity: sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + crc@3.8.0: + resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@25.1.8: + resolution: {integrity: sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==} + + dmg-license@1.0.11: + resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} + engines: {node: '>=8'} + os: [darwin] + hasBin: true + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@25.1.8: + resolution: {integrity: sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==} + + electron-builder@25.1.8: + resolution: {integrity: sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@25.1.7: + resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==} + + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + + electron@33.4.11: + resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==} + engines: {node: '>= 12.20.55'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-corefoundation@1.1.7: + resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} + engines: {node: ^8.11.2 || >=10} + os: [darwin] + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infer-owner@1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-fetch-happen@10.2.1: + resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@2.1.2: + resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + + node-addon-api@1.7.2: + resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-gyp@9.4.1: + resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} + engines: {node: ^12.13 || ^14.13 || >=16} + hasBin: true + + nopt@6.0.0: + resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@7.0.0: + resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} + engines: {node: '>= 10'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssri@9.0.1: + resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unique-filename@2.0.1: + resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + unique-slug@3.0.0: + resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + + vite-plugin-electron-renderer@0.14.7: + resolution: {integrity: sha512-hHBMKuZ24MB2SIxG7U7ix+DDEnvxou7Bgy/TdhYxNz3S5N3Yh7Hjvj9blfMeGEJ0oaZJn7y5z0V/RyDmJ5OuCA==} + + vite-plugin-electron@0.28.8: + resolution: {integrity: sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==} + peerDependencies: + vite-plugin-electron-renderer: '*' + peerDependenciesMeta: + vite-plugin-electron-renderer: + optional: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.31: + resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + +snapshots: + + 7zip-bin@5.2.0: {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@develar/schema-utils@2.6.5': + dependencies: + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.1': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@3.6.1': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + chalk: 4.1.2 + debug: 4.4.3 + detect-libc: 2.1.2 + fs-extra: 10.1.0 + got: 11.8.6 + node-abi: 3.89.0 + node-api-version: 0.2.1 + node-gyp: 9.4.1 + ora: 5.4.1 + read-binary-file-arch: 1.0.6 + semver: 7.7.4 + tar: 6.2.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bluebird + - supports-color + + '@electron/universal@2.0.1': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + dir-compare: 4.2.0 + fs-extra: 11.3.4 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@epic-web/invariant@1.0.0': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@gar/promisify@1.1.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@2.1.2': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.7.4 + + '@npmcli/move-file@2.0.1': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@sindresorhus/is@4.6.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@tootallnate/once@2.0.0': {} + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 20.19.37 + '@types/responselike': 1.0.3 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree@1.0.8': {} + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 25.5.0 + + '@types/http-cache-semantics@4.2.0': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 20.19.37 + + '@types/ms@2.1.0': {} + + '@types/node@20.19.37': + dependencies: + undici-types: 6.21.0 + + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + + '@types/plist@3.0.5': + dependencies: + '@types/node': 25.5.0 + xmlbuilder: 15.1.1 + optional: true + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 20.19.37 + + '@types/verror@1.10.11': + optional: true + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.0 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 20.19.37 + optional: true + + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@25.5.0))(vue@3.5.31(typescript@5.9.3))': + dependencies: + vite: 6.4.3(@types/node@25.5.0) + vue: 3.5.31(typescript@5.9.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.31': + dependencies: + '@babel/parser': 7.29.2 + '@vue/shared': 3.5.31 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.31': + dependencies: + '@vue/compiler-core': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/compiler-sfc@3.5.31': + dependencies: + '@babel/parser': 7.29.2 + '@vue/compiler-core': 3.5.31 + '@vue/compiler-dom': 3.5.31 + '@vue/compiler-ssr': 3.5.31 + '@vue/shared': 3.5.31 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.8 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.31': + dependencies: + '@vue/compiler-dom': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.31 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.31 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.31': + dependencies: + '@vue/shared': 3.5.31 + + '@vue/runtime-core@3.5.31': + dependencies: + '@vue/reactivity': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/runtime-dom@3.5.31': + dependencies: + '@vue/reactivity': 3.5.31 + '@vue/runtime-core': 3.5.31 + '@vue/shared': 3.5.31 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.31(vue@3.5.31(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.31 + '@vue/shared': 3.5.31 + vue: 3.5.31(typescript@5.9.3) + + '@vue/shared@3.5.31': {} + + '@xmldom/xmldom@0.8.12': {} + + abbrev@1.1.1: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-keywords@3.5.2(ajv@6.14.0): + dependencies: + ajv: 6.14.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@1.0.13: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + app-builder-bin@5.0.0-alpha.10: {} + + app-builder-lib@25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8): + dependencies: + '@develar/schema-utils': 2.6.5 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.1 + '@electron/rebuild': 3.6.1 + '@electron/universal': 2.0.1 + '@malept/flatpak-bundler': 0.4.0 + '@types/fs-extra': 9.0.13 + async-exit-hook: 2.0.1 + bluebird-lst: 1.0.9 + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chromium-pickle-js: 0.2.0 + config-file-ts: 0.2.8-rc1 + debug: 4.4.3 + dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 25.1.8(dmg-builder@25.1.8) + electron-publish: 25.1.7 + form-data: 4.0.5 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + is-ci: 3.0.1 + isbinaryfile: 5.0.7 + js-yaml: 4.2.0 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + resedit: 1.7.2 + sanitize-filename: 1.6.4 + semver: 7.7.4 + tar: 6.2.1 + temp-file: 3.4.0 + transitivePeerDependencies: + - bluebird + - supports-color + + aproba@2.1.0: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + argparse@2.0.1: {} + + assert-plus@1.0.0: + optional: true + + astral-regex@2.0.0: + optional: true + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird-lst@1.0.9: + dependencies: + bluebird: 3.7.2 + + bluebird@3.7.2: {} + + boolean@3.2.0: + optional: true + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builder-util-runtime@9.2.10: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@25.1.7: + dependencies: + 7zip-bin: 5.2.0 + '@types/debug': 4.1.13 + app-builder-bin: 5.0.0-alpha.10 + bluebird-lst: 1.0.9 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-ci: 3.0.1 + js-yaml: 4.2.0 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + transitivePeerDependencies: + - supports-color + + cacache@16.1.3: + dependencies: + '@npmcli/fs': 2.1.2 + '@npmcli/move-file': 2.0.1 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 8.1.0 + infer-owner: 1.0.4 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1 + rimraf: 3.0.2 + ssri: 9.0.1 + tar: 6.2.1 + unique-filename: 2.0.1 + transitivePeerDependencies: + - bluebird + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chownr@2.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@3.9.0: {} + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + optional: true + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-support@1.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@5.1.0: {} + + compare-version@0.1.2: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + concat-map@0.0.1: {} + + config-file-ts@0.2.8-rc1: + dependencies: + glob: 10.4.5 + typescript: 5.9.3 + + console-control-strings@1.1.0: {} + + core-util-is@1.0.2: + optional: true + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + crc@3.8.0: + dependencies: + buffer: 5.7.1 + optional: true + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + detect-libc@2.1.2: {} + + detect-node@2.1.0: + optional: true + + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@25.1.8(electron-builder-squirrel-windows@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + fs-extra: 10.1.0 + iconv-lite: 0.6.3 + js-yaml: 4.2.0 + optionalDependencies: + dmg-license: 1.0.11 + transitivePeerDependencies: + - bluebird + - electron-builder-squirrel-windows + - supports-color + + dmg-license@1.0.11: + dependencies: + '@types/plist': 3.0.5 + '@types/verror': 1.10.11 + ajv: 6.14.0 + crc: 3.8.0 + iconv-corefoundation: 1.1.7 + plist: 3.1.0 + smart-buffer: 4.2.0 + verror: 1.10.1 + optional: true + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@25.1.8(dmg-builder@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + archiver: 5.3.2 + builder-util: 25.1.7 + fs-extra: 10.1.0 + transitivePeerDependencies: + - bluebird + - dmg-builder + - supports-color + + electron-builder@25.1.8(electron-builder-squirrel-windows@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8) + fs-extra: 10.1.0 + is-ci: 3.0.1 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.2 + transitivePeerDependencies: + - bluebird + - electron-builder-squirrel-windows + - supports-color + + electron-publish@25.1.7: + dependencies: + '@types/fs-extra': 9.0.13 + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.2.0 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + electron@33.4.11: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 20.19.37 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + err-code@2.0.3: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es6-error@4.1.1: + optional: true + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: + optional: true + + estree-walker@2.0.2: {} + + exponential-backoff@3.1.3: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.4.1: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gauge@4.0.4: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + + gopd@1.2.0: {} + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + http-cache-semantics@4.2.0: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iconv-corefoundation@1.1.7: + dependencies: + cli-truncate: 2.1.0 + node-addon-api: 1.7.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + infer-owner@1.0.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ip-address@10.1.0: {} + + is-ci@3.0.1: + dependencies: + ci-info: 3.9.0 + + is-fullwidth-code-point@3.0.0: {} + + is-interactive@1.0.0: {} + + is-lambda@1.0.1: {} + + is-unicode-supported@0.1.0: {} + + isarray@1.0.0: {} + + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stringify-safe@5.0.1: + optional: true + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + lazy-val@1.0.5: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.isequal@4.5.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.union@4.6.0: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lowercase-keys@2.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-fetch-happen@10.2.1: + dependencies: + agentkeepalive: 4.6.0 + cacache: 16.1.3 + http-cache-semantics: 4.2.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-lambda: 1.0.1 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 2.1.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 7.0.0 + ssri: 9.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.3 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.3 + + minimist@1.2.8: {} + + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + + minipass-fetch@2.1.2: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.3: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.11: {} + + negotiator@0.6.4: {} + + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + + node-addon-api@1.7.2: + optional: true + + node-api-version@0.2.1: + dependencies: + semver: 7.7.4 + + node-gyp@9.4.1: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 10.2.1 + nopt: 6.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.7.4 + tar: 6.2.1 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + + nopt@6.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + normalize-url@6.1.0: {} + + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + + object-keys@1.1.1: + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + package-json-from-dist@1.0.1: {} + + path-browserify@1.0.1: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pe-library@0.4.1: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.12 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + + promise-inflight@1.0.1: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + quick-lru@5.1.1: {} + + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + require-directory@2.1.1: {} + + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + retry@0.12.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.0: {} + + semver-compare@1.0.0: + optional: true + + semver@6.3.1: {} + + semver@7.7.4: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + set-blocking@2.0.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + optional: true + + smart-buffer@4.2.0: {} + + socks-proxy-agent@7.0.0: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.1.3: + optional: true + + ssri@9.0.1: + dependencies: + minipass: 3.3.6 + + stat-mode@1.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + tiny-typed-emitter@2.1.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.5 + + tmp@0.2.5: {} + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + type-fest@0.13.1: + optional: true + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici-types@7.18.2: {} + + unique-filename@2.0.1: + dependencies: + unique-slug: 3.0.0 + + unique-slug@3.0.0: + dependencies: + imurmurhash: 0.1.4 + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utf8-byte-length@1.0.5: {} + + util-deprecate@1.0.2: {} + + verror@1.10.1: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + optional: true + + vite-plugin-electron-renderer@0.14.7: {} + + vite-plugin-electron@0.28.8(vite-plugin-electron-renderer@0.14.7): + optionalDependencies: + vite-plugin-electron-renderer: 0.14.7 + + vite@6.4.3(@types/node@25.5.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + + vscode-uri@3.1.0: {} + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.31(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.31 + '@vue/compiler-sfc': 3.5.31 + '@vue/runtime-dom': 3.5.31 + '@vue/server-renderer': 3.5.31(vue@3.5.31(typescript@5.9.3)) + '@vue/shared': 3.5.31 + optionalDependencies: + typescript: 5.9.3 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 diff --git a/mateclaw-desktop/public/logo/mateclaw_logo_s.png b/mateclaw-desktop/public/logo/mateclaw_logo_s.png new file mode 100644 index 0000000000000000000000000000000000000000..90c9decd43cb0918fbb34d2b28eb4db34e763214 GIT binary patch literal 68958 zcmdpc^K+fw_kC=$aT?pUZQR&NW7}qf#59CD;e2ArvCr!XrU}MdKhw38TNgl-4l+n2m#9{G}=9S@dBmc`C zIZw3}z}bsw+6y~cMb=g~7l?ml3v%6#|RqZ7xVvnA`o zw|GxUO8j{4=;>O#K^Om@87jPTwHqorb*T(m!0W7!6Dl}b5g1^Z%h9=lgwjr?K%i*F z2oBKSFIkozbPjqlm9(@h`}$z|WW!vnB7X5<@OT|OBMd*%=dzJV`PTdVH;@_Fc)h-a zt_yx}Vd?2L|B$d4Q~d;S@|pxb5+nsJ!dmz2wzy9RPj?oF-v6oDSXA(-Yv4_}Abwh>7P`=Zs;d1xf@xk<$L5Kz17oifT#10k&r;}zwWy&xl=_IM67pLgb zq5xL*=UInArJV+qVj0F?@i>G|txTx`-J1l1cHW;WugOI3VpTVTKCML_o1t4j7vF%5 zvOy2ffMDT8Mib4|RbKGs{dDKv*@TJfC3WU|e@5us2=mNOxD$MxGELd)9!W6;F~m^0 z%o1vPiXW*Il+qe;X;@zrJ{L!BTwevJf!E8=B@OxrN|BlY<>EED1u_dmAZ;YASD|8s zEQYW3<50POR54#eCN+el#Owitr*><<8-5ITUNwg+%8GvcyahYI9DTl&v%XtgH}{ct z{4a89b)fhZmacLZFTc^w)KUUz+KFi5*^8PSZszsRMrTtY1Wj?c5u$N1| z6Rq4)RxP%*oa#R;rQl-)2Je5z8Y!DmuN)a_DCusG0K9sqC;q}N>sU{oQyV|Nm)Kkr ze(g0tC-!@r0VlBrfj$GS!4Ef$LH%cq!DEBpmdGFO8@o?s3;cjbqMYjlJs+owEgk^u zLM<&qdkqYQP%79o3b1SMBHh<CHTX z#xpo(=N2fAG%G;p!xd0I!V?W&{>5Q@w{s7ahNjx6S}_s|apZ$SI+G(|>qjaKC~9O7 z*84?SpB{mbsblHs#RIsnZJ0xHyIS7!GdQ^^d|%VKe*XE?<7^=N8hVbY2M$>>LI3uk zQB36AZFU-6;Q4?qauBB&Z06wk?79GtUsju)RvTi2L+*R+h&)0{=++R6!@5jp$v{qw z`USz1-h2tsj9+%Ld{YBWCPs0akBJO(b^LTd=VwiO8#k;&#dQLjoI4YkfLMj~pxmH* zyg20+(P&8wfYix#1GI-3qf_Zi1U9Gq&;sRy>dM17MJSSOM2dfYEBzY&*HqxKy^dvi z;=Pxizg|CmYYTiGyhYo4r&;*ASZpzmZis&VKahI?Sk=HwWZ==K?$g_(&+AggKP#$N zMkF_jFLK#w_v#=yHx-OV6=0pR7~URt*YYIkyPV~9Hn?@GUCC#twJ`ru9ams z9G7&koC0Q^hM3}jGBl1|9HLoA#jpPIk*qM|dOBvaW{*i|dLQoY?_XWrDC=Q=ov*ex ze%wZjyv6ms+zhw^;Txi@{(~Xd9XYSW!9iK&oka%qzb#ImTOWOAbU!ykycA^Q>Nj@@ri6`1e8fFz8k; z&6}UUZg4IGF=F>v16lpLfvah(nkq^Q^D!uM%2AA?@{dS3!XpRbZGvc^u- zUeCY&FO@z|#szsdK@+TNcMGhYd&0Q@nL=S-7kT}Ldb1&V4ye~T5Yt}vECSszW_uZf zQJcqX^JwKK{_Qi!Ss9YM?kh7R=I^YWJ?zi!;`(zdP@6KnN*c$G zF{A`zdh0yC9Bxni`pxDxRB4bZ#|bX89S(v(uL?2XRk)t(?tvr`VkGpb34pM8lUanT zRtm?4Ly|t}{aAp_ZxZg#Se74lY{QN-z1}pZ%5?b=&RFm{5SxOHn3UsO=`jaJhV|Qb;PPEiyal5U&4;vX8 zhy0TzL%{Vw9mi2=UwXJ&i-}f_-?mCT`R*B7=iMHF^?7Bw+IEJ*l!Ic4#4M=jkqIrJ zY1SpC87BVAHshX88kr;x>>ur$@7TmsJsHLACB@6`vrI>xM98C29 zmz{oHpApX2#cdP!TcE7*L+0Iv4&1%CksPAlShRG>sVtX$T(1M*BA0Tjx=D%I1I!pEbEvwb3N0nU;T+I z&Dn!5E(8J!5#LYjt$SKOt8PA|%YHu3^u9O&OY4K@{y|;S`$3~`<>RWA6pYO3vo`{0 ze`~eY!N-z2rQ17f8k06Kv#ZbE+vzne$V`!_{yJAiN`f_s^Sl2|HzZs|oVOPifoBZj zJm!~6v8s-zeoGeA((C=SeDJiw&`U{O1(zZNTrLInKGGr9XBtwn4><1>li1fqmjX{R zqPq)yL^e7vu7!ur6H-K>qb;rzar`!L4LKeo9nQ^dT#tWzu&QO{OZ5QG;PN{I^}Hp; zw1Oeh5F$ZTa`U`uoyzCzy)kJ0Nm0}$aHN*3No6V$^*-nGM)>685nbqWqE_%Q?B&t! zB8^c66G4t}uObmc*RR%>KV=TWh}(z$jPJWKU7So%smKXInwS@*BUM5I1i+qlCyt4U zWqcTUI#v0kkaD4(x@NW(NgU=>{-vZ}zqFUsQXlubbH%W^Li9l#0=d#d%V7(N z3cr96aY??G=CKJ+R;sp;cN2_t>z zW^OjK2bvL?q;-0<^BZB>lNfnlcaTrdX(Ze*kqRl&WDSE5uP zP4*2yPpdONhXJ(+cl5qR&M=Kf$UfDxm)l>`Ds(azz!yFf=Y^yldZo)GB_?xS{1eWX z8H-8^SMgDp!e9v-nv4jw6IYHGb=szaC#crIGsINVdx<1l?@q_qBg20M-Wl8tS_#(= zjPSf;fr!ry0!AtKJ;Y`1aCSHX>(Z|@N&s0+;wMy_SyH-8$Bj!Uv2;i}G2GK^Kj`CT zmGgPb)HQ@+3kCjYB7_x$O@VqiijmUk^4^|D#|4^T?MMNGtkbXhkSC>Im_f@%&8grB(A)NtCO!xwpw=P2y`mBzy)ohdY% zm76$l&zPbZfCZ%?hWo>n2IFTk>>qod1^VAv2DnQ#QI@_LWR@jHIwUKWh{A!j=u}hr z)q6`NG_a)<_7fOGHVCJo!y5T*aXle*bKLqlH6u70YBho+)$7cN^i%bK4WK^>kcm~k zD?bjgKBJ^I?TVs9#dU>utKyN!sSkV;2U_{nqX2A7wT09U1TnJ)e;~64?MI_;b*&5S zqD3=!1rQ*o=8uqK8qX_yP2N=c?(yBNQBV#^j)BuA-2vlp_ykR6SA19V5R*x$O!*RP zKqx;3#8?1U%Fb*cHqN4c6`z(7r_W|_B(sFjV3E@lxo*A24X-2(=VjKBaY4hJmdvZU z9xjd}2wmS@$I#0+#I9}qOiETSH-Vg5Arv?hp!lhI)TufH|3M5FO!A&!4#ft5>8t3| zuBNHmfLz;lVy+aeomVztBwGPiM6_LzpW0$1dRl2~;870CEKgMI)wi^4CZDtRy_P#? z7;IC49~}RyHvE7_6t7eFzWa|*$v>h^{wGTN{(FN!cN5|;+)cDBr{+Uv+t0)pP%fR1Bz)r>7!+FRnt<|iD-mP={g zR=7z+tHu5m`P|-W(&5gwPIz$UFEJ~EobuA+CtAIwX&z#BOuU5wLms+i1g2^t;LjQ@ z(;Hmn_wF9m8(Y8wa>W1xrS@C${$hh&4IpFQ-|$CyIc#=ru40uPEM#ijGsFOsT9I|Q z2kuy=EmF6Q8^9SEPORt`0#i zUw;eig$}*e>}pP(5mO;4ZF^u#d;!}2k~ry(3Bh7ghK@eU_EHzO+eWUTSpg}J*^yJ@ zY69lvazUqqZH9Ms)(ZATKO{LdIZWkL=;Y6A%edOO7GJ4{jBBc!Tbbaq>_VX_ppYJ4 zn}m2&6rrVLOy-ITYG(3h2$`BOoH&+9qhV2@aGwaxA^qbbTI?RV^>s5_!rB!MdH|mS zinVSY-!$+Nr=W!Qggg!A0ZDaQLZ@vyW(fzFZ^$;I<1&o6(yLS(c(9*B&>yuz`&h+2LE&m;lI2a>p$LG8m({?OUV>QdeZ!7$81l1Vjz&5DI7l6bw!?$C7xdn*qbmKBXW06Q+Ym@HY} zy@-NW?#@6}<8T%U#x}+{HcsqW5>T8j6KG;|dkOOP*Za2M&(9I0+gUxRS9ZTBFQmm- zTsu9Er0>z@t+pcxjFOtfgo45VS=9Q81Tw<%d9pjAl!;10bMWy&DFdwcvL;oMNYg(4 z^_qWAoXJSXcf~#r$@{*~*@-K`$NFhlYz_h>ZA=@2zNT&Atxh}-E|=nxa-vNirlao? zZ2Rjq0Zl5dQ2}|$<3R6PxhHq>#%}|5MwJ*9Uyt=IRpAtEVt-vfKhHGj^>YClfF2Qm^I(iNCDQHn7r>2XG@a%t{xr{c6yj+52(OU(6rr zVs=})moC-8?kIHpG15hi?R8!{hj%9;s>2IATT(*Mp4v>{g%M5P;xPL1nyq|oQbl%1 z{v({4R#s)9;$#1sT8?tqPF(KYs=Hn`kyutMuJwJp6Z>oB-z&ym;cv<&6&5nA3kQ@?}%h+ zrk|^O1o1SlR;0JfwLEzmDIH9UG3&^!Cm1LjVJFFR3N7Y2whscOokHj{PbCV*8ZM#x~wVhd{_6a zQ~fs_V}ZEl*07i#qvVx=W~wL!*z}=Dhs-Sh3$o>mDtPfByzjH-y6a>$aO=DNIEQ!F zV)@7_(R_c~IQySLmc;g-xnkIaY#2>gXEqECw>bRE75l^F@YYB!V5hQDC`W*POUiQ% zvyKs?g^nT9fjhR1I=x*g4ruji((R;2E<$DkADML;+*8YXJd)W^nH@GL6uhIR|M*+~ z+#2zl8eu3ME`Pe*fI~*KKay4E zP|~))C$Dg**-17jkxcazg!|3X+foPkCsG3y6zkW7(6j^nCe@e@B30L4e-asgBv#6c znhKx(Bj9TiB|gPHRQSS21oOtC6r9MitJzp8L#ND5YBtOX( zZ&|VwjH*Uz1UNiT)g(0h%knXni?7Iy<)H8WW9g6Br}PCmNO^Jp{H6@!JVmQ)e@y70YrGq>L?8hrwu z*8A4FoN27ojVhhs;^?#vO2r{9+Mw!9sTda`HEnR%2ZpAhBBC9N#jMo+$nC{i`W?1g z?`m%S&~K;}k>@XB7zk%HxiQbaZnp|@!ZW^#^Ue{d(<~Qlr=RO#osjZUcN@)whR~-sDto6ZJVEBR4#RvasUn0NB4U`Eoz-6R zrcGWv=NYw{35Zi-ni+SqU$aC@+@ID;XUZjnAQI*Zx8;n~`${A**G_~A_1w_=om|*b zo03nj&{QiBFz%%ZL5xstd6a>WOsf16rvFVo%80P?fzR~3a&+>&yK`{2wgz+y?Qdzm z1F4s1lD>t0k(!+Rz#$}%6U2I_Vnw! zy~MuKbcwY}PM?_;OTaw#;mtlyO_+2~#X{J%Q4@I;?m)jC!GfZqW`bhrh|Td*br${@WT+@I zMj75bkE6D2%J+lV0tHO7mW3FIxI(oiJGBRa4B0$_eoHYn;P ztXc5wpAZrHxqMxmf-A=+xklQRaxHzs`veM*ushsbM=no*m*{B-iBMbRtYM_0niF!b z&AVTfop^4hY$4XKf&(14WH;eDrcNoW`Vzhui>fZ2joe<=!2h|X)|i^dq;c zW0rb32-7}Y`dqXMGhe{k%Uv2$QPxqr)k`j%Ro<^jR7so&B@x7K4pJgr?NyZ-8CJX0 zT9a9UYkv&WK7L1f)R8GtR}jFs>p@+M0S!UDYb%m!H{vUa<}Uw8V8xOnst**pjB^7v z_PXu-y$*UxEcKFk9XK;-dWAgivNqf3KIZ1l^Ib@7+~)4B=bUxb9#0Y2EwM@1KMhSZ z7O=|{)7LS2pM0Q#u>T|ZH9dY&t@%pZ#p0|nBUw&Cv!nm4cpfy3KEBIuJx;JkRAxZN+4*_? zCXy9~yDcHACJghxyk`nPbAAs$F}}C37Jay)JgB8t`k8eDW3R;i;9Kx8xK|_*mI=re z{Wo zQBbvkusLhR;tNXBG!`dBScUrl193N+37&oQ79gUL`nHV zg^H(70A1WrbJaGM?l9fsUkA_^OQ5*oO91RmB{0sHHO5ppB^7&b?oJHNV)J#FHjeeH zm-rP)1uEhtk)-=OgEJOk+v+!>V7)o zhb^7#ynuJS1K-BcRc3x_X4$&yv<}ZtElxM#%#6O z4$J5>vB13}Z3{STl+5hBaCB=4zjYw(R@k|ClpBiv_E&J#A;LT0M}POtm9A1iPz zS15>%qJMojm>tO!iK$v-kWgJ z?KumV)j8|bX2rkJYWXJkxB%!rIJhF6EDt$zmj-w`5yGAAc8g69W39tN7vlo_lBgP! z!31OZ6w;SDE$ro?7AFOi@Rg4gu!}&pqNekU-_@F)OskPLqjYS6gV^&`iD+emVg^n6 zBNcYZLN*e}S;>aNb7>87{2>&rSWs-eWUt-d5mlxnjbSp_s#s`5F8imAT7plUd`&k< z_4J%3`gg^Un5waK(=Lt_N1%3m2Z*qq`N_$Bl{#;S``BSY6*=B&BM05~OEcJWb|H*H z;IrG!*VYPj{CLzi%F-pqk16M%v!ug4WWcsc`9A{9g4K6`vq9u-;@4$%8u$2hnThf1 z`RCuk_P&SfpwnMueJ+1@rdXCdf9X?Coo79YA82iec{`sUFMlYzEX5 z6;*@8kNN<5iaNW*84aP0ft0QQWqcL_{gYqbIt4eJjW3J-M|+3>K0VfeJ!GOTC-6P* z-)dE_pi9~y(-VSxm#4sMa6V9{@AcN;(v=n*bX6IV^bQsAx$58h1YgMIKD9ILSv!lw ztSy-0!D6ne4y@x?PDRZAMvqP#nVMcI2=#gz&?Y}JkO#fm8=jUR0Oz2F3PHR+D+lwo z)bg_b-Rg6ZCH6Ao*3!NKar(U^L`Y&Q6>WD%B2i)Exad{DsrGnmlHWue$v{(=aPrQ| zF{+#X&xNr1xD^%@YEZ^@R8EjF;p~};j@M3bBk`KQuG7o#7Du}obeLv!s2nm%4Yj*e z2t-=qsmfdi1i|fFIcs*H;B?8btG|MHo4Z(T&F%%Z>eB2*2UFW$njWdIQxVfLh6(yZ4bX03sG z+Plc22c0Zp^(*D=s#6l}LV#}PeVJ#i$t-mdxAxzQj^SkVp8YR!1|FVF#h(y3i+;99 z0XG-M+Hfh8<}y1Y(WI8$H<-N!LsNl<0hzCV=Hso6dtPpyJ}-89l}HQPP|Cm`{-5tF zG8?xf{qWbPb$lKh!Uu)PseIBG@O83IX6Qk$ zjmCGvE7K--bCFAJBWD10kb{x#gBuOmXp}cUbE___W%@{(xl6U9iq>?*_JIQ@W*Oh2 znX57F>n2u5gYE8(@5uy#juFj)?31FsFKIo;WTGAA$=^J}$r9>@tv3I87-$6=yGj!P zn}(5tL!g&G##z4K-q_rsDg`yF%lo9!k3_>SwQkdKs3@Zi1`~l#oAmIH#3_PKU8>7T zjzbpT!yvW8abRjH6h@#vwX)o$CG@1Bg|xiPQ4>?(1C`Woo^YBO)(8FV&r)%^5$OgM z5$xIWWkvaRy_P3--m+&d1(?upb2^&yleK(J?AiD6PIgdpz^;pliU3DoTK?4Szb9`f{el#yhcTW(vhyXw?VP}E z`RB^$;l*zzW)h1m$tY_%hIy>WvVa5zGcF!sbfWU%c}e-k#{8XdII>5?oWnt=2G<8E z{t+vcJmG)37S|FZ)6tQn9Z^G#3KF}k{l>2?>Vvo8qetg$MvGFSNWw;hBp(m>-1EY2l=Py;A}b&aHY4%|MDb>d8mmvLkK3{MXw6A=Mb>#Aznjka^xQvc=I(W z&$KQVzZnUr5jnbaP-oOuV8nSbk;~3klq%z{bk;UD;}0r5Jl!l~VkTDXR*I$P^W_tM z5>zh)P^X`Zn~-Qd<`I4I>m45$Jwu9Nr6NU(nUhH2^%d^9=L=YT;Vu7}qec1pq9sAKxtR`Y}5mjs>lhHXK0AT}~20G$jv*cN7EkNd~a_4Q-3< z;w`9z9(e{c{26U5-l5YwW~9;!_-H^Czr!5XBb+v7cZ4~@MZ zbv?ibf#XU?=wS&Kts>{vg#CDxE8oe3#*F3nZV#QzK#PT8pWBRuZO<`kYlr02AEj6D zb3r{i7Ps9U&!QBR(R@l z>y1E3i9aRSl!I}=RZr<=Y>DeN6f&;3a-6unO$j;GOeB&6!z&J9%c5JL>vjO0`cG_I z;M;6+#cLqR32BxdtZeol)3qNABqKuu;cjf@p(ZbPjvSp%lnBo;AyfNOq~mdm&C?aW z!=d6(=6Pwf@Jn>L&%AXG3KTl1#?pFCE!AhiD5o|QTgz#!Yc~CM`g2QGnR%3-k0&2z z2Y0iqHvmzvq$nuCq8PKrV+3J27b4Vg7geFQY5iKaJk@86J9A=j2*%KwHz~0*99!{j zaKx|*Cg9_@O3pu6qF>*AzM7bb+-d4;H#t7qz%JlaB6%qhH`Lz_S!3e>W{VtK2n`q( z6!Cip5u}8fe^dlg*gz~xpQm!$hQuU4W(c0eRI~B@W7EC!7wJuMv$l~JQwBmsGNVw@ znj6*BHJj6(9tm08;Z^+h)#CnKK*jZ_n|h9A5R8Olaf({mRS-=zlVNS=B@QkIY_1=t z6c!RVe$`%2YsF4&dHQ%eR4nY%<8fY{gwV=jFX%};U%TW>rHJ9?-B=`dwzV6`h4RD8 zCwMIHUsBrwj4fBtOb1gOa)UJ=BwJy`8#mKG47$<}Cm*gwt|COW6w$j11a3l~uKC+K z=_{b=z-$pcenQVo!kCQndfkmUVzRQDcDiF?NLA3lEQ?B_%#&JL(Q|qHUM-cD|3bBM z$Ghc``aXbM&|$qN=fc>0#11pazW}30vsZ`mh5vAIK(%Igc+O)CSH|uQ+jUsxpz6=B$S;s5ti7cD+_BpF5);f??O+M}T$JGAuP{KmUO&|DVftDi|PQQGq zF*UOO;46zpzS%fRNNdOEKE!!M91j}3&rQWD6n@N!nU?O`Zd+?+N**`ybs}$Snk8o2 zKEdKf8GFS#@CDWq^9A%hu9 zHe{9`x7pA@Hh}V@TGIxfB0&jUGmC+CViyG){d%TUgy&Qnt~iGd?26rsFd3aa@>$@5 zf2z6Oc(WAjH>isUnB~g$Xp7ZlN)f)HD?|_~| z{eyQrXkKO6k3rNXy!{H-r&C_Qc3RND;YS`O9X1{@d0d_{}# z7u|?7<&tgTq2;JTxI<;!&_+_hCh!CD$ytwI%-fn~LMq!QT4ds=6Us3rcy%y-H*p86eD; zi1|54L-gAf(ocjNAiK=50U$c^HF*c#fbIG%?i<1__HVzLChNga+bbZ2@C{@`_aX@%KMXk3OWh;S41jaH~gbrpI1WnDiq&<7+y` z*?d*$TCWA6dd4xta&d~=*$(7tjFJ2yuVCHWsBu#zKutJNjZIJ8fbuE%0AIR$Pg4|x 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 +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { version } from '../package.json' + +const appVersion = version + +const status = ref<'starting' | 'ready' | 'language-select' | 'initializing' | 'timeout' | 'crashed' | 'restarting' | 'connection-select'>('starting') +const errorMessage = ref('') +const isDark = ref(true) +const selectedLanguage = ref<'zh-CN' | 'en-US' | null>(null) + +// ─── Connection chooser state ────────────────────────── +const connectionMode = ref<'local' | 'remote' | null>(null) +const connView = ref<'choose' | 'remote-form'>('choose') +const remoteUrlInput = ref('') +const testing = ref(false) +const testResult = ref<{ ok: boolean; msg: string } | null>(null) +const recentServers = ref([]) +// Build variant: 'local' = full (bundles JRE+JAR), 'remote' = lite (connect to remote server only) +const buildMode = ref<'local' | 'remote'>('local') + +let BACKEND_URL = '' + +let unsubStatus: (() => void) | null = null +let unsubCrashed: (() => void) | null = null +let unsubUpdater: (() => void) | null = null + +// ─── Updater state ───────────────────────────────────── +const updater = ref({ status: 'idle' }) + +const showUpdateBanner = computed(() => + ['available', 'downloading', 'downloaded', 'error'].includes(updater.value.status) +) + +const downloadPercent = computed(() => + Math.round(updater.value.progress?.percent ?? 0) +) + +function handleDownloadUpdate() { + window.mateClawAPI?.downloadUpdate() +} + +function handleInstallUpdate() { + window.mateClawAPI?.installUpdate() +} + +// Step progress +const currentStep = computed(() => { + switch (status.value) { + case 'starting': return 1 + case 'restarting': return 0 + case 'language-select': return 2 + case 'initializing': return 2 + case 'ready': return 4 + case 'timeout': + case 'crashed': return -1 + default: return 0 + } +}) + +const progressWidth = computed(() => { + if (status.value === 'crashed' || status.value === 'timeout') return '100%' + if (status.value === 'ready') return '100%' + if (status.value === 'restarting') return '15%' + if (status.value === 'language-select') return '60%' + if (status.value === 'initializing') return '80%' + return '45%' +}) + +const steps = [ + { label: 'Environment' }, + { label: 'Starting' }, + { label: 'Language' }, + { label: 'Ready' }, +] + +function stepClass(index: number) { + if (status.value === 'crashed' || status.value === 'timeout') return index === 0 ? 'done' : '' + if (currentStep.value > index) return 'done' + if (currentStep.value === index) return 'active' + return '' +} + +function toggleTheme() { + isDark.value = !isDark.value +} + +async function checkSetupStatus() { + try { + const res = await fetch(`${BACKEND_URL}/api/v1/setup/status`) + const data = await res.json() + if (data.data?.initialized) { + // Already initialized — skip language selection, go directly to app + status.value = 'ready' + navigateToApp() + } else { + // First run — show language selection + status.value = 'language-select' + } + } catch (e) { + console.error('Failed to check setup status:', e) + // If API fails, default to language selection + status.value = 'language-select' + } +} + +async function selectLanguage(lang: 'zh-CN' | 'en-US') { + selectedLanguage.value = lang + status.value = 'initializing' + + try { + const res = await fetch(`${BACKEND_URL}/api/v1/setup/init`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language: lang }), + }) + + if (res.ok || res.status === 409) { + // 409 means already initialized — that's fine too + status.value = 'ready' + setTimeout(() => navigateToApp(), 600) + } else { + const err = await res.text() + throw new Error(err) + } + } catch (e: any) { + console.error('Failed to initialize:', e) + errorMessage.value = e.message || 'Initialization failed' + status.value = 'crashed' + } +} + +function navigateToApp() { + if (window.mateClawAPI?.navigateToApp) { + window.mateClawAPI.navigateToApp() + } +} + +// ─── Backend-ready dispatch ──────────────────────────── +async function handleBackendReady() { + if (connectionMode.value === 'remote') { + // A remote server is already initialized by its administrator — skip the + // local setup/language flow and enter the application directly. + if (window.mateClawAPI) { + BACKEND_URL = await window.mateClawAPI.getBackendUrl() + } + status.value = 'ready' + setTimeout(() => navigateToApp(), 300) + } else { + checkSetupStatus() + } +} + +// ─── Connection chooser actions ──────────────────────── +async function chooseLocal() { + connectionMode.value = 'local' + status.value = 'starting' + await window.mateClawAPI?.useLocalConnection() + if (window.mateClawAPI) { + BACKEND_URL = await window.mateClawAPI.getBackendUrl() + } +} + +function openRemoteForm() { + connView.value = 'remote-form' + testResult.value = null +} + +function backToChoose() { + connView.value = 'choose' + testResult.value = null +} + +function describeConnError(r: { error?: string }): string { + switch (r.error) { + case 'invalid-url': return '地址格式无效' + case 'timeout': return '连接超时,请检查地址与网络' + default: return r.error ? `连接失败: ${r.error}` : '连接失败' + } +} + +async function testRemote() { + if (!remoteUrlInput.value.trim() || !window.mateClawAPI) return + testing.value = true + testResult.value = null + try { + const r = await window.mateClawAPI.testConnection(remoteUrlInput.value) + testResult.value = r.ok + ? { ok: true, msg: '连接成功' } + : { ok: false, msg: describeConnError(r) } + } finally { + testing.value = false + } +} + +async function connectRemote(url?: string) { + const target = (url ?? remoteUrlInput.value).trim() + if (!target || !window.mateClawAPI) return + remoteUrlInput.value = target + const r = await window.mateClawAPI.useRemoteConnection(target) + if (!r.ok) { + testResult.value = { ok: false, msg: describeConnError(r) } + return + } + connectionMode.value = 'remote' + testResult.value = null + status.value = 'starting' +} + +onMounted(async () => { + // Detect system dark mode preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + isDark.value = prefersDark + + createParticles() + + if (window.mateClawAPI) { + // Get dynamic backend URL from main process + BACKEND_URL = await window.mateClawAPI.getBackendUrl() + + unsubStatus = window.mateClawAPI.onBackendStatus((s: string) => { + if (s === 'choose') { + status.value = 'connection-select' + } else if (s === 'ready') { + handleBackendReady() + } else { + status.value = s as typeof status.value + } + }) + unsubCrashed = window.mateClawAPI.onBackendCrashed((msg: string) => { + status.value = 'crashed' + errorMessage.value = msg + }) + + // Decide the initial screen from saved connection configuration. + try { + const cfg = await window.mateClawAPI.getConnectionConfig() + recentServers.value = cfg.servers || [] + remoteUrlInput.value = cfg.remoteUrl || '' + buildMode.value = cfg.buildMode || 'local' + + // Remote (lite) builds: skip the mode chooser, go straight to the + // remote server form — the "local" option is not available. + if (buildMode.value === 'remote') { + if (cfg.forceChoose || !cfg.mode || cfg.mode === 'local') { + status.value = 'connection-select' + connView.value = 'remote-form' + } else { + connectionMode.value = cfg.mode + if (await window.mateClawAPI.isBackendReady()) { + handleBackendReady() + } + } + } else if (cfg.forceChoose || !cfg.mode) { + status.value = 'connection-select' + connView.value = 'choose' + } else { + connectionMode.value = cfg.mode + // The backend may have become ready before listeners attached. + if (await window.mateClawAPI.isBackendReady()) { + handleBackendReady() + } + } + } catch (e) { + console.error('Failed to load connection config:', e) + } + + unsubUpdater = window.mateClawAPI.onUpdaterState((state: UpdaterState) => { + updater.value = state + }) + window.mateClawAPI.getUpdaterState().then((state) => { + if (state) updater.value = state + }) + } +}) + +onUnmounted(() => { + unsubStatus?.() + unsubCrashed?.() + unsubUpdater?.() +}) + +function handleRestart() { + status.value = 'restarting' + errorMessage.value = '' + window.mateClawAPI?.restartBackend() +} + +function createParticles() { + const container = document.getElementById('particles') + if (!container) return + for (let i = 0; i < 12; i++) { + const p = document.createElement('div') + p.className = 'particle' + const size = Math.random() * 3 + 1.5 + p.style.width = size + 'px' + p.style.height = size + 'px' + p.style.left = Math.random() * 100 + '%' + p.style.bottom = '-10px' + p.style.animationDuration = (Math.random() * 6 + 5) + 's' + p.style.animationDelay = (Math.random() * 8) + 's' + container.appendChild(p) + } +} + + + + + diff --git a/mateclaw-desktop/src/env.d.ts b/mateclaw-desktop/src/env.d.ts new file mode 100644 index 00000000..cd290465 --- /dev/null +++ b/mateclaw-desktop/src/env.d.ts @@ -0,0 +1,67 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +interface UpdaterState { + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + version?: string + releaseNotes?: string + progress?: { percent: number; bytesPerSecond: number; transferred: number; total: number } + error?: string +} + +interface RemoteServer { + url: string + name?: string + lastUsed?: number +} + +interface ConnectionConfigState { + mode: 'local' | 'remote' | null + remoteUrl: string + servers: RemoteServer[] + forceChoose: boolean + buildMode: 'local' | 'remote' +} + +interface ConnectionTestResult { + ok: boolean + status?: number + error?: string +} + +interface MateClawAPI { + getPlatform: () => Promise + getVersion: () => Promise + getBuildMode: () => Promise<'local' | 'remote'> + getBackendUrl: () => Promise + isBackendReady: () => Promise + getUserDataPath: () => Promise + openExternal: (url: string) => Promise + restartBackend: () => Promise + onBackendStatus: (callback: (status: string) => void) => () => void + onBackendCrashed: (callback: (message: string) => void) => () => void + navigateToApp: () => void + + // Connection management + getConnectionConfig: () => Promise + testConnection: (url: string) => Promise + useLocalConnection: () => Promise + useRemoteConnection: (url: string) => Promise + switchServer: () => Promise + + // Auto-updater + getUpdaterState: () => Promise + checkForUpdates: () => Promise + downloadUpdate: () => Promise + installUpdate: () => Promise + onUpdaterState: (callback: (state: UpdaterState) => void) => () => void +} + +interface Window { + mateClawAPI: MateClawAPI +} diff --git a/mateclaw-desktop/src/main.ts b/mateclaw-desktop/src/main.ts new file mode 100644 index 00000000..01433bca --- /dev/null +++ b/mateclaw-desktop/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/mateclaw-desktop/tsconfig.json b/mateclaw-desktop/tsconfig.json new file mode 100644 index 00000000..8c753115 --- /dev/null +++ b/mateclaw-desktop/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "resolveJsonModule": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "electron/**/*.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/mateclaw-desktop/tsconfig.node.json b/mateclaw-desktop/tsconfig.node.json new file mode 100644 index 00000000..faab2271 --- /dev/null +++ b/mateclaw-desktop/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "composite": true, + "skipLibCheck": true, + "noEmit": false + }, + "include": ["vite.config.ts"] +} diff --git a/mateclaw-desktop/tsconfig.node.tsbuildinfo b/mateclaw-desktop/tsconfig.node.tsbuildinfo new file mode 100644 index 00000000..188364d4 --- /dev/null +++ b/mateclaw-desktop/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2020.full.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/modulerunnertransport.d-dj_me5sf.d.ts","./node_modules/vite/dist/node/module-runner.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","../../../../node_modules/lightningcss/node/ast.d.ts","../../../../node_modules/lightningcss/node/targets.d.ts","../../../../node_modules/lightningcss/node/index.d.ts","./node_modules/vite/types/internal/lightningcssoptions.d.ts","./node_modules/vite/types/internal/csspreprocessoroptions.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@vue/shared/dist/shared.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@vue/compiler-core/dist/compiler-core.d.ts","./node_modules/magic-string/dist/magic-string.es.d.mts","./node_modules/typescript/lib/typescript.d.ts","./node_modules/@vue/compiler-sfc/dist/compiler-sfc.d.ts","./node_modules/vue/compiler-sfc/index.d.mts","./node_modules/@vitejs/plugin-vue/dist/index.d.mts","./node_modules/vite-plugin-electron/dist/utils.d.ts","./node_modules/vite-plugin-electron/dist/index.d.ts","./node_modules/vite-plugin-electron-renderer/dist/index.d.ts","./vite.config.ts","./node_modules/keyv/src/index.d.ts","./node_modules/@types/http-cache-semantics/index.d.ts","./node_modules/@types/responselike/index.d.ts","./node_modules/@types/cacheable-request/index.d.ts","./node_modules/@types/ms/index.d.ts","./node_modules/@types/debug/index.d.ts","./node_modules/@types/fs-extra/index.d.ts","./node_modules/@types/keyv/index.d.ts","./node_modules/xmlbuilder/typings/index.d.ts","./node_modules/@types/plist/index.d.ts","./node_modules/@types/verror/index.d.ts","./node_modules/@types/yauzl/index.d.ts"],"fileIdsList":[[57,104,190],[57,104],[57,104,115,118,145,152,203,204,205],[57,104,207],[57,104,116,152],[57,104,115,152],[57,101,104],[57,103,104],[104],[57,104,109,137],[57,104,105,110,115,123,134,145],[57,104,105,106,115,123],[52,53,54,57,104],[57,104,107,146],[57,104,108,109,116,124],[57,104,109,134,142],[57,104,110,112,115,123],[57,103,104,111],[57,104,112,113],[57,104,114,115],[57,103,104,115],[57,104,115,116,117,134,145],[57,104,115,116,117,130,134,137],[57,104,112,115,118,123,134,145],[57,104,115,116,118,119,123,134,142,145],[57,104,118,120,134,142,145],[55,56,57,58,59,60,61,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],[57,104,115,121],[57,104,122,145,150],[57,104,112,115,123,134],[57,104,124],[57,104,125],[57,103,104,126],[57,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],[57,104,128],[57,104,129],[57,104,115,130,131],[57,104,130,132,146,148],[57,104,115,134,135,137],[57,104,136,137],[57,104,134,135],[57,104,137],[57,104,138],[57,101,104,134,139],[57,104,115,140,141],[57,104,140,141],[57,104,109,123,134,142],[57,104,143],[57,104,123,144],[57,104,118,129,145],[57,104,109,146],[57,104,134,147],[57,104,122,148],[57,104,149],[57,99,104],[57,99,104,115,117,126,134,137,145,148,150],[57,104,134,151],[57,104,152,211],[57,104,118,134,152],[57,104,115,134,152],[57,104,189,197],[57,104,190,191,192],[57,104,181,190,192,193,194,195],[57,104,115],[57,104,177],[57,104,175,177],[57,104,166,174,175,176,178,180],[57,104,164],[57,104,167,172,177,180],[57,104,163,180],[57,104,167,168,171,172,173,180],[57,104,167,168,169,171,172,180],[57,104,164,165,166,167,168,172,173,174,176,177,178,180],[57,104,180],[57,104,162,164,165,166,167,168,169,171,172,173,174,175,176,177,178,179],[57,104,162,180],[57,104,167,169,170,172,173,180],[57,104,171,180],[57,104,172,173,177,180],[57,104,165,175],[57,104,154,188,189],[57,104,153,154],[57,71,75,104,145],[57,71,104,134,145],[57,66,104],[57,68,71,104,142,145],[57,104,123,142],[57,104,152],[57,66,104,152],[57,68,71,104,123,145],[57,63,64,67,70,104,115,134,145],[57,71,78,104],[57,63,69,104],[57,71,92,93,104],[57,67,71,104,137,145,152],[57,92,104,152],[57,65,66,104,152],[57,71,104],[57,65,66,67,68,69,70,71,72,73,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,93,94,95,96,97,98,104],[57,71,86,104],[57,71,78,79,104],[57,69,71,79,80,104],[57,70,104],[57,63,66,71,104],[57,71,75,79,80,104],[57,75,104],[57,69,71,74,104,145],[57,63,68,71,78,104],[57,104,134],[57,66,71,92,104,150,152],[57,104,161,189],[57,104,105,152,154,188,189,199],[57,104,189,200],[57,104,115,116,118,119,120,123,134,142,145,151,152,154,155,156,157,159,160,161,181,185,186,187,188,189],[57,104,156,157,158,159],[57,104,156],[57,104,157],[57,104,184],[57,104,154,189],[57,104,196],[57,104,125,189,198,200,201],[57,104,182,183]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7a3c8b952931daebdfc7a2897c53c0a1c73624593fa070e46bd537e64dcd20a","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"1305d1e76ca44e30fb8b2b8075fa522b83f60c0bcf5d4326a9d2cf79b53724f8","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"714435130b9015fae551788df2a88038471a5a11eb471f27c4ede86552842bc9","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"27fdb0da0daf3b337c5530c5f266efe046a6ceb606e395b346974e4360c36419","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"47ab634529c5955b6ad793474ae188fce3e6163e3a3fb5edd7e0e48f14435333","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"fad4e3c207fe23922d0b2d06b01acbfb9714c4f2685cf80fd384c8a100c82fd0","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"a7ca8df4f2931bef2aa4118078584d84a0b16539598eaadf7dce9104dfaa381c","impliedFormat":1},{"version":"11443a1dcfaaa404c68d53368b5b818712b95dd19f188cab1669c39bee8b84b3","impliedFormat":1},{"version":"36977c14a7f7bfc8c0426ae4343875689949fb699f3f84ecbe5b300ebf9a2c55","impliedFormat":1},{"version":"035d0934d304483f07148427a5bd5b98ac265dae914a6b49749fe23fbd893ec7","impliedFormat":99},{"version":"e2ed5b81cbed3a511b21a18ab2539e79ac1f4bc1d1d28f8d35d8104caa3b429f","impliedFormat":99},{"version":"161c8e0690c46021506e32fda85956d785b70f309ae97011fd27374c065cac9b","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"7965dc3c7648e2a7a586d11781cabb43d4859920716bc2fdc523da912b06570d","impliedFormat":1},{"version":"90c2bd9a3e72fe08b8fa5982e78cb8dc855a1157b26e11e37a793283c52bf64b","impliedFormat":1},{"version":"a8122fe390a2a987079e06c573b1471296114677923c1c094c24a53ddd7344a2","impliedFormat":1},{"version":"70c2cb19c0c42061a39351156653aa0cf5ba1ecdc8a07424dd38e3a1f1e3c7f4","impliedFormat":1},{"version":"a8fb10fd8c7bc7d9b8f546d4d186d1027f8a9002a639bec689b5000dab68e35c","impliedFormat":1},{"version":"c9b467ea59b86bd27714a879b9ad43c16f186012a26d0f7110b1322025ceaa83","impliedFormat":1},{"version":"57ea19c2e6ba094d8087c721bac30ff1c681081dbd8b167ac068590ef633e7a5","impliedFormat":1},{"version":"cba81ec9ae7bc31a4dc56f33c054131e037649d6b9a2cfa245124c67e23e4721","impliedFormat":1},{"version":"ad193f61ba708e01218496f093c23626aa3808c296844a99189be7108a9c8343","impliedFormat":1},{"version":"a0544b3c8b70b2f319a99ea380b55ab5394ede9188cdee452a5d0ce264f258b2","impliedFormat":1},{"version":"8c654c17c334c7c168c1c36e5336896dc2c892de940886c1639bebd9fc7b9be4","impliedFormat":1},{"version":"6a4da742485d5c2eb6bcb322ae96993999ffecbd5660b0219a5f5678d8225bb0","impliedFormat":1},{"version":"c65ca21d7002bdb431f9ab3c7a6e765a489aa5196e7e0ef00aed55b1294df599","impliedFormat":1},{"version":"c8fc655c2c4bafc155ceee01c84ab3d6c03192ced5d3f2de82e20f3d1bd7f9fa","impliedFormat":1},{"version":"be5a7ff3b47f7e553565e9483bdcadb0ca2040ac9e5ec7b81c7e115a81059882","impliedFormat":1},{"version":"1a93f36ecdb60a95e3a3621b561763e2952da81962fae217ab5441ac1d77ffc5","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"f7eebe1b25040d805aefe8971310b805cd49b8602ec206d25b38dc48c542f165","impliedFormat":1},{"version":"a18642ddf216f162052a16cba0944892c4c4c977d3306a87cb673d46abbb0cbf","impliedFormat":1},{"version":"509f8efdfc5f9f6b52284170e8d7413552f02d79518d1db691ee15acc0088676","impliedFormat":1},{"version":"4ec16d7a4e366c06a4573d299e15fe6207fc080f41beac5da06f4af33ea9761e","impliedFormat":1},{"version":"7870becb94cbc11d2d01b77c4422589adcba4d8e59f726246d40cd0d129784d8","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"f70b8328a15ca1d10b1436b691e134a49bc30dcf3183a69bfaa7ba77e1b78ecd","impliedFormat":1},{"version":"683b035f752e318d02e303894e767a1ac16ac4493baa2b593195d7976e6b7310","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"f468b74459f1ad4473b36a36d49f2b255f3c6b5d536c81239c2b2971df089eaf","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"524a409ad72186b7f6cb16898c349465cfa876f641d6cb6137b3123d5cfca619","impliedFormat":1},{"version":"2be2227c3810dfd84e46674fd33b8d09a4a28ad9cb633ed536effd411665ea1e","impliedFormat":99},{"version":"e134052a6b1ded61693b4037f615dc72f14e2881e79c1ddbff6c514c8a516b05","impliedFormat":1},{"version":"957a44f864ab3c182edc747428e8eec1765257deee7fac86c1147eeac897d832","impliedFormat":1},{"version":"3feec212c0aeb91e5a6e62caaf9f128954590210f8c302910ea377c088f6b61a","impliedFormat":99},{"version":"bbdfaf7d9b20534c5df1e1b937a20f17ca049d603a2afe072983bf7aff2279f5","impliedFormat":99},{"version":"0890467498b67e20cec24aaa50bebc232dc4f588a982e60dc6bb07b6e797da52","impliedFormat":1},{"version":"928e3aa1a5dab12a194d90c71959d1251917515554f45793f98d06ab731f3fbf","impliedFormat":1},{"version":"c400678110f688feba4d6d3f93269fca02834bb3d6a1ecd99b28b3f69f7d23f4","impliedFormat":1},"3fbb27229bdcfcb4241a0742f967fb5eb7e753bd8ee74d0fa5a8dc90fcb7b20f",{"version":"42baf4ca38c38deaf411ea73f37bc39ff56c6e5c761a968b64ac1b25c92b5cd8","impliedFormat":1},{"version":"4f6ae308c5f2901f2988c817e1511520619e9025b9b12cc7cce2ab2e6ffed78a","impliedFormat":1},{"version":"8718fa41d7cf4aa91de4e8f164c90f88e0bf343aa92a1b9b725a9c675c64e16b","impliedFormat":1},{"version":"f992cd6cc0bcbaa4e6c810468c90f2d8595f8c6c3cf050c806397d3de8585562","impliedFormat":1},{"version":"fb893a0dfc3c9fb0f9ca93d0648694dd95f33cbad2c0f2c629f842981dfd4e2e","impliedFormat":1},{"version":"89e326922cadcc2331d7e851011cf9f0456a681aaf3c95b48b81f8d80e8cdfba","impliedFormat":1},{"version":"ed19da84b7dbf00952ad0b98ce5c194f1903bcf7c94d8103e8e0d63b271543ae","impliedFormat":1},{"version":"fec943fdb3275eb6e006b35e04a8e2e99e9adf3f4b969ddf15315ac7575a93e4","impliedFormat":1},{"version":"10a60d0cc51552184ceb31c27ef547eb365b918b0927b155176be3c3d5cba82c","impliedFormat":1},{"version":"1a86aff0e5cf0da881c826ada253aa5256ff0d53c2123c62cd7851559eaee9b9","impliedFormat":1},{"version":"62ba45a86b9a31eb84ea03ae0b9e800a507d980c1f38dcec6528f10078cfdedd","impliedFormat":1},{"version":"74d5a87c3616cd5d8691059d531504403aa857e09cbaecb1c64dfb9ace0db185","impliedFormat":1}],"root":[202],"options":{"allowImportingTsExtensions":true,"composite":true,"module":99,"skipLibCheck":true,"target":7},"referencedMap":[[192,1],[190,2],[206,3],[208,4],[153,2],[209,5],[204,2],[210,6],[207,2],[101,7],[102,7],[103,8],[57,9],[104,10],[105,11],[106,12],[52,2],[55,13],[53,2],[54,2],[107,14],[108,15],[109,16],[110,17],[111,18],[112,19],[113,19],[114,20],[115,21],[116,22],[117,23],[58,2],[56,2],[118,24],[119,25],[120,26],[152,27],[121,28],[122,29],[123,30],[124,31],[125,32],[126,33],[127,34],[128,35],[129,36],[130,37],[131,37],[132,38],[133,2],[134,39],[136,40],[135,41],[137,42],[138,43],[139,44],[140,45],[141,46],[142,47],[143,48],[144,49],[145,50],[146,51],[147,52],[148,53],[149,54],[59,2],[60,2],[61,2],[100,55],[150,56],[151,57],[212,58],[205,59],[213,2],[214,60],[198,61],[193,62],[196,63],[191,2],[62,2],[161,2],[203,64],[194,2],[178,65],[176,66],[177,67],[165,68],[166,66],[173,69],[164,70],[169,71],[179,2],[170,72],[175,73],[181,74],[180,75],[163,76],[171,77],[172,78],[167,79],[174,65],[168,80],[155,81],[154,82],[162,2],[49,2],[50,2],[10,2],[8,2],[9,2],[14,2],[13,2],[2,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[3,2],[23,2],[24,2],[4,2],[25,2],[29,2],[26,2],[27,2],[28,2],[30,2],[31,2],[32,2],[5,2],[33,2],[34,2],[35,2],[36,2],[6,2],[40,2],[37,2],[38,2],[39,2],[41,2],[7,2],[42,2],[51,2],[47,2],[48,2],[43,2],[44,2],[45,2],[46,2],[1,2],[12,2],[11,2],[195,2],[78,83],[88,84],[77,83],[98,85],[69,86],[68,87],[97,88],[91,89],[96,90],[71,91],[85,92],[70,93],[94,94],[66,95],[65,88],[95,96],[67,97],[72,98],[73,2],[76,98],[63,2],[99,99],[89,100],[80,101],[81,102],[83,103],[79,104],[82,105],[92,88],[74,106],[75,107],[84,108],[64,109],[87,100],[86,98],[90,2],[93,110],[201,111],[200,112],[199,113],[189,114],[160,115],[159,116],[157,116],[156,2],[158,117],[187,2],[186,2],[185,118],[188,119],[197,120],[211,109],[202,121],[182,2],[184,122],[183,2]],"affectedFilesPendingEmit":[[202,17]],"emitSignatures":[202],"version":"5.9.3"} \ No newline at end of file diff --git a/mateclaw-desktop/vite.config.ts b/mateclaw-desktop/vite.config.ts new file mode 100644 index 00000000..5dd690f7 --- /dev/null +++ b/mateclaw-desktop/vite.config.ts @@ -0,0 +1,72 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import electron from 'vite-plugin-electron' +import renderer from 'vite-plugin-electron-renderer' +import { resolve } from 'path' +import { brandingPlugin } from './scripts/branding.cjs' + +export default defineConfig(({ command }) => { + const isServe = command === 'serve' + const isBuild = command === 'build' + + // Shared branding plugin instance — applied to the renderer build as well + // as the electron main/preload builds so brand strings are replaced + // everywhere without touching source code. + const brand = brandingPlugin() + + return { + plugins: [ + vue(), + // White-label branding: replaces "MateClaw" with the configured brand + // name at build time. Source code stays untouched. Configure via + // branding.config.json or BRAND_* env vars. + brand, + electron([ + { + entry: 'electron/main/index.ts', + onstart(args) { + args.startup() + }, + vite: { + plugins: [brand], + build: { + sourcemap: isServe, + minify: isBuild, + outDir: 'dist-electron/main', + rollupOptions: { + external: ['electron', 'electron-updater'], + }, + }, + }, + }, + { + entry: 'electron/preload/index.ts', + onstart(args) { + args.reload() + }, + vite: { + plugins: [brand], + build: { + sourcemap: isServe ? 'inline' : undefined, + minify: isBuild, + outDir: 'dist-electron/preload', + rollupOptions: { + external: ['electron'], + }, + }, + }, + }, + ]), + renderer(), + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + } +}) diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java index 5eb50c5e..58f5f762 100644 --- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java @@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.tool.ToolCallback; import vip.mate.plugin.api.channel.PluginChannelAdapter; import vip.mate.plugin.api.memory.PluginMemoryProvider; +import vip.mate.plugin.api.search.PluginSearchProvider; import java.util.function.Supplier; @@ -60,6 +61,19 @@ public interface PluginContext { */ void registerMemoryProvider(PluginMemoryProvider provider); + /** + * Register a web-search provider that joins the platform's search provider + * chain used by the {@code web_search} tool. + *

+ * The provider id must be globally unique — registration fails with a + * {@link PluginException} if it clashes with a built-in provider + * (serper / tavily / searxng / duckduckgo) or another plugin's provider. + * + * @param provider the search provider + * @throws PluginException if the id is blank or already taken + */ + void registerSearchProvider(PluginSearchProvider provider); + /** * Read a configuration value from the plugin's config. * diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java index e6287ec1..e2de252e 100644 --- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java @@ -17,5 +17,8 @@ public enum PluginType { CHANNEL, /** Register new memory providers */ - MEMORY + MEMORY, + + /** Register new web-search providers for the web_search tool */ + SEARCH } diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java new file mode 100644 index 00000000..ba67e2b2 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java @@ -0,0 +1,53 @@ +package vip.mate.plugin.api.search; + +import java.util.List; + +/** + * SPI for plugin-provided web-search providers. + *

+ * Implementations are registered via {@code PluginContext#registerSearchProvider} + * and appear in the platform's search provider chain alongside the built-in + * providers (serper / tavily / searxng / duckduckgo). + *

+ * Configuration (API keys, base URLs, ...) is NOT passed in — plugins read their + * own config declared in {@code mateclaw-plugin.json} via + * {@code PluginContext#getConfig(String, Class)}. + * + * @author MateClaw Team + */ +public interface PluginSearchProvider { + + /** Globally unique provider id, e.g. "my-search". Must not clash with built-in ids. */ + String id(); + + /** Human-readable display name. */ + String label(); + + /** Whether this provider needs a credential (affects auto-detect priority). */ + default boolean requiresCredential() { + return true; + } + + /** + * Auto-detect ordering (ascending). Built-in providers occupy 50-400; + * plugin providers default to 500 (after built-ins) but may override. + */ + default int autoDetectOrder() { + return 500; + } + + /** + * Whether the provider is currently usable — typically: required config present. + * Called on every provider resolution; keep it cheap (no network I/O). + */ + boolean isAvailable(); + + /** + * Execute the search. + * + * @param query the query (never null) + * @return results; empty list if nothing found. Must not return null. + * Throw on failure — the platform falls back to the next provider. + */ + List search(PluginSearchQuery query); +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java new file mode 100644 index 00000000..68a84fd0 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java @@ -0,0 +1,22 @@ +package vip.mate.plugin.api.search; + +/** + * Search query passed from the platform to a plugin search provider. + *

+ * Self-contained SDK type — must not depend on any mateclaw-server class, + * because plugin JARs are compiled only against mateclaw-plugin-api. + * + * @param query search keywords (never null/blank) + * @param freshness time-range filter: day / week / month / year (nullable) + * @param language language preference, e.g. zh-CN / en (nullable) + * @param count max results 1-10, already clamped by the platform (never null) + * + * @author MateClaw Team + */ +public record PluginSearchQuery( + String query, + String freshness, + String language, + Integer count +) { +} diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java new file mode 100644 index 00000000..b10749d3 --- /dev/null +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java @@ -0,0 +1,24 @@ +package vip.mate.plugin.api.search; + +/** + * A single search result returned by a plugin search provider. + *

+ * Self-contained SDK type — mirrors the platform's internal SearchResult + * (title/url/snippet/source/date) without depending on server classes. + * + * @param title result title + * @param url result link + * @param snippet short excerpt + * @param source source domain, e.g. "reuters.com" (nullable) + * @param date published date as raw string (nullable) + * + * @author MateClaw Team + */ +public record PluginSearchResult( + String title, + String url, + String snippet, + String source, + String date +) { +} diff --git a/mateclaw-plugin-search-sample/pom.xml b/mateclaw-plugin-search-sample/pom.xml new file mode 100644 index 00000000..6959beee --- /dev/null +++ b/mateclaw-plugin-search-sample/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + vip.mate + mateclaw + ${revision} + ../pom.xml + + + mateclaw-plugin-search-sample + jar + + MateClaw Search Provider Sample Plugin + Sample plugin registering a custom web-search provider via the MateClaw Plugin SDK + + + + + 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 + + + diff --git a/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java b/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java new file mode 100644 index 00000000..306e4918 --- /dev/null +++ b/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java @@ -0,0 +1,124 @@ +package vip.mate.plugin.sample.search; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.search.PluginSearchProvider; +import vip.mate.plugin.api.search.PluginSearchQuery; +import vip.mate.plugin.api.search.PluginSearchResult; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Sample plugin demonstrating {@code PluginType.SEARCH}: registers a search + * provider that queries a configurable JSON endpoint. Expected response shape: + * {@code {"results":[{"title":"...","url":"...","snippet":"..."}]}} + * + * @author MateClaw Team + */ +public class SimpleSearchPlugin implements MateClawPlugin { + + private Logger log; + + @Override + public void onLoad(PluginContext context) { + this.log = context.getLogger(); + context.registerSearchProvider(new DemoSearchProvider(context)); + log.info("SimpleSearchPlugin loaded, search provider registered"); + } + + @Override + public void onEnable() { + if (log != null) log.info("SimpleSearchPlugin enabled"); + } + + @Override + public void onDisable() { + if (log != null) log.info("SimpleSearchPlugin disabled"); + } + + static class DemoSearchProvider implements PluginSearchProvider { + + private static final Duration TIMEOUT = Duration.ofSeconds(15); + + private final PluginContext context; + private final HttpClient http = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + DemoSearchProvider(PluginContext context) { + this.context = context; + } + + @Override + public String id() { + return "demo-search"; + } + + @Override + public String label() { + return "Demo Search"; + } + + @Override + public boolean isAvailable() { + String baseUrl = context.getConfig("baseUrl", String.class); + return baseUrl != null && !baseUrl.isBlank(); + } + + @Override + public List search(PluginSearchQuery query) { + String baseUrl = context.getConfig("baseUrl", String.class); + String apiKey = context.getConfig("apiKey", String.class); + + // Minimal demo: only q/count are wired. query.freshness() and query.language() + // are also available — see the built-in SearXNGSearchProvider for how to map them. + String url = baseUrl + (baseUrl.contains("?") ? "&" : "?") + + "q=" + URLEncoder.encode(query.query(), StandardCharsets.UTF_8) + + "&count=" + query.count(); + + HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url)) + .timeout(TIMEOUT) + .GET(); + if (apiKey != null && !apiKey.isBlank()) { + req.header("Authorization", "Bearer " + apiKey); + } + + try { + HttpResponse resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + throw new IllegalStateException("Search endpoint returned HTTP " + resp.statusCode()); + } + return parse(resp.body()); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Search request failed: " + e.getMessage(), e); + } + } + + private List parse(String body) throws JsonProcessingException { + List results = new ArrayList<>(); + JsonNode items = objectMapper.readTree(body).path("results"); + for (JsonNode item : items) { + results.add(new PluginSearchResult( + item.path("title").asText(null), + item.path("url").asText(null), + item.path("snippet").asText(null), + null, + null)); + } + return results; + } + } +} diff --git a/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json b/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json new file mode 100644 index 00000000..221ca6bd --- /dev/null +++ b/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json @@ -0,0 +1,24 @@ +{ + "name": "mateclaw-plugin-search-demo", + "version": "1.0.0", + "type": "search", + "displayName": "Demo Search Provider", + "description": "Registers a custom web-search provider backed by a configurable JSON search endpoint.", + "entrypoint": "vip.mate.plugin.sample.search.SimpleSearchPlugin", + "minPlatformVersion": "1.1.0", + "author": "MateClaw Team", + "config": { + "baseUrl": { + "type": "string", + "required": true, + "secret": false, + "description": "Search endpoint returning {\"results\":[{\"title\",\"url\",\"snippet\"}]}" + }, + "apiKey": { + "type": "string", + "required": false, + "secret": true, + "description": "Optional bearer token sent as Authorization header" + } + } +} 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 d42f2cad..a6a3d058 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -38,7 +38,12 @@ import vip.mate.llm.chatmodel.ReasoningEffortResolver; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelFamily; import vip.mate.llm.model.ModelProtocol; +import vip.mate.agent.context.PrefixBudgetPlan; +import vip.mate.agent.context.PrefixBudgetPlanner; +import vip.mate.agent.context.TokenEstimator; import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.probe.ModelContextWindowResolver; +import vip.mate.llm.routing.ProviderModelRef; import vip.mate.llm.routing.ProviderRouter; import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.service.ModelProviderService; @@ -47,6 +52,7 @@ import vip.mate.skill.runtime.SkillCatalogRenderer; import vip.mate.skill.service.SkillService; import vip.mate.system.service.SystemSettingService; import vip.mate.tool.ToolRegistry; +import vip.mate.tool.disclosure.ToolUsageRecencyTracker; import vip.mate.memory.spi.MemoryManager; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.tool.guard.service.ToolGuardService; @@ -96,6 +102,9 @@ public class AgentGraphBuilder { private final ConversationService conversationService; private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; + private final ModelContextWindowResolver contextWindowResolver; + private final PrefixBudgetPlanner prefixBudgetPlanner; + private final ToolUsageRecencyTracker toolUsageRecencyTracker; private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService; private final ProviderRouter providerRouter; private final PlanningService planningService; @@ -353,6 +362,12 @@ public class AgentGraphBuilder { ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel()); + // Effective context window: explicit config > local-server probe > null + // (downstream keeps its global-default fallback). Without probing, a + // local 8k/16k model with maxInputTokens unset budgets against the + // 128k global default and the first oversized request fails outright. + Integer effectiveMaxInputTokens = contextWindowResolver.resolveMaxInputTokens(provider, runtimeModel); + // 内置搜索检测(DashScope / Kimi),但不再移除 WebSearchTool — 两者协同而非互斥 boolean builtinSearchEnabled = false; Map providerKwargs = modelProviderService.readProviderGenerateKwargs(provider); @@ -387,23 +402,45 @@ public class AgentGraphBuilder { } } - String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled); + // Prefix injection budget: optional blocks (memory / wiki / skill + // catalog / extension catalog / ledger) share a token budget scaled + // to the model's effective window. The agent's own prompt and the + // tool schemas are never truncated — they are subtracted from the + // budget so the optional blocks absorb the squeeze. + int basePromptTokens = TokenEstimator.estimateTokens(entity.getSystemPrompt()); + int toolSchemaTokens = TokenEstimator.estimateToolsTokens(toolSet.callbacks()); + PrefixBudgetPlan prefixBudgetPlan = prefixBudgetPlanner.plan( + effectiveMaxInputTokens, basePromptTokens, toolSchemaTokens); + if (basePromptTokens > prefixBudgetPlan.effectiveMaxTokens() / 2) { + log.warn("Agent {} 的身份 prompt 约 {} tokens,已超过模型有效窗口 {} 的一半——" + + "系统不会截断用户自写的身份 prompt,请自行精简,否则小上下文模型可能无法响应", + entity.getId(), basePromptTokens, prefixBudgetPlan.effectiveMaxTokens()); + } + + String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled, prefixBudgetPlan.memoryTokens()); // Runtime skill-catalog renderer — captures this agent's bound skills, // effective tool allowlist, model window and workspace; invoked each // turn by the reasoning / step-execution nodes with the skills loaded // so far this run so load_skill pins float to the top of the catalog. SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer( - entity, boundTools, runtimeModel.getMaxInputTokens()); + entity, boundTools, effectiveMaxInputTokens); // Extension-tool catalog — only for ReAct. The dynamic tool split runs // in ReasoningNode; Plan-Execute keeps advertising every tool (it has no // action node to record enable_tool), so baking the catalog there would // describe an enable_tool flow that can never take effect. + // Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute + // would remove it with no enable_tool path to recover it. boolean isPlanExecute = "plan_execute".equals(entity.getAgentType()); + Set autoDemotedTools = Set.of(); if (!isPlanExecute) { + if (prefixBudgetPlan.enabled()) { + autoDemotedTools = toolDisclosureService.computeAutoDemotions( + toolSet, prefixBudgetPlan.toolSchemaBudgetTokens()); + } String extensionCatalog = toolDisclosureService.renderExtensionCatalog( - toolSet, runtimeModel.getMaxInputTokens()); + toolSet, effectiveMaxInputTokens, autoDemotedTools); if (extensionCatalog != null && !extensionCatalog.isBlank()) { enhancedPrompt = enhancedPrompt + extensionCatalog; } @@ -423,7 +460,8 @@ public class AgentGraphBuilder { log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})", entity.getName(), maxIter, toolSet.size(), protocol.getId()); } else { - agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer); + agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer, + prefixBudgetPlan, autoDemotedTools); // StateGraph 路径下工具调用由 ActionNode 控制,始终启用 toolCallingEnabled = true; log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})", @@ -451,7 +489,7 @@ public class AgentGraphBuilder { agent.userLocale = resolveLocale(); agent.temperature = runtimeModel.getTemperature(); agent.maxTokens = runtimeModel.getMaxTokens(); - agent.maxInputTokens = runtimeModel.getMaxInputTokens(); + agent.maxInputTokens = effectiveMaxInputTokens; agent.topP = runtimeModel.getTopP(); agent.toolCallingEnabled = toolCallingEnabled; @@ -510,11 +548,17 @@ public class AgentGraphBuilder { StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) { + return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, skillCatalogRenderer, null, Set.of()); + } + + StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, + int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer, + PrefixBudgetPlan prefixBudgetPlan, Set autoDemotedTools) { ChatModel chatModel = buildRuntimeChatModel(runtimeModel); ChatClient chatClient = ChatClient.create(chatModel); String reasoningEffort = resolveReasoningEffortForModel(runtimeModel); CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, - runtimeModel, agentId, skillCatalogRenderer); + runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools); return new StateGraphReActAgent(chatClient, conversationService, compiledGraph, chatModel, conversationWindowManager, toolSet); } @@ -565,6 +609,14 @@ public class AgentGraphBuilder { streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker, primaryModelConfig != null ? primaryModelConfig.getProvider() : null, providerPool); + if (primaryModelConfig != null) { + // Feed "prompt too long" rejections back into the window resolver + // so the next turn budgets against the server-reported limit. + streamingHelper.setContextLimitObserver(errorMessage -> + contextWindowResolver.noteContextLimitError( + primaryModelConfig.getProvider(), + primaryModelConfig.getModelName(), errorMessage)); + } ToolExecutionExecutor executor = new ToolExecutionExecutor( toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry, @@ -573,6 +625,7 @@ public class AgentGraphBuilder { // LLM mis-calls a skill name as a tool, the response tells it // the right invocation pattern instead of a dead-end error. executor.setSkillRuntimeService(skillRuntimeService); + executor.setUsageRecencyTracker(toolUsageRecencyTracker); // Optional: route child-agent denied-tool audit events through // the audit pipeline. Null when audit is not wired (legacy / test). if (auditEventService != null) { @@ -647,6 +700,9 @@ public class AgentGraphBuilder { // Token Usage .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) @@ -829,12 +885,28 @@ public class AgentGraphBuilder { CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort, ModelConfigEntity primaryModelConfig, Long agentId, SkillCatalogRenderer skillCatalogRenderer) { + return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, + primaryModelConfig, agentId, skillCatalogRenderer, null, Set.of()); + } + + CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, + String reasoningEffort, ModelConfigEntity primaryModelConfig, + Long agentId, SkillCatalogRenderer skillCatalogRenderer, + PrefixBudgetPlan prefixBudgetPlan, Set autoDemotedTools) { try { List fallbackChain = buildFallbackChain(primaryModelConfig, agentId); NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper( streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker, primaryModelConfig != null ? primaryModelConfig.getProvider() : null, providerPool); + if (primaryModelConfig != null) { + // Feed "prompt too long" rejections back into the window resolver + // so the next turn budgets against the server-reported limit. + streamingHelper.setContextLimitObserver(errorMessage -> + contextWindowResolver.noteContextLimitError( + primaryModelConfig.getProvider(), + primaryModelConfig.getModelName(), errorMessage)); + } ToolExecutionExecutor executor = new ToolExecutionExecutor( toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry, @@ -843,6 +915,7 @@ public class AgentGraphBuilder { // LLM mis-calls a skill name as a tool, the response tells it // the right invocation pattern instead of a dead-end error. executor.setSkillRuntimeService(skillRuntimeService); + executor.setUsageRecencyTracker(toolUsageRecencyTracker); // Optional: route child-agent denied-tool audit events through // the audit pipeline. Null when audit is not wired (legacy / test). if (auditEventService != null) { @@ -853,10 +926,21 @@ public class AgentGraphBuilder { // capability from reasoningEffort == null. boolean supportsReasoningEffort = primaryModelConfig != null && ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort(); + // Honor the model's configured output cap. Passing 0 here made the + // node fall back to its 16384 default, so the user-configured + // maxTokens never took effect and strict local servers (vLLM's + // max_model_len pre-check) rejected the request outright. + int configuredMaxOutputTokens = (primaryModelConfig != null + && primaryModelConfig.getMaxTokens() != null + && primaryModelConfig.getMaxTokens() > 0) + ? primaryModelConfig.getMaxTokens() : 0; ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, - streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService, + streamingHelper, conversationWindowManager, streamTracker, + configuredMaxOutputTokens, wikiContextService, skillCatalogRenderer, toolDisclosureService, progressLedgerService); + reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan); + reasoningNode.setAutoDemotedTools(autoDemotedTools); ActionNode actionNode = new ActionNode(executor, streamTracker); ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties); ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker); @@ -935,6 +1019,9 @@ public class AgentGraphBuilder { // Token Usage .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) // SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的 @@ -1184,68 +1271,101 @@ public class AgentGraphBuilder { String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null; String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null; - // RFC-009 PR-3: bias by agent preferences (if any). Listed providers win - // their declared order; everything else keeps the global priority order. - List preferred = agentId == null - ? java.util.Collections.emptyList() - : agentBindingService.getPreferredProviderIds(agentId); - if (!preferred.isEmpty()) { - providers = reorderByPreferences(providers, preferred); - log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred); - } - - // RFC-090 §9.2 调整 C — second-pass reorder: lift providers - // that satisfy the bound-skill capability set (vision / video / - // audio) ahead of those that don't. Stable otherwise so the - // user-preferred order still wins among capable providers. + // RFC-090 §9.2 调整 C — lift providers that satisfy the bound-skill + // capability set (vision / video / audio) ahead of those that don't. + // Run before planning so the non-preferred tail inherits this order; + // the explicit preferred-model head keeps the user's declared order. try { providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers)); } catch (Exception e) { log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage()); } + // Preferred-model chain: explicit (provider, model) entries lead in the + // user's order — the same provider may repeat with different models — + // then every non-preferred provider follows with its default model. + List preferred = agentId == null + ? java.util.Collections.emptyList() + : agentBindingService.getPreferredProviderModels(agentId); + List globalProviderIds = providers.stream() + .map(ModelProviderEntity::getProviderId) + .toList(); + List plan = planFallbackOrder(preferred, globalProviderIds); + if (!preferred.isEmpty()) { + log.debug("[LlmFailover] agent={} preferred-model chain={} -> plan={}", agentId, preferred, plan); + } + + // Dedup by exact (provider, model) — seeded with the primary so we never + // rebuild the primary call, but OTHER models of the primary provider are + // still legitimate fallback entries. List chain = new ArrayList<>(); - for (ModelProviderEntity p : providers) { - // Don't put the primary provider's row into the fallback chain — same-instance - // skipping is also done in the runtime walker, but excluding here saves building - // a duplicate ChatModel at agent-build time. - if (primaryProviderId != null && primaryProviderId.equals(p.getProviderId())) { - log.debug("[LlmFailover] skipping primary provider {} in fallback chain", primaryProviderId); - continue; - } - // RFC-009 Phase 4: skip providers known-bad at build time. The runtime walker in - // NodeStreamingChatHelper re-checks pool membership per request, so a provider - // that re-enters the pool later still gets used (the graph is rebuilt on + Set seen = new java.util.HashSet<>(); + if (primaryProviderId != null && primaryModelName != null) { + seen.add(primaryProviderId + "::" + primaryModelName); + } + for (ProviderModelRef ref : plan) { + String pid = ref.providerId(); + // RFC-009 Phase 4: skip providers known-bad at build time. The runtime + // walker re-checks pool membership per request, so a provider that + // re-enters the pool later still gets used (graph rebuilt on // ModelConfigChangedEvent). - if (providerPool != null && !providerPool.contains(p.getProviderId())) { - log.debug("[LlmFailover] skipping provider {} — not in available pool", - p.getProviderId()); + if (providerPool != null && !providerPool.contains(pid)) { + log.debug("[LlmFailover] skipping provider {} — not in available pool", pid); continue; } - ModelConfigEntity fallbackConfig = pickFallbackModel(p.getProviderId()); + ModelConfigEntity fallbackConfig = resolveChainModel(ref); if (fallbackConfig == null) { - log.debug("[LlmFailover] skipping provider {} — no enabled chat model", - p.getProviderId()); + log.debug("[LlmFailover] skipping {} — no usable chat model", pid); continue; } - if (primaryModelName != null && primaryModelName.equals(fallbackConfig.getModelName())) { - // Same model name picked for a different provider — exact same call, skip. + String key = pid + "::" + fallbackConfig.getModelName(); + if (!seen.add(key)) { + // Exact (provider, model) already queued or equal to the primary. continue; } try { ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build()); - chain.add(new vip.mate.llm.failover.FallbackEntry(p.getProviderId(), m)); - log.info("[LlmFailover] chain[{}] = {}/{} (priority={})", - chain.size(), p.getProviderId(), fallbackConfig.getModelName(), - p.getFallbackPriority()); + chain.add(new vip.mate.llm.failover.FallbackEntry(pid, m)); + log.info("[LlmFailover] chain[{}] = {}/{}", chain.size(), pid, fallbackConfig.getModelName()); } catch (Exception e) { - log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", - p.getProviderId(), e.getMessage()); + log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", pid, e.getMessage()); } } return chain; } + /** + * Resolve a planned chain entry to a concrete chat model. A pinned model + * ({@code modelId != null}) is used when it still exists and is enabled; + * otherwise we fall back to the provider's default chat model so a deleted + * or disabled pin keeps the provider in the chain. + */ + private ModelConfigEntity resolveChainModel(ProviderModelRef ref) { + if (ref.modelId() != null) { + try { + ModelConfigEntity m = modelConfigService.getModel(ref.modelId()); + // Honour the pin only when it is a usable chat model that actually + // belongs to this entry's provider. The FallbackEntry is keyed by + // ref.providerId() for cooldown/pool, so a model from a different + // provider would mis-key the chain; an embedding model would never + // serve as a chat fallback. Either case falls back to the + // provider's default chat model. + if (m != null && Boolean.TRUE.equals(m.getEnabled()) + && ref.providerId().equals(m.getProvider()) + && (m.getModelType() == null || "chat".equals(m.getModelType()))) { + return m; + } + log.info("[LlmFailover] pinned model {} for provider {} not usable " + + "(disabled / wrong provider / non-chat), using provider default", + ref.modelId(), ref.providerId()); + } catch (Exception e) { + log.info("[LlmFailover] pinned model {} for provider {} unresolved ({}), using provider default", + ref.modelId(), ref.providerId(), e.getMessage()); + } + } + return pickFallbackModel(ref.providerId()); + } + /** * Pick a chat model to use as a fallback for the given provider: *

    @@ -1275,33 +1395,40 @@ public class AgentGraphBuilder { } /** - * Reorder a provider list by an agent's preference list. Listed provider - * ids come first in their preference order; any provider not in the - * preference list keeps its original position relative to other unlisted - * providers (stable partition). Preference entries that don't match any - * actual provider are silently dropped. + * Plan the fallback order as a list of (provider, model) refs. + * + *

    Head: the agent's explicit preference entries in declared order, + * model-granular — the same provider may appear more than once with + * different models. Exact (provider, model) duplicates are dropped. + * + *

    Tail: every provider not named in the preferences, in the supplied + * global order, each using its default model ({@code modelId == null}). + * + *

    Preference entries with a blank provider id are ignored. Package-private + * for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */ - /** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */ - static List reorderByPreferences(List providers, - List preferredOrder) { - Map byId = new java.util.LinkedHashMap<>(); - for (ModelProviderEntity p : providers) { - byId.put(p.getProviderId(), p); - } - List reordered = new ArrayList<>(providers.size()); - Set placed = new java.util.HashSet<>(); - for (String prefId : preferredOrder) { - ModelProviderEntity p = byId.get(prefId); - if (p != null && placed.add(prefId)) { - reordered.add(p); + static List planFallbackOrder(List preferred, + List globalProviderIds) { + List plan = new ArrayList<>(); + Set headEntryKeys = new java.util.HashSet<>(); + Set headProviderIds = new java.util.HashSet<>(); + if (preferred != null) { + for (ProviderModelRef ref : preferred) { + if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue; + String key = ref.providerId() + "::" + (ref.modelId() == null ? "" : ref.modelId()); + if (!headEntryKeys.add(key)) continue; // exact (provider, model) dup + plan.add(ref); + headProviderIds.add(ref.providerId()); } } - for (ModelProviderEntity p : providers) { - if (placed.add(p.getProviderId())) { - reordered.add(p); + if (globalProviderIds != null) { + for (String pid : globalProviderIds) { + if (pid == null || pid.isBlank()) continue; + if (headProviderIds.contains(pid)) continue; // already led by an explicit entry + plan.add(new ProviderModelRef(pid, null)); } } - return reordered; + return plan; } /** @@ -1327,7 +1454,7 @@ public class AgentGraphBuilder { * @throws IllegalArgumentException when an absolute override escapes the * workspace root */ - static String resolveAgentBasePath(String agentOverride, String workspaceBase) { + public static String resolveAgentBasePath(String agentOverride, String workspaceBase) { boolean hasOverride = agentOverride != null && !agentOverride.isBlank(); boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank(); if (!hasOverride) { @@ -1347,6 +1474,15 @@ public class AgentGraphBuilder { return agentOverride; } if (hasWorkspace) { + // Relative override resolves under the workspace root; reject any value + // that escapes it via "../" so attachment/media/tool I/O stays contained. + Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize(); + Path resolved = wsRoot.resolve(agentOverride).normalize(); + if (!resolved.startsWith(wsRoot)) { + throw new IllegalArgumentException( + "Agent workspaceBasePath override must stay inside the workspace root: " + + resolved + " escapes " + wsRoot); + } return Paths.get(workspaceBase).resolve(agentOverride).toString(); } return agentOverride; @@ -1393,6 +1529,10 @@ public class AgentGraphBuilder { """; private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) { + return buildEnhancedPrompt(entity, builtinSearchEnabled, Integer.MAX_VALUE); + } + + private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled, int memoryBudgetTokens) { // The agent's own systemPrompt encodes its identity (role / goal / // backstory). The memory block from workspace files (AGENTS.md, SOUL.md, // PROFILE.md, MEMORY.md, ...) augments that identity with durable @@ -1401,7 +1541,7 @@ public class AgentGraphBuilder { // dropped the identity prompt, so editor-side identity changes never // reached runtime if the agent had any workspace files. String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : ""; - String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId()); + String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId(), memoryBudgetTokens); StringBuilder basePromptBuilder = new StringBuilder(); if (!identityPrompt.isEmpty()) { basePromptBuilder.append(identityPrompt); @@ -1480,10 +1620,11 @@ public class AgentGraphBuilder { adopting a KB article as the user's project. ## Session Search - - `session_search(agentId, currentConversationId, mode, query, limit)` — search conversation history + - `session_search(agentId, mode, query, limit)` — search conversation history - mode="recent": list recent conversations (titles, times, message counts) - mode="search": keyword full-text search across past messages - Use this to recall previous discussions, look up past decisions, or find context from earlier conversations + - Only completed sessions (not currently running) are included in results ## Tool Usage Guidelines When you have available tools, use them to access local system information, files, or execute commands. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 344b8640..f3fe7208 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -1218,10 +1218,14 @@ public abstract class BaseAgent { } /** - * 解析图片文件的绝对路径。 + * Resolve the absolute path of an image file. *

    - * 上传文件存储在 data/chat-uploads/ 下,是相对于 Spring Boot 工作目录的路径。 - * MCP 工具的工作目录可能不同,所以这里直接解析为绝对路径。 + * The storage location of uploaded files is resolved by + * {@code ChatUploadLocationResolver} in priority order: the Agent's + * workspaceBasePath → the Workspace's basePath → a configurable default + * directory ({@code mateclaw.chat.upload.base-dir}, default + * {@code data/chat-uploads}). An MCP tool's working directory may differ, + * so this resolves directly to an absolute path. */ /** * 构建当前用户消息的 UserMessage(含 multimodal 图片注入)。 diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java index fccea4a5..71f55799 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.agent.AgentService; import vip.mate.agent.binding.model.AgentProviderPreference; +import vip.mate.llm.routing.ProviderModelRef; import vip.mate.agent.binding.model.AgentSkillBinding; import vip.mate.agent.binding.model.AgentToolBinding; import vip.mate.agent.binding.model.AgentWikiKbBinding; @@ -122,18 +123,18 @@ public class AgentBindingController { return R.ok(bindingService.listProviderPreferences(agentId)); } - @Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)") + @Operation(summary = "批量设置 Agent 的偏好模型链(供应商 + 模型,替换模式)") @PutMapping("/provider-preferences") @RequireWorkspaceRole("member") public R setProviderPreferences( @PathVariable Long agentId, - @RequestBody List providerIds, + @RequestBody List preferences, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyAgentWorkspace(agentId, workspaceId); - bindingService.setProviderPreferences(agentId, providerIds); + bindingService.setProviderModelPreferences(agentId, preferences); agentService.invalidateAgentCache(agentId); auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId), - "providers=" + providerIds.size(), null); + "entries=" + (preferences == null ? 0 : preferences.size()), null); return R.ok(); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java index 81eeebe7..ad3e0dc4 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java @@ -29,6 +29,16 @@ public class AgentProviderPreference { /** Provider id (matches {@code mate_model_provider.provider_id}). */ private String providerId; + /** + * Specific chat model to pin for this entry (matches + * {@code mate_model_config.id}). {@code null} means "use the provider's + * default chat model" — backward compatible with provider-only + * preferences. With this column the same {@code providerId} may appear + * in multiple rows, each pinning a different model, forming a per-agent + * preferred-model chain. + */ + private Long modelId; + /** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */ private Integer sortOrder; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListener.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListener.java new file mode 100644 index 00000000..048a8c1e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListener.java @@ -0,0 +1,71 @@ +package vip.mate.agent.binding.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.tool.mcp.event.McpServerRemovedEvent; +import vip.mate.tool.mcp.runtime.McpToolNameResolver; + +import java.util.List; + +/** + * Drops {@code mate_agent_tool} rows that pointed at a now-removed MCP server's + * tools (issue #127, MCP half). + * + *

    MCP tool bindings are stored under the resolved name + * {@code mcp___}. Deleting the server used to leave these + * rows behind: the agent edit page kept showing the bindings and the user could + * not clear them (the tools no longer exist in the live set, so the picker can't + * render a row to uncheck). This mirrors the agent-skill cleanup for removed + * skills. + * + *

    Matching is done with an exact Java prefix rather than a SQL {@code LIKE}: + * the literal underscores in {@code mcp__} are wildcards in {@code LIKE}, + * so {@code mcp_123_%} would also match server {@code 1234}'s tools. The coarse + * query narrows to MCP bindings; the precise {@code startsWith} avoids deleting a + * sibling server's rows. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentBindingMcpRemovalListener { + + private final AgentToolBindingMapper toolBindingMapper; + + @EventListener + public void onMcpServerRemoved(McpServerRemovedEvent event) { + if (event == null || event.serverId() == null) { + return; + } + String serverPrefix = McpToolNameResolver.PREFIX + event.serverId() + "_"; + + // Coarse-filter to MCP bindings in SQL, then match the exact server + // prefix in Java to avoid the LIKE-underscore-wildcard false match. + List candidates = toolBindingMapper.selectList( + new LambdaQueryWrapper() + .likeRight(AgentToolBinding::getToolName, McpToolNameResolver.PREFIX)); + List orphanIds = candidates.stream() + .filter(b -> belongsToServer(b.getToolName(), serverPrefix)) + .map(AgentToolBinding::getId) + .toList(); + if (orphanIds.isEmpty()) { + return; + } + int dropped = toolBindingMapper.delete( + new LambdaQueryWrapper() + .in(AgentToolBinding::getId, orphanIds)); + if (dropped > 0) { + log.info("Cleaned {} agent-tool binding row(s) for removed MCP server {} (id={})", + dropped, event.serverName(), event.serverId()); + } + } + + /** Exact prefix test: {@code mcp_123_x} belongs to server 123, {@code mcp_1234_x} does not. */ + static boolean belongsToServer(String toolName, String serverPrefix) { + return toolName != null && toolName.startsWith(serverPrefix); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 68ebb40f..f7e0dec3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -18,6 +18,7 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; import vip.mate.llm.routing.AgentBindingResolver; +import vip.mate.llm.routing.ProviderModelRef; import vip.mate.skill.acp.AcpSkillBridge; import vip.mate.skill.mcp.McpSkillBridge; import vip.mate.skill.lifecycle.BlockedByBindingRow; @@ -935,30 +936,33 @@ public class AgentBindingService implements AgentBindingResolver { * fallback chain order per agent.

    */ @Override - public List getPreferredProviderIds(Long agentId) { + public List getPreferredProviderModels(Long agentId) { if (agentId == null) return Collections.emptyList(); return listProviderPreferences(agentId).stream() .filter(p -> Boolean.TRUE.equals(p.getEnabled())) - .map(AgentProviderPreference::getProviderId) + .map(p -> new ProviderModelRef(p.getProviderId(), p.getModelId())) .collect(Collectors.toList()); } /** - * Replace the full preference list for an agent. {@code providerIds} - * is the new ordered preference (index 0 = highest preference). - * Empty / null list clears all preferences for the agent. + * Replace the full preference list for an agent with (provider, model) + * entries. {@code refs} is the new ordered preference (index 0 = highest); + * a {@code modelId} of {@code null} pins the provider's default model. The + * same provider may appear multiple times with different models, forming a + * preferred-model chain. Empty / null list clears all preferences. */ - public void setProviderPreferences(Long agentId, List providerIds) { + public void setProviderModelPreferences(Long agentId, List refs) { providerPreferenceMapper.delete( new LambdaQueryWrapper() .eq(AgentProviderPreference::getAgentId, agentId)); - if (providerIds == null) return; + if (refs == null) return; int order = 0; - for (String providerId : providerIds) { - if (providerId == null || providerId.isBlank()) continue; + for (ProviderModelRef ref : refs) { + if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue; AgentProviderPreference row = new AgentProviderPreference(); row.setAgentId(agentId); - row.setProviderId(providerId.trim()); + row.setProviderId(ref.providerId().trim()); + row.setModelId(ref.modelId()); row.setSortOrder(order++); row.setEnabled(true); providerPreferenceMapper.insert(row); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 5e2e3a17..46118d6a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -66,7 +66,16 @@ public record ChatOrigin( * can still mint absolute download links. Null for IM/cron origins, which * have no request host; those rely on {@code mateclaw.server.public-base-url}. */ - @Nullable String baseUrl + @Nullable String baseUrl, + /** + * Immutable numeric id of the MateClaw user behind this request, when the + * requester is an authenticated account (JWT/PAT login via the + * web console). Null for non-account origins — webchat visitors, IM + * senders, cron — which carry no MateClaw user row. On-behalf-of identity + * forwarding uses this to tell "MateClaw authenticated this user" apart + * from "this is an external/anonymous identifier" (RFC: identity typing). + */ + @Nullable Long requesterUserId ) { /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ @@ -74,7 +83,7 @@ public record ChatOrigin( /** Sentinel used by AgentService default overloads where no origin is supplied. */ public static final ChatOrigin EMPTY = - new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null); + new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null); // ---------------- Factories per entry point ---------------- @@ -90,9 +99,25 @@ public record ChatOrigin( @Nullable Long workspaceId, @Nullable String workspaceBasePath, @Nullable String baseUrl) { + return web(conversationId, requesterId, workspaceId, workspaceBasePath, baseUrl, null); + } + + /** + * Web-console origin that also carries the authenticated user's immutable + * numeric id. Use this overload from the authenticated web entry point so + * on-behalf-of identity forwarding can assert "MateClaw authenticated this + * user" rather than an external/anonymous identifier. + */ + public static ChatOrigin web(@Nullable String conversationId, + @Nullable String requesterId, + @Nullable Long workspaceId, + @Nullable String workspaceBasePath, + @Nullable String baseUrl, + @Nullable Long requesterUserId) { return new ChatOrigin(null, conversationId, requesterId != null ? requesterId : "", - workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl); + workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl, + requesterUserId); } public static ChatOrigin cron(@Nullable String conversationId, @@ -101,7 +126,7 @@ public record ChatOrigin( @Nullable Long channelId, @Nullable ChannelTarget target) { return new ChatOrigin(null, conversationId, "system", - workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null); + workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null); } // ---------------- Wither-style updates ---------------- @@ -109,27 +134,27 @@ public record ChatOrigin( public ChatOrigin withAgent(@Nullable Long newAgentId) { return new ChatOrigin(newAgentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl); + senderName, channelType, chatId, baseUrl, requesterUserId); } public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl); + senderName, channelType, chatId, baseUrl, requesterUserId); } public ChatOrigin withConversationId(@Nullable String newConversationId) { return new ChatOrigin(agentId, newConversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl); + senderName, channelType, chatId, baseUrl, requesterUserId); } /** Carry a request-derived public base URL (see {@link #baseUrl()}). */ public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, newBaseUrl); + senderName, channelType, chatId, newBaseUrl, requesterUserId); } /** @@ -143,7 +168,7 @@ public record ChatOrigin( @Nullable String newChatId) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - newSenderName, newChannelType, newChatId, baseUrl); + newSenderName, newChannelType, newChatId, baseUrl, requesterUserId); } // ---------------- Spring AI ToolContext interop ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 345ba438..aac41650 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -3,6 +3,7 @@ package vip.mate.agent.context; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.ToolResponseMessage; @@ -118,6 +119,18 @@ public class ConversationWindowManager { private final MemoryManager memoryManager; private final ConversationService conversationService; + /** + * Optional — adaptive compaction trigger for small context windows. + * Setter-injected so the many direct test constructions keep the plain + * configured ratio (null → previous behavior). + */ + private PrefixBudgetPlanner prefixBudgetPlanner; + + @Autowired(required = false) + public void setPrefixBudgetPlanner(PrefixBudgetPlanner prefixBudgetPlanner) { + this.prefixBudgetPlanner = prefixBudgetPlanner; + } + /** * Optional spill store, injected via setter so unit tests and the two * existing 3-arg constructor callers in tests stay source-compatible. @@ -251,7 +264,12 @@ public class ConversationWindowManager { int effectiveMax = (maxInputTokens != null && maxInputTokens > 0) ? maxInputTokens : properties.getDefaultMaxInputTokens(); - int triggerThreshold = (int) (effectiveMax * properties.getCompactTriggerRatio()); + // Small windows compact later (higher trigger ratio): summarizing at + // 75% of an 8k window throws away room it cannot afford to lose. + double triggerRatio = prefixBudgetPlanner != null + ? prefixBudgetPlanner.compactTriggerRatioFor(effectiveMax, properties.getCompactTriggerRatio()) + : properties.getCompactTriggerRatio(); + int triggerThreshold = (int) (effectiveMax * triggerRatio); int systemTokens = TokenEstimator.estimateTokens(systemPrompt); int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD; @@ -1009,6 +1027,33 @@ public class ConversationWindowManager { + "' can be called again if its result is needed.]"; } + /** + * One-line informative summary for a cleared tool result: tool name, + * original size, and the first line as a gist. Far more useful to the + * model than a bare "removed" marker — it can decide whether re-running + * the tool is worth it without guessing what the output was. + */ + static String buildInformativeCleared(String toolName, String body) { + String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName; + int length = body == null ? 0 : body.length(); + String gist = ""; + if (body != null) { + for (String line : body.split("\n", 8)) { + String candidate = line.strip(); + if (!candidate.isEmpty()) { + gist = candidate.length() > 80 ? candidate.substring(0, 80) + "…" : candidate; + break; + } + } + } + StringBuilder sb = new StringBuilder("[").append(safeName) + .append(" → ").append(length).append(" chars, cleared to save context"); + if (!gist.isEmpty()) { + sb.append("; began: \"").append(gist).append('"'); + } + return sb.append("; call the tool again if the result is still needed]").toString(); + } + /** * Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。 *

    Spill-marker responses are left untouched so their on-disk pointer @@ -1063,7 +1108,8 @@ public class ConversationWindowManager { replaced.add(r); continue; } - replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]")); + replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), + buildInformativeCleared(r.name(), r.responseData()))); changed = true; } if (changed) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlan.java b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlan.java new file mode 100644 index 00000000..d0b71856 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlan.java @@ -0,0 +1,41 @@ +package vip.mate.agent.context; + +/** + * Per-agent-build token budget for the prompt prefix's optional injection + * blocks. Produced once by {@link PrefixBudgetPlanner} when the agent graph + * is assembled (the inputs — effective window, base prompt, tool schemas — + * are all stable per build) and handed to each injection site. + * + *

    {@code enabled == false} means budgeting is switched off: every budget + * field holds {@link Integer#MAX_VALUE} and consumers keep their existing + * absolute caps untouched. + */ +public record PrefixBudgetPlan( + boolean enabled, + int effectiveMaxTokens, + Profile profile, + int injectionBudgetTokens, + int memoryTokens, + int wikiTokens, + int skillCatalogTokens, + int extensionCatalogTokens, + int ledgerTokens, + int toolSchemaBudgetTokens) { + + /** Window-size tier. Small windows tighten the injection ratio. */ + public enum Profile { + /** Regular window — budget shares rarely bind (absolute caps are smaller). */ + NORMAL, + /** Window below the compact threshold — tightened injection ratio. */ + COMPACT, + /** Window below the minimal threshold — injection cut to the bone. */ + MINIMAL + } + + /** Budgeting disabled — unlimited budgets, previous behavior. */ + public static PrefixBudgetPlan unlimited(int effectiveMaxTokens) { + return new PrefixBudgetPlan(false, effectiveMaxTokens, Profile.NORMAL, + Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, + Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java new file mode 100644 index 00000000..fd9ab65a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java @@ -0,0 +1,120 @@ +package vip.mate.agent.context; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; +import vip.mate.config.ConversationWindowProperties; +import vip.mate.config.PrefixBudgetProperties; + +/** + * Computes the {@link PrefixBudgetPlan} for one agent build: how many tokens + * each optional prefix injection block (memory / wiki / skill catalog / + * extension catalog / progress ledger) may spend, scaled to the model's + * effective context window. + * + *

    + * injectionBudget = max(0, effectiveMax × ratio(profile)
    + *                          − basePromptTokens − toolSchemaTokens)
    + * block budget    = injectionBudget × normalizedShare(block)
    + * 
    + * + * The agent's own prompt and the tool schemas are never truncated here — + * they are subtracted from the injection budget so the optional blocks + * absorb the squeeze. On large windows the shares far exceed each block's + * absolute cap, so behavior is byte-identical to the pre-budget code. + */ +@Slf4j +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties(PrefixBudgetProperties.class) +public class PrefixBudgetPlanner { + + /** COMPACT-profile ceiling for the wiki relevance injection (~one page). */ + static final int COMPACT_WIKI_TOKEN_CAP = 2000; + + private final PrefixBudgetProperties properties; + private final ConversationWindowProperties windowProperties; + + /** + * @param effectiveMaxInputTokens the model's effective window (explicit + * config or probed); null/0 falls back to + * the global default + * @param basePromptTokens estimated tokens of the agent's own + * identity prompt (before memory/guidance) + * @param toolSchemaTokens estimated tokens of the advertised tool + * schemas + */ + public PrefixBudgetPlan plan(Integer effectiveMaxInputTokens, int basePromptTokens, int toolSchemaTokens) { + int effectiveMax = (effectiveMaxInputTokens != null && effectiveMaxInputTokens > 0) + ? effectiveMaxInputTokens : windowProperties.getDefaultMaxInputTokens(); + if (!properties.isEnabled()) { + return PrefixBudgetPlan.unlimited(effectiveMax); + } + + PrefixBudgetPlan.Profile profile = profileFor(effectiveMax); + double ratio = switch (profile) { + case NORMAL -> properties.getInjectionRatio(); + case COMPACT -> properties.getCompactInjectionRatio(); + case MINIMAL -> properties.getMinimalInjectionRatio(); + }; + + int injectionBudget = Math.max(0, + (int) (effectiveMax * ratio) - Math.max(0, basePromptTokens) - Math.max(0, toolSchemaTokens)); + + PrefixBudgetProperties.Shares shares = properties.getShares(); + double sum = shares.getMemory() + shares.getWiki() + shares.getSkill() + + shares.getExtensionCatalog() + shares.getLedger(); + if (sum <= 0) { + sum = 1.0; + } + + // Profile-specific wiki clamps: knowledge-base reference pages are the + // most dispensable block on a small window — the wiki tools stay + // callable, only the automatic pre-injection shrinks. COMPACT caps it + // at roughly one page; MINIMAL disables it outright. + int wikiTokens = (int) (injectionBudget * shares.getWiki() / sum); + wikiTokens = switch (profile) { + case NORMAL -> wikiTokens; + case COMPACT -> Math.min(wikiTokens, COMPACT_WIKI_TOKEN_CAP); + case MINIMAL -> 0; + }; + + PrefixBudgetPlan plan = new PrefixBudgetPlan( + true, effectiveMax, profile, injectionBudget, + (int) (injectionBudget * shares.getMemory() / sum), + wikiTokens, + (int) (injectionBudget * shares.getSkill() / sum), + (int) (injectionBudget * shares.getExtensionCatalog() / sum), + (int) (injectionBudget * shares.getLedger() / sum), + (int) (effectiveMax * properties.getToolSchemaRatio())); + + if (profile != PrefixBudgetPlan.Profile.NORMAL) { + log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens" + + "(memory={}, wiki={}, skill={}, extCatalog={}, ledger={})", + effectiveMax, profile, injectionBudget, + plan.memoryTokens(), plan.wikiTokens(), plan.skillCatalogTokens(), + plan.extensionCatalogTokens(), plan.ledgerTokens()); + } + return plan; + } + + /** Compaction trigger ratio for this window size (small windows fill up before compacting). */ + public double compactTriggerRatioFor(int effectiveMaxTokens, double defaultRatio) { + if (!properties.isEnabled()) { + return defaultRatio; + } + return profileFor(effectiveMaxTokens) == PrefixBudgetPlan.Profile.NORMAL + ? defaultRatio : properties.getCompactTriggerRatioOverride(); + } + + private PrefixBudgetPlan.Profile profileFor(int effectiveMax) { + if (effectiveMax < properties.getMinimalThresholdTokens()) { + return PrefixBudgetPlan.Profile.MINIMAL; + } + if (effectiveMax < properties.getCompactThresholdTokens()) { + return PrefixBudgetPlan.Profile.COMPACT; + } + return PrefixBudgetPlan.Profile.NORMAL; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java new file mode 100644 index 00000000..57155dca --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java @@ -0,0 +1,95 @@ +package vip.mate.agent.delegation; + +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Per-conversation accumulator for delegated sub-agent token usage. + * + *

    When a parent turn delegates work to sub-agents, each child runs as its own + * agent invocation in a separate conversation, so its token usage never lands in + * the parent graph's own usage counters. This accumulator lets the delegation + * layer record each completed child's usage keyed by the root + * (user-facing) conversation, so the parent turn's {@code _usage_final} emission + * can roll the whole sub-tree up into the turn total — surfaced live on the SSE + * stream and persisted on the assistant message. + * + *

    No double counting across nesting: every descendant (child, + * grandchild, …) records against the same root conversation, because the + * delegation context carries the original root forward. The root agent drains + * the full tree exactly once at its {@code _usage_final}; intermediate agents + * drain their own conversation key, which holds nothing. A child agent's own + * usage (returned to its parent and recorded once by the parent's delegation + * call) is therefore counted a single time. + * + *

    Exposed via a static accessor because the StateGraph agents that emit + * {@code _usage_final} are built per-config and are not Spring-managed beans, so + * they cannot receive this singleton by constructor injection. + */ +@Component +public class DelegatedUsageAccumulator { + + private static volatile DelegatedUsageAccumulator instance; + + @PostConstruct + void register() { + instance = this; + } + + /** Returns the singleton, or {@code null} before the context is ready. */ + public static DelegatedUsageAccumulator getInstance() { + return instance; + } + + private record Usage(AtomicLong prompt, AtomicLong completion) { + Usage() { + this(new AtomicLong(), new AtomicLong()); + } + } + + private final Map byConversation = new ConcurrentHashMap<>(); + + /** Record one completed child's usage against its root conversation. */ + public void add(String rootConversationId, int promptTokens, int completionTokens) { + if (rootConversationId == null || rootConversationId.isBlank()) { + return; + } + if (promptTokens <= 0 && completionTokens <= 0) { + return; + } + Usage u = byConversation.computeIfAbsent(rootConversationId, k -> new Usage()); + if (promptTokens > 0) { + u.prompt().addAndGet(promptTokens); + } + if (completionTokens > 0) { + u.completion().addAndGet(completionTokens); + } + } + + /** Token pair carrier for a drained accumulation. */ + public record Drained(long promptTokens, long completionTokens) { + public boolean isEmpty() { + return promptTokens <= 0 && completionTokens <= 0; + } + } + + /** Atomically read and remove the accumulated delegated usage for a conversation. */ + public Drained drain(String conversationId) { + if (conversationId == null) { + return new Drained(0, 0); + } + Usage u = byConversation.remove(conversationId); + return u == null ? new Drained(0, 0) : new Drained(u.prompt().get(), u.completion().get()); + } + + /** Discard any accumulation for a conversation — leak guard on error/cancel. */ + public void clear(String conversationId) { + if (conversationId != null) { + byConversation.remove(conversationId); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java new file mode 100644 index 00000000..6a9920e9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java @@ -0,0 +1,75 @@ +package vip.mate.agent.delegation; + +import java.util.Set; + +/** + * Immutable snapshot of one delegation layer's runtime identity. + * + *

    This is the canonical value object that carries "who am I in the delegation + * tree" down a single child agent run: tree depth, the immediate parent + * conversation, the human-facing root conversation, the subagent id of the layer + * currently executing, and the tool deny set in force for this layer. + * + *

    It exists as a first-class, named record (rather than an anonymous frame + * buried in a ThreadLocal stack) so the same identity can later be passed + * explicitly through the call chain instead of being reconstructed from + * thread-local state — explicit passing survives virtual-thread and reactive + * hops, where a thread-confined stack does not. {@link vip.mate.tool.builtin.DelegationContext} + * currently holds a stack of these per thread; callers that already have a + * context in hand should prefer threading it explicitly. + * + * @param depth 1-based tree depth; {@code 0} means the top-level + * (non-delegated) call. + * @param parentConversationId the immediate parent conversation that spawned + * this layer, or {@code null} at the top level. + * @param rootConversationId the human-facing conversation at the top of the + * whole delegation tree; every layer carries it + * unchanged so a deep child's progress events can + * broadcast to the stream the user is watching. + * @param currentSubagentId the subagent id of the layer executing now; a + * deeper child reads it as its own parent id to + * reconstruct the spawn tree. + * @param deniedTools tool names this layer's agent may not call; + * normalised to a non-null immutable set. + * + * @author MateClaw Team + */ +public record SubagentRunContext( + int depth, + String parentConversationId, + String rootConversationId, + String currentSubagentId, + Set deniedTools +) { + + /** The top-level context: not inside any delegation. */ + public static final SubagentRunContext ROOT = new SubagentRunContext(0, null, null, null, Set.of()); + + public SubagentRunContext { + // Normalise the deny set so every read site gets a non-null immutable + // view without re-checking — mirrors the old accessor's null guard. + deniedTools = (deniedTools == null) ? Set.of() : Set.copyOf(deniedTools); + } + + /** True when this context represents a delegated (sub-agent) layer. */ + public boolean isDelegated() { + return depth > 0; + } + + /** + * Build the context for the next layer spawned beneath this one. The root + * conversation is inherited unchanged (falling back to the child's parent + * conversation when this is the first delegation), and depth advances by one. + * + * @param childParentConversationId the spawning conversation for the child + * @param childSubagentId the subagent id assigned to the child + * @param childDeniedTools tool deny set for the child + */ + public SubagentRunContext childFrame(String childParentConversationId, + String childSubagentId, + Set childDeniedTools) { + String inheritedRoot = (rootConversationId != null) ? rootConversationId : childParentConversationId; + return new SubagentRunContext(depth + 1, childParentConversationId, + inheritedRoot, childSubagentId, childDeniedTools); + } +} 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 b378c137..3aacd086 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 @@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; /** * 节点级流式 LLM 调用辅助 @@ -162,6 +163,19 @@ public class NodeStreamingChatHelper { this.providerPool = providerPool; } + /** + * Optional hook fired with the raw error chain whenever the PRIMARY model + * rejects a call for exceeding its context window. Lets the caller feed + * the server-reported limit back into the context-window resolver so the + * next turn budgets against the model's true window. Fallback-model + * rejections are not reported — they belong to a different model. + */ + private Consumer contextLimitObserver; + + public void setContextLimitObserver(Consumer observer) { + this.contextLimitObserver = observer; + } + private static List wrap(ChatModel m) { // Legacy single-fallback path: providerId is unknown so health tracking // is silently disabled for that one entry (it gets a synthetic id). @@ -585,6 +599,15 @@ public class NodeStreamingChatHelper { private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt, String conversationId, String phase, boolean broadcast) { + // Normalize every assistant tool call in the outgoing history to valid + // JSON arguments. The streaming aggregator already does this for the + // current turn's calls, but tool calls replayed from persisted history + // (e.g. an earlier MCP tool call with empty arguments, or messages + // stored by an older build) bypass that path. Strict providers reject + // the whole request with HTTP 400 when any function.arguments is not + // parseable JSON, so harmonize them here at the single send chokepoint. + prompt = normalizeToolCallArguments(prompt); + // 在开始 LLM 调用前检查停止标志 if (streamTracker.isStopRequested(conversationId)) { log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId); @@ -632,7 +655,7 @@ public class NodeStreamingChatHelper { } llmCallCount++; if (attempt > 0) retryCount++; - lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt); + lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true); if (lastResult != null) { // PTL: 不重试,直接返回给上层 Node 处理 if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) { @@ -774,7 +797,7 @@ public class NodeStreamingChatHelper { failoverCount++; llmCallCount++; StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId, - phase + "_fallback_" + (i + 1), broadcast, 0); + phase + "_fallback_" + (i + 1), broadcast, 0, false); // 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. @@ -821,7 +844,7 @@ public class NodeStreamingChatHelper { */ private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt, String conversationId, String phase, - boolean broadcast, int attempt) { + boolean broadcast, int attempt, boolean primaryCall) { // 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 @@ -874,7 +897,7 @@ public class NodeStreamingChatHelper { } try { - return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt); + return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall); } finally { // Idempotent: if consumer already took the entry, discard is a no-op. if (relayToken != null) { @@ -906,7 +929,7 @@ public class NodeStreamingChatHelper { private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt, String conversationId, String phase, - boolean broadcast, int attempt) { + boolean broadcast, int attempt, boolean primaryCall) { if (attempt > 0) { long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs); // 加入 jitter 防止雷群效应 @@ -945,9 +968,10 @@ public class NodeStreamingChatHelper { AtomicReference errorRef = new AtomicReference<>(); AtomicInteger promptTokens = new AtomicInteger(0); AtomicInteger completionTokens = new AtomicInteger(0); - // RFC-014: Anthropic prompt cache 计数(其它 provider 永远为 0) + // Prompt cache / reasoning counters; providers that don't report them stay 0. AtomicInteger cacheReadTokens = new AtomicInteger(0); AtomicInteger cacheWriteTokens = new AtomicInteger(0); + AtomicInteger reasoningTokens = new AtomicInteger(0); // thinking-only soft cap 触发后设为 true,外层轮询线程据此 dispose 订阅。 // 注意:内容流的字符级 / 句子级重复检测已整体移除(设计取舍: @@ -1133,10 +1157,12 @@ public class NodeStreamingChatHelper { if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) { completionTokens.set(usage.getCompletionTokens().intValue()); } - // RFC-014: 反射抽取 Anthropic prompt cache 字段(DashScope/OpenAI 自然返回 0) + // Reflective extraction of provider-native cache / reasoning + // counters (Anthropic / OpenAI-compatible / DashScope). var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage); if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens()); if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens()); + if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens()); } }) .subscribe( @@ -1188,7 +1214,8 @@ public class NodeStreamingChatHelper { toolCallAccumulators.size(), conversationId); return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators, promptTokens.get(), completionTokens.get(), - cacheReadTokens.get(), cacheWriteTokens.get(), phase); + cacheReadTokens.get(), cacheWriteTokens.get(), + reasoningTokens.get(), phase); } log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}", phase, conversationId); @@ -1222,6 +1249,7 @@ public class NodeStreamingChatHelper { return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, promptTokens.get(), completionTokens.get(), cacheReadTokens.get(), cacheWriteTokens.get(), + reasoningTokens.get(), phase, true, error.getMessage()); } @@ -1232,6 +1260,17 @@ public class NodeStreamingChatHelper { if (errorType == ErrorType.PROMPT_TOO_LONG) { log.warn("[{}] Prompt too long error, returning to node for compaction: {}", phase, error.getMessage()); + // Teach the context-window resolver the server-reported limit so + // the next turn budgets against the model's true window. Raw + // chain (incl. response body) — the friendly text may drop the + // numbers. Primary model only; fallbacks are different models. + if (primaryCall && contextLimitObserver != null) { + try { + contextLimitObserver.accept(extractFullErrorChain(error)); + } catch (Exception observerError) { + log.debug("context-limit observer failed: {}", observerError.getMessage()); + } + } return buildErrorResultWithType("Prompt 过长: " + extractUserFriendlyError(error), conversationId, phase, errorType); } @@ -1310,7 +1349,8 @@ public class NodeStreamingChatHelper { : null; return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, promptTokens.get(), completionTokens.get(), - cacheReadTokens.get(), cacheWriteTokens.get(), phase, + cacheReadTokens.get(), cacheWriteTokens.get(), + reasoningTokens.get(), phase, truncated, truncationReason); } @@ -1319,7 +1359,8 @@ public class NodeStreamingChatHelper { private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum, List toolCallAccumulators, int promptTok, int completionTok, - int cacheReadTok, int cacheWriteTok, String phase) { + int cacheReadTok, int cacheWriteTok, + int reasoningTok, String phase) { List finalToolCalls = buildFinalToolCalls(toolCallAccumulators); String fullContent = contentAccum.toString(); String fullThinking = thinkingAccum.toString(); @@ -1342,14 +1383,14 @@ public class NodeStreamingChatHelper { recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); return new StreamResult(fullContent, fullThinking, assembledMessage, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, - true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok); + true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok); } /** 组装最终 StreamResult(成功或 partial) */ private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum, List toolCallAccumulators, int promptTok, int completionTok, - int cacheReadTok, int cacheWriteTok, + int cacheReadTok, int cacheWriteTok, int reasoningTok, String phase, boolean partial, String errorMsg) { List finalToolCalls = buildFinalToolCalls(toolCallAccumulators); String fullContent = contentAccum.toString(); @@ -1375,7 +1416,7 @@ public class NodeStreamingChatHelper { recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); return new StreamResult(fullContent, fullThinking, assembledMessage, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, - partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok); + partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok, reasoningTok); } /** @@ -1539,6 +1580,66 @@ public class NodeStreamingChatHelper { return new Prompt(new ArrayList<>(messages.subList(0, end)), prompt.getOptions()); } + /** + * Rebuild any {@link AssistantMessage} whose tool calls carry blank or + * non-JSON {@code function.arguments} so the entire outgoing prompt stays + * acceptable to strict OpenAI-compatible providers (e.g. aliyun-codingplan, + * which 400s the whole request otherwise). Messages with no tool calls, or + * whose tool-call arguments are already valid JSON, pass through untouched — + * preserving content, metadata, and media. Returns the input unchanged when + * nothing needs fixing. + */ + static Prompt normalizeToolCallArguments(Prompt prompt) { + if (prompt == null) { + return null; + } + List messages = prompt.getInstructions(); + if (messages == null || messages.isEmpty()) { + return prompt; + } + List rebuilt = null; + for (int i = 0; i < messages.size(); i++) { + Message m = messages.get(i); + if (!(m instanceof AssistantMessage am) + || am.getToolCalls() == null || am.getToolCalls().isEmpty()) { + if (rebuilt != null) rebuilt.add(m); + continue; + } + List fixedCalls = null; + List calls = am.getToolCalls(); + for (int j = 0; j < calls.size(); j++) { + AssistantMessage.ToolCall tc = calls.get(j); + String safe = sanitizeToolCallArguments(tc.name(), tc.arguments()); + if (!safe.equals(tc.arguments())) { + if (fixedCalls == null) fixedCalls = new ArrayList<>(calls); + fixedCalls.set(j, new AssistantMessage.ToolCall(tc.id(), tc.type(), tc.name(), safe)); + } + } + if (fixedCalls == null) { + if (rebuilt != null) rebuilt.add(m); + continue; + } + if (rebuilt == null) { + rebuilt = new ArrayList<>(messages.subList(0, i)); + } + AssistantMessage.Builder builder = AssistantMessage.builder() + .content(am.getText() == null ? "" : am.getText()) + .toolCalls(fixedCalls); + if (am.getMetadata() != null && !am.getMetadata().isEmpty()) { + builder.properties(am.getMetadata()); + } + if (am.getMedia() != null && !am.getMedia().isEmpty()) { + builder.media(am.getMedia()); + } + rebuilt.add(builder.build()); + } + if (rebuilt == null) { + return prompt; + } + log.debug("[normalizeToolCallArguments] normalized non-JSON tool-call arguments in outgoing prompt"); + return new Prompt(rebuilt, prompt.getOptions()); + } + private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) { log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg); if (streamTracker != null && conversationId != null) { @@ -1753,17 +1854,19 @@ public class NodeStreamingChatHelper { ErrorType errorType, /** 用户主动停止(stopRequested)导致的提前返回 */ boolean stopped, - /** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0) */ + /** Prompt cache 命中 tokens(provider 未上报时为 0) */ int cacheReadTokens, - /** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0) */ - int cacheWriteTokens + /** Prompt cache 写入 tokens(provider 未上报时为 0) */ + int cacheWriteTokens, + /** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 0) */ + int reasoningTokens ) { /** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */ public StreamResult(String text, String thinking, AssistantMessage assistantMessage, List toolCalls, boolean hasToolCalls, int promptTokens, int completionTokens) { this(text, thinking, assistantMessage, toolCalls, hasToolCalls, - promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0); + promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0, 0); } /** 兼容 10-arg 调用点 */ @@ -1772,17 +1875,17 @@ public class NodeStreamingChatHelper { int promptTokens, int completionTokens, boolean partial, String errorMessage, ErrorType errorType) { this(text, thinking, assistantMessage, toolCalls, hasToolCalls, - promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0); + promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0, 0); } - /** 兼容 12-arg 调用点(pre-RFC-014) */ + /** 兼容 11-arg 调用点(无 cache/reasoning 计数) */ public StreamResult(String text, String thinking, AssistantMessage assistantMessage, List toolCalls, boolean hasToolCalls, int promptTokens, int completionTokens, boolean partial, String errorMessage, ErrorType errorType, boolean stopped) { this(text, thinking, assistantMessage, toolCalls, hasToolCalls, - promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0); + promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0, 0); } /** 是否有不可忽略的错误(无内容 + 有错误) */ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index 6e0fcd01..369dff8a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -13,6 +13,7 @@ import reactor.core.publisher.Mono; import vip.mate.agent.AgentService; import vip.mate.agent.AgentState; import vip.mate.agent.BaseAgent; +import vip.mate.agent.delegation.DelegatedUsageAccumulator; import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.context.ConversationWindowManager; @@ -197,6 +198,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC AtomicInteger sentEventCount = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalCompletionTokens = new AtomicInteger(0); + AtomicInteger finalCacheReadTokens = new AtomicInteger(0); + AtomicInteger finalCacheWriteTokens = new AtomicInteger(0); + AtomicInteger finalReasoningTokens = new AtomicInteger(0); AtomicReference finalModelName = new AtomicReference<>(""); AtomicReference finalProviderId = new AtomicReference<>(""); // 防重保护:同 chatStructuredStream @@ -268,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0)); finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0)); + finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0)); + finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0)); + finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0)); finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); @@ -282,10 +289,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC return deltas; }) .concatWith(Mono.fromSupplier(() -> { - if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) { + DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance(); + DelegatedUsageAccumulator.Drained delegated = acc != null + ? acc.drain(conversationId) + : new DelegatedUsageAccumulator.Drained(0, 0); + long promptTokens = finalPromptTokens.get() + delegated.promptTokens(); + long completionTokens = finalCompletionTokens.get() + delegated.completionTokens(); + if (promptTokens > 0 || completionTokens > 0) { return AgentService.StreamDelta.event("_usage_final", Map.of( - "promptTokens", finalPromptTokens.get(), - "completionTokens", finalCompletionTokens.get(), + "promptTokens", promptTokens, + "completionTokens", completionTokens, + "delegatedPromptTokens", delegated.promptTokens(), + "delegatedCompletionTokens", delegated.completionTokens(), + "cacheReadTokens", finalCacheReadTokens.get(), + "cacheWriteTokens", finalCacheWriteTokens.get(), + "reasoningTokens", finalReasoningTokens.get(), "runtimeModelName", finalModelName.get(), "runtimeProviderId", finalProviderId.get() )); @@ -304,6 +322,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC .doOnError(e -> { log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage()); setState(AgentState.ERROR); + }) + // Leak guard: discard delegated usage if the turn ends without + // emitting _usage_final (error / cancel). + .doFinally(sig -> { + DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance(); + if (acc != null) acc.clear(conversationId); }); } catch (Exception e) { setState(AgentState.ERROR); @@ -333,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC // Token usage 追踪(每次 NodeOutput 更新最新累计值,最后一次即最终值) AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalCompletionTokens = new AtomicInteger(0); + AtomicInteger finalCacheReadTokens = new AtomicInteger(0); + AtomicInteger finalCacheWriteTokens = new AtomicInteger(0); + AtomicInteger finalReasoningTokens = new AtomicInteger(0); AtomicReference finalModelName = new AtomicReference<>(""); AtomicReference finalProviderId = new AtomicReference<>(""); // 防重保护:StateGraph 对每个节点都 emit NodeOutput,FINAL_ANSWER 一旦写入后续节点都携带, @@ -418,6 +445,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC // 3. 更新最新累计 token usage finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0)); finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0)); + finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0)); + finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0)); + finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0)); finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); @@ -434,10 +464,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC }) // 流正常完成后追加内部 usage 事件 .concatWith(Mono.fromSupplier(() -> { - if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) { + DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance(); + DelegatedUsageAccumulator.Drained delegated = acc != null + ? acc.drain(conversationId) + : new DelegatedUsageAccumulator.Drained(0, 0); + long promptTokens = finalPromptTokens.get() + delegated.promptTokens(); + long completionTokens = finalCompletionTokens.get() + delegated.completionTokens(); + if (promptTokens > 0 || completionTokens > 0) { return AgentService.StreamDelta.event("_usage_final", Map.of( - "promptTokens", finalPromptTokens.get(), - "completionTokens", finalCompletionTokens.get(), + "promptTokens", promptTokens, + "completionTokens", completionTokens, + "delegatedPromptTokens", delegated.promptTokens(), + "delegatedCompletionTokens", delegated.completionTokens(), + "cacheReadTokens", finalCacheReadTokens.get(), + "cacheWriteTokens", finalCacheWriteTokens.get(), + "reasoningTokens", finalReasoningTokens.get(), "runtimeModelName", finalModelName.get(), "runtimeProviderId", finalProviderId.get() )); @@ -457,6 +498,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC .doOnError(e -> { log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage()); setState(AgentState.ERROR); + }) + // Leak guard: discard delegated usage if the turn ends without + // emitting _usage_final (error / cancel). + .doFinally(sig -> { + DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance(); + if (acc != null) acc.clear(conversationId); }); } catch (Exception e) { setState(AgentState.ERROR); @@ -522,6 +569,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC inputs.put(FORCED_TOOL_CALL, ""); inputs.put(PROMPT_TOKENS, 0); inputs.put(COMPLETION_TOKENS, 0); + inputs.put(CACHE_READ_TOKENS, 0); + inputs.put(CACHE_WRITE_TOKENS, 0); + inputs.put(REASONING_TOKENS, 0); inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : ""); inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : ""); inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8)); 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 71363c40..765096c8 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 @@ -7,6 +7,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.ToolCallback; import vip.mate.tool.builtin.ToolExecutionContext; +import vip.mate.tool.disclosure.ToolUsageRecencyTracker; import vip.mate.agent.AgentToolSet; import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.context.ChatOrigin; @@ -251,6 +252,13 @@ public class ToolExecutionExecutor { */ private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService; + /** Optional recency feed for budget-driven tool-disclosure demotion. */ + private ToolUsageRecencyTracker usageRecencyTracker; + + public void setUsageRecencyTracker(ToolUsageRecencyTracker tracker) { + this.usageRecencyTracker = tracker; + } + public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) { this.skillRuntimeService = s; } @@ -885,6 +893,12 @@ public class ToolExecutionExecutor { ToolExecutionContext.clear(); } + // Recency feed for budget-driven disclosure demotion: recently used + // tools keep their advertised schema, never-used ones demote first. + if (usageRecencyTracker != null) { + usageRecencyTracker.recordUse(toolName); + } + int rawLen = result != null ? result.length() : 0; // RFC-052: returnDirect tools bypass spill / truncation / LLM context. // Their full text goes to the user verbatim and is never persisted to 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 426bf067..d27cf8fc 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 @@ -1,8 +1,10 @@ package vip.mate.agent.graph.executor; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.ToolResponseMessage; import vip.mate.agent.context.StructuredTruncator; +import vip.mate.tool.guard.WorkspacePathGuard; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; @@ -73,6 +75,30 @@ public class ToolResultStorage { this.excludedToolsSnapshot = props.excludedToolsSet(); } + /** + * Trust the deterministic spill roots with the workspace path guard at + * startup, before any spill happens in this JVM. Without this, a + * conversation that spilled in a previous run and is then resumed after a + * restart would have its {@code read_file} of the still-on-disk spill path + * rejected as a boundary escape until the next spill re-registers the root. + * The per-workspace branch ({@code /.mateclaw/tool-results}) is + * intentionally not registered here — it already sits inside its own + * workspace boundary. + */ + @PostConstruct + void registerSpillRootsAsTrusted() { + if (!props.isEnabled()) { + return; + } + if (!props.getStorageBaseDir().isEmpty()) { + WorkspacePathGuard.addTrustedRoot(props.getStorageBaseDir()); + } + String tmp = System.getProperty("java.io.tmpdir"); + if (tmp != null && !tmp.isEmpty()) { + WorkspacePathGuard.addTrustedRoot(Paths.get(tmp, "mateclaw", "tool-results").toString()); + } + } + /** D-6: current cumulative spill count (monotonically increasing). */ public long getSpillCount() { return spillCount.get(); @@ -283,18 +309,30 @@ public class ToolResultStorage { private Path resolveBaseDir(String workspaceBasePath) { Path base; + boolean outsideWorkspace; if (!props.getStorageBaseDir().isEmpty()) { base = Paths.get(props.getStorageBaseDir()); + outsideWorkspace = true; } else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) { + // Inside the workspace boundary already — read_file of these spill + // files is permitted without an extra trusted-root registration. base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results"); + outsideWorkspace = false; } else { String tmp = System.getProperty("java.io.tmpdir"); if (tmp == null || tmp.isEmpty()) return null; base = Paths.get(tmp, "mateclaw", "tool-results"); + outsideWorkspace = true; } // Register so the retention sweep and conversation-delete hook can // reach this root even when the workspace path is no longer in scope. observedRoots.add(base); + // A spill directory that lives outside the workspace must be trusted by + // the path guard; otherwise the read_file the spill preview tells the + // agent to perform is rejected as a workspace-boundary escape. + if (outsideWorkspace) { + WorkspacePathGuard.addTrustedRoot(base.toString()); + } return base; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java index 7ddec09d..3870c022 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java @@ -265,12 +265,15 @@ public class FinalAnswerNode implements NodeAction { /** * Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss) - * with a user-visible warning. No-op when no cache is wired (legacy - * tests) or when the answer is empty. + * with a user-visible warning, and wrap live bare URLs into + * {@code [filename](url)} markdown links so the chat shows the file name + * instead of the raw id. No-op when no cache is wired (legacy tests) or + * when the answer is empty. */ private String scrubFakeUrls(String text) { if (generatedFileCache == null || text == null || text.isEmpty()) return text; - return generatedFileCache.scrubMissingReferences(text); + return generatedFileCache.linkifyBareReferences( + generatedFileCache.scrubMissingReferences(text)); } private FinishReason parseFinishReason(String reason) { 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 0ec0da7d..053b4223 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 @@ -22,6 +22,7 @@ import vip.mate.llm.chatmodel.ThinkingLevelHolder; import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.LoopBudgetConfig; +import vip.mate.agent.context.PrefixBudgetPlan; import vip.mate.agent.context.LoopMessageBudgeter; import vip.mate.agent.context.RuntimeContextInjector; import vip.mate.agent.context.TokenEstimator; @@ -350,6 +351,52 @@ public class ReasoningNode implements NodeAction { */ private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService; + /** + * Token budget for the optional prefix injection blocks, computed at + * agent-build time against the model's effective context window. Null + * when the graph was assembled without budgeting (tests, legacy paths) — + * all injection sites then keep their previous absolute-cap behavior. + */ + private PrefixBudgetPlan prefixBudgetPlan; + + public void setPrefixBudgetPlan(PrefixBudgetPlan prefixBudgetPlan) { + this.prefixBudgetPlan = prefixBudgetPlan; + } + + /** + * Core-tier tools auto-demoted to the extension catalog because the + * advertised schemas exceeded the window's tool-schema budget. Decided + * once at agent-build time (kept stable for prompt caching); the baked + * extension catalog lists them so {@code enable_tool} can surface any of + * them back. + */ + private Set autoDemotedTools = Set.of(); + + public void setAutoDemotedTools(Set autoDemotedTools) { + this.autoDemotedTools = autoDemotedTools == null ? Set.of() : autoDemotedTools; + } + + /** Floor for the window-aware output clamp — an answer needs at least this much room. */ + private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512; + + /** + * Output cap actually sent to the provider. Strict local servers (vLLM) + * statically reject {@code max_tokens >= max_model_len}, so when the + * effective context window is known and smaller than the configured / + * default output cap, clamp to half the window (leaving the other half + * for the prompt). No-op when the window is unknown or already larger. + */ + int effectiveMaxOutputTokens() { + int window = (prefixBudgetPlan != null) ? prefixBudgetPlan.effectiveMaxTokens() : 0; + if (window > 0 && maxOutputTokens >= window) { + int clamped = Math.max(MIN_CLAMPED_OUTPUT_TOKENS, window / 2); + log.info("[ReasoningNode] max_tokens {} ≥ 模型窗口 {},钳制为 {}(窗口一半)以避免服务端拒绝", + maxOutputTokens, window, clamped); + return clamped; + } + return maxOutputTokens; + } + public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, NodeStreamingChatHelper streamingHelper, ConversationWindowManager conversationWindowManager, @@ -470,6 +517,12 @@ public class ReasoningNode implements NodeAction { * otherwise the documented fallback. */ private int loopContextWindowTokens() { + // Per-model effective window (explicit config or probed) beats the + // global default — the loop budgeter is otherwise blind to small + // local models and never trims for them. + if (prefixBudgetPlan != null && prefixBudgetPlan.effectiveMaxTokens() > 0) { + return prefixBudgetPlan.effectiveMaxTokens(); + } if (conversationWindowManager != null) { int v = conversationWindowManager.getDefaultMaxInputTokens(); if (v > 0) return v; @@ -709,13 +762,35 @@ public class ReasoningNode implements NodeAction { // so an enable_tool call earlier in this loop takes effect immediately. // Falls back to the full tool set when no disclosure service is wired. List activeCallbacks = (toolDisclosureService != null && toolSet != null) - ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks() + ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools) + .activeCallbacks() : toolCallbacks; ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); Prompt prompt = new Prompt(promptMessages, options); + // Prefix accounting: how much of the window the never-trimmed prefix + // (system prompt + runtime context + wiki + skill catalog + ledger) + // and the advertised tool schemas consume. Logged on the turn's first + // call so a small-window overflow is diagnosable per block instead of + // surfacing as an opaque provider 400. + int prefixEstimateTokens = TokenEstimator.estimateTokens(nonHistoryPrefix); + int toolSchemaEstimateTokens = TokenEstimator.estimateToolsTokens(activeCallbacks); + if (accessor.llmCallCount() == 0) { + log.info("[ReasoningNode] Prefix accounting conv={}: window={} tokens, prefix={} " + + "(system+context+wiki+skills+ledger), toolSchemas={}, history={}", + conversationId, loopContextWindowTokens(), prefixEstimateTokens, + toolSchemaEstimateTokens, TokenEstimator.estimateTokens(messages)); + } + // The prefix cannot be compacted (history compaction is the only lever), + // so a prefix that alone exceeds the window makes the request doomed — + // fail fast with the same PROMPT_TOO_LONG shape a provider rejection + // would produce instead of sending it. Gated on budgeting being active + // (an estimation false-positive must not block requests otherwise). + boolean prefixOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled() + && prefixEstimateTokens + toolSchemaEstimateTokens > prefixBudgetPlan.effectiveMaxTokens(); + // ======= LLM 调用区域 ======= // nextLlmCallCount 在首次 streamCall 之前计算。 // 所有退出路径(正常、stopped、fatal error、CancellationException)都必须写回此值。 @@ -745,7 +820,19 @@ public class ReasoningNode implements NodeAction { NodeStreamingChatHelper.StreamResult result; try { - result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning"); + if (prefixOverflow) { + String overflowMessage = "Prompt 前缀估算 " + (prefixEstimateTokens + toolSchemaEstimateTokens) + + " tokens(注入块 " + prefixEstimateTokens + " + 工具 schema " + toolSchemaEstimateTokens + + ")已超过模型上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens() + + " tokens,历史压缩无法解决——请精简 Agent 身份 prompt、减少绑定工具/技能," + + "或换用更大窗口的模型"; + log.error("[ReasoningNode] {}", overflowMessage); + result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false, + 0, 0, false, overflowMessage, + NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0); + } else { + result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning"); + } // PTL 处理:结构化压缩后重试。复用 nonHistoryPrefix 保证重试 // Prompt 仍带 wiki / runtime context;早期的 tail-only 路径会把 @@ -1119,7 +1206,9 @@ public class ReasoningNode implements NodeAction { if (!projectRecalled && wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) { try { Long parsedAgentId = Long.parseLong(agentIdStr); - String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg); + Integer wikiBudgetTokens = (prefixBudgetPlan != null && prefixBudgetPlan.enabled()) + ? prefixBudgetPlan.wikiTokens() : null; + String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg, wikiBudgetTokens); if (wikiRelevant != null && !wikiRelevant.isBlank()) { prefix.add(new UserMessage(wikiRelevant)); } @@ -1166,11 +1255,11 @@ public class ReasoningNode implements NodeAction { default -> 16384; }; builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens); - builder.maxTokens(budgetTokens + maxOutputTokens); + builder.maxTokens(budgetTokens + effectiveMaxOutputTokens()); builder.temperature(1.0); log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens); } else { - builder.maxTokens(maxOutputTokens); + builder.maxTokens(effectiveMaxOutputTokens()); if (thinkingOn && !isClaudeModel) { log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel); } @@ -1185,7 +1274,7 @@ public class ReasoningNode implements NodeAction { // DashScope rejects max_tokens above its 8192 ceiling with a 400 that // the failover layer misreads as "model not found"; clamp so a // DashScope-backed model never overflows the provider limit. - int effectiveMaxTokens = maxOutputTokens; + int effectiveMaxTokens = effectiveMaxOutputTokens(); if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel && effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) { log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model", diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java index 7710241d..06af1b05 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java @@ -11,6 +11,7 @@ import reactor.core.publisher.Mono; import vip.mate.agent.AgentService; import vip.mate.agent.AgentState; import vip.mate.agent.BaseAgent; +import vip.mate.agent.delegation.DelegatedUsageAccumulator; import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.graph.plan.state.PlanStateKeys; @@ -97,8 +98,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId); Map inputs = buildInitialState(userMessage, conversationId); - // 从 DB 恢复 awaiting_approval 状态的计划上下文 - PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(); + // 从 DB 恢复 awaiting_approval 状态的计划上下文(按 conversationId 过滤,避免并发会话误取) + PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(conversationId); if (ctx != null) { inputs.put(PlanStateKeys.PLAN_ID, ctx.planId()); inputs.put(PlanStateKeys.PLAN_STEPS, ctx.steps()); @@ -142,8 +143,15 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS AtomicInteger sentEventCount = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalCompletionTokens = new AtomicInteger(0); + AtomicInteger finalCacheReadTokens = new AtomicInteger(0); + AtomicInteger finalCacheWriteTokens = new AtomicInteger(0); + AtomicInteger finalReasoningTokens = new AtomicInteger(0); AtomicReference finalModelName = new AtomicReference<>(""); AtomicReference finalProviderId = new AtomicReference<>(""); + // Root conversation for this turn — used to roll delegated sub-agent + // token usage into the turn's _usage_final and to clear the accumulator + // on terminal so an errored turn never leaks an entry. + final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID); // 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容 AtomicReference lastPersistedStepResult = new AtomicReference<>(""); AtomicReference lastPersistedStepThinking = new AtomicReference<>(""); @@ -203,16 +211,33 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS // 3. 更新最新累计 token usage finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0)); finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0)); + finalCacheReadTokens.set(output.state().value(MateClawStateKeys.CACHE_READ_TOKENS, 0)); + finalCacheWriteTokens.set(output.state().value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0)); + finalReasoningTokens.set(output.state().value(MateClawStateKeys.REASONING_TOKENS, 0)); finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, "")); finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "")); return deltas; }) .concatWith(Mono.fromSupplier(() -> { - if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) { + // Roll delegated sub-agent usage (whole sub-tree, keyed by this + // root conversation) into the turn total so the assistant + // message reflects what the orchestrator + all children cost. + DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance(); + DelegatedUsageAccumulator.Drained delegated = acc != null + ? acc.drain(usageConversationId) + : new DelegatedUsageAccumulator.Drained(0, 0); + long promptTokens = finalPromptTokens.get() + delegated.promptTokens(); + long completionTokens = finalCompletionTokens.get() + delegated.completionTokens(); + if (promptTokens > 0 || completionTokens > 0) { return AgentService.StreamDelta.event("_usage_final", Map.of( - "promptTokens", finalPromptTokens.get(), - "completionTokens", finalCompletionTokens.get(), + "promptTokens", promptTokens, + "completionTokens", completionTokens, + "delegatedPromptTokens", delegated.promptTokens(), + "delegatedCompletionTokens", delegated.completionTokens(), + "cacheReadTokens", finalCacheReadTokens.get(), + "cacheWriteTokens", finalCacheWriteTokens.get(), + "reasoningTokens", finalReasoningTokens.get(), "runtimeModelName", finalModelName.get(), "runtimeProviderId", finalProviderId.get() )); @@ -223,6 +248,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS .doOnError(e -> { log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage()); setState(AgentState.ERROR); + }) + // Leak guard: if the turn ends without emitting _usage_final + // (error / cancel), discard any delegated usage left for this + // conversation so it can't bleed into a later turn. + .doFinally(sig -> { + DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance(); + if (acc != null) acc.clear(usageConversationId); }); } @@ -297,6 +329,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS inputs.put(MateClawStateKeys.REQUESTER_ID, ""); inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0); inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0); + inputs.put(MateClawStateKeys.CACHE_READ_TOKENS, 0); + inputs.put(MateClawStateKeys.CACHE_WRITE_TOKENS, 0); + inputs.put(MateClawStateKeys.REASONING_TOKENS, 0); inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : ""); inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : ""); inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8)); 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 33280fc7..fac3be5a 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 @@ -33,6 +33,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -190,6 +191,48 @@ public class PlanGenerationNode implements NodeAction { return goal; } + /** Whole injected long-term-memory recall block (any casing). */ + private static final Pattern MEMORY_CONTEXT_BLOCK = + Pattern.compile("(?is)<\\s*memory-context\\s*>.*?"); + /** Stray open/close memory-context fence tags left after block removal. */ + private static final Pattern MEMORY_CONTEXT_TAG = + Pattern.compile("(?i)"); + /** Marker that introduces the real instruction inside a scheduled-run wrapper. */ + private static final String CRON_TASK_MARKER = "[任务指令]"; + /** Suffix appended by a goal-driven re-plan pass; not part of the user's ask. */ + private static final String FOLLOWUP_MARKER = "[Follow-up guidance]"; + + /** + * Recovers the user's actual request from the fully-assembled agent prompt so + * the persisted/displayed plan goal reads as the task itself, not the + * framework scaffolding wrapped around it. The graph receives the goal already + * enriched — a {@code } recall block is + * prepended for every turn, scheduled runs add a wrapper whose real payload + * sits after {@code [任务指令]}, and a re-plan pass appends a + * {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan + * board showing "<memory-context> The following is what you…" instead of + * the user's goal. Strips, in order: the recall block, the scheduled-run + * preamble (keeping only the instruction body), and the follow-up suffix. + * Falls back to the raw goal if scrubbing would leave nothing. + */ + static String displayGoal(String goal) { + if (goal == null || goal.isBlank()) { + return goal == null ? "" : goal; + } + String s = MEMORY_CONTEXT_BLOCK.matcher(goal).replaceAll(""); + s = MEMORY_CONTEXT_TAG.matcher(s).replaceAll(""); + int task = s.lastIndexOf(CRON_TASK_MARKER); + if (task >= 0) { + s = s.substring(task + CRON_TASK_MARKER.length()); + } + int followup = s.indexOf(FOLLOWUP_MARKER); + if (followup >= 0) { + s = s.substring(0, followup); + } + s = s.strip(); + return s.isEmpty() ? goal.strip() : s; + } + public PlanGenerationNode(ChatModel chatModel, PlanningService planningService, NodeStreamingChatHelper streamingHelper, ConversationWindowManager conversationWindowManager, @@ -260,7 +303,7 @@ public class PlanGenerationNode implements NodeAction { if (goalService.findActiveByConversation(convId) != null) { return null; // respect an existing goal (incl. re-plan passes) } - String request = stripInjectedContext(accessor.goal()).strip(); + String request = displayGoal(accessor.goal()); GoalCreateRequest req = new GoalCreateRequest(); req.setConversationId(convId); req.setAgentId(origin.agentId()); @@ -368,10 +411,16 @@ public class PlanGenerationNode implements NodeAction { String agentId = state.value(MateClawStateKeys.AGENT_ID, ""); String conversationId = accessor.conversationId(); - log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal); + // The graph's goal carries framework scaffolding (memory recall block, + // scheduled-run wrapper, follow-up suffix). Persist and display the + // scrubbed user request so the Plan board shows the actual task; the raw + // goal still feeds the triage LLM below. + String persistGoal = displayGoal(goal); + + log.info("[PlanGeneration] Evaluating goal: {}", persistGoal.length() > 100 ? persistGoal.substring(0, 100) + "..." : persistGoal); List events = new ArrayList<>(); - events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal))); + events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal))); // Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM. Long existingPlanId = state.value(PlanStateKeys.PLAN_ID).orElse(null); @@ -508,8 +557,8 @@ public class PlanGenerationNode implements NodeAction { log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; " + "downgrading to single-step plan so tools can execute (goal: {})", goal.length() > 60 ? goal.substring(0, 60) + "..." : goal); - List gatedSteps = List.of(goal); - var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps); + List gatedSteps = List.of(persistGoal); + var gatedPlan = planningService.createPlan(agentId, conversationId, persistGoal, gatedSteps); events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps)); return PlanStateAccessor.output() .needsPlanning(true) @@ -547,7 +596,7 @@ public class PlanGenerationNode implements NodeAction { // can still reach the tools. (Previous behavior dropped back to // direct_answer, which silently stripped tool capability.) log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan"); - steps = List.of(goal); + steps = List.of(persistGoal); } // Resolve any per-step agent delegation the planner asked for. Null @@ -555,7 +604,7 @@ public class PlanGenerationNode implements NodeAction { List stepAgentIds = resolveStepAgents(steps, triage != null ? triage.stepAgents() : null, chatOrigin.workspaceId(), agentId); - var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds); + var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds); log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}", plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step", stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : ""); @@ -600,12 +649,12 @@ public class PlanGenerationNode implements NodeAction { // answer. This preserves tool access on the failure path; the previous // "direct answer" fallback silently degraded tool-requiring tasks. try { - var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal)); - events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal))); + var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal)); + events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal))); return PlanStateAccessor.output() .needsPlanning(true) .planId(plan.getId()) - .planSteps(List.of(goal)) + .planSteps(List.of(persistGoal)) .planValid(true) .currentStepIndex(0) .currentPhase("plan_generated") diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 59c4ca8a..d2895c26 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -30,6 +30,7 @@ import vip.mate.planning.service.PlanningService; import vip.mate.agent.context.ChatOrigin; import vip.mate.skill.runtime.SkillCatalogRenderer; import vip.mate.tool.builtin.DelegateAgentTool; +import vip.mate.tool.builtin.DelegateAgentTool.ChildResult; import vip.mate.tool.builtin.DelegationContext; import vip.mate.tool.builtin.ToolExecutionContext; @@ -247,6 +248,9 @@ public class StepExecutionNode implements NodeAction { String approvalToolName = null; int stepPromptTokens = 0; int stepCompletionTokens = 0; + int stepCacheReadTokens = 0; + int stepCacheWriteTokens = 0; + int stepReasoningTokens = 0; // RFC-052: any returnDirect tool that fires inside this step must // short-circuit the entire plan (not just this step). We accumulate @@ -321,6 +325,9 @@ public class StepExecutionNode implements NodeAction { stepPromptTokens += result.promptTokens(); stepCompletionTokens += result.completionTokens(); + stepCacheReadTokens += result.cacheReadTokens(); + stepCacheWriteTokens += result.cacheWriteTokens(); + stepReasoningTokens += result.reasoningTokens(); if (!result.thinking().isEmpty()) { stepThinking = result.thinking(); @@ -443,8 +450,8 @@ public class StepExecutionNode implements NodeAction { .currentPhase("awaiting_approval") .contentStreamed(true) .thinkingStreamed(!stepThinking.isEmpty()) - .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) - .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .addStepUsage(state, stepPromptTokens, stepCompletionTokens, + stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) .build(); } @@ -479,10 +486,8 @@ public class StepExecutionNode implements NodeAction { .contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送 .put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true) .put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs)) - .put(MateClawStateKeys.PROMPT_TOKENS, - state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) - .put(MateClawStateKeys.COMPLETION_TOKENS, - state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .addStepUsage(state, stepPromptTokens, stepCompletionTokens, + stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) .build(); } @@ -531,8 +536,8 @@ public class StepExecutionNode implements NodeAction { .currentStepTitle("") .currentStepResult("") .contentStreamed(false) - .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) - .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .addStepUsage(state, stepPromptTokens, stepCompletionTokens, + stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) .build(); } @@ -589,8 +594,8 @@ public class StepExecutionNode implements NodeAction { .currentStepTitle("") .currentStepResult("") .contentStreamed(false) - .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) - .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .addStepUsage(state, stepPromptTokens, stepCompletionTokens, + stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) .build(); } @@ -601,8 +606,8 @@ public class StepExecutionNode implements NodeAction { .currentStepResult(shortError) .currentPhase("plan_aborted") .contentStreamed(false) - .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) - .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .addStepUsage(state, stepPromptTokens, stepCompletionTokens, + stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) .build(); } @@ -645,8 +650,8 @@ public class StepExecutionNode implements NodeAction { .currentPhase("step_completed") .contentStreamed(true) .thinkingStreamed(!stepThinking.isEmpty()) - .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) - .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .addStepUsage(state, stepPromptTokens, stepCompletionTokens, + stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) .build(); } @@ -678,7 +683,7 @@ public class StepExecutionNode implements NodeAction { // Seed the delegation context with the plan's REAL conversation id (from // graph state) so the delegated child conversation is parented to it and // stays hidden from the user's conversation list. The ChatOrigin in the - // plan-execute path carries no conversationId, so delegateByAgentId can't + // plan-execute path carries no conversationId, so the delegation can't // derive the parent on its own — we provide it here. boolean seeded = false; if (conversationId != null && !conversationId.isBlank() @@ -687,20 +692,30 @@ public class StepExecutionNode implements NodeAction { DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0); seeded = true; } - String result; + ChildResult childResult = null; + String delegateError = null; try { - result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin); + childResult = delegateAgentTool.delegateByAgentIdStructured(assignedAgentId, step, chatOrigin); } catch (Exception e) { log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e); - result = "[错误] 委派执行异常:" + e.getMessage(); + delegateError = e.getMessage(); } finally { if (seeded) { DelegationContext.exit(); } } - String finalResult = result != null ? result : ""; - boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]"); + // Branch on the structured outcome instead of pattern-matching an error + // prefix out of the reply text: a successful child with non-empty content + // is the only "ok" case; blank / error / missing all count as failure. + boolean ok = childResult != null && childResult.success() && !childResult.isBlank(); + String finalResult = ok + ? (childResult.result() != null ? childResult.result() : "") + : "[错误] 委派执行失败:" + (delegateError != null ? delegateError + : childResult != null && childResult.error() != null ? childResult.error() + : childResult != null && childResult.isBlank() ? "子 Agent 返回内容为空" + : "未知错误"); + boolean failed = !ok; if (failed) { planningService.updateSubPlanFailure(planId, stepIndex, finalResult); } else { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java index 98d3e385..f041871e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java @@ -258,10 +258,37 @@ public final class PlanStateAccessor { int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0); map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens()); + map.put(MateClawStateKeys.CACHE_READ_TOKENS, + currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + result.cacheReadTokens()); + map.put(MateClawStateKeys.CACHE_WRITE_TOKENS, + currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens()); + map.put(MateClawStateKeys.REASONING_TOKENS, + currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + result.reasoningTokens()); map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1); return this; } + /** + * 将一个 step 的累计 usage(含 cache / reasoning 分项)加到 state 已有值上。 + * StepExecutionNode 在多个出口路径上写回同一组键,统一走这里避免漏项。 + */ + public OutputBuilder addStepUsage(OverAllState currentState, + int promptTokens, int completionTokens, + int cacheReadTokens, int cacheWriteTokens, + int reasoningTokens) { + map.put(MateClawStateKeys.PROMPT_TOKENS, + currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0) + promptTokens); + map.put(MateClawStateKeys.COMPLETION_TOKENS, + currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + completionTokens); + map.put(MateClawStateKeys.CACHE_READ_TOKENS, + currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + cacheReadTokens); + map.put(MateClawStateKeys.CACHE_WRITE_TOKENS, + currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + cacheWriteTokens); + map.put(MateClawStateKeys.REASONING_TOKENS, + currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + reasoningTokens); + return this; + } + public Map build() { return map; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index 23a8b6f8..5529f440 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -537,6 +537,12 @@ public final class MateClawStateAccessor { int existingCompletion = currentState.value(COMPLETION_TOKENS, 0); map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens()); + map.put(CACHE_READ_TOKENS, + currentState.value(CACHE_READ_TOKENS, 0) + result.cacheReadTokens()); + map.put(CACHE_WRITE_TOKENS, + currentState.value(CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens()); + map.put(REASONING_TOKENS, + currentState.value(REASONING_TOKENS, 0) + result.reasoningTokens()); return this; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index 56b66517..2bddc46c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -155,6 +155,12 @@ public final class MateClawStateKeys { // ===== Token Usage 累计(REPLACE 策略,节点内累加后写回)===== public static final String PROMPT_TOKENS = "prompt_tokens"; public static final String COMPLETION_TOKENS = "completion_tokens"; + /** Prompt cache 命中 tokens 累计(provider 未上报时保持 0) */ + public static final String CACHE_READ_TOKENS = "cache_read_tokens"; + /** Prompt cache 写入 tokens 累计(provider 未上报时保持 0) */ + public static final String CACHE_WRITE_TOKENS = "cache_write_tokens"; + /** 思考(reasoning)tokens 累计(provider 未上报时保持 0) */ + public static final String REASONING_TOKENS = "reasoning_tokens"; // ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)===== public static final String RUNTIME_MODEL_NAME = "runtime_model_name"; diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java index fe40501d..238e17fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java @@ -335,6 +335,29 @@ public class ApprovalWorkflowService implements ApplicationRunner { approvalMapper.insert(entity); log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}", entity.getId(), runId, workspaceId, kind); + + // ISSUE #413: register the workflow approval into the in-memory map + // so the resolve → resume bridge actually fires. Without this, the + // row only lives in DB and ApprovalService.getPending("wf-...") returns + // null, so performResolve() short-circuits at the "not pending" guard + // and never reaches the WorkflowApprovalResolvedEvent publish in + // Phase 4 — leaving ApprovalResumeBridge as dead code. Mirrors the + // recoverFromDb() snapshot shape exactly. + Instant createdAt = entity.getCreatedAt() != null + ? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant() + : Instant.now(); + PendingApproval snapshot = new PendingApproval( + entity.getPendingId(), + entity.getConversationId(), + /*userId*/ null, + entity.getToolName(), + entity.getToolArguments(), + /*reason*/ entity.getSummary(), + createdAt, + "pending"); + snapshot.setSummary(entity.getSummary()); + approvalService.registerRecovered(snapshot); + return entity.getId(); } catch (Exception e) { log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage()); @@ -775,6 +798,17 @@ public class ApprovalWorkflowService implements ApplicationRunner { /** * 代理查询方法 */ + /** + * Look up a pending approval by its exact id. Delegates to the underlying + * {@link ApprovalService#getPending} so callers that only hold the workflow + * facade (e.g. WebChatController) can fetch the precise record for an IDOR + * cross-check without falling back to {@code findPendingByConversation} + * (which returns the earliest pending, wrong when several coexist). + */ + public java.util.Optional getPending(String pendingId) { + return approvalService.getPending(pendingId); + } + public PendingApproval findPendingByConversation(String conversationId) { return approvalService.findPendingByConversation(conversationId); } diff --git a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java index 07ccad95..1bf490e3 100644 --- a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java @@ -50,7 +50,8 @@ public class AuthService { .eq(UserEntity::getUsername, request.getUsername()) .eq(UserEntity::getEnabled, true)); - if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { + if (user == null || user.getPassword() == null + || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误"); } @@ -212,7 +213,10 @@ public class AuthService { return userMapper.selectById(userId); } - private String generateToken(UserEntity user) { + /** + * 生成 JWT token。SSO 登录路径复用此方法签发格式一致的 token。 + */ + public String generateToken(UserEntity user) { return Jwts.builder() .subject(user.getUsername()) .claim("userId", user.getId()) diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java new file mode 100644 index 00000000..e361201a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java @@ -0,0 +1,33 @@ +package vip.mate.auth.sso; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import vip.mate.auth.sso.provider.FeishuSsoProvider; + +/** + * SSO 配置。启用 {@link SsoProperties} 绑定 + 按需注册飞书 Provider。 + *

    + * 仅当 {@code mateclaw.sso.enabled=true} 时此配置生效。飞书 Provider 进一步要求 + * {@code mateclaw.sso.feishu.enabled=true}。 + * + * @author MateClaw Team + */ +@Configuration +@EnableScheduling +@EnableConfigurationProperties(SsoProperties.class) +@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true") +public class SsoAutoConfiguration { + + /** + * 飞书 SSO Provider。仅当飞书 SSO 启用时注册。 + */ + @Bean + @ConditionalOnProperty(name = "mateclaw.sso.feishu.enabled", havingValue = "true") + public FeishuSsoProvider feishuSsoProvider(SsoProperties ssoProperties, ObjectMapper objectMapper) { + return new FeishuSsoProvider(ssoProperties.getFeishu(), objectMapper); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java new file mode 100644 index 00000000..ca81a91a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java @@ -0,0 +1,43 @@ +package vip.mate.auth.sso; + +import lombok.AllArgsConstructor; +import lombok.Data; +import vip.mate.auth.model.LoginResponse; + +/** + * SSO 回调响应。两种互斥形态由 {@code bindRequired} 区分: + *

      + *
    • {@code bindRequired=false}: 登录成功, {@code loginResponse} 携带 JWT
    • + *
    • {@code bindRequired=true}: link-only 模式未绑定, {@code bindToken} 供前端引导绑定
    • + *
    + * + *

    替代了原先用 {@code R.fail(200, Map.toString())} 传递绑定信号的 hack。 + * + * @author MateClaw Team + */ +@Data +@AllArgsConstructor +public class SsoCallbackResponse { + + /** link-only 模式下未绑定时为 true */ + private boolean bindRequired; + + /** 登录成功时非空 */ + private LoginResponse loginResponse; + + /** bindRequired=true 时非空, 供前端调 /sso/bind */ + private String bindToken; + + private String provider; + private String displayName; + + /** 登录成功响应工厂 */ + public static SsoCallbackResponse of(LoginResponse loginResponse) { + return new SsoCallbackResponse(false, loginResponse, null, null, null); + } + + /** 需绑定响应工厂 (link-only 模式) */ + public static SsoCallbackResponse bindRequired(String bindToken, String provider, String displayName) { + return new SsoCallbackResponse(true, null, bindToken, provider, displayName); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java new file mode 100644 index 00000000..a34e7e3c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java @@ -0,0 +1,72 @@ +package vip.mate.auth.sso; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.*; +import vip.mate.auth.model.LoginResponse; +import vip.mate.auth.sso.provider.SsoProviderRegistry; +import vip.mate.common.result.R; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * SSO 单点登录 HTTP 端点。全部 permitAll (与 /auth/login 同级)。 + * + * @author MateClaw Team + */ +@Tag(name = "SSO 单点登录") +@Slf4j +@RestController +@RequestMapping("/api/v1/auth/sso") +@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SsoController { + + private final SsoProviderRegistry registry; + private final SsoService ssoService; + + @Operation(summary = "列出已启用的 SSO Provider") + @GetMapping("/providers") + public R>> providers() { + List> list = registry.listEnabled().stream() + .map(p -> Map.of("id", p.id(), "displayName", p.displayName())) + .collect(Collectors.toList()); + return R.ok(list); + } + + @Operation(summary = "获取 SSO 授权 URL") + @GetMapping("/{provider}/authorize") + public R> authorize(@PathVariable String provider) { + return R.ok(ssoService.handleAuthorize(provider)); + } + + @Operation(summary = "SSO 回调: 授权码换 JWT") + @PostMapping("/{provider}/callback") + public R callback(@PathVariable String provider, + @RequestBody CallbackRequest body) { + if (body == null || body.code() == null || body.state() == null) { + return R.fail(400, "code 和 state 是必填项"); + } + // handleCallback 返回结构化响应: bindRequired=false 时 loginResponse 非空, + // bindRequired=true 时 bindToken 非空 (link-only 模式)。两种形态由前端判断。 + return R.ok(ssoService.handleCallback(provider, body.code(), body.state())); + } + + @Operation(summary = "绑定 SSO 身份到已有账号 (link-only 模式)") + @PostMapping("/bind") + public R bind(@RequestBody BindRequest body) { + if (body == null || body.bindToken() == null || body.username() == null || body.password() == null) { + return R.fail(400, "bindToken, username, password 是必填项"); + } + LoginResponse resp = ssoService.handleBind(body.bindToken(), body.username(), body.password()); + return R.ok(resp); + } + + public record CallbackRequest(String code, String state) {} + public record BindRequest(String bindToken, String username, String password) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoProperties.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoProperties.java new file mode 100644 index 00000000..9c2c86e3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoProperties.java @@ -0,0 +1,51 @@ +package vip.mate.auth.sso; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * SSO 单点登录配置。 + *

    + * 全局开关默认关闭, 不影响未启用 SSO 的现有部署。 + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.sso") +public class SsoProperties { + + /** 是否启用 SSO (全局开关) */ + private boolean enabled = false; + + /** + * 「仅允许绑定已有账号」模式。 + * {@code false} = 未绑定时自动创建 mate_user; {@code true} = 要求绑定已存在的账号。 + */ + private boolean linkOnly = false; + + /** 新建 SSO 用户的默认角色 */ + private String defaultRole = "user"; + + /** 飞书 Provider 配置 */ + private Feishu feishu = new Feishu(); + + @Data + public static class Feishu { + /** 是否启用飞书 SSO */ + private boolean enabled = false; + /** 飞书应用 App ID */ + private String appId; + /** 飞书应用 App Secret */ + private String appSecret; + /** + * 国际版切换: {@code feishu} (国内) / {@code lark} (国际版 Lark)。 + * 决定 apiBase: {@code https://open.feishu.cn} / {@code https://open.larksuite.com} + */ + private String domain = "feishu"; + /** + * SSO 回调地址, 通常 {@code https://your-domain/login?sso=callback}。 + * 飞书授权后带 code 回跳到此地址。 + */ + private String redirectUri; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java new file mode 100644 index 00000000..7b45ecc8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java @@ -0,0 +1,267 @@ +package vip.mate.auth.sso; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import vip.mate.auth.model.LoginResponse; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.repository.UserMapper; +import vip.mate.auth.service.AuthService; +import vip.mate.auth.sso.model.ExternalIdentityEntity; +import vip.mate.auth.sso.provider.SsoProvider; +import vip.mate.auth.sso.provider.SsoProviderRegistry; +import vip.mate.auth.sso.provider.SsoUserInfo; +import vip.mate.auth.sso.repository.ExternalIdentityMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.exception.MateClawException; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * SSO 核心业务逻辑: 授权 URL 构造、回调用户映射、账号绑定。 + *

    + * 用户映射策略 (两者结合): + *

      + *
    • 已绑定 → 更新 last_login + external 信息 → 签发 JWT
    • + *
    • 未绑定 + link-only → 签发 bind_token, 前端引导绑定
    • + *
    • 未绑定 + 默认 → 自动创建 mate_user + external_identity
    • + *
    + * + * @author MateClaw Team + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SsoService { + + private final SsoProviderRegistry registry; + private final SsoStateService stateService; + private final ExternalIdentityMapper identityMapper; + private final UserMapper userMapper; + private final AuthService authService; + private final SsoProperties ssoProperties; + private final BCryptPasswordEncoder passwordEncoder; + private final ObjectMapper objectMapper; + /** Optional — audit may be null in narrow test contexts. */ + @Autowired(required = false) + private AuditEventService auditService; + + // ==================== authorize ==================== + + /** + * 构造授权 URL + 签发 state。 + * + * @return { authorizeUrl, state } + */ + public Map handleAuthorize(String providerId) { + SsoProvider provider = registry.get(providerId) + .orElseThrow(() -> new MateClawException("err.sso.unknown_provider", + 400, "未知的 SSO provider: " + providerId)); + String state = stateService.issueState(providerId); + String url = provider.authorizeUrl(state); + return Map.of("authorizeUrl", url, "state", state); + } + + // ==================== callback ==================== + + /** + * OAuth2 回调: code → JWT。 + *

    + * 已绑定用户直接签发 JWT; 未绑定用户根据 link-only 策略决定行为: + *

      + *
    • link-only → 返回 {@link SsoCallbackResponse#bindRequired} 携带 bind_token
    • + *
    • 默认 → 自动创建 mate_user (含并发幂等保护)
    • + *
    + */ + public SsoCallbackResponse handleCallback(String providerId, String code, String state) { + // 1. 校验 state (签名 + 过期 + 一次性消费) + stateService.verifyState(state); + + // 2. code → IdP 用户信息 + SsoProvider provider = registry.get(providerId) + .orElseThrow(() -> new MateClawException("err.sso.unknown_provider", + 400, "未知的 SSO provider: " + providerId)); + SsoUserInfo info = provider.resolve(code, state); + + // 3. 查已有绑定 + ExternalIdentityEntity identity = findIdentity(providerId, info); + if (identity != null) { + UserEntity user = userMapper.selectById(identity.getUserId()); + if (user == null || !Boolean.TRUE.equals(user.getEnabled())) { + throw new MateClawException("err.sso.account_disabled", + 403, "账号已停用或不存在"); + } + updateIdentityOnLogin(identity, info); + audit("sso.login", providerId, info.externalId(), user.getId()); + return SsoCallbackResponse.of(loginSuccess(user)); + } + + // 4. 未绑定 + if (ssoProperties.isLinkOnly()) { + String bindToken = stateService.issueBindToken(providerId, info); + audit("sso.bind_required", providerId, info.externalId(), null); + return SsoCallbackResponse.bindRequired(bindToken, providerId, info.displayName()); + } + + // 5. 默认: 自动创建 (含并发幂等, 最多重试一次) + UserEntity newUser = createSsoUser(providerId, info, false); + return SsoCallbackResponse.of(loginSuccess(newUser)); + } + + // ==================== bind (link-only 模式) ==================== + + /** + * 绑定 SSO 身份到已有 mate_user 账号。 + * 校验 bind_token + 用户名密码 → 创建 external_identity → 签发 JWT。 + */ + public LoginResponse handleBind(String bindToken, String username, String password) { + SsoStateService.BindTokenClaims claims = stateService.verifyBindToken(bindToken); + + // 校验用户名密码 (与 AuthService.login 一致的 BCrypt 校验) + UserEntity user = authService.findByUsername(username); + if (user == null || user.getPassword() == null + || !passwordEncoder.matches(password, user.getPassword())) { + throw new MateClawException("err.auth.invalid_credentials", + 401, "用户名或密码错误"); + } + if (!Boolean.TRUE.equals(user.getEnabled())) { + throw new MateClawException("err.sso.account_disabled", + 403, "账号已停用"); + } + + // 创建绑定 (并发幂等: UNIQUE(provider, external_id) 兜底) + try { + ExternalIdentityEntity identity = new ExternalIdentityEntity(); + identity.setUserId(user.getId()); + identity.setProvider(claims.provider()); + identity.setExternalId(claims.externalId()); + identity.setUnionId(claims.unionId()); + identity.setExternalName(claims.externalName()); + identity.setLastLoginAt(LocalDateTime.now()); + identityMapper.insert(identity); + } catch (DuplicateKeyException e) { + throw new MateClawException("err.sso.already_bound", + 409, "该飞书账号已绑定到其他用户"); + } + + audit("sso.bind", claims.provider(), claims.externalId(), user.getId()); + return loginSuccess(user); + } + + /** + * 查找已绑定的外部身份。union_id 优先, 回退 external_id。 + */ + private ExternalIdentityEntity findIdentity(String providerId, SsoUserInfo info) { + // 优先 union_id + if (info.unionId() != null && !info.unionId().isBlank()) { + ExternalIdentityEntity byUnion = identityMapper.selectOne( + new LambdaQueryWrapper() + .eq(ExternalIdentityEntity::getProvider, providerId) + .eq(ExternalIdentityEntity::getUnionId, info.unionId())); + if (byUnion != null) return byUnion; + } + // 回退 external_id + return identityMapper.selectOne( + new LambdaQueryWrapper() + .eq(ExternalIdentityEntity::getProvider, providerId) + .eq(ExternalIdentityEntity::getExternalId, info.externalId())); + } + + /** + * 更新绑定记录的 last_login + external 信息。 + */ + private void updateIdentityOnLogin(ExternalIdentityEntity identity, SsoUserInfo info) { + identityMapper.update(null, new LambdaUpdateWrapper() + .eq(ExternalIdentityEntity::getId, identity.getId()) + .set(ExternalIdentityEntity::getLastLoginAt, LocalDateTime.now()) + .set(ExternalIdentityEntity::getExternalName, info.displayName()) + .set(ExternalIdentityEntity::getExternalAvatar, info.avatarUrl()) + .set(ExternalIdentityEntity::getExternalEmail, info.email())); + } + + /** + * 自动创建 SSO 用户 (含并发幂等: catch DuplicateKeyException → 回滚孤儿 user → 重查)。 + * + * @param retry 是否已重试过一次。第二次仍撞 PK 时直接抛异常 (不再递归, 避免栈溢出)。 + */ + private UserEntity createSsoUser(String providerId, SsoUserInfo info, boolean retry) { + UserEntity newUser = new UserEntity(); + newUser.setUsername(providerId + "_" + info.externalId()); // feishu_ + newUser.setPassword(null); // 仅 SSO 登录 + newUser.setNickname(info.displayName()); + newUser.setAvatar(info.avatarUrl()); + newUser.setEmail(info.email()); + newUser.setRole(ssoProperties.getDefaultRole()); + newUser.setEnabled(true); + + try { + userMapper.insert(newUser); + ExternalIdentityEntity identity = new ExternalIdentityEntity(); + identity.setUserId(newUser.getId()); + identity.setProvider(providerId); + identity.setExternalId(info.externalId()); + identity.setUnionId(info.unionId()); + identity.setExternalName(info.displayName()); + identity.setExternalAvatar(info.avatarUrl()); + identity.setExternalEmail(info.email()); + identity.setLastLoginAt(LocalDateTime.now()); + identityMapper.insert(identity); + audit("sso.auto_create", providerId, info.externalId(), newUser.getId()); + return newUser; + } catch (DuplicateKeyException e) { + // 并发: 另一个请求已创建了该用户。回滚刚建的孤儿 user, 重查已存在的 identity。 + log.info("[SSO] Concurrent auto-create for provider={}, externalId={}: " + + "rolling back orphan user {}, falling back to existing", providerId, info.externalId(), newUser.getId()); + userMapper.deleteById(newUser.getId()); // mate_user 无 @TableLogic, 物理删 + ExternalIdentityEntity existing = findIdentity(providerId, info); + if (existing != null) { + return userMapper.selectById(existing.getUserId()); + } + // 极端竞态: identity 也被并发删了。重试一次, 不再递归。 + if (retry) { + throw new MateClawException("err.sso.concurrent_create_failed", + 503, "SSO 登录遇到并发冲突, 请重试"); + } + return createSsoUser(providerId, info, true); + } catch (RuntimeException e) { + // Identity insert failed for a non-duplicate reason (e.g. transient DB error). + // The two inserts are not in a shared transaction — this method is self-invoked + // and the enclosing callback performs a network call, so a method-level + // @Transactional would not apply. Roll back the freshly inserted user here so we + // never leave a passwordless orphan account behind. + if (newUser.getId() != null) { + userMapper.deleteById(newUser.getId()); + } + throw e; + } + } + + private LoginResponse loginSuccess(UserEntity user) { + String token = authService.generateToken(user); + return new LoginResponse(user.getId(), token, user.getUsername(), + user.getNickname(), user.getRole()); + } + + private void audit(String action, String provider, String externalId, Long userId) { + if (auditService != null) { + try { + String detail = objectMapper.writeValueAsString(Map.of( + "provider", provider != null ? provider : "", + "userId", userId != null ? userId : "null")); + auditService.record(action, "sso", + provider + ":" + externalId, externalId, detail); + } catch (Exception e) { + log.debug("[SSO] audit write failed for {}: {}", action, e.getMessage()); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java new file mode 100644 index 00000000..6e5e7597 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java @@ -0,0 +1,233 @@ +package vip.mate.auth.sso; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import vip.mate.auth.sso.model.SsoStateEntity; +import vip.mate.auth.sso.provider.SsoUserInfo; +import vip.mate.auth.sso.repository.SsoStateMapper; +import vip.mate.exception.MateClawException; + +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +/** + * OAuth2 state / bind_token 签发与校验服务。 + *

    + * 落 DB (sso_state 表) 而非内存: 多节点部署下 /authorize 与 /callback 可能落到不同节点。 + * state 和 bind_token 的 jti 共用同一张表, 用 {@code kind} 列区分。 + * + *

    State (OAuth2 CSRF): + *

      + *
    • 签发: Base64(nonce + "." + HMAC-SHA256(nonce, jwtSecret)), 存 DB (kind=state)
    • + *
    • 校验: 验 HMAC 签名 + 5min TTL + 一次性消费 (UPDATE consumed=1 WHERE consumed=0)
    • + *
    + * + *

    bind_token (link-only 模式, 自包含 JWT): + *

      + *
    • 签发: JWT(jti, provider, externalId, ..., exp=10min), 用 jwtSecret 签名
    • + *
    • 校验: 验签 + 过期 + 单次消费 (jti 写入 sso_state 撞 PK, 只有首个请求成功)
    • + *
    + * + * @author MateClaw Team + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SsoStateService { + + private static final int STATE_TTL_SECONDS = 5 * 60; // 5 min + private static final int BIND_TOKEN_TTL_SECONDS = 10 * 60; // 10 min + private static final String KIND_STATE = "state"; + private static final String KIND_BIND = "bind"; + + private final SsoStateMapper stateMapper; + + @Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}") + private String jwtSecret; + + // ==================== State (OAuth2 CSRF) ==================== + + /** + * 签发 OAuth2 state token 并持久化。返回 Base64(nonce.signature) 格式。 + */ + public String issueState(String provider) { + String nonce = UUID.randomUUID().toString().replace("-", ""); + String signature = hmacSha256Hex(nonce); + String state = nonce + "." + signature; + + SsoStateEntity entity = new SsoStateEntity(); + entity.setToken(state); + entity.setKind(KIND_STATE); + entity.setProvider(provider); + entity.setConsumed(0); + entity.setCreatedAt(LocalDateTime.now()); + stateMapper.insert(entity); + + return state; + } + + /** + * 校验 state 签名 + 过期 + 一次性消费。校验失败抛 400。 + */ + public void verifyState(String state) { + if (state == null || state.isBlank()) { + throw new MateClawException("err.sso.state_missing", 400, "缺少 state 参数"); + } + int dot = state.indexOf('.'); + if (dot <= 0 || dot >= state.length() - 1) { + throw new MateClawException("err.sso.state_invalid", 400, "state 格式无效"); + } + String nonce = state.substring(0, dot); + String signature = state.substring(dot + 1); + + // 1. 验 HMAC 签名 + String expected = hmacSha256Hex(nonce); + if (!expected.equals(signature)) { + throw new MateClawException("err.sso.state_invalid", 400, "state 签名校验失败"); + } + + // 2. 一次性消费 + TTL: UPDATE consumed=1 WHERE token=? AND consumed=0 AND created_at > cutoff. + // 加 created_at 条件让 5min TTL 在消费阶段强制生效 —— 否则未消费的 state + // 只在 1h purge 后才物理删除, /authorize 后 30min 的 /callback 仍能通过。 + LocalDateTime cutoff = LocalDateTime.now().minusSeconds(STATE_TTL_SECONDS); + int rows = stateMapper.update(null, new LambdaUpdateWrapper() + .eq(SsoStateEntity::getToken, state) + .eq(SsoStateEntity::getConsumed, 0) + .gt(SsoStateEntity::getCreatedAt, cutoff) + .set(SsoStateEntity::getConsumed, 1)); + if (rows == 0) { + throw new MateClawException("err.sso.state_expired_or_used", + 400, "state 已过期或已被使用, 请重新登录"); + } + } + + // ==================== bind_token (link-only 模式) ==================== + + /** + * 签发 bind_token (自包含 JWT), 携带 IdP 用户信息。TTL 10min。 + */ + public String issueBindToken(String provider, SsoUserInfo info) { + long now = System.currentTimeMillis(); + return Jwts.builder() + .id(UUID.randomUUID().toString()) // jti + .claim("provider", provider) + .claim("externalId", info.externalId()) + .claim("unionId", info.unionId()) + .claim("externalName", info.displayName()) + .issuedAt(new Date(now)) + .expiration(new Date(now + BIND_TOKEN_TTL_SECONDS * 1000L)) + .signWith(getSignKey()) + .compact(); + } + + /** + * 校验 bind_token 验签 + 过期 + 单次消费 (jti 撞 PK)。返回 claims 供绑定使用。 + */ + public BindTokenClaims verifyBindToken(String bindToken) { + if (bindToken == null || bindToken.isBlank()) { + throw new MateClawException("err.sso.bind_token_missing", 400, "缺少 bind_token"); + } + // 1. 验签 + 过期 + Claims claims; + try { + claims = Jwts.parser() + .verifyWith(getSignKey()) + .build() + .parseSignedClaims(bindToken) + .getPayload(); + } catch (Exception e) { + throw new MateClawException("err.sso.bind_token_invalid", + 400, "bind_token 无效或已过期"); + } + + String jti = claims.getId(); + if (jti == null) { + throw new MateClawException("err.sso.bind_token_invalid", 400, "bind_token 缺少 jti"); + } + + // 2. 单次消费: INSERT (token=jti, kind=bind) 撞 PK, 只有首个请求成功 + SsoStateEntity consumed = new SsoStateEntity(); + consumed.setToken(jti); + consumed.setKind(KIND_BIND); + consumed.setProvider(claims.get("provider", String.class)); + consumed.setConsumed(1); + consumed.setCreatedAt(LocalDateTime.now()); + try { + stateMapper.insert(consumed); + } catch (DuplicateKeyException e) { + throw new MateClawException("err.sso.bind_token_used", + 400, "bind_token 已被使用, 请重新登录"); + } + + return new BindTokenClaims( + claims.get("provider", String.class), + claims.get("externalId", String.class), + claims.get("unionId", String.class), + claims.get("externalName", String.class)); + } + + /** bind_token 校验通过后返回的 claims。 */ + public record BindTokenClaims(String provider, String externalId, + String unionId, String externalName) {} + + // ==================== 过期清理 (ShedLock 定时任务) ==================== + + /** + * 每小时清理过期 state/bind_token 行。 + * 走 LambdaQuery + Java 时间过滤, 通吃三方言 (不用 NOW() - INTERVAL SQL 方言)。 + */ + @Scheduled(fixedDelay = 3600_000) // 1h + public void purgeExpired() { + LocalDateTime cutoff = LocalDateTime.now().minus(1, ChronoUnit.HOURS); + int deleted = stateMapper.delete(new LambdaQueryWrapper() + .lt(SsoStateEntity::getCreatedAt, cutoff)); + if (deleted > 0) { + log.info("[SsoState] Purged {} expired state/bind rows (cutoff={})", deleted, cutoff); + } + } + + // ==================== helpers ==================== + + private SecretKey getSignKey() { + byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8); + // HMAC-SHA 需要 >= 256 bit (32 byte); 短 secret 用 0x00 填充到 32 byte (与 AuthService 一致) + if (keyBytes.length < 32) { + byte[] padded = new byte[32]; + System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length); + keyBytes = padded; + } + return Keys.hmacShaKeyFor(keyBytes); + } + + private String hmacSha256Hex(String input) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(getSignKey()); + byte[] hash = mac.doFinal(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new IllegalStateException("HMAC-SHA256 failed", e); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/model/ExternalIdentityEntity.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/ExternalIdentityEntity.java new file mode 100644 index 00000000..97f54142 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/ExternalIdentityEntity.java @@ -0,0 +1,60 @@ +package vip.mate.auth.sso.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户外部身份关联实体(SSO)。 + *

    + * 一个 {@code mate_user} 可绑定多个 IdP 身份;一个 {@code (provider, external_id)} + * 至多归属一个用户。匹配优先级:union_id(跨应用唯一)优先,回退到 external_id。 + * + * @author MateClaw Team + */ +@Data +@TableName("mate_user_external_identity") +public class ExternalIdentityEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long userId; + + /** 身份提供方标识: feishu / dingtalk / wecom / ... */ + private String provider; + + /** IdP 内用户标识, 通常是 open_id */ + private String externalId; + + /** 跨应用唯一标识 (飞书特有), nullable */ + private String unionId; + + private String externalName; + private String externalAvatar; + private String externalEmail; + + private LocalDateTime lastLoginAt; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 逻辑删除 (与 wiki 系列表 @TableLogic 约定一致)。 + *

    + * 注意: {@code mate_user.deleted} 当前无 @TableLogic, 全局无 logic-delete-field, + * 其 deleteById 是物理删 —— 本表的逻辑删除独立于 mate_user。解绑时 service 层 + * 改写 external_id / union_id 为 {@code <原值>_del_} 释放唯一约束。 + */ + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/model/SsoStateEntity.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/SsoStateEntity.java new file mode 100644 index 00000000..35d44428 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/SsoStateEntity.java @@ -0,0 +1,37 @@ +package vip.mate.auth.sso.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * OAuth2 state / bind-token 防重放存储。 + *

    + * 落 DB 而非内存: 多节点部署下 /authorize 与 /callback 可能落到不同节点, + * 内存存储会导致 state 找不到、登录硬失败。{@code kind} 区分 {@code state} + * (OAuth2 CSRF state) 与 {@code bind} (bind_token 的 jti)。 + * + *

    一次性消费: state 用 {@code UPDATE ... SET consumed=1 WHERE token=? AND consumed=0}, + * affected rows 必须 = 1; bind_token 的 jti 用 {@code INSERT} 撞 PK 实现首个消费成功。 + * + * @author MateClaw Team + */ +@Data +@TableName("sso_state") +public class SsoStateEntity { + + @TableId(type = IdType.INPUT) + private String token; + + /** state | bind */ + private String kind; + + private String provider; + + private Integer consumed; + + private LocalDateTime createdAt; +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/FeishuSsoProvider.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/FeishuSsoProvider.java new file mode 100644 index 00000000..8605e502 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/FeishuSsoProvider.java @@ -0,0 +1,230 @@ +package vip.mate.auth.sso.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import vip.mate.auth.sso.SsoProperties; +import vip.mate.exception.MateClawException; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; + +/** + * 飞书 OAuth2 SSO Provider。 + *

    + * 授权码流程: + *

      + *
    1. app_id + app_secret → app_access_token (有效期 2h, Caffeine 缓存 ~110min)
    2. + *
    3. app_access_token + code → user_access_token (飞书 OIDC 端点)
    4. + *
    5. user_access_token → 用户信息 (open_id / union_id / name / email / avatar)
    6. + *
    + * + *

    HTTP 调用模式复刻 {@code FeishuChannelAdapter.getUserName}: + * JDK {@code HttpClient} + Jackson {@code ObjectMapper} + 飞书 {@code code==0} 约定。 + * 注意 SSO 的 app_access_token 与 IM 渠道的 tenant_access_token 是不同 token、不同应用, 无法复用。 + * + *

    apiBase 按 {@code domain} 切换: {@code feishu} → {@code https://open.feishu.cn}; + * {@code lark} → {@code https://open.larksuite.com}。 + * + * @author MateClaw Team + */ +public class FeishuSsoProvider implements SsoProvider { + + private static final String PROVIDER_ID = "feishu"; + private static final String DISPLAY_NAME = "飞书"; + + private final SsoProperties.Feishu cfg; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final String apiBase; + + /** app_access_token 缓存: 飞书有效期 2h, TTL 110min 留余量 */ + private final Cache appTokenCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(110)) + .maximumSize(1) + .build(); + + public FeishuSsoProvider(SsoProperties.Feishu cfg, ObjectMapper objectMapper) { + this.cfg = cfg; + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + this.apiBase = "lark".equalsIgnoreCase(cfg.getDomain()) + ? "https://open.larksuite.com" + : "https://open.feishu.cn"; + } + + @Override + public String id() { return PROVIDER_ID; } + + @Override + public String displayName() { return DISPLAY_NAME; } + + @Override + public String authorizeUrl(String state) { + return apiBase + "/open-apis/authen/v1/authorize" + + "?app_id=" + cfg.getAppId() + + "&redirect_uri=" + encode(cfg.getRedirectUri()) + + "&response_type=code" + + "&state=" + encode(state); + } + + @Override + public SsoUserInfo resolve(String code, String state) { + String appAccessToken = getAppAccessToken(); + String userAccessToken = exchangeUserAccessToken(code, appAccessToken); + return fetchUserInfo(userAccessToken); + } + + // ------------------------------------------------------------------ + // 飞书 API 调用 + // ------------------------------------------------------------------ + + /** + * 获取 app_access_token (带缓存)。POST /auth/v3/app_access_token/internal。 + */ + private String getAppAccessToken() { + String cached = appTokenCache.getIfPresent("token"); + if (cached != null) return cached; + + try { + String body = objectMapper.writeValueAsString(Map.of( + "app_id", cfg.getAppId(), + "app_secret", cfg.getAppSecret())); + Map resp = postJson( + apiBase + "/open-apis/auth/v3/app_access_token/internal", body, null); + checkCode(resp, "app_access_token"); + String token = (String) resp.get("app_access_token"); + if (token == null || token.isBlank()) { + throw new MateClawException("err.sso.feishu_token_empty", + 502, "飞书未返回 app_access_token"); + } + appTokenCache.put("token", token); + return token; + } catch (MateClawException e) { + throw e; + } catch (Exception e) { + throw new MateClawException("err.sso.feishu_app_token_failed", + 502, "获取飞书 app_access_token 失败: " + e.getMessage()); + } + } + + /** + * code → user_access_token。POST /authen/v1/oidc/access_token。 + */ + private String exchangeUserAccessToken(String code, String appAccessToken) { + try { + String body = objectMapper.writeValueAsString(Map.of( + "grant_type", "authorization_code", + "code", code)); + Map resp = postJson( + apiBase + "/open-apis/authen/v1/oidc/access_token", body, appAccessToken); + checkCode(resp, "user_access_token"); + @SuppressWarnings("unchecked") + Map data = (Map) resp.get("data"); + if (data == null) { + throw new MateClawException("err.sso.feishu_no_data", 502, "飞书未返回 token 数据"); + } + String token = (String) data.get("access_token"); + if (token == null || token.isBlank()) { + throw new MateClawException("err.sso.feishu_user_token_empty", + 502, "飞书未返回 user_access_token"); + } + return token; + } catch (MateClawException e) { + throw e; + } catch (Exception e) { + throw new MateClawException("err.sso.feishu_code_exchange_failed", + 502, "飞书授权码换取 token 失败: " + e.getMessage()); + } + } + + /** + * user_access_token → 用户信息。GET /authen/v1/user_info。 + */ + @SuppressWarnings("unchecked") + private SsoUserInfo fetchUserInfo(String userAccessToken) { + try { + Map resp = getJson( + apiBase + "/open-apis/authen/v1/user_info", userAccessToken); + checkCode(resp, "user_info"); + Map data = (Map) resp.get("data"); + if (data == null) { + throw new MateClawException("err.sso.feishu_no_user_data", + 502, "飞书未返回用户信息"); + } + String openId = str(data.get("open_id")); + if (openId == null || openId.isBlank()) { + throw new MateClawException("err.sso.feishu_no_open_id", + 502, "飞书用户信息缺少 open_id"); + } + return new SsoUserInfo( + openId, + str(data.get("union_id")), + str(data.get("name")), + str(data.get("avatar")), + str(data.get("email")), + str(data.get("mobile"))); + } catch (MateClawException e) { + throw e; + } catch (Exception e) { + throw new MateClawException("err.sso.feishu_user_info_failed", + 502, "获取飞书用户信息失败: " + e.getMessage()); + } + } + + // ------------------------------------------------------------------ + // HTTP helpers (复刻 FeishuChannelAdapter.getUserName 模式) + // ------------------------------------------------------------------ + + private Map postJson(String url, String jsonBody, String bearerToken) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json; charset=utf-8") + .timeout(Duration.ofSeconds(5)) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); + if (bearerToken != null) { + builder.header("Authorization", "Bearer " + bearerToken); + } + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return objectMapper.readValue(response.body(), Map.class); + } + + private Map getJson(String url, String bearerToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + bearerToken) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return objectMapper.readValue(response.body(), Map.class); + } + + private void checkCode(Map resp, String api) { + Integer code = resp.get("code") instanceof Number n ? n.intValue() : null; + if (code == null || code != 0) { + String msg = str(resp.get("msg")); + throw new MateClawException("err.sso.feishu_api_error", + 502, "飞书 " + api + " 接口返回错误: code=" + code + ", msg=" + msg); + } + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + private static String encode(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (Exception e) { + return s; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProvider.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProvider.java new file mode 100644 index 00000000..7e314f70 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProvider.java @@ -0,0 +1,34 @@ +package vip.mate.auth.sso.provider; + +/** + * SSO 身份提供方抽象。每个 IdP(飞书/钉钉/企微/...)实现此接口。 + *

    + * 注册到 {@link SsoProviderRegistry} 后由 {@code SsoController} 按 id 路由。 + * + * @author MateClaw Team + */ +public interface SsoProvider { + + /** Provider 标识, 如 "feishu" */ + String id(); + + /** 展示名, 如 "飞书" (前端渲染按钮用) */ + String displayName(); + + /** + * 构造授权 URL。前端 window.location 跳转到此 URL 让用户授权。 + * + * @param state CSRF 防护 token, 原样附加到授权 URL 的 state 参数 + * @return 完整的 IdP 授权 URL + */ + String authorizeUrl(String state); + + /** + * 用授权码换取用户信息。 + * + * @param code IdP 回调带回的授权码 + * @param state 回调带回的 state(已由 Controller 校验过签名 + 一次性消费) + * @return IdP 侧的标准化用户身份信息 + */ + SsoUserInfo resolve(String code, String state); +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProviderRegistry.java new file mode 100644 index 00000000..51aac08e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProviderRegistry.java @@ -0,0 +1,50 @@ +package vip.mate.auth.sso.provider; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * SSO Provider 注册表。按 id 查找 Provider, 列出已启用的 Provider 供前端渲染按钮。 + *

    + * Provider 通过构造函数注入 (Spring 按 {@code @ConditionalOnProperty} 按需实例化)。 + * + * @author MateClaw Team + */ +@Component +@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true") +public class SsoProviderRegistry { + + private final Map providers = new LinkedHashMap<>(); + + /** + * Spring 注入所有已启用的 {@link SsoProvider} bean。当 SSO 未启用时该列表为空。 + */ + public SsoProviderRegistry(List providerBeans) { + if (providerBeans != null) { + for (SsoProvider p : providerBeans) { + providers.put(p.id(), p); + } + } + } + + /** 按 id 查 */ + public Optional get(String providerId) { + if (providerId == null) return Optional.empty(); + return Optional.ofNullable(providers.get(providerId)); + } + + /** 列出所有已启用的 Provider (供前端渲染 SSO 按钮) */ + public List listEnabled() { + return List.copyOf(providers.values()); + } + + /** 是否有任何 Provider 已启用 */ + public boolean hasEnabled() { + return !providers.isEmpty(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoUserInfo.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoUserInfo.java new file mode 100644 index 00000000..5962931f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoUserInfo.java @@ -0,0 +1,22 @@ +package vip.mate.auth.sso.provider; + +/** + * IdP 返回的标准化用户信息。各 Provider 把平台特异字段映射到此结构。 + * + * @param externalId open_id(provider 内唯一) + * @param unionId union_id(跨应用唯一, nullable — 飞书需开启 union_id 数据权限) + * @param displayName 昵称 + * @param avatarUrl 头像 URL + * @param email 邮箱(nullable) + * @param mobile 手机(nullable) + * + * @author MateClaw Team + */ +public record SsoUserInfo( + String externalId, + String unionId, + String displayName, + String avatarUrl, + String email, + String mobile +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/ExternalIdentityMapper.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/ExternalIdentityMapper.java new file mode 100644 index 00000000..70dc564e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/ExternalIdentityMapper.java @@ -0,0 +1,14 @@ +package vip.mate.auth.sso.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.auth.sso.model.ExternalIdentityEntity; + +/** + * 用户外部身份关联 Mapper。 + * + * @author MateClaw Team + */ +@Mapper +public interface ExternalIdentityMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/SsoStateMapper.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/SsoStateMapper.java new file mode 100644 index 00000000..8e6c62a3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/SsoStateMapper.java @@ -0,0 +1,14 @@ +package vip.mate.auth.sso.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.auth.sso.model.SsoStateEntity; + +/** + * OAuth2 state / bind-token 存储 Mapper。 + * + * @author MateClaw Team + */ +@Mapper +public interface SsoStateMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java index f8b166d4..7a4dbd90 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java @@ -44,7 +44,8 @@ public class ChannelChatOriginFactory { ? message.getChannelType() : channel.getChannelType(), /* chatId */ message.getChatId(), - /* baseUrl */ null); // IM origins have no request host; rely on public-base-url config + /* baseUrl */ null, // IM origins have no request host; rely on public-base-url config + /* requesterUserId */ null); // IM senders are external platform ids, not MateClaw accounts } /** 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 30489180..dc3bd96d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -134,6 +134,13 @@ public class ChannelManager { */ private final ChannelLeaderElection leaderElection; + /** + * Workspace/agent-aware chat-upload resolver, passed into adapters so their + * inbound media downloads land under the channel's workspace base path + * (falling back to the configured default dir). + */ + private final vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + /** 运行中的渠道适配器:channelId -> adapter */ private final Map activeAdapters = new HashMap<>(); @@ -1197,14 +1204,16 @@ public class ChannelManager { case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper, feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager, - feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService); + feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService, + chatUploadLocationResolver); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler, - generatedFileCache); + generatedFileCache, chatUploadLocationResolver); case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper); - case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper); + case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper, + chatUploadLocationResolver); 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/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 17adde6b..d0790db7 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -70,6 +70,13 @@ public class ChannelMessageRouter { @Autowired(required = false) private ApplicationEventPublisher events; + /** Field-injected for the same reason as {@link #events}: the chat-upload + * resolver resolves the workspace-aware TTS output directory on the + * voice-reply path. Optional so tests that build the router directly + * still work; falls back to the legacy default dir when unset. */ + @Autowired(required = false) + private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + /** 队列条目:封装消息及其路由上下文 */ private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {} @@ -783,7 +790,7 @@ public class ChannelMessageRouter { StringBuilder replyAccumulator = new StringBuilder(); final String channelType = adapter.getChannelType(); // Token usage + model attribution: capture _usage_final event emitted at stream end - final int[] usage = {0, 0}; // [promptTokens, completionTokens] + final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] agentService.chatStructuredStream(agentId, promptText, conversationId, message.getSenderId(), chatOrigin) @@ -793,6 +800,9 @@ public class ChannelMessageRouter { 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(); @@ -833,7 +843,7 @@ public class ChannelMessageRouter { String status = isError ? "error" : "completed"; MessageEntity saved = conversationService.saveMessage( conversationId, "assistant", reply, null, status, - usage[0], usage[1], modelInfo[0], modelInfo[1]); + usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null); savedAssistantId = saved != null ? saved.getId() : null; if (!isError) { publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin); @@ -948,13 +958,16 @@ public class ChannelMessageRouter { // 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}; // [promptTokens, completionTokens] + 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(); @@ -982,7 +995,7 @@ public class ChannelMessageRouter { String status = isError ? "error" : "completed"; MessageEntity saved = conversationService.saveMessage( conversationId, "assistant", finalContent, null, status, - usage[0], usage[1], modelInfo[0], modelInfo[1]); + usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null); if (!isError) { publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin); } @@ -1491,10 +1504,12 @@ public class ChannelMessageRouter { // 构建音频 MessageContentPart String audioUrl = (String) result.get("audioUrl"); String fileName = Paths.get(audioUrl).getFileName().toString(); - Path audioPath = Paths.get("data", "chat-uploads", conversationId, fileName); + // TTS output may live under a workspace-scoped dir or the legacy + // default dir — probe each candidate root to find the file. + Path audioPath = resolveVoiceReplyAudio(conversationId, fileName); - if (!Files.exists(audioPath)) { - log.warn("[voice-reply] TTS output file not found: {}", audioPath); + if (audioPath == null) { + log.warn("[voice-reply] TTS output file not found for conversation {} ({})", conversationId, fileName); return; } @@ -1516,6 +1531,27 @@ public class ChannelMessageRouter { }); } + /** + * Resolve the TTS audio file across every candidate upload root. Returns the + * first existing match, or {@code null} when the file is absent under every + * root. Used by the voice-reply path so workspace-scoped and legacy default + * outputs are both found. + */ + private Path resolveVoiceReplyAudio(String conversationId, String fileName) { + if (chatUploadLocationResolver != null) { + for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path candidate = root.resolve(conversationId).resolve(fileName); + if (Files.exists(candidate)) { + return candidate; + } + } + } + // Fallback to the legacy default dir when the resolver is absent + // (e.g. direct-construction unit tests). + Path legacy = Paths.get("data", "chat-uploads", conversationId, fileName); + return Files.exists(legacy) ? legacy : 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 ce44b1ae..61f6d262 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 @@ -229,8 +229,48 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre .build(); // Package-private for testing: redirect to a temp directory without touching real disk. + // Used as the fallback upload root when no ChatUploadLocationResolver is wired + // (e.g. direct-construction unit tests set this to a tmp dir). Path chatUploadsRoot = Path.of("data", "chat-uploads"); + /** + * Workspace/agent-aware upload-root resolver. Set by the production factory + * (ChannelManager); null in unit tests, which override {@link #chatUploadsRoot} + * instead. When non-null, attachment reads/writes resolve through it so files + * land under the workspace base path; otherwise the legacy + * {@link #chatUploadsRoot} field applies. + */ + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + + /** + * Resolve the upload root for a conversation, preferring the wired resolver + * (workspace/agent-aware) and falling back to the legacy field. Read paths + * should use {@link #candidateChatUploadRoots(String)} to probe both the + * workspace-scoped root and the legacy root. + */ + private java.nio.file.Path chatUploadRootFor(String conversationId) { + if (chatUploadLocationResolver != null) { + return chatUploadLocationResolver.resolveUploadRoot(conversationId); + } + return chatUploadsRoot; + } + + /** + * Ordered candidate upload roots for a conversation: workspace-scoped first + * (when the resolver is wired), then the legacy field. Used by read/scan + * paths so attachments written before the workspace-aware relocation are + * still found. + */ + private java.util.List candidateChatUploadRoots(String conversationId) { + java.util.List roots = new java.util.ArrayList<>(); + if (chatUploadLocationResolver != null) { + roots.addAll(chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)); + } else { + roots.add(chatUploadsRoot); + } + return roots; + } + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -291,6 +331,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre FeishuClientFactory clientFactory, vip.mate.tool.document.GeneratedFileCache generatedFileCache, vip.mate.stt.SttService sttService) { + this(channelEntity, messageRouter, objectMapper, mediaUploader, + generatedFileScrubber, streamingCardManager, cardDispatcher, + clientFactory, generatedFileCache, sttService, null); + } + + /** + * Full constructor used by the production factory (ChannelManager). The + * trailing {@code chatUploadLocationResolver} enables workspace/agent-aware + * attachment storage; {@code null} (or a shorter overload) keeps the legacy + * {@code data/chat-uploads} behaviour. + */ + public FeishuChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + FeishuMediaUploader mediaUploader, + GeneratedFileScrubber generatedFileScrubber, + FeishuStreamingCardManager streamingCardManager, + vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher, + FeishuClientFactory clientFactory, + vip.mate.tool.document.GeneratedFileCache generatedFileCache, + vip.mate.stt.SttService sttService, + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) { super(channelEntity, messageRouter, objectMapper); this.mediaUploader = mediaUploader; this.generatedFileScrubber = generatedFileScrubber; @@ -299,6 +361,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre this.clientFactory = clientFactory; this.generatedFileCache = generatedFileCache; this.sttService = sttService; + this.chatUploadLocationResolver = chatUploadLocationResolver; // Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1); } @@ -1712,8 +1775,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre : maybeDownloadResource(messageId, fileKey, type, fileName); if (dl == null) return null; - // Save to data/chat-uploads/{conversationId}/ - Path uploadDir = chatUploadsRoot.resolve(conversationId); + // Save under the workspace/agent-aware upload root ({convId}/ subdir) + Path uploadDir = chatUploadRootFor(conversationId).resolve(conversationId); Files.createDirectories(uploadDir); String rawName = (dl.fileName() != null && !dl.fileName().isBlank()) ? dl.fileName() : fileKey; @@ -1796,15 +1859,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre } /** - * Scan {@code data/chat-uploads/{conversationId}/} on disk and return - * the most recent files as {@link RecentFileEntry}s. Used as a - * fallback when the in-memory Caffeine cache has been evicted - * (process restart, TTL expiry, GC pressure) but the staged copies - * are still on disk. + * Scan the conversation's upload dir(s) on disk and return the most recent + * files as {@link RecentFileEntry}s. Used as a fallback when the in-memory + * Caffeine cache has been evicted (process restart, TTL expiry, GC pressure) + * but the staged copies are still on disk. Probes every candidate root + * (workspace-scoped + legacy default) so files written before the + * workspace-aware relocation are still found. */ private List loadRecentFilesFromDisk(String conversationId) { long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L; - return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff); + List merged = new java.util.ArrayList<>(); + for (Path root : candidateChatUploadRoots(conversationId)) { + merged.addAll(loadRecentFilesFromDisk(root.resolve(conversationId), cutoff)); + } + return merged; } /** 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 78476f4a..c6c85d4f 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 @@ -9,7 +9,9 @@ import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData; import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse; import lombok.extern.slf4j.Slf4j; import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.PendingApproval; +import vip.mate.approval.ResolveOutcome; import vip.mate.channel.ChannelMessage; import vip.mate.channel.feishu.FeishuChannelAdapter; import vip.mate.channel.feishu.cards.FeishuCardHandler; @@ -59,11 +61,23 @@ import java.util.Optional; public class ToolGuardCardHandler implements FeishuCardHandler { private final ApprovalService approvalService; + /** + * ISSUE #413 P2-B3: needed to resolve workflow-scoped approvals + * ({@code wf-} pendingIds) directly from the card click. Workflow + * approvals cannot go through the synthetic /approve injection + * (their conversationId is {@code workflow:run:{runId}}, which no + * IM conversation matches), so the handler resolves them inline — + * mirroring the Web / WebChat path. May be null in narrow test + * contexts (wf- approvals then fall back to the admin console). + */ + private final ApprovalWorkflowService approvalWorkflowService; private final ToolGuardButtonValue buttonValue; public ToolGuardCardHandler(ApprovalService approvalService, + ApprovalWorkflowService approvalWorkflowService, ToolGuardButtonValue buttonValue) { this.approvalService = approvalService; + this.approvalWorkflowService = approvalWorkflowService; this.buttonValue = buttonValue; } @@ -101,6 +115,19 @@ public class ToolGuardCardHandler implements FeishuCardHandler { PendingApproval pending = opt.get(); // ---- 3. Identity check (fail-closed) + // Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no + // human requester — the userId is null because the run is system- + // initiated. Their approval cards are only ever pushed to the + // channels declared in await_approval's approverChannels, so any + // member of that audience is a legitimate approver; we skip the + // requester==clicker guard and resolve inline (the synthetic /approve + // injection is a dead end for wf- ids: their conversationId is + // workflow:run:{runId}, which no IM conversation matches, so the + // router's findPendingByConversation would miss it). + if (pendingId.startsWith("wf-")) { + return handleWorkflowApproval(pendingId, decoded.toolName(), act, clickerOpenId); + } + // Agent/cron ("system") or unattributed (null) approvals have no human // requester to match the clicker against. A guarded-tool card landing in // a group chat would otherwise let ANY member click Approve and run the @@ -142,6 +169,54 @@ public class ToolGuardCardHandler implements FeishuCardHandler { return buildResolvedResponse(decoded.toolName(), act, clickerOpenId); } + // ------------------------------------------------------------------ + // Workflow-scoped approval (ISSUE #413 P2-B3) + // ------------------------------------------------------------------ + + /** + * Resolve a {@code wf-} workflow approval directly from the card click, + * bypassing the synthetic /approve injection. Workflow approvals live + * under a synthetic {@code workflow:run:{runId}} conversationId that no + * IM conversation matches, so the router path is a dead end. Instead we + * resolve inline (mirroring the Web / WebChat path); the + * {@link vip.mate.workflow.runtime.ApprovalResumeBridge} then picks up + * the {@code WorkflowApprovalResolvedEvent} published inside + * {@code ApprovalWorkflowService.resolve} and resumes the paused run. + * + *

    No tool-call replay is needed — a workflow {@code await_approval} + * step is a declarative gate, not a tool invocation; resume simply + * advances to the next step. + * + *

    Identity: any audience member may resolve. The card only reaches + * the channels declared in {@code await_approval.approverChannels} + * (pushed by {@code AwaitApprovalStepAdapter}'s notify step), so whoever + * can see it is a designated approver. + */ + private P2CardActionTriggerResponse handleWorkflowApproval(String pendingId, String toolName, + ToolGuardButtonValue.Action act, + String clickerOpenId) { + if (approvalWorkflowService == null) { + log.warn("[feishu-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} " + + "(use the admin console)", pendingId); + return buildErrorResponse("⚠️ 工作流审批需在管理端处理"); + } + String decision = act == ToolGuardButtonValue.Action.APPROVE ? "approved" : "denied"; + try { + ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerOpenId, decision); + if (!outcome.dbSynced()) { + // already resolved / superseded — not an error, but tell the clicker. + log.info("[feishu-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision()); + return buildExpiredResponse(toolName); + } + log.info("[feishu-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)", + pendingId, decision, abbrev(clickerOpenId)); + return buildResolvedResponse(toolName, act, clickerOpenId); + } catch (Exception e) { + log.error("[feishu-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e); + return buildErrorResponse("⚠️ 工作流审批未生效,请重试或在管理端处理"); + } + } + // ------------------------------------------------------------------ // Response builders — assemble P2CardActionTriggerResponse{toast,card} // ------------------------------------------------------------------ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java index 757cc06e..435aab0c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java @@ -3,6 +3,7 @@ package vip.mate.channel.feishu.cards.tool_guard; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.channel.feishu.cards.FeishuCardKind; /** @@ -27,21 +28,27 @@ public class ToolGuardCardKindFactory { public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX; private final ApprovalService approvalService; + /** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */ + private final ApprovalWorkflowService approvalWorkflowService; private final ObjectMapper objectMapper; public ToolGuardCardKindFactory(ApprovalService approvalService, + ApprovalWorkflowService approvalWorkflowService, ObjectMapper objectMapper) { this.approvalService = approvalService; + this.approvalWorkflowService = approvalWorkflowService; this.objectMapper = objectMapper; } public FeishuCardKind create() { ToolGuardButtonValue buttonValue = new ToolGuardButtonValue(objectMapper); ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonValue); - // Handler no longer needs ApprovalWorkflowService — the canonical - // resolve + replay path runs via a synthetic /approve|/deny - // message injected back into the router. - ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonValue); + // ISSUE #413 P2-B3: handler needs ApprovalWorkflowService to resolve + // wf- workflow approvals inline (the synthetic /approve injection is + // a dead end for wf- ids). Regular tool approvals still go through + // the synthetic /approve | /deny router path as before. + ToolGuardCardHandler handler = new ToolGuardCardHandler( + approvalService, approvalWorkflowService, buttonValue); return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler); } } 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 651c5575..1492785d 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 @@ -30,7 +30,6 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.io.IOException; import reactor.core.Disposable; @@ -38,6 +37,8 @@ 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; @@ -62,7 +63,7 @@ public class ChatController { private final ObjectMapper objectMapper; private final ConversationCompletionPublisher completionPublisher; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; - private final Path uploadRoot = Paths.get("data", "chat-uploads"); + private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver; // 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()) private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @@ -356,6 +357,9 @@ public class ChatController { persistStatus, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); // includes toolCalls metadata @@ -435,6 +439,9 @@ public class ChatController { errStatus, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -555,7 +562,7 @@ public class ChatController { // tools that need a workspace path read it from the agent (origin // is enriched with workspaceBasePath in StateGraph buildInitialState). vip.mate.agent.context.ChatOrigin webOrigin = - memoryOrigin(conversationId, username, workspaceId, request.getEndUserId()) + memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()) .withBaseUrl(requestBaseUrl); Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin) .doOnNext(delta -> { @@ -628,6 +635,9 @@ public class ChatController { persistStatus, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -746,6 +756,9 @@ public class ChatController { status, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -848,6 +861,9 @@ public class ChatController { status, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -1056,7 +1072,7 @@ public class ChatController { // Carry the web origin so per-owner memory recall (read) and the // post-conversation memory write below agree on the same owner key. vip.mate.agent.context.ChatOrigin webOrigin = - memoryOrigin(request.getConversationId(), username, workspaceId, request.getEndUserId()); + memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()); AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin); String response = result.content(); conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", @@ -1075,7 +1091,9 @@ public class ChatController { Authentication auth) throws IOException { String username = auth != null ? auth.getName() : "anonymous"; - // 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户) + // 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)。 + // 注意:会话尚不存在时,附件暂存到默认目录(resolveUploadRoot 查不到会话即回退); + // 会话创建后读取走双重查找,仍能命中。 if (conversationService.conversationExists(conversationId) && !conversationService.isConversationOwner(conversationId, username)) { return R.fail(403, "无权操作该会话"); @@ -1087,6 +1105,7 @@ public class ChatController { String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_"); String storedName = System.currentTimeMillis() + "_" + safeFilename; + Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId); Path conversationDir = uploadRoot.resolve(conversationId); Files.createDirectories(conversationDir); Path target = conversationDir.resolve(storedName); @@ -1099,8 +1118,8 @@ public class ChatController { response.setFileName(originalFilename); response.setStoredName(storedName); response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName); - // 使用相对路径,避免暴露服务端绝对路径 - response.setPath(uploadRoot.resolve(conversationId).resolve(storedName).toString()); + // 用 root 相对路径,避免暴露服务端绝对路径(uploadRoot 现在恒为绝对路径)。 + response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName)); response.setSize(file.getSize()); response.setContentType(file.getContentType()); return R.ok(response); @@ -1119,8 +1138,20 @@ public class ChatController { return ResponseEntity.status(403).build(); } - Path filePath = uploadRoot.resolve(conversationId).resolve(storedName).normalize(); - if (!Files.exists(filePath) || !filePath.startsWith(uploadRoot.resolve(conversationId).normalize())) { + // 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; + for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path conversationDir = root.resolve(conversationId).normalize(); + Path candidate = conversationDir.resolve(storedName).normalize(); + if (Files.exists(candidate) && candidate.startsWith(conversationDir)) { + filePath = candidate; + break; + } + } + if (filePath == null) { return ResponseEntity.notFound().build(); } @@ -1154,17 +1185,33 @@ public class ChatController { * MateClaw user ({@code user:}). */ private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username, - Long workspaceId, String endUserId) { + Long requesterUserId, Long workspaceId, + String endUserId) { // Resolve the public base URL here, on the request thread, so it can ride // the origin into async tool execution where no request is bound. Tools // then mint absolute download links without operator config. String baseUrl = resolveRequestBaseUrl(); if (endUserId != null && !endUserId.isBlank()) { + // Third-party single-account integration: the requester is an external + // end-user id, not a MateClaw account — no requesterUserId to assert. return vip.mate.agent.context.ChatOrigin .web(conversationId, endUserId.trim(), workspaceId, null, baseUrl) .withSender(null, "api", null); } - return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl); + // Authenticated web user: carry the immutable id so on-behalf-of identity + // forwarding can assert "MateClaw authenticated this user" (not an anon id). + return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl, requesterUserId); + } + + /** + * Extract the authenticated user's immutable numeric id from the + * {@link Authentication} details (stamped by {@code JwtAuthFilter} for both + * the JWT and PAT paths). Null when not authenticated or details absent. + */ + private Long requesterUserIdOf(org.springframework.security.core.Authentication auth) { + if (auth == null) return null; + Object details = auth.getDetails(); + return details instanceof Long id ? id : null; } /** @@ -1343,6 +1390,9 @@ public class ChatController { persistStatus, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -1395,6 +1445,9 @@ public class ChatController { "failed", accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -1484,6 +1537,31 @@ public class ChatController { return savedAssistant != null; } + /** + * Build the value stored in {@code ChatUploadResponse.path} (and, downstream, + * the message content part): a root-relative path like + * {@code chat-uploads/{convId}/{storedName}}, never the absolute on-disk + * location. + *

    + * {@code uploadRoot} is always absolute (the resolver normalizes it via + * {@code toAbsolutePath().normalize()}), and this field is purely + * informational — it is rendered into the LLM prompt ("附件: foo (path)") and + * returned to the client, while retrieval goes through the basename-based + * {@code ChatUploadResolver} plus the {@code /api/v1/chat/files/...} URL. So + * the absolute form must be avoided: it leaks the server's filesystem layout + * into the prompt/response and breaks if the deploy directory ever moves. + *

    + * The path is made relative to {@code uploadRoot}'s parent so the trailing + * upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and + * separators are normalized to {@code /} so the value is stable across OSes. + */ + static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) { + Path target = uploadRoot.resolve(conversationId).resolve(storedName); + Path base = uploadRoot.getParent(); + Path relative = base != null ? base.relativize(target) : target; + return relative.toString().replace('\\', '/'); + } + private MessageEntity saveEmptyAssistantPlaceholder(String conversationId, String status, StreamAccumulator accumulator, String source) { log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}", @@ -1493,6 +1571,9 @@ public class ChatController { emptyAssistantPlaceholder(status), null, status, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -1540,6 +1621,19 @@ public class ChatController { } if (promptTokens > 0) payload.put("promptTokens", promptTokens); if (completionTokens > 0) payload.put("completionTokens", completionTokens); + // Cache / reasoning detail rides on the persisted row so the live bubble + // can render the usage breakdown without waiting for a history reload. + if (savedAssistant != null) { + if (savedAssistant.getCacheReadTokens() != null && savedAssistant.getCacheReadTokens() > 0) { + payload.put("cacheReadTokens", savedAssistant.getCacheReadTokens()); + } + if (savedAssistant.getCacheWriteTokens() != null && savedAssistant.getCacheWriteTokens() > 0) { + payload.put("cacheWriteTokens", savedAssistant.getCacheWriteTokens()); + } + if (savedAssistant.getReasoningTokens() != null && savedAssistant.getReasoningTokens() > 0) { + payload.put("reasoningTokens", savedAssistant.getReasoningTokens()); + } + } payload.put("persisted", persisted); if (messageCount != null) payload.put("messageCount", messageCount); return payload; @@ -1594,6 +1688,9 @@ public class ChatController { status, accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), accumulator.getRuntimeModelName(), accumulator.getRuntimeProviderId(), accumulator.toMetadataJson()); @@ -1717,6 +1814,11 @@ 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。 *

    @@ -1739,9 +1841,14 @@ public class ChatController { 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; @@ -1788,6 +1895,9 @@ public class ChatController { 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; @@ -2010,6 +2120,30 @@ public class ChatController { 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); } } @@ -2052,6 +2186,9 @@ public class ChatController { 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; } @@ -2133,6 +2270,9 @@ public class ChatController { // 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 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 45499c16..f4afe6af 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 @@ -47,6 +47,10 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.regex.Pattern; import java.util.stream.Collectors; +import reactor.core.Disposable; +import vip.mate.approval.PendingApproval; +import vip.mate.approval.ResolveOutcome; +import vip.mate.agent.context.ChatOrigin; /** * WebChat 嵌入式对话接口 @@ -79,6 +83,14 @@ public class WebChatController { private final vip.mate.skill.repository.SkillMapper skillMapper; private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper; private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper; + /** + * ISSUE #413 P1-A2/A3/A4: drives the approval lifecycle for WebChat + * (API-Key) channels. Before this, a tool guarded by ToolGuard would + * create a pending approval and park the turn, but the visitor had no + * way to resolve it -- the approval hung until the 30-min GC timeout + * and the turn was wasted. + */ + private final vip.mate.approval.ApprovalWorkflowService approvalService; /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */ static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; @@ -200,7 +212,7 @@ public class WebChatController { // delta is not a persistence-only echo of content already streamed by inner nodes. StringBuilder assistantReply = new StringBuilder(); // Token usage + model attribution: capture _usage_final event emitted at stream end - final int[] usage = {0, 0}; // [promptTokens, completionTokens] + final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] // Attribute memory to this external visitor so each end-user @@ -218,6 +230,9 @@ public class WebChatController { 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(); @@ -253,7 +268,7 @@ public class WebChatController { if (!reply.isBlank()) { conversationService.saveMessage( conversationId, "assistant", reply, List.of(), - "completed", usage[0], usage[1], modelInfo[0], modelInfo[1]); + "completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null); } completionPublisher.publish( resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey); @@ -1013,9 +1028,12 @@ public class WebChatController { * Disposable 实际中断 Flux;返回 {@code stopped=false} 表示当前没有活跃流 * (幂等,不报错)。 *

    - * 不做 approval sweep:webchat 渠道目前不暴露 approval UI,且无 MateClaw - * username 可传给 {@code denyAllByConversation}。若未来 webchat 接入审批流, - * 再单独评估是否补这层。 + * Approval sweep (ISSUE #413 P1-A4): deny any pending approvals on this + * conversation so they do not hang for 30 minutes until the GC timeout. + * The visitor username derived from visitorId is the actor -- it resolves + * the "no MateClaw username" blocker noted in the old javadoc. + * Each denied approval broadcasts a { tool_approval_resolved} SSE + * event so the SDK clears its banner immediately. */ @Operation(summary = "停止访客会话线程的进行中流") @PostMapping("/sessions/stop") @@ -1049,9 +1067,319 @@ public class WebChatController { conversationId, visitorId, stopped); audit(channel, visitorId, "webchat.stop-session", conversationId, "{\"sessionId\":\"" + sid + "\",\"stopped\":" + stopped + "}"); + // ISSUE #413 P1-A4: deny pending approvals so they do not linger for + // 30 min waiting on a GC timeout. The visitor username is the actor, + // mirroring how the web ChatController uses the logged-in username. + int deniedCount = 0; + try { + java.util.List denied = + approvalService.denyAllByConversation(conversationId, webchatUsername(visitorId)); + deniedCount = denied.size(); + for (ResolveOutcome o : denied) { + try { + streamTracker.broadcast(conversationId, "tool_approval_resolved", + objectMapper.writeValueAsString(java.util.Map.of( + "pendingId", o.pendingId(), + "decision", "denied", + "toolName", o.toolName() != null ? o.toolName() : ""))); + } catch (Exception broadcastErr) { + log.debug("[WebChat] approval_resolved broadcast failed for {}: {}", + o.pendingId(), broadcastErr.getMessage()); + } + } + } catch (Exception sweepErr) { + log.warn("[WebChat] approval sweep failed for {}: {}", conversationId, sweepErr.getMessage()); + } + if (deniedCount > 0) { + log.info("[WebChat] Denied {} pending approval(s) on stop for {}", deniedCount, conversationId); + } return R.ok(Map.of("stopped", stopped)); } + /** + * 拒绝一个待审批的工具调用 (ISSUE #413 P1-A2)。 + *

    + * 鉴权同其它会话管理端点 (API Key + visitorToken + 会话归属)。仅允许 + * 发起对话的访客拒绝自己会话的审批 —— 身份校验通过 + * {@code webchatUsername(visitorId)} 与 pending.userId 的等价比较 + * (对位 IM 渠道的 senderId == requester 校验)。 + *

    + * resolve 后立即广播 {@code tool_approval_resolved} SSE 事件,让 SDK + * 实时清理审批 banner。返回同步 JSON (非 SSE),因为 deny 不需要重放工具。 + * + * @param pendingId the approval pendingId returned in the + * {@code tool_approval_requested} event + */ + @Operation(summary = "拒绝访客会话中的待审批工具调用") + @PostMapping("/sessions/deny") + public R> denySession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestParam String pendingId) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return R.fail(404, "Session not found"); + } + // IDOR guard (review #415): the caller owns the conversation, but the + // pendingId is client-supplied — cross-check that the pending actually + // belongs to this conversation before resolving, otherwise a visitor + // could resolve / replay another visitor's guarded tool call. + // getPending(pendingId) gives the exact record (vs findPendingByConversation + // which returns the earliest, wrong when several pendings coexist). + var ownedOpt = approvalService.getPending(pendingId); + if (ownedOpt.isEmpty() + || !conversationId.equals(ownedOpt.get().getConversationId())) { + return R.fail(404, "Pending approval not found for this session"); + } + // Resolve and broadcast outside the persistence transaction: SSE is + // not rollback-capable, so the broadcast must follow a committed DB write. + String actor = webchatUsername(visitorId); + ResolveOutcome outcome = approvalService.resolve(pendingId, actor, "denied"); + broadcastApprovalResolved(conversationId, outcome); + audit(channel, visitorId, "webchat.deny-approval", conversationId, + "{\"pendingId\":\"" + escapeJson(pendingId) + "\",\"resolved\":" + + outcome.dbSynced() + "}"); + return R.ok(Map.of("resolved", outcome.dbSynced(), "decision", outcome.decision())); + } + + /** + * 批准一个待审批的工具调用并重放 (ISSUE #413 P1-A2 + P1-A3)。 + *

    + * 与 deny 不同,approve 返回 SSE 流:原子消费审批记录后,用捕获的 + * toolCallPayload 重放工具调用,把工具结果回灌 agent 继续本轮对话。 + * 重放模式对位 web 渠道的 ChatController —— 复用 + * {@code chatWithReplayStream} + {@code restoreChatOrigin} 恢复原始 + * ChatOrigin (webchat origin 在 createPending 时已通过 ChatOriginHolder + * 持久化到 approval 行)。 + *

    + * 重放期间可能再次触发审批 (一个工具批准后 agent 可能调用下一个受保护 + * 工具) —— 该场景由 {@code tool_approval_requested} 直推事件自然覆盖, + * 无需特殊处理。事件投递走与 {@link #chatStream} 相同的 broadcast 路径。 + * + * @param pendingId the approval pendingId returned in the + * {@code tool_approval_requested} event + */ + @Operation(summary = "批准访客会话中的待审批工具调用并重放") + @PostMapping(value = "/sessions/approve", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter approveSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestParam String pendingId) { + SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L); + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + sendErrorAndComplete(emitter, "Invalid API Key"); + return emitter; + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + sendErrorAndComplete(emitter, "Invalid or missing visitor token"); + return emitter; + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + sendErrorAndComplete(emitter, ex.getMessage()); + return emitter; + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + sendErrorAndComplete(emitter, "Session not found"); + return emitter; + } + // IDOR guard (review #415): cross-check the client-supplied pendingId + // actually belongs to this conversation before resolving, otherwise a + // visitor could approve + replay another visitor's guarded tool call. + var ownedApprovalOpt = approvalService.getPending(pendingId); + if (ownedApprovalOpt.isEmpty() + || !conversationId.equals(ownedApprovalOpt.get().getConversationId())) { + sendErrorAndComplete(emitter, "Pending approval not found for this session"); + return emitter; + } + + emitter.onCompletion(() -> log.debug("[WebChat] approve SSE completed: {}", conversationId)); + emitter.onTimeout(() -> { + log.debug("[WebChat] approve SSE timeout: {}", conversationId); + streamTracker.complete(conversationId); + }); + emitter.onError(e -> { + log.debug("[WebChat] approve SSE error: {} - {}", conversationId, e.getMessage()); + streamTracker.complete(conversationId); + }); + + String actor = webchatUsername(visitorId); + sseExecutor.execute(() -> { + // Register + attach the emitter FIRST so every downstream branch + // (already-resolved, no-agent, error, replay) can broadcast a + // terminal event the SDK actually receives. Doing this after + // resolveAndConsume left the already-resolved / error paths + // broadcasting into a subscriber-less tracker, so the SSE hung + // to the 10-min timeout (review #415). + streamTracker.register(conversationId); + streamTracker.attach(conversationId, emitter); + try { + // Atomically consume the approval (DB + metadata + memory, single tx). + ResolveOutcome consumed = approvalService.resolveAndConsume(pendingId, actor); + if (consumed.consumedSnapshot() == null) { + // already resolved / not found — emit a terminal done so the + // SDK's stream listener closes cleanly instead of hanging. + broadcastApprovalResolved(conversationId, consumed); + streamTracker.broadcast(conversationId, "done", + "{\"status\":\"already_resolved\"}"); + return; + } + + // Notify the SDK the approval flipped (clears the banner) before + // replay output starts streaming. + broadcastApprovalResolved(conversationId, consumed); + + PendingApproval snapshot = consumed.consumedSnapshot(); + Long replayAgentId = snapshot.getAgentId() != null + ? parseLongOrNull(snapshot.getAgentId()) : null; + if (replayAgentId == null) { + log.warn("[WebChat] approve: no agentId on consumed approval {}, cannot replay", + pendingId); + streamTracker.broadcast(conversationId, "done", + "{\"status\":\"error\",\"message\":\"No agent bound to approval\"}"); + return; + } + + // Restore the original ChatOrigin captured at createPending time. + // Falls back to a fresh webchat origin when none was persisted + // (defensive — mirrors ChatController:304-306). + ChatOrigin replayOrigin = + approvalService.restoreChatOrigin(snapshot.getChatOrigin()); + if (replayOrigin == ChatOrigin.EMPTY) { + var agent = agentService.getAgent(replayAgentId); + Long wsId = agent != null ? agent.getWorkspaceId() : 1L; + replayOrigin = ChatOrigin.web( + conversationId, actor, wsId, null).withSender(null, "api", null); + } + + // Neutral replay prompt (aligned with IM + web channels — naming a + // tool here can mislead the LLM on fallthrough). + String replayPrompt = "继续执行已批准的工具调用。"; + StringBuilder assistantReply = new StringBuilder(); + final int[] usage = {0, 0}; + final String[] modelInfo = {null, null}; + + streamTracker.broadcast(conversationId, "message_start", + "{\"role\":\"assistant\"}"); + + Disposable disposable = agentService.chatWithReplayStream( + replayAgentId, replayPrompt, conversationId, + snapshot.getToolCallPayload(), actor, replayOrigin) + .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(); + } + if (delta.isEvent()) { + forwardVisitorEvent(conversationId, delta.eventType(), delta.eventData()); + } + if (delta.content() != null && !delta.content().isEmpty()) { + assistantReply.append(delta.content()); + if (!delta.persistenceOnly()) { + streamTracker.broadcast(conversationId, "content_delta", + "{\"text\":" + escapeJson(delta.content()) + "}"); + } + } + if (delta.thinking() != null && !delta.thinking().isEmpty() + && !delta.persistenceOnly()) { + streamTracker.broadcast(conversationId, "thinking_delta", + "{\"text\":" + escapeJson(delta.thinking()) + "}"); + } + }) + .doOnComplete(() -> { + String reply = assistantReply.toString(); + try { + if (!reply.isBlank()) { + conversationService.saveMessage( + conversationId, "assistant", reply, List.of(), + "completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null); + } + } catch (Exception persistErr) { + log.warn("[WebChat] approve replay persist failed: {}", persistErr.getMessage()); + } + streamTracker.broadcast(conversationId, "done", + "{\"status\":\"completed\"}"); + streamTracker.complete(conversationId); + }) + .doOnError(e -> { + log.error("[WebChat] approve replay stream error: {}", e.getMessage()); + streamTracker.broadcast(conversationId, "error", + "{\"message\":" + escapeJson(e.getMessage()) + "}"); + streamTracker.complete(conversationId); + }) + .subscribe(); + streamTracker.setDisposable(conversationId, disposable); + } catch (Exception e) { + log.error("[WebChat] approve failed for {}: {}", conversationId, e.getMessage()); + try { + streamTracker.broadcast(conversationId, "error", + "{\"message\":" + escapeJson(e.getMessage()) + "}"); + } catch (Exception ignored) {} + streamTracker.complete(conversationId); + } + }); + audit(channel, visitorId, "webchat.approve-approval", conversationId, + "{\"pendingId\":\"" + escapeJson(pendingId) + "\",\"replay\":true}"); + return emitter; + } + + /** Parse a Long leniently; null/blank/non-numeric return null. */ + private static Long parseLongOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { + return Long.parseLong(s.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Broadcast a {@code tool_approval_resolved} event so the SDK clears its + * approval banner in real time. Shared by approve / deny / stop-sweep. + * (ISSUE #413 P1) + */ + private void broadcastApprovalResolved(String conversationId, ResolveOutcome outcome) { + try { + streamTracker.broadcast(conversationId, "tool_approval_resolved", + objectMapper.writeValueAsString(Map.of( + "pendingId", outcome.pendingId(), + "decision", outcome.decision() != null ? outcome.decision() : "", + "toolName", outcome.toolName() != null ? outcome.toolName() : ""))); + } catch (Exception e) { + log.debug("[WebChat] approval_resolved broadcast failed for {}: {}", + outcome.pendingId(), e.getMessage()); + } + } + /** * 重新生成最后一条助手回复。 *

    diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java index defcc725..87832cb2 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java @@ -5,11 +5,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Arrays; import java.util.Locale; import java.util.Optional; @@ -39,9 +39,6 @@ import java.util.stream.Stream; @Service public class WebChatFileService { - /** Shared with the JWT chat upload dir so deleteConversation cleanup applies. */ - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); - /** How long an uploaded-but-unreferenced file lingers before the sweep removes it. */ private static final long STAGING_TTL_MS = 60 * 60 * 1000L; // 1 hour @@ -50,6 +47,7 @@ public class WebChatFileService { private final Set allowedExtensions; private final int maxFilesPerConversation; private final long maxTotalBytesPerConversation; + private final ChatUploadLocationResolver uploadLocationResolver; /** fileId (== storedName) -> staged metadata, pending a /stream reference. */ private final ConcurrentHashMap staged = new ConcurrentHashMap<>(); @@ -61,7 +59,8 @@ public class WebChatFileService { + "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log," + "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv, @Value("${mateclaw.webchat.upload.max-files-per-conversation:50}") int maxFilesPerConversation, - @Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation) { + @Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation, + ChatUploadLocationResolver uploadLocationResolver) { this.enabled = enabled; this.maxSizeBytes = maxSizeMb * 1024 * 1024; this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(",")) @@ -70,6 +69,7 @@ public class WebChatFileService { .collect(Collectors.toUnmodifiableSet()); this.maxFilesPerConversation = maxFilesPerConversation; this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024; + this.uploadLocationResolver = uploadLocationResolver; } /** Metadata for a staged upload. */ @@ -111,7 +111,7 @@ public class WebChatFileService { String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; // Strip any directory components, then collapse to a safe charset. - String baseName = Paths.get(originalName).getFileName().toString(); + String baseName = Path.of(originalName).getFileName().toString(); String ext = extensionOf(baseName); if (ext.isEmpty() || !allowedExtensions.contains(ext)) { throw new UploadRejectedException("File type not allowed: ." + ext); @@ -119,8 +119,9 @@ public class WebChatFileService { String safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_"); String storedName = UUID.randomUUID() + "_" + safeName; - Path dir = UPLOAD_ROOT.resolve(conversationId).normalize(); - if (!dir.startsWith(UPLOAD_ROOT.normalize())) { + Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize(); + Path dir = uploadRoot.resolve(conversationId).normalize(); + if (!dir.startsWith(uploadRoot)) { // conversationId is server-derived, so this should never happen; fail closed if it does. throw new UploadRejectedException("Invalid conversation"); } @@ -169,12 +170,16 @@ public class WebChatFileService { if (storedName == null || storedName.isBlank()) { return Optional.empty(); } - Path base = UPLOAD_ROOT.resolve(conversationId).normalize(); - Path file = base.resolve(storedName).normalize(); - if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) { - return Optional.empty(); + // Check every candidate root (workspace-scoped dir + legacy default dir) + // so files written before the workspace-aware relocation still resolve. + for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path base = root.resolve(conversationId).normalize(); + Path file = base.resolve(storedName).normalize(); + if (file.startsWith(base) && Files.exists(file) && Files.isRegularFile(file)) { + return Optional.of(file); + } } - return Optional.of(file); + return Optional.empty(); } /** Map a content type to the MessageContentPart type the agent/UI understands. */ 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 1f690284..284fae60 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 @@ -280,6 +280,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { */ private final vip.mate.tool.document.GeneratedFileCache generatedFileCache; + /** + * Workspace/agent-aware upload-root resolver, set by the production factory. + * Null in unit tests (the legacy {@code data/chat-uploads} default applies). + */ + private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + public WeComChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper, @@ -297,11 +303,30 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepaliveScheduler, vip.mate.tool.document.GeneratedFileCache generatedFileCache) { + this(channelEntity, messageRouter, objectMapper, approvalNotificationService, + cardDispatcher, keepaliveScheduler, generatedFileCache, 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. + */ + public WeComChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService, + vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher, + WeComKeepaliveScheduler keepaliveScheduler, + vip.mate.tool.document.GeneratedFileCache generatedFileCache, + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) { super(channelEntity, messageRouter, objectMapper); this.approvalNotificationService = approvalNotificationService; this.cardDispatcher = cardDispatcher; this.keepaliveScheduler = keepaliveScheduler; this.generatedFileCache = generatedFileCache; + this.chatUploadLocationResolver = chatUploadLocationResolver; // Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential) // so the UI eventually settles in ERROR instead of getting stuck in // RECONNECTING forever. User config still overrides (-1 = infinite). @@ -2936,14 +2961,17 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { */ private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId, String fileNameHint, String conversationId) { - // Store under data/chat-uploads/{conversationId} so the existing + // Store under the workspace/agent-aware upload root ({convId}/ subdir), + // falling back to the legacy data/chat-uploads default, so the existing // /api/v1/chat/files/{convId}/{storedName} endpoint serves the file // back to the chat bubble — the WeCom CDN URL carries a short-lived // signature that expires before a browser can fetch it. The shared // pipeline owns retry/backoff, magic-byte type detection, and the // dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here // inside the byte source so a fetch + decrypt is retried as one unit. - Path uploadDir = Path.of("data", "chat-uploads", conversationId); + Path uploadDir = (chatUploadLocationResolver != null) + ? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId) + : Path.of("data", "chat-uploads", conversationId); String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint; return InboundMediaDownloader.download( () -> { 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 c73230b3..ac28885d 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 @@ -2,7 +2,9 @@ package vip.mate.channel.wecom.cards.tool_guard; import lombok.extern.slf4j.Slf4j; import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.PendingApproval; +import vip.mate.approval.ResolveOutcome; import vip.mate.channel.ChannelMessage; import vip.mate.channel.wecom.WeComChannelAdapter; import vip.mate.channel.wecom.cards.WeComCardHandler; @@ -37,10 +39,20 @@ import java.util.Optional; public class ToolGuardCardHandler implements WeComCardHandler { private final ApprovalService approvalService; + /** + * ISSUE #413 P2-B3: resolves workflow-scoped ({@code wf-}) approvals + * inline — the synthetic /approve injection is a dead end for wf- ids + * (their conversationId is {@code workflow:run:{runId}}, unmatched by + * any IM conversation). May be null in narrow test contexts. + */ + private final ApprovalWorkflowService approvalWorkflowService; private final ToolGuardButtonKey buttonKey; - public ToolGuardCardHandler(ApprovalService approvalService, ToolGuardButtonKey buttonKey) { + public ToolGuardCardHandler(ApprovalService approvalService, + ApprovalWorkflowService approvalWorkflowService, + ToolGuardButtonKey buttonKey) { this.approvalService = approvalService; + this.approvalWorkflowService = approvalWorkflowService; this.buttonKey = buttonKey; } @@ -76,6 +88,19 @@ public class ToolGuardCardHandler implements WeComCardHandler { PendingApproval pending = opt.get(); // ---- 3. Identity check (fail-closed) ---- + // Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no + // human requester (userId is null — the run is system-initiated). + // Their cards only reach the channels declared in await_approval's + // approverChannels, so any audience member is a legitimate approver. + // We resolve inline (no synthetic injection: the router path can't + // route a workflow:run:{runId} conversationId) and the + // ApprovalResumeBridge resumes the run off the resolved event. + if (pendingId.startsWith("wf-")) { + handleWorkflowApproval(adapter, eventReqId, taskId, pendingId, + decoded.toolName(), action, clickerUserId); + return; + } + // Agent/cron ("system") or unattributed (null) approvals have no human // requester to match the clicker against; a group card would let any // member resolve a guarded action. Reject here (mirrors the feishu card @@ -117,6 +142,48 @@ public class ToolGuardCardHandler implements WeComCardHandler { } } + // ------------------------------------------------------------------ + // Workflow-scoped approval (ISSUE #413 P2-B3) + // ------------------------------------------------------------------ + + /** + * Resolve a {@code wf-} workflow approval inline, then render the + * resolved card — both within the WeCom 5s callback window. The + * synthetic /approve injection is bypassed because the router cannot + * route a {@code workflow:run:{runId}} conversationId. The + * {@link vip.mate.workflow.runtime.ApprovalResumeBridge} picks up the + * {@code WorkflowApprovalResolvedEvent} published inside resolve and + * resumes the paused run asynchronously. + * + *

    Identity: any audience member may resolve — the card only reaches + * the channels declared in {@code await_approval.approverChannels}. + */ + private void handleWorkflowApproval(WeComChannelAdapter adapter, String eventReqId, String taskId, + String pendingId, String toolName, + ToolGuardButtonKey.Action action, String clickerUserId) { + if (approvalWorkflowService == null) { + log.warn("[wecom-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} " + + "(use the admin console)", pendingId); + renderExpired(adapter, eventReqId, taskId, toolName); + return; + } + String decision = action == ToolGuardButtonKey.Action.APPROVE ? "approved" : "denied"; + try { + ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerUserId, decision); + if (!outcome.dbSynced()) { + log.info("[wecom-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision()); + renderExpired(adapter, eventReqId, taskId, toolName); + return; + } + log.info("[wecom-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)", + pendingId, decision, abbrev(clickerUserId)); + renderResolved(adapter, eventReqId, taskId, toolName, action, clickerUserId); + } catch (Exception e) { + log.error("[wecom-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e); + renderExpired(adapter, eventReqId, taskId, toolName); + } + } + // ------------------------------------------------------------------ // Card rendering helpers // ------------------------------------------------------------------ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java index e36e5422..d37e7a49 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java @@ -3,6 +3,7 @@ package vip.mate.channel.wecom.cards.tool_guard; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.channel.wecom.cards.WeComCardKind; /** @@ -34,17 +35,23 @@ public class ToolGuardCardKindFactory { public static final String MESSAGE_TYPE = "tool_guard_approval"; private final ApprovalService approvalService; + /** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */ + private final ApprovalWorkflowService approvalWorkflowService; private final ObjectMapper objectMapper; - public ToolGuardCardKindFactory(ApprovalService approvalService, ObjectMapper objectMapper) { + public ToolGuardCardKindFactory(ApprovalService approvalService, + ApprovalWorkflowService approvalWorkflowService, + ObjectMapper objectMapper) { this.approvalService = approvalService; + this.approvalWorkflowService = approvalWorkflowService; this.objectMapper = objectMapper; } public WeComCardKind create() { ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(objectMapper); ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey); - ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonKey); + ToolGuardCardHandler handler = new ToolGuardCardHandler( + approvalService, approvalWorkflowService, buttonKey); return new WeComCardKind( "tool_guard_approval", MESSAGE_TYPE, 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 79e825f0..125d3c31 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 @@ -148,12 +148,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { /** 用于文件 URL 下载的 HttpClient */ private HttpClient uploadHttpClient; + /** + * Workspace/agent-aware upload-root resolver, set by the production factory. + * Null in unit tests (the legacy {@code data/chat-uploads} default applies). + */ + private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + public WeixinChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { super(channelEntity, messageRouter, objectMapper); } + /** + * 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. + */ + public WeixinChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) { + super(channelEntity, messageRouter, objectMapper); + this.chatUploadLocationResolver = chatUploadLocationResolver; + } + @Override public String getChannelType() { return CHANNEL_TYPE; @@ -666,7 +686,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { return null; } - Path uploadDir = Path.of("data", "chat-uploads", conversationId); + Path uploadDir = (chatUploadLocationResolver != null) + ? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId) + : Path.of("data", "chat-uploads", conversationId); return InboundMediaDownloader.download( () -> client.downloadMedia("", aesKey, encryptQueryParam), filenameHint, diff --git a/mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java b/mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java new file mode 100644 index 00000000..cb6b9b81 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java @@ -0,0 +1,80 @@ +package vip.mate.cli; + +import org.springframework.stereotype.Component; +import vip.mate.operational.service.OperationalDataExportService; + +import java.io.IOException; +import java.time.LocalDate; + +/** + * {@code --cli.command=export} — generate a 9-sheet operational data report. + * + *

    Writes the ZIP bytes to stdout so the caller can redirect:

    + *
    {@code
    + *   java -jar app.jar --cli.command=export \
    + *     --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
    + * }
    + */ +@Component +public class ExportCommand implements MateClawCli.CliCommand { + + private final OperationalDataExportService exportService; + + public ExportCommand(OperationalDataExportService exportService) { + this.exportService = exportService; + } + + @Override public String name() { return "export"; } + @Override public String description() { return "Generate the operational data report (9-sheet Excel, written to stdout)"; } + + @Override public String usage() { + return """ + \s + export — generate the operational data report; ZIP bytes are written to stdout + \s + Required: + --cli.start=YYYY-MM-DD start date (inclusive) + --cli.end=YYYY-MM-DD end date (inclusive) + \s + Optional: + --cli.dry-run dry-run mode + \s + Example: + java -jar app.jar --cli.command=export \\ + \s --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip + \s"""; + } + + @Override + public void execute(MateClawCli.CliContext ctx) { + LocalDate start = ctx.requireDate("cli.start"); + LocalDate end = ctx.requireDate("cli.end"); + + if (ctx.isDryRun()) { + ctx.header("Export dry-run"); + ctx.info("Date range", start + " ~ " + end); + ctx.info("Result", "ZIP bytes would be written to stdout (not executed)"); + ctx.done("Dry-run complete"); + ctx.exit(0); + return; + } + + byte[] zip = exportService.exportBackendBytes(start, end); + + try { + // Diagnostics go to stderr so stdout stays a clean binary stream for redirection. + System.err.println("=== Operational data export ==="); + System.err.printf(" Date range : %s ~ %s%n", start, end); + System.err.printf(" Size : %d KB%n", zip.length / 1024); + System.err.printf(" File name : ops_data_%s_%s.zip%n", start, end); + System.err.println("\n=== Writing to stdout (redirect: ... > report.zip) ==="); + System.out.write(zip); + System.out.flush(); + System.err.println("=== Export complete ==="); + } catch (IOException e) { + ctx.error("Failed to write to stdout: " + e.getMessage()); + } + + ctx.exit(0); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java b/mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java new file mode 100644 index 00000000..7acdd517 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java @@ -0,0 +1,178 @@ +package vip.mate.cli; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.ExitCodeGenerator; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * MateClaw CLI framework — single-file core containing: + *
      + *
    • {@link CliCommand} — interface for pluggable commands
    • + *
    • {@link CliContext} — argument parsing, output formatting, lifecycle
    • + *
    • {@link CliRunner} — auto-discovery dispatcher
    • + *
    + * + *

    Usage

    + *
    {@code
    + *   java -jar app.jar --cli.command=help
    + *   java -jar app.jar --cli.command=export \
    + *     --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
    + * }
    + * + *

    Adding a new command

    + *
    {@code
    + *   @Component
    + *   public class MyCommand implements MateClawCli.CliCommand {
    + *       public String name()        { return "mycmd"; }
    + *       public String description() { return "does something"; }
    + *       public String usage()       { return "  --cli.x=...\n  example: ..."; }
    + *       public void execute(MateClawCli.CliContext ctx) {
    + *           ctx.exit(0);
    + *       }
    + *   }
    + * }
    + */ +public final class MateClawCli { private MateClawCli() { /* namespace */ } + + // ═══ CliCommand — interface for pluggable commands ═══ + + public interface CliCommand { + String name(); + String description(); + default String usage() { return ""; } + void execute(CliContext ctx); + } + + // ═══ CliContext — argument parsing & output ═══ + + public static class CliContext { + private static final Logger log = LoggerFactory.getLogger(CliContext.class); + private final ApplicationArguments args; + private final ApplicationContext springCtx; + private final boolean dryRun; + + public CliContext(ApplicationArguments args, ApplicationContext springCtx) { + this.args = args; + this.springCtx = springCtx; + this.dryRun = args.getOptionNames().contains("cli.dry-run"); + } + + /** Read optional string param (null if absent). */ + public String arg(String key) { + var vals = args.getOptionValues(key); + return vals != null && !vals.isEmpty() ? vals.get(0) : null; + } + + /** Read required string param. Absent = error + exit. */ + public String requireArg(String key) { + String val = arg(key); + if (val == null || val.isBlank()) error("Missing required parameter: --" + key); + return val; + } + + /** Read required date param (YYYY-MM-DD). Bad format = error + exit. */ + public LocalDate requireDate(String key) { + String raw = requireArg(key); + try { return LocalDate.parse(raw); } + catch (DateTimeParseException e) { error("Invalid date format: --" + key + "=" + raw + " (expected YYYY-MM-DD)"); return null; } + } + + /** True when {@code --cli.dry-run} was passed. */ + public boolean isDryRun() { return dryRun; } + + // —— output —————————————————————————————————————————— + public void header(String title) { System.out.println(); System.out.println("=== " + title + " ==="); } + public void info(String key, Object val) { System.out.printf(" %-12s : %s%n", key, val); } + public void done(String msg) { System.out.println(); System.out.println("=== " + msg + " ==="); System.out.println(); } + public void warn(String msg) { log.warn(msg); System.err.println("[WARN] " + msg); } + public void error(String msg) { log.error(msg); System.err.println("[ERROR] " + msg); exit(1); } + + public void exit(int code) { + System.out.flush(); System.err.flush(); + try { Thread.sleep(200); } catch (InterruptedException ignored) {} + SpringApplication.exit(springCtx, (ExitCodeGenerator) () -> code); + System.exit(code); + } + } + + // ═══ CliRunner — auto-discovery ApplicationRunner ═══ + + @Component + @Order(9999) + public static class CliRunner implements ApplicationRunner { + private static final Logger log = LoggerFactory.getLogger(CliRunner.class); + private final ApplicationContext springCtx; + private final Map registry; + + public CliRunner(List commands, ApplicationContext springCtx) { + var tmp = new TreeMap(); + for (var c : commands) { + if (tmp.containsKey(c.name())) throw new IllegalStateException("Duplicate CLI command name: '" + c.name() + "'"); + tmp.put(c.name(), c); + } + this.registry = Collections.unmodifiableMap(tmp); + this.springCtx = springCtx; + log.info("CLI ready, registered {} command(s): {}", registry.size(), registry.keySet()); + } + + @Override public void run(ApplicationArguments args) { + String cmdName = arg(args, "cli.command"); + // No CLI command requested: normal web startup, stay inert. + if (cmdName == null) return; + + CliContext ctx = new CliContext(args, springCtx); + if ("help".equalsIgnoreCase(cmdName)) { printHelp(); ctx.exit(0); return; } + + CliCommand cmd = registry.get(cmdName.toLowerCase()); + if (cmd == null) { + System.err.println("[ERROR] Unknown command: " + cmdName); + System.err.println(" Available commands: " + String.join(", ", registry.keySet())); + ctx.exit(1); return; + } + try { + log.info("CLI executing: {}", cmd.name()); + cmd.execute(ctx); + } catch (Exception e) { + log.error("Command '{}' failed", cmd.name(), e); + System.err.println("\n[ERROR] Command '" + cmd.name() + "' threw an exception"); + System.err.println(" " + e.getClass().getSimpleName() + ": " + e.getMessage()); + var trace = e.getStackTrace(); + for (int i = 0; i < Math.min(8, trace.length); i++) System.err.println(" at " + trace[i]); + ctx.exit(1); + } + } + + private void printHelp() { + System.out.println("\n MateClaw CLI\n ═══════════════════════════════════════"); + System.out.println(" java -jar app.jar --cli.command= [options]"); + System.out.println(" Docker: docker exec java -jar /app/app.jar --cli.command= [options]"); + System.out.println("\n Global options:"); + System.out.println(" --cli.command= command to run"); + System.out.println(" --cli.dry-run dry-run mode"); + System.out.println("\n Available commands:"); + System.out.printf(" %-12s %s%n", "help", "show this help"); + for (var c : registry.values()) System.out.printf(" %-12s %s%n", c.name(), c.description()); + System.out.println("\n Detailed usage:"); + for (var c : registry.values()) { String u = c.usage(); if (!u.isBlank()) System.out.println(u); } + System.out.println(" Spring Boot options may be appended directly (--spring.profiles.active, etc.)\n"); + } + + static String arg(ApplicationArguments args, String key) { + var vals = args.getOptionValues(key); + return vals != null && !vals.isEmpty() ? vals.get(0) : null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/common/net/SsrfAllowlist.java b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfAllowlist.java new file mode 100644 index 00000000..083c7935 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfAllowlist.java @@ -0,0 +1,138 @@ +package vip.mate.common.net; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.util.Collection; + +/** + * Shared matching logic for the outbound-request SSRF allowlist. + * + *

    Outbound HTTP guards (browser navigation, hook webhooks, image download) + * block loopback, private, link-local and cloud-metadata targets by default. + * Administrators can punch a narrow hole for a specific internal host via the + * allowlist; each entry is one of: + *

      + *
    • a literal hostname — {@code internal.corp}
    • + *
    • a literal IP — {@code 192.168.100.100}
    • + *
    • an IPv4 CIDR block — {@code 192.168.100.0/24}
    • + *
    + * + *

    {@link #matchesHost} compares against the URL host string as written (no + * DNS lookup); {@link #matchesAddress} compares against an already-resolved + * address. A guard that resolves DNS should consult both so that neither the + * literal host nor any resolved address is missed. + */ +public final class SsrfAllowlist { + + private SsrfAllowlist() {} + + /** True when the literal host string (hostname or IP literal) matches an allowlist entry. */ + public static boolean matchesHost(String host, Collection allowlist) { + if (host == null || host.isBlank() || allowlist == null || allowlist.isEmpty()) { + return false; + } + String h = stripBrackets(host.trim()); + Integer hostIp = ipv4ToInt(h); // non-null only when h is an IPv4 literal + for (String raw : allowlist) { + String entry = trimOrNull(raw); + if (entry == null) { + continue; + } + if (entry.indexOf('/') >= 0) { + if (hostIp != null && ipv4InCidr(hostIp, entry)) { + return true; + } + } else if (entry.equalsIgnoreCase(h)) { + return true; + } + } + return false; + } + + /** True when a resolved address matches an allowlist entry (literal IP or IPv4 CIDR). */ + public static boolean matchesAddress(InetAddress addr, Collection allowlist) { + if (addr == null || allowlist == null || allowlist.isEmpty()) { + return false; + } + String ip = addr.getHostAddress(); + Integer addrIp = (addr instanceof Inet4Address) ? bytesToInt(addr.getAddress()) : null; + for (String raw : allowlist) { + String entry = trimOrNull(raw); + if (entry == null) { + continue; + } + if (entry.indexOf('/') >= 0) { + if (addrIp != null && ipv4InCidr(addrIp, entry)) { + return true; + } + } else if (entry.equalsIgnoreCase(ip)) { + return true; + } + } + return false; + } + + private static String trimOrNull(String raw) { + if (raw == null) { + return null; + } + String t = raw.trim(); + return t.isEmpty() ? null : t; + } + + private static String stripBrackets(String host) { + return host.startsWith("[") && host.endsWith("]") + ? host.substring(1, host.length() - 1) + : host; + } + + /** Membership test for an IPv4 address (as a 32-bit int) against a {@code a.b.c.d/prefix} block. */ + private static boolean ipv4InCidr(int addrBits, String cidr) { + int slash = cidr.indexOf('/'); + Integer networkBits = ipv4ToInt(cidr.substring(0, slash).trim()); + if (networkBits == null) { + return false; + } + int prefix; + try { + prefix = Integer.parseInt(cidr.substring(slash + 1).trim()); + } catch (NumberFormatException e) { + return false; + } + if (prefix < 0 || prefix > 32) { + return false; + } + int mask = prefix == 0 ? 0 : 0xFFFFFFFF << (32 - prefix); + return (addrBits & mask) == (networkBits & mask); + } + + /** Parse a dotted-quad IPv4 literal into a 32-bit int, or null if it is not one. */ + private static Integer ipv4ToInt(String ip) { + String[] parts = ip.split("\\."); + if (parts.length != 4) { + return null; + } + int result = 0; + for (String part : parts) { + int octet; + try { + octet = Integer.parseInt(part); + } catch (NumberFormatException e) { + return null; + } + if (octet < 0 || octet > 255) { + return null; + } + result = (result << 8) | octet; + } + return result; + } + + private static int bytesToInt(byte[] bytes) { + int result = 0; + for (byte b : bytes) { + result = (result << 8) | (b & 0xFF); + } + return result; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/common/net/SsrfProperties.java b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfProperties.java new file mode 100644 index 00000000..ea281a2c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfProperties.java @@ -0,0 +1,28 @@ +package vip.mate.common.net; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shared SSRF guard configuration consulted by every outbound-request tool + * (browser navigation, hook webhooks, image download). + * + *

    By default outbound guards block loopback, private, link-local and + * cloud-metadata targets. {@link #ssrfAllowlist} lets an administrator reach a + * specific internal host through all of those guards at once. Each entry is a + * literal hostname, a literal IP, or an IPv4 CIDR block — see {@link SsrfAllowlist}. + * Keep the list as narrow as possible; entries here can re-expose + * cloud-metadata endpoints too. + */ +@Data +@Component +@ConfigurationProperties(prefix = "mateclaw.security") +public class SsrfProperties { + + /** Hosts/IPs/CIDR blocks permitted through the SSRF guards despite being otherwise restricted. */ + private List ssrfAllowlist = new ArrayList<>(); +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java b/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java index 8a5b17ab..71aaf2a1 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java @@ -79,6 +79,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { user.getUsername(), null, List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase())) ); + auth.setDetails(user.getId()); // immutable user id for on-behalf-of forwarding SecurityContextHolder.getContext().setAuthentication(auth); patService.recordUse(pat); // debounced inside the service } catch (Exception ignored) { @@ -99,6 +100,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { username, null, List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase())) ); + auth.setDetails(user.getId()); // immutable user id for on-behalf-of forwarding SecurityContextHolder.getContext().setAuthentication(auth); // 滑动窗口续期:Token 接近过期时自动签发新 Token diff --git a/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java b/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java index a56a32ce..6352c9de 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java @@ -10,11 +10,12 @@ import org.springframework.stereotype.Component; import java.io.IOException; import java.time.Duration; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; /** - * Rate limiter for login endpoint — prevents brute force attacks. - * Allows max 5 login attempts per IP per minute. + * Rate limiter for login + SSO bind endpoints — prevents brute force attacks. + * Allows max 5 attempts per IP per minute across all password-checking paths. * * @author MateClaw Team */ @@ -23,7 +24,20 @@ import java.util.concurrent.atomic.AtomicInteger; public class LoginRateLimitFilter implements Filter { private static final int MAX_ATTEMPTS = 5; - private static final String LOGIN_PATH = "/api/v1/auth/login"; + /** + * Endpoints that accept a username + password and must be rate-limited + * against brute force. SSO callback is excluded (no password submitted). + *

    + * The counter is keyed by client IP only (not IP + path), so 5 failed + * password attempts on /auth/login also locks /auth/sso/bind for the same + * IP within the window. This is intentional: all entries share the same + * brute-force surface, and a normal user who fat-fingers their password + * 5 times is unlikely to immediately need SSO bind. If finer isolation is + * needed later, switch the cache key to {@code ip + ":" + path}. + */ + private static final Set PROTECTED_PATHS = Set.of( + "/api/v1/auth/login", + "/api/v1/auth/sso/bind"); /** IP → attempt count, auto-expires after 1 minute */ private final Cache attempts = Caffeine.newBuilder() @@ -36,7 +50,7 @@ public class LoginRateLimitFilter implements Filter { throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) request; - if ("POST".equalsIgnoreCase(httpReq.getMethod()) && LOGIN_PATH.equals(httpReq.getRequestURI())) { + if ("POST".equalsIgnoreCase(httpReq.getMethod()) && PROTECTED_PATHS.contains(httpReq.getRequestURI())) { String ip = getClientIp(httpReq); AtomicInteger count = attempts.get(ip, k -> new AtomicInteger(0)); int current = count.incrementAndGet(); diff --git a/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java b/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java new file mode 100644 index 00000000..5bf11f40 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java @@ -0,0 +1,99 @@ +package vip.mate.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * SpringDoc OpenAPI 全局配置。 + *

    + * 项目已依赖 springdoc-openapi-starter-webmvc-ui(见根 pom 的 + * {@code springdoc.version}),但此前没有任何 OpenAPI 配置 Bean,导致 + * Swagger UI 缺少标题/描述、缺少安全方案(Authorize 按钮不可用)。 + * 本类补齐这些全局元信息,让现有 Controller 上已有的 + * {@code @Tag} / {@code @Operation} 注解直接可用。 + * + *

    安全方案

    + * 两种 token 都通过标准 {@code Authorization: Bearer } 头传入, + * {@link JwtAuthFilter} 按 token 前缀分发:JWT 以 {@code eyJ} 开头, + * Personal Access Token 以 {@code mc_} 开头(PAT_PREFIX)。因此 Swagger + * UI 的 Authorize 按钮只需填入任意一种 token 即可。 + * + *

    访问控制(在 SecurityConfig,不在本类)

    + * Swagger UI / OpenAPI 文档路径({@code /swagger-ui*}、{@code /v3/api-docs*}、 + * {@code /webjars/**})的鉴权由 {@link SecurityConfig#filterChain} 通过 + * {@code mateclaw.openapi.expose-ui} 开关控制:本地/默认 profile 公开, + * 生产数据库 profile(mysql/kingbase/postgres)默认要求全局管理员 + * ({@code ROLE_ADMIN})。访问规则只属于 SecurityConfig,不要加到本类。 + * + *

    未做的事(与「全局配置 + 安全方案」范围一致)

    + * 不逐个 Controller 补 {@code @Parameter} / {@code @ApiResponse} / + * {@code @Schema} / 公开端点的 {@code @SecurityRequirements({})} opt-out。 + * 这些属于「关键端点注解」增强档,留作后续。 + * + * @author MateClaw Team + */ +@Configuration +public class OpenApiConfig { + + /** HTTP Bearer 安全方案的引用键,与 {@link Components#getSecuritySchemes()} 中的登记名一致。 */ + public static final String BEARER_AUTH = "bearerAuth"; + + @Bean + public OpenAPI mateclawOpenAPI( + @Value("${mateclaw.openapi.title:MateClaw REST API}") String title, + @Value("${mateclaw.openapi.description:#{null}}") String description, + @Value("${mateclaw.openapi.version:1.0}") String version, + @Value("${mateclaw.openapi.server-url:}") String serverUrl) { + + Info info = new Info() + .title(title) + .version(version) + .description(defaultIfBlank(description, "" + + "MateClaw 多用户 AI Agent 平台的 REST API。" + + "所有业务端点使用 /api/v1 前缀,绝大多数 JSON 响应走 {code,msg,data} 统一信封。" + + "点击右上角 Authorize 并粘贴 JWT 或 Personal Access Token (mc_) 即可调试受保护端点。" + + "完整人读文档见部署地址的 /docs 页面。")); + + Components components = new Components() + .addSecuritySchemes(BEARER_AUTH, bearerScheme( + "JWT 或 Personal Access Token。两种都通过 Authorization: Bearer 头传入," + + "服务端按前缀分发:JWT 以 eyJ 开头,PAT 以 mc_ 开头。" + + "EventSource 不支持自定义请求头,SSE 流式端点可用 ?token= 查询参数替代。")); + + OpenAPI openAPI = new OpenAPI() + .info(info) + .components(components) + // 默认所有端点需要鉴权;公开端点(登录、SSE 等)在 SecurityConfig 中放行, + // Swagger 上会仍标注锁图标,但不影响实际调用。 + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)); + + if (serverUrl != null && !serverUrl.isBlank()) { + openAPI.servers(List.of(new Server().url(serverUrl))); + } + // serverUrl 为空时不显式配置 —— SpringDoc 默认从请求 host 推导, + // 避免「Try it out」打到错误地址。 + + return openAPI; + } + + private SecurityScheme bearerScheme(String description) { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description(description); + } + + private static String defaultIfBlank(String value, String fallback) { + return (value == null || value.isBlank()) ? fallback : value; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java b/mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java new file mode 100644 index 00000000..0bcd5dc2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java @@ -0,0 +1,69 @@ +package vip.mate.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Budget configuration for the prompt prefix's optional injection blocks + * (memory, wiki relevance, skill catalog, extension-tool catalog, progress + * ledger). + * + *

    Each block already has its own absolute cap (chars), but those caps are + * mutually blind and sized for large cloud models — stacked together they + * easily exceed a local 8k/16k window on the very first request, when there + * is no history to compact. This budget scales every block against the + * model's effective context window instead: the enforced limit is always + * {@code min(block's own cap, its share of the injection budget)}, so large + * windows behave exactly as before while small windows shrink each block + * proportionally. + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.context.prefix-budget") +public class PrefixBudgetProperties { + + /** Kill switch — false restores the previous per-block-absolute-cap behavior. */ + private boolean enabled = true; + + /** Fraction of the effective window granted to prefix injection blocks (normal profile). */ + private double injectionRatio = 0.35; + + /** Injection ratio when the window is below {@link #compactThresholdTokens}. */ + private double compactInjectionRatio = 0.25; + + /** Injection ratio when the window is below {@link #minimalThresholdTokens}. */ + private double minimalInjectionRatio = 0.15; + + /** Windows below this enter the compact profile. */ + private int compactThresholdTokens = 32768; + + /** Windows below this enter the minimal profile. */ + private int minimalThresholdTokens = 8192; + + /** + * Compaction trigger ratio used for compact / minimal profiles instead of + * the global {@code compactTriggerRatio}. Small windows should be used up + * before summarizing — compacting at 75% of an 8k window wastes what + * little room there is. + */ + private double compactTriggerRatioOverride = 0.85; + + /** + * Fraction of the effective window the advertised tool schemas may + * occupy. When the core tool set estimates above this, the least + * recently used demotable tools are auto-moved to the extension catalog + * (recoverable via {@code enable_tool}) until the set fits. + */ + private double toolSchemaRatio = 0.25; + + /** Relative shares of the injection budget. Normalized at plan time. */ + private Shares shares = new Shares(); + + @Data + public static class Shares { + private double memory = 0.35; + private double wiki = 0.30; + private double skill = 0.20; + private double extensionCatalog = 0.10; + private double ledger = 0.05; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index 5217b667..3db75426 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -1,6 +1,7 @@ package vip.mate.config; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -13,6 +14,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import jakarta.servlet.http.HttpServletResponse; +import vip.mate.kbopen.auth.KbOpenApiAuthFilter; /** * Spring Security 配置 @@ -27,6 +29,23 @@ import jakarta.servlet.http.HttpServletResponse; public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; + private final KbOpenApiAuthFilter kbOpenApiAuthFilter; + + /** + * SpringDoc Swagger UI / OpenAPI document paths. These serve the full REST + * surface (every endpoint plus request/response schemas), so they are gated + * explicitly instead of relying on the {@code .anyRequest().permitAll()} + * fallthrough. Whether they are public or admin-only is driven by + * {@code mateclaw.openapi.expose-ui} (see {@link #filterChain}). + */ + private static final String[] OPENAPI_PATHS = { + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs", + "/v3/api-docs/**", + "/v3/api-docs.yaml", + "/webjars/**" + }; /** * 密码编码器独立配置(打破 SecurityConfig → JwtAuthFilter → AuthService → BCryptPasswordEncoder 循环) @@ -39,8 +58,19 @@ public class SecurityConfig { } } + /** + * Configure the security filter chain. + * + * @param exposeOpenApiUi when {@code true} the Swagger UI / OpenAPI document + * paths are public; when {@code false} they require a global admin + * ({@code ROLE_ADMIN}). Defaults to {@code false} (locked down) when the + * property is absent — the base {@code application.yml} enables it for + * local dev while the production database profiles keep it off. + */ @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain( + HttpSecurity http, + @Value("${mateclaw.openapi.expose-ui:false}") boolean exposeOpenApiUi) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .headers(headers -> headers @@ -48,30 +78,46 @@ public class SecurityConfig { .frameOptions(frame -> frame.sameOrigin()) ) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth + .authorizeHttpRequests(auth -> { // GET /settings/language stays anonymous (first-paint i18n). PUT // requires login + admin (see @RequireGlobalAdmin on the controller). - .requestMatchers(HttpMethod.GET, "/api/v1/settings/language").permitAll() + auth.requestMatchers(HttpMethod.GET, "/api/v1/settings/language").permitAll() // 公开 API 接口 .requestMatchers( "/api/v1/auth/login", + "/api/v1/auth/sso/**", "/api/v1/agents/*/chat/stream", "/api/v1/chat/stream", "/api/v1/chat/*/stop", "/api/v1/setup/**", "/api/v1/channels/webhook/**", "/api/v1/channels/webchat/**", + // KB Open API: authenticated by KbOpenApiAuthFilter (API key), + // not JWT — must be permitAll so the filter is the sole gatekeeper (R1). + "/api/v1/open/kb/**", "/api/v1/talk/ws", + // Desktop local-tool tunnel — the handshake interceptor + // authenticates the ?token= query param itself, so the + // upgrade request is opened to the filter chain like talk/ws. + "/api/v1/desktop/ws", // RFC-045: tool-generated files served via unguessable UUID; entries // expire after GeneratedFileCache.TTL (7 days) — delayed access (e.g. an // IM-delivered link opened later) is intentional, the UUID is the guard. "/api/v1/files/generated/**" - ).permitAll() + ).permitAll(); + // Swagger UI / OpenAPI document — explicit rule rather than the + // permitAll() fallthrough. Public for local dev, admin-only in + // production, driven by mateclaw.openapi.expose-ui. + if (exposeOpenApiUi) { + auth.requestMatchers(OPENAPI_PATHS).permitAll(); + } else { + auth.requestMatchers(OPENAPI_PATHS).hasRole("ADMIN"); + } // 所有其他 API 接口需要认证 - .requestMatchers("/api/**").authenticated() - // 非 API 请求(前端路由、静态资源、Swagger、H2 Console 等)全部放行 - .anyRequest().permitAll() - ) + auth.requestMatchers("/api/**").authenticated() + // 非 API 请求(前端路由、静态资源、H2 Console 等)全部放行 + .anyRequest().permitAll(); + }) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); @@ -79,6 +125,7 @@ public class SecurityConfig { response.getWriter().write("{\"code\":401,\"msg\":\"Token expired or invalid\",\"data\":null}"); }) ) + .addFilterBefore(kbOpenApiAuthFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java index 0b6099c0..84cc3024 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java @@ -8,6 +8,7 @@ import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import vip.mate.kbopen.auth.KbScopeInterceptor; /** * Web MVC 配置(跨域、拦截器等) @@ -20,6 +21,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; public class WebMvcConfig implements WebMvcConfigurer { private final WorkspaceAccessInterceptor workspaceAccessInterceptor; + private final KbScopeInterceptor kbScopeInterceptor; /** CORS allowed origins, comma-separated. Default "*" for dev, restrict in production. */ @Value("${mateclaw.cors.allowed-origins:*}") @@ -29,6 +31,10 @@ public class WebMvcConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(workspaceAccessInterceptor) .addPathPatterns("/api/**"); + // KB Open API scope+ownership checks (A1). Runs after KbOpenApiAuthFilter + // injected the KbApiKeyContext into the request attributes. + registry.addInterceptor(kbScopeInterceptor) + .addPathPatterns("/api/v1/open/kb/**"); } @Override diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java index 9644f675..cd0bc9c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java @@ -9,6 +9,8 @@ import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; import vip.mate.channel.web.TalkModeWebSocketHandler; +import vip.mate.tool.local.DesktopBridgeHandshakeInterceptor; +import vip.mate.tool.local.DesktopBridgeWebSocketHandler; /** * WebSocket 配置 @@ -37,11 +39,19 @@ public class WebSocketConfig implements WebSocketConfigurer { private static final int MAX_TEXT_BUFFER_BYTES = 64 * 1024; private final TalkModeWebSocketHandler talkModeHandler; + private final DesktopBridgeWebSocketHandler desktopBridgeHandler; + private final DesktopBridgeHandshakeInterceptor desktopBridgeHandshakeInterceptor; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(talkModeHandler, "/api/v1/talk/ws") .setAllowedOrigins("*"); + // Desktop local-tool tunnel. The handshake interceptor authenticates the + // ?token= query param and pins the username into the session attributes; + // an unauthenticated socket never reaches the handler. + registry.addHandler(desktopBridgeHandler, "/api/v1/desktop/ws") + .addInterceptors(desktopBridgeHandshakeInterceptor) + .setAllowedOrigins("*"); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java b/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java index c2c073ab..1e71f171 100644 --- a/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java @@ -7,6 +7,7 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import vip.mate.common.net.SsrfProperties; import vip.mate.hook.action.*; import vip.mate.hook.model.HookEntity; @@ -26,6 +27,7 @@ public class HookActionFactory { private final ObjectMapper objectMapper; private final HookProperties props; + private final SsrfProperties ssrfProperties; /** 懒加载的共享 RestClient;所有 HttpAction 复用同一连接池。 */ private volatile RestClient httpRestClient; @@ -48,6 +50,7 @@ public class HookActionFactory { URI.create(required(cfg, "url")), text(cfg, "body", null), props.getTrustedDomains(), + ssrfProperties.getSsrfAllowlist(), timeoutMs, text(cfg, "hmacSecret", null), text(cfg, "signatureHeader", null)); diff --git a/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java b/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java index 38e0623c..8269c588 100644 --- a/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java +++ b/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import vip.mate.common.net.SsrfAllowlist; import vip.mate.hook.event.MateHookEvent; import javax.crypto.Mac; @@ -41,6 +42,8 @@ public final class HttpAction implements HookAction { private final URI url; private final String bodyTemplate; // 可含 {{event.xxx}} 占位 private final List trustedDomains; + /** Hosts/IPs/CIDR blocks permitted through the private-address SSRF block (shared SSRF allowlist). */ + private final List ssrfAllowlist; private final long timeoutMs; /** RFC-03 Lane H1 — when set, the rendered body is signed with HMAC-SHA-256 * and the resulting hex digest is placed in the header named by @@ -56,14 +59,23 @@ public final class HttpAction implements HookAction { this(restClient, method, url, bodyTemplate, trustedDomains, timeoutMs, null, null); } + /** Constructor without an SSRF allowlist — preserved for existing callers. */ public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate, List trustedDomains, long timeoutMs, String hmacSecret, String signatureHeader) { + this(restClient, method, url, bodyTemplate, trustedDomains, List.of(), timeoutMs, + hmacSecret, signatureHeader); + } + + public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate, + List trustedDomains, List ssrfAllowlist, long timeoutMs, + String hmacSecret, String signatureHeader) { this.restClient = restClient; this.method = (method == null) ? "POST" : method.toUpperCase(); this.url = url; this.bodyTemplate = bodyTemplate; this.trustedDomains = List.copyOf(trustedDomains == null ? List.of() : trustedDomains); + this.ssrfAllowlist = List.copyOf(ssrfAllowlist == null ? List.of() : ssrfAllowlist); this.timeoutMs = Math.max(100L, timeoutMs); this.hmacSecret = (hmacSecret == null || hmacSecret.isBlank()) ? null : hmacSecret; this.signatureHeader = (signatureHeader == null || signatureHeader.isBlank()) @@ -82,7 +94,7 @@ public final class HttpAction implements HookAction { if (!isAllowedHost(url.getHost())) { throw new IllegalArgumentException("host not in trusted-domains: " + url.getHost()); } - if (isPrivateAddress(url.getHost())) { + if (isPrivateAddress(url.getHost()) && !SsrfAllowlist.matchesHost(url.getHost(), ssrfAllowlist)) { throw new IllegalArgumentException("private/loopback host is forbidden: " + url.getHost()); } if (!"GET".equals(method) && !"POST".equals(method)) { diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyContext.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyContext.java new file mode 100644 index 00000000..6377730f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyContext.java @@ -0,0 +1,39 @@ +package vip.mate.kbopen.auth; + +import java.util.Set; + +/** + * Authentication context injected by {@code KbOpenApiAuthFilter} into each + * request's attributes. Carries the resolved API key identity (keyId, + * workspace, bound KBs, scopes) so that Controller-layer authorization + * (via {@code @RequireKbScope}) can check access without re-querying the DB. + * + *

    This is intentionally not a Spring Security + * {@code Authentication} — KB API Keys do not represent a user identity and + * must not inherit user-level permissions. The filter writes to a request + * attribute instead of the {@code SecurityContextHolder}. + */ +public record KbApiKeyContext( + Long keyId, + Long workspaceId, + Set kbIds, + Set scopes, + int rateLimitPerMin +) { + + /** Request attribute key under which the context is stored. */ + public static final String ATTR = "kbOpenApiKeyContext"; + + /** Check whether the key grants the given scope (or the wildcard). */ + public boolean hasScope(String scope) { + return scopes.contains("kb:*") || scopes.contains(scope); + } + + /** + * Check whether the key is authorized to access the given KB. An empty + * {@code kbIds} set means zero access (R3). + */ + public boolean canAccessKb(Long kbId) { + return kbId != null && kbIds.contains(kbId); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyRateLimiter.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyRateLimiter.java new file mode 100644 index 00000000..c138ce70 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyRateLimiter.java @@ -0,0 +1,46 @@ +package vip.mate.kbopen.auth; + +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-key sliding-window rate limiter for KB Open API (R2). + * + *

    Follows the same pattern as {@code TriggerRateLimiter}: each key keeps a + * 60-second window of request timestamps; a request is admitted iff fewer + * than {@code rateLimitPerMin} entries already live in the window. Local to + * this node — for multi-node deployments the cap is per-node, not global. + * v0 accepts this trade because the alternative (DB-backed counters) costs a + * round-trip on every request. + */ +@Component +public class KbApiKeyRateLimiter { + + private final Map> windows = new ConcurrentHashMap<>(); + private final Duration windowSize = Duration.ofMinutes(1); + + /** + * Try to admit a request for {@code keyId} at {@code now}. Returns + * {@code true} when the request fits under {@code limitPerMin}; + * {@code false} when the window is full (caller should return 429). + */ + public boolean tryAcquire(long keyId, int limitPerMin, Instant now) { + if (limitPerMin <= 0) return true; + Deque window = windows.computeIfAbsent(keyId, k -> new ArrayDeque<>()); + Instant cutoff = now.minus(windowSize); + synchronized (window) { + while (!window.isEmpty() && !window.peekFirst().isAfter(cutoff)) { + window.pollFirst(); + } + if (window.size() >= limitPerMin) return false; + window.addLast(now); + return true; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java new file mode 100644 index 00000000..db153008 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java @@ -0,0 +1,240 @@ +package vip.mate.kbopen.auth; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.kbopen.auth.model.KbApiKeyBindingEntity; +import vip.mate.kbopen.auth.model.KbApiKeyEntity; +import vip.mate.kbopen.auth.repository.KbApiKeyBindingMapper; +import vip.mate.kbopen.auth.repository.KbApiKeyMapper; + +import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Lifecycle management for KB Open API Keys: mint, authenticate, revoke, + * and manage KB bindings. + * + *

    Plaintext is shown exactly once at creation time — the DB stores only + * the SHA-256 hash (via {@link TokenHashUtil}, the shared kernel). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KbApiKeyService { + + public static final String KEY_PREFIX = "mck_"; + private static final int KEY_ENTROPY_BYTES = 32; + private static final int PREFIX_DISPLAY_LEN = 4; + private static final long LAST_USED_DEBOUNCE_SECONDS = 60; + private static final int DEFAULT_RATE_LIMIT = 60; + + private final KbApiKeyMapper keyMapper; + private final KbApiKeyBindingMapper bindingMapper; + private final TokenHashUtil tokenHashUtil; + + // ── Mint ────────────────────────────────────────────────────────────── + + /** + * Mint a new API Key bound to the given KBs. + * + * @param workspaceId owning workspace + * @param createdBy user id of the creator + * @param name human-readable label + * @param scopes comma-separated scopes, null/blank = "kb:*" + * @param kbIds KBs this key can access — must be non-empty (R3) + * @param expiresAt optional expiry, null = never + * @return the created entity + one-shot plaintext + */ + @Transactional + public CreatedKey create(Long workspaceId, Long createdBy, String name, + String scopes, Set kbIds, LocalDateTime expiresAt) { + if (kbIds == null || kbIds.isEmpty()) { + // R3: empty binding = zero access, which is useless for an external key. + throw new MateClawException(400, "At least one knowledge base must be bound to the API key"); + } + String plaintext = tokenHashUtil.generate(KEY_PREFIX, KEY_ENTROPY_BYTES); + String hash = tokenHashUtil.hash(plaintext); + String displayPrefix = plaintext.substring(0, Math.min(PREFIX_DISPLAY_LEN + KEY_PREFIX.length(), plaintext.length())); + + KbApiKeyEntity entity = new KbApiKeyEntity(); + entity.setName(name); + entity.setTokenHash(hash); + entity.setPrefix(displayPrefix); + entity.setWorkspaceId(workspaceId); + entity.setCreatedBy(createdBy); + entity.setScopes(scopes != null && !scopes.isBlank() ? scopes : "kb:*"); + entity.setEnabled(true); + entity.setExpiresAt(expiresAt); + entity.setRateLimitPerMin(DEFAULT_RATE_LIMIT); + entity.setDeleted(0); + keyMapper.insert(entity); + + for (Long kbId : kbIds) { + KbApiKeyBindingEntity binding = new KbApiKeyBindingEntity(); + binding.setApiKeyId(entity.getId()); + binding.setKbId(kbId); + bindingMapper.insert(binding); + } + + log.info("[KbOpenApi] Created key id={} workspaceId={} name={} boundKbs={}", + entity.getId(), workspaceId, name, kbIds.size()); + return new CreatedKey(entity.getId(), plaintext, entity); + } + + // ── Authenticate ────────────────────────────────────────────────────── + + /** + * Auth-filter hot path: find an enabled, unexpired key whose hash matches + * the SHA-256 of {@code plaintext}, and load its bound KBs + scopes. + * + * @return empty for null/blank, wrong prefix, hash miss, disabled, or expired + */ + public Optional authenticate(String plaintext) { + if (plaintext == null || plaintext.isBlank() || !plaintext.startsWith(KEY_PREFIX)) { + return Optional.empty(); + } + String hash = tokenHashUtil.hash(plaintext); + KbApiKeyEntity entity = keyMapper.selectOne( + new LambdaQueryWrapper() + .eq(KbApiKeyEntity::getTokenHash, hash) + .eq(KbApiKeyEntity::getEnabled, true) + .eq(KbApiKeyEntity::getDeleted, 0) + .last("LIMIT 1")); + if (entity == null) { + return Optional.empty(); + } + if (entity.getExpiresAt() != null && entity.getExpiresAt().isBefore(LocalDateTime.now())) { + return Optional.empty(); + } + + Set kbIds = loadBoundKbIds(entity.getId()); + Set scopes = parseScopes(entity.getScopes()); + int rateLimit = entity.getRateLimitPerMin() != null ? entity.getRateLimitPerMin() : DEFAULT_RATE_LIMIT; + + KbApiKeyContext context = new KbApiKeyContext( + entity.getId(), entity.getWorkspaceId(), kbIds, scopes, rateLimit); + return Optional.of(new AuthResult(context, entity.getLastUsedAt())); + } + + /** + * Resolve a context from a known entity id (used by admin endpoints). + */ + public KbApiKeyEntity getById(Long id) { + return keyMapper.selectById(id); + } + + // ── List / Revoke / Update ──────────────────────────────────────────── + + public List listByWorkspace(Long workspaceId) { + return keyMapper.selectList( + new LambdaQueryWrapper() + .eq(KbApiKeyEntity::getWorkspaceId, workspaceId) + .eq(KbApiKeyEntity::getDeleted, 0) + .orderByDesc(KbApiKeyEntity::getCreateTime)); + } + + public Set loadBoundKbIds(Long apiKeyId) { + List bindings = bindingMapper.selectList( + new LambdaQueryWrapper() + .eq(KbApiKeyBindingEntity::getApiKeyId, apiKeyId)); + return bindings.stream() + .map(KbApiKeyBindingEntity::getKbId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @Transactional + public void updateBindings(Long apiKeyId, Set newKbIds) { + if (newKbIds == null || newKbIds.isEmpty()) { + // R3: cannot reduce to zero access. + throw new MateClawException(400, "At least one knowledge base must be bound to the API key"); + } + bindingMapper.delete(new LambdaQueryWrapper() + .eq(KbApiKeyBindingEntity::getApiKeyId, apiKeyId)); + for (Long kbId : newKbIds) { + KbApiKeyBindingEntity binding = new KbApiKeyBindingEntity(); + binding.setApiKeyId(apiKeyId); + binding.setKbId(kbId); + bindingMapper.insert(binding); + } + } + + @Transactional + public void update(Long apiKeyId, Long workspaceId, String name, String scopes, + Set kbIds, LocalDateTime expiresAt) { + KbApiKeyEntity existing = keyMapper.selectById(apiKeyId); + if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1) + || !workspaceId.equals(existing.getWorkspaceId())) { + throw new MateClawException(404, "API key not found: " + apiKeyId); + } + if (name != null) existing.setName(name); + if (scopes != null) existing.setScopes(scopes); + if (expiresAt != null) existing.setExpiresAt(expiresAt); + keyMapper.updateById(existing); + if (kbIds != null) { + updateBindings(apiKeyId, kbIds); + } + } + + @Transactional + public void revoke(Long apiKeyId, Long workspaceId) { + KbApiKeyEntity existing = keyMapper.selectById(apiKeyId); + if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1) + || !workspaceId.equals(existing.getWorkspaceId())) { + throw new MateClawException(404, "API key not found: " + apiKeyId); + } + existing.setEnabled(false); + existing.setDeleted(1); + keyMapper.updateById(existing); + log.info("[KbOpenApi] Revoked key id={} workspaceId={}", apiKeyId, workspaceId); + } + + // ── Usage tracking ──────────────────────────────────────────────────── + + /** + * Record last-used timestamp, debounced to once per minute per key + * (same pattern as PAT). {@code previousLastUsedAt} is the entity's + * current lastUsedAt value, used to decide whether enough time has passed. + */ + public void recordUse(Long apiKeyId, LocalDateTime previousLastUsedAt) { + if (apiKeyId == null) return; + if (!shouldRecordUse(previousLastUsedAt)) return; + try { + KbApiKeyEntity update = new KbApiKeyEntity(); + update.setId(apiKeyId); + update.setLastUsedAt(LocalDateTime.now()); + keyMapper.updateById(update); + } catch (Exception e) { + log.debug("[KbOpenApi] last_used_at write failed for key {}: {}", apiKeyId, e.getMessage()); + } + } + + static boolean shouldRecordUse(LocalDateTime previousLastUsedAt) { + if (previousLastUsedAt == null) return true; + return !previousLastUsedAt.plusSeconds(LAST_USED_DEBOUNCE_SECONDS).isAfter(LocalDateTime.now()); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private Set parseScopes(String scopes) { + if (scopes == null || scopes.isBlank()) { + return Set.of("kb:*"); + } + return Set.of(scopes.split(",")).stream() + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + } + + /** Return value of {@link #create}. */ + public record CreatedKey(Long id, String plaintext, KbApiKeyEntity entity) {} + + /** Return value of {@link #authenticate}: context + lastUsedAt for debounce. */ + public record AuthResult(KbApiKeyContext context, LocalDateTime lastUsedAt) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbOpenApiAuthFilter.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbOpenApiAuthFilter.java new file mode 100644 index 00000000..3328806b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbOpenApiAuthFilter.java @@ -0,0 +1,142 @@ +package vip.mate.kbopen.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import vip.mate.kbopen.auth.KbApiKeyService.AuthResult; + +import java.io.IOException; +import java.time.Instant; +import java.util.Optional; + +/** + * Authentication filter for {@code /api/v1/open/kb/**} — the sole gatekeeper + * for the permitAll open API path. + * + *

    R1: this filter must reject, never pass-through. Because + * the path is in the SecurityConfig {@code permitAll} whitelist, there is no + * downstream Spring Security chain to catch an unauthenticated request. A + * missing, malformed, or invalid key must result in an immediate 401 + * — it cannot fall through to the Controller (which would run without a + * {@link KbApiKeyContext}). + * + *

    R2: per-key rate limiting. After successful auth, the + * filter checks the sliding-window limiter. Exceeding + * {@code rateLimitPerMin} returns 429. + * + *

    R5 / R7: SSE token fallback is scope-limited. The + * {@code ?token=} query param is accepted only on SSE stream paths + * ({@link #isSseStreamPath}), where browser EventSource cannot set an + * Authorization header (R7). It is rejected on every other path so the API + * key never leaks into access / proxy logs for normal calls (R5). Application + * logging here uses {@code getRequestURI()} (no query string), so the key does + * not reach app logs — but a reverse proxy may still log the query string, so + * keep the fallback as narrow as possible. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KbOpenApiAuthFilter extends OncePerRequestFilter { + + private final KbApiKeyService keyService; + private final KbApiKeyRateLimiter rateLimiter; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // Only guard the open API path. Other paths fall through to normal security. + if (!isOpenApiPath(request)) { + filterChain.doFilter(request, response); + return; + } + + boolean sse = isSseStreamPath(request); + String token = extractToken(request, sse); + if (!StringUtils.hasText(token)) { + sendUnauthorized(response, sse + ? "Missing API key (Authorization header or ?token= for EventSource)" + : "Missing API key"); + return; + } + + Optional authResult = keyService.authenticate(token); + if (authResult.isEmpty()) { + sendUnauthorized(response, "Invalid or expired API key"); + return; + } + + KbApiKeyContext context = authResult.get().context(); + + // R2: rate limit check — but NOT on the SSE stream path. EventSource + // reconnects/heartbeats would otherwise burn the per-minute window and + // can 429 the key's own POST /research start. Rate limiting belongs on + // the cost-producing endpoints (start/status/cancel), not the progress + // subscription. Per-key concurrency is still enforced upstream. + if (!sse && !rateLimiter.tryAcquire(context.keyId(), context.rateLimitPerMin(), Instant.now())) { + sendTooManyRequests(response, context.rateLimitPerMin()); + return; + } + + // Inject context for downstream @RequireKbScope authorization + request.setAttribute(KbApiKeyContext.ATTR, context); + + // Debounced last-used recording + keyService.recordUse(context.keyId(), authResult.get().lastUsedAt()); + + filterChain.doFilter(request, response); + } + + private boolean isOpenApiPath(HttpServletRequest request) { + String uri = request.getRequestURI(); + return uri.startsWith("/api/v1/open/kb/"); + } + + /** + * SSE progress stream paths — the only place {@code ?token=} is accepted, + * because browser EventSource cannot set an Authorization header (R7). + * Matched on URI suffix + content type so the fallback tracks whichever + * endpoints expose SSE, without hard-coding a single kbId/sessionId. + */ + private boolean isSseStreamPath(HttpServletRequest request) { + String uri = request.getRequestURI(); + return uri.startsWith("/api/v1/open/kb/") && uri.endsWith("/stream"); + } + + /** + * Extract the API key. Header is always accepted; the {@code ?token=} + * query param is accepted only on SSE stream paths ({@code sse}), + * to keep the key out of access/proxy logs on every other request (R5). + */ + private String extractToken(HttpServletRequest request, boolean sse) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7).trim(); + } + if (sse) { + String queryToken = request.getParameter("token"); + if (StringUtils.hasText(queryToken)) { + return queryToken.trim(); + } + } + return null; + } + + private void sendUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":401,\"msg\":\"" + message + "\",\"data\":null}"); + } + + private void sendTooManyRequests(HttpServletResponse response, int limit) throws IOException { + response.setStatus(429); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":429,\"msg\":\"Rate limit exceeded (" + limit + "/min)\",\"data\":null}"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbScopeInterceptor.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbScopeInterceptor.java new file mode 100644 index 00000000..cb238f0e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbScopeInterceptor.java @@ -0,0 +1,97 @@ +package vip.mate.kbopen.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +import java.util.Map; + +/** + * Intercepts methods annotated with {@link RequireKbScope} and enforces: + *

      + *
    1. Scope check — the request's {@link KbApiKeyContext} must grant the + * required scope (or {@code kb:*}).
    2. + *
    3. KB ownership — the {@code kbId} path variable must be in the + * context's bound KB set (R3: empty set = zero access).
    4. + *
    + * + *

    Modeled after {@code WorkspaceAccessInterceptor}. Both checks return 403 + * on failure; the request is rejected before the Controller method runs. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KbScopeInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + RequireKbScope annotation = handlerMethod.getMethodAnnotation(RequireKbScope.class); + if (annotation == null) { + return true; + } + + KbApiKeyContext context = (KbApiKeyContext) request.getAttribute(KbApiKeyContext.ATTR); + if (context == null) { + // Filter should have already rejected this, but fail-closed just in case. + sendForbidden(response, "Authentication required"); + return false; + } + + // Layer 2: scope check + String requiredScope = annotation.value(); + if (!context.hasScope(requiredScope)) { + log.warn("[KbOpenApi] Scope denied: keyId={} required={} has={}", + context.keyId(), requiredScope, context.scopes()); + sendForbidden(response, "Insufficient scope: requires " + requiredScope); + return false; + } + + // Layer 3: KB ownership — extract kbId from path variable + Long kbId = extractKbId(request); + if (kbId == null) { + // No kbId in path — let the controller handle it (e.g. taxonomy may not need one). + return true; + } + if (!context.canAccessKb(kbId)) { + log.warn("[KbOpenApi] KB access denied: keyId={} kbId={} boundKbs={}", + context.keyId(), kbId, context.kbIds()); + sendForbidden(response, "Knowledge base not bound to this API key"); + return false; + } + + return true; + } + + @SuppressWarnings("unchecked") + private Long extractKbId(HttpServletRequest request) { + Object attr = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (!(attr instanceof Map)) { + return null; + } + Object rawKbId = ((Map) attr).get("kbId"); + if (rawKbId == null) { + return null; + } + try { + return Long.parseLong(rawKbId.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private void sendForbidden(HttpServletResponse response, String message) throws Exception { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"msg\":\"" + message + "\",\"data\":null}"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/RequireKbScope.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/RequireKbScope.java new file mode 100644 index 00000000..4425c543 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/RequireKbScope.java @@ -0,0 +1,29 @@ +package vip.mate.kbopen.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares the minimum scope required by a KB Open API endpoint. + * + *

    Processed by {@code KbScopeInterceptor} which checks, in order: + *

      + *
    1. A {@link KbApiKeyContext} exists on the request (filter ran).
    2. + *
    3. The context's scopes include the annotation value or {@code kb:*}.
    4. + *
    5. The path's {@code kbId} variable is in the context's bound KB set.
    6. + *
    + * + *

    This mirrors the existing {@code @RequireWorkspaceRole} + + * {@code WorkspaceAccessInterceptor} pattern, centralizing authorization so + * it is not hand-written per endpoint (A1 — avoids repeating the #438/#439 + * Wiki IDOR pattern on the outward-facing API). + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireKbScope { + + /** Required scope, e.g. {@code "kb:search"}, {@code "kb:read"}. */ + String value(); +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/TokenHashUtil.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/TokenHashUtil.java new file mode 100644 index 00000000..29b442b2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/TokenHashUtil.java @@ -0,0 +1,58 @@ +package vip.mate.kbopen.auth; + +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; + +/** + * Shared token hash kernel (A4). + * + *

    Generates cryptographically random token plaintext, computes its SHA-256 + * hash for storage, and verifies a plaintext against a stored hash. This is + * the single source of truth for token hashing so that KB API Keys and (in a + * future refactor) PAT share one implementation instead of drifting apart. + * + *

    Extracted from {@code PersonalAccessTokenService}'s private hash methods + * — the logic is identical; making it a Spring bean lets both services inject + * it without duplicating the SHA-256 boilerplate. + */ +@Component +public class TokenHashUtil { + + private final SecureRandom secureRandom = new SecureRandom(); + + /** Hash a plaintext token to its lowercase SHA-256 hex digest. */ + public String hash(String plaintext) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(plaintext.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable on this JVM", e); + } + } + + /** + * Generate a random plaintext token of the given byte-entropy, prefixed + * with {@code prefix} (e.g. {@code "mck_"}). + */ + public String generate(String prefix, int entropyBytes) { + byte[] bytes = new byte[entropyBytes]; + secureRandom.nextBytes(bytes); + String body = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + return prefix + body; + } + + /** Verify that a plaintext hashes to the expected stored digest. */ + public boolean matches(String plaintext, String storedHash) { + if (plaintext == null || storedHash == null) { + return false; + } + return hash(plaintext).equals(storedHash); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyBindingEntity.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyBindingEntity.java new file mode 100644 index 00000000..9ce74f51 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyBindingEntity.java @@ -0,0 +1,33 @@ +package vip.mate.kbopen.auth.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Many-to-many binding between an API Key and a Knowledge Base. + * + *

    An empty binding set means zero access (R3) — unlike + * internal {@code AgentWikiKbBinding} where empty means "all KBs". This + * prevents an external key from silently gaining access to the entire + * workspace or auto-including newly created KBs. + */ +@Data +@TableName("mate_kb_api_key_binding") +public class KbApiKeyBindingEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long apiKeyId; + + private Long kbId; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyEntity.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyEntity.java new file mode 100644 index 00000000..3096cdf3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/model/KbApiKeyEntity.java @@ -0,0 +1,64 @@ +package vip.mate.kbopen.auth.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Knowledge Base Open API Key entity. + * + *

    Plaintext keys are never persisted; only the SHA-256 hash lives in + * {@link #tokenHash}. The {@code prefix} column stores the first 8 chars of + * the plaintext purely for UI display ({@code "mck_ab12****"}) — it does not + * compromise security because the hash is not reversible. + */ +@Data +@TableName("mate_kb_api_key") +public class KbApiKeyEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private String name; + + /** SHA-256 hex of the plaintext, lowercase. UNIQUE indexed for O(1) auth. */ + @JsonIgnore + private String tokenHash; + + /** First 8 chars of plaintext ({@code "mck_" + 4 random}), for UI display only. */ + private String prefix; + + /** Owning workspace — the isolation boundary. */ + private Long workspaceId; + + /** User who created the key. */ + private Long createdBy; + + /** Comma-separated scope tokens, e.g. "kb:search,kb:read". Null/empty = kb:*. */ + private String scopes; + + private Boolean enabled; + + private LocalDateTime expiresAt; + + private LocalDateTime lastUsedAt; + + /** Per-key rate limit, enforced by the auth filter (R2). */ + private Integer rateLimitPerMin; + + @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/kbopen/auth/repository/KbApiKeyBindingMapper.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyBindingMapper.java new file mode 100644 index 00000000..d6792c4d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyBindingMapper.java @@ -0,0 +1,9 @@ +package vip.mate.kbopen.auth.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.kbopen.auth.model.KbApiKeyBindingEntity; + +@Mapper +public interface KbApiKeyBindingMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyMapper.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyMapper.java new file mode 100644 index 00000000..828a50f2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/repository/KbApiKeyMapper.java @@ -0,0 +1,9 @@ +package vip.mate.kbopen.auth.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.kbopen.auth.model.KbApiKeyEntity; + +@Mapper +public interface KbApiKeyMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbApiKeyAdminController.java b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbApiKeyAdminController.java new file mode 100644 index 00000000..8de3dab8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbApiKeyAdminController.java @@ -0,0 +1,137 @@ +package vip.mate.kbopen.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.kbopen.auth.KbApiKeyService; +import vip.mate.kbopen.auth.KbApiKeyService.CreatedKey; +import vip.mate.kbopen.auth.model.KbApiKeyEntity; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Admin CRUD for KB Open API Keys (JWT-authenticated, workspace-scoped). + * + *

    Plaintext is returned exactly once on {@link #create}; subsequent lookups + * expose only metadata (id, name, prefix, scopes, bound KBs, lastUsedAt). + */ +@Tag(name = "KB Open API Keys") +@RestController +@RequestMapping("/api/v1/open/keys") +@RequiredArgsConstructor +public class KbApiKeyAdminController { + + private final KbApiKeyService keyService; + private final AuthService authService; + + @Operation(summary = "List API keys in the current workspace (metadata only)") + @GetMapping + @RequireWorkspaceRole("admin") + public R> list( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + long wsId = workspaceId != null ? workspaceId : 1L; + return R.ok(keyService.listByWorkspace(wsId)); + } + + @Operation(summary = "Mint a new API key — plaintext shown once, cannot be recovered") + @PostMapping + @RequireWorkspaceRole("admin") + public R> create( + @RequestBody CreateKeyRequest req, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication auth) { + UserEntity user = requireUser(auth); + long wsId = workspaceId != null ? workspaceId : 1L; + CreatedKey created = keyService.create( + wsId, + user.getId(), + req.name(), + req.scopes(), + req.kbIds(), + req.expiresAt()); + return R.ok(Map.of( + "id", created.id(), + "plaintext", created.plaintext(), + "name", req.name() == null ? "" : req.name(), + "prefix", created.entity().getPrefix(), + "scopes", created.entity().getScopes() == null ? "" : created.entity().getScopes(), + "kbIds", req.kbIds(), + "expiresAt", req.expiresAt() == null ? "" : req.expiresAt())); + } + + @Operation(summary = "Get key details (metadata only, no plaintext)") + @GetMapping("/{id}") + @RequireWorkspaceRole("admin") + public R> detail( + @PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + long wsId = workspaceId != null ? workspaceId : 1L; + KbApiKeyEntity entity = keyService.getById(id); + if (entity == null || (entity.getDeleted() != null && entity.getDeleted() == 1) + || !entity.getWorkspaceId().equals(wsId)) { + throw new MateClawException(404, "API key not found: " + id); + } + Set kbIds = keyService.loadBoundKbIds(id); + return R.ok(Map.of( + "id", entity.getId(), + "name", entity.getName(), + "prefix", entity.getPrefix(), + "scopes", entity.getScopes() == null ? "" : entity.getScopes(), + "kbIds", kbIds, + "enabled", entity.getEnabled(), + "rateLimitPerMin", entity.getRateLimitPerMin(), + "lastUsedAt", entity.getLastUsedAt() == null ? "" : entity.getLastUsedAt(), + "expiresAt", entity.getExpiresAt() == null ? "" : entity.getExpiresAt(), + "createTime", entity.getCreateTime() == null ? "" : entity.getCreateTime())); + } + + @Operation(summary = "Update key (name / scopes / kb-ids / expiresAt — cannot change plaintext)") + @PutMapping("/{id}") + @RequireWorkspaceRole("admin") + public R update( + @PathVariable Long id, + @RequestBody UpdateKeyRequest req, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + long wsId = workspaceId != null ? workspaceId : 1L; + keyService.update(id, wsId, req.name(), req.scopes(), req.kbIds(), req.expiresAt()); + return R.ok(); + } + + @Operation(summary = "Revoke (soft-delete) an API key") + @DeleteMapping("/{id}") + @RequireWorkspaceRole("admin") + public R revoke( + @PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + long wsId = workspaceId != null ? workspaceId : 1L; + keyService.revoke(id, wsId); + return R.ok(); + } + + private UserEntity requireUser(Authentication auth) { + if (auth == null || auth.getName() == null) { + throw new MateClawException("err.auth.unauthenticated", "Authentication required"); + } + UserEntity user = authService.findByUsername(auth.getName()); + if (user == null) { + throw new MateClawException("err.auth.user_not_found", + "Authenticated user not found: " + auth.getName()); + } + return user; + } + + public record CreateKeyRequest(String name, String scopes, Set kbIds, LocalDateTime expiresAt) {} + + public record UpdateKeyRequest(String name, String scopes, Set kbIds, LocalDateTime expiresAt) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenApiController.java b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenApiController.java new file mode 100644 index 00000000..089d247f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenApiController.java @@ -0,0 +1,331 @@ +package vip.mate.kbopen.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.kbopen.auth.RequireKbScope; +import vip.mate.kbopen.dto.KbOpenApiDtos.*; +import vip.mate.kbopen.service.KbOpenApiService; +import vip.mate.wiki.dto.PageCitationWithRaw; +import vip.mate.wiki.dto.PageSearchResult; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiEntityEntity; +import vip.mate.wiki.model.WikiEntityRelationEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiChunkMapper; +import vip.mate.wiki.repository.WikiEntityMapper; +import vip.mate.wiki.repository.WikiEntityRelationMapper; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.service.HybridRetriever; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiPageService; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * KB Open API — 9 read-only endpoints for programmatic knowledge base access. + * + *

    All endpoints are under {@code /api/v1/open/kb/**} (SecurityConfig + * permitAll), authenticated by {@code KbOpenApiAuthFilter} (API Key), and + * authorized by {@code @RequireKbScope} (scope + KB ownership). + * + *

    A5: returns explicit DTOs, never raw entities. + * A6: service-layer methods return pure DTOs (no HTTP coupling). + */ +@Tag(name = "KB Open API") +@RestController +@RequestMapping("/api/v1/open/kb") +@RequiredArgsConstructor +public class KbOpenApiController { + + private final WikiPageService pageService; + private final HybridRetriever hybridRetriever; + private final WikiKnowledgeBaseService kbService; + private final WikiPageCitationMapper citationMapper; + private final WikiChunkMapper chunkMapper; + private final WikiEntityMapper entityMapper; + private final WikiEntityRelationMapper relationMapper; + private final KbOpenApiService openApiService; + + // ── 1. GET /pages/{slug} — entity card / page detail ────────────────── + + @RequireKbScope("kb:read") + @GetMapping("/{kbId}/pages/{slug}") + @Operation(summary = "Get entity card / page detail (mode controls content depth)") + public R getPage( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestParam(defaultValue = "summary") String mode, + @RequestParam(required = false) String fields) { + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) { + throw new MateClawException(404, "Page not found: " + slug); + } + return R.ok(openApiService.assembleCard(page, mode, fields)); + } + + // ── 2. POST /search — hybrid retrieval ──────────────────────────────── + + @RequireKbScope("kb:search") + @PostMapping("/{kbId}/search") + @Operation(summary = "Hybrid search (granularity controls result shape)") + public R> search( + @PathVariable Long kbId, + @RequestBody SearchRequest req) { + String query = req.query() != null ? req.query() : ""; + String mode = req.mode() != null ? req.mode() : "hybrid"; + int topK = req.topK() != null ? Math.min(req.topK(), 20) : 5; + + List hits = hybridRetriever.search(kbId, query, mode, topK); + + // Filter by pageType if specified + if (req.pageType() != null && !req.pageType().isBlank()) { + hits = hits.stream().filter(h -> { + WikiPageEntity p = pageService.getBySlug(kbId, h.slug()); + return p != null && req.pageType().equals(p.getPageType()); + }).collect(Collectors.toList()); + } + + // granularity: entity (default) returns summary-level hits; chunk is via /search/chunks + List> results = hits.stream().map(h -> { + Map m = new LinkedHashMap<>(); + m.put("slug", h.slug()); + m.put("title", h.title()); + m.put("summary", h.summary()); + m.put("snippet", h.snippet()); + m.put("matchedBy", h.matchedBy()); + m.put("score", h.score()); + return m; + }).collect(Collectors.toList()); + + return R.ok(Map.of( + "kbId", kbId, + "query", query, + "mode", mode, + "count", results.size(), + "results", results)); + } + + public record SearchRequest(String query, String mode, String pageType, + String granularity, Integer topK) {} + + // ── 3. POST /search/chunks — chunk-level retrieval ──────────────────── + + @RequireKbScope("kb:search") + @PostMapping("/{kbId}/search/chunks") + @Operation(summary = "Chunk-level semantic search (fine-grained RAG evidence)") + public R> searchChunks( + @PathVariable Long kbId, + @RequestBody ChunkSearchRequest req) { + String query = req.query() != null ? req.query() : ""; + int topK = req.topK() != null ? Math.min(req.topK(), 20) : 5; + + List hits = hybridRetriever.searchChunks(kbId, query, topK); + + List> results = hits.stream().map(h -> { + Map m = new LinkedHashMap<>(); + m.put("chunkId", h.chunkId()); + m.put("rawId", h.rawId()); + m.put("snippet", h.snippet()); + m.put("score", h.score()); + m.put("pageNumber", h.pageNumber()); + m.put("headerBreadcrumb", h.headerBreadcrumb()); + return m; + }).collect(Collectors.toList()); + + return R.ok(Map.of( + "kbId", kbId, + "count", results.size(), + "chunks", results)); + } + + public record ChunkSearchRequest(String query, Integer topK) {} + + // ── 4. POST /pages/{slug}/traverse — entity relation graph ──────────── + + @RequireKbScope("kb:read") + @PostMapping("/{kbId}/pages/{slug}/traverse") + @Operation(summary = "Traverse entity relations (depth ≤ 2)") + public R traverse( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestBody(required = false) TraverseRequest req) { + String relation = req != null ? req.relation() : null; + int depth = req != null && req.depth() != null ? Math.min(req.depth(), 2) : 1; + String direction = req != null && req.direction() != null ? req.direction() : "both"; + int limit = req != null && req.limit() != null ? Math.min(req.limit(), 50) : 20; + return R.ok(openApiService.traverse(kbId, slug, relation, depth, direction, limit)); + } + + public record TraverseRequest(String relation, Integer depth, String direction, Integer limit) {} + + // ── 5. GET /pages/{slug}/trace — provenance ─────────────────────────── + + @RequireKbScope("kb:read") + @GetMapping("/{kbId}/pages/{slug}/trace") + @Operation(summary = "Trace page provenance (page → chunk → raw)") + public R trace( + @PathVariable Long kbId, + @PathVariable String slug) { + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) { + throw new MateClawException(404, "Page not found: " + slug); + } + var citations = citationMapper.listWithRawByPageId(page.getId()); + + // Group by rawId + Map> byRaw = citations.stream() + .collect(Collectors.groupingBy(PageCitationWithRaw::rawId)); + + List sources = byRaw.entrySet().stream().map(e -> { + List details = e.getValue().stream().map(c -> + new CitationDetail(c.chunkId(), c.snippet(), + c.confidence() != null ? c.confidence().doubleValue() : null, + null)).collect(Collectors.toList()); // pageNumber from chunk lookup omitted for brevity + String rawTitle = e.getValue().get(0).rawTitle(); + return new SourceGroup(e.getKey(), rawTitle, details); + }).collect(Collectors.toList()); + + return R.ok(new TraceResult( + page.getSlug(), + page.getPageType(), + page.getKnowledgeLayer(), + sources, + page.getUpdateTime(), + page.getVersion())); + } + + // ── 6. GET /taxonomy — type enumeration map ─────────────────────────── + + @RequireKbScope("kb:list") + @GetMapping("/{kbId}/taxonomy") + @Operation(summary = "Get type/scope taxonomy (pageTypes, entityTypes, relationTypes)") + public R taxonomy(@PathVariable Long kbId) { + // Page types + List pages = pageService.listByKbId(kbId); + Map pageTypeCounts = pages.stream() + .filter(p -> p.getPageType() != null) + .collect(Collectors.groupingBy(WikiPageEntity::getPageType, Collectors.counting())); + List pageTypes = pageTypeCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .map(e -> new TypeCount(e.getKey(), e.getValue().intValue())) + .collect(Collectors.toList()); + + // Entity types + List entities = entityMapper.selectList( + new LambdaQueryWrapper().eq(WikiEntityEntity::getKbId, kbId)); + Map entityTypeCounts = entities.stream() + .filter(e -> e.getType() != null) + .collect(Collectors.groupingBy(WikiEntityEntity::getType, Collectors.counting())); + List entityTypes = entityTypeCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .map(e -> new TypeCount(e.getKey(), e.getValue().intValue())) + .collect(Collectors.toList()); + + // Relation types + List rels = relationMapper.selectList( + new LambdaQueryWrapper().eq(WikiEntityRelationEntity::getKbId, kbId)); + Map relTypeCounts = rels.stream() + .filter(r -> r.getPredicate() != null) + .collect(Collectors.groupingBy(WikiEntityRelationEntity::getPredicate, Collectors.counting())); + List relationTypes = relTypeCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .map(e -> new TypeCount(e.getKey(), e.getValue().intValue())) + .collect(Collectors.toList()); + + return R.ok(new TaxonomyResult(pageTypes, entityTypes, relationTypes)); + } + + // ── 7. GET /whats-new — freshness query ─────────────────────────────── + + @RequireKbScope("kb:meta") + @GetMapping("/{kbId}/whats-new") + @Operation(summary = "Query recent changes and stale pages") + public R whatsNew( + @PathVariable Long kbId, + @RequestParam(defaultValue = "updated") String kind, + @RequestParam(required = false) LocalDateTime since, + @RequestParam(defaultValue = "50") int limit) { + if (since == null) { + since = LocalDateTime.now().minusDays(7); + } + int safeLimit = Math.min(limit, 200); + + List changed = "created".equals(kind) + ? pageService.findRecentCreated(kbId, since, safeLimit) + : pageService.findRecentUpdated(kbId, since, safeLimit); + + List changedPages = changed.stream() + .map(p -> new ChangedPage(p.getSlug(), p.getTitle(), p.getKnowledgeLayer(), + p.getUpdateTime(), null)) + .collect(Collectors.toList()); + + // Stale pages + List allPages = pageService.listByKbId(kbId); + List stalePages = allPages.stream() + .filter(p -> p.getStale() != null && p.getStale() == 1) + .map(p -> new ChangedPage(p.getSlug(), p.getTitle(), p.getKnowledgeLayer(), + p.getUpdateTime(), "Upstream fact page changed")) + .collect(Collectors.toList()); + + return R.ok(new WhatsNewResult(kbId, since, changedPages, stalePages)); + } + + // ── 8. GET /stats — KB metadata ─────────────────────────────────────── + + @RequireKbScope("kb:meta") + @GetMapping("/{kbId}/stats") + @Operation(summary = "Get KB statistics") + public R stats(@PathVariable Long kbId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null) { + throw new MateClawException(404, "Knowledge base not found: " + kbId); + } + int pageCount = pageService.countByKbId(kbId); + // Must use listByKbIdWithContent — listByKbId nulls out content. + List pagesWithContent = pageService.listByKbIdWithContent(kbId); + long pagesWithLinks = pagesWithContent.stream() + .filter(p -> p.getContent() != null && p.getContent().contains("[[")) + .count(); + + return R.ok(new KbStats( + kbId, + kb.getName(), + pageCount, + kb.getRawCount() != null ? kb.getRawCount() : 0, + 0, // chunkCount not trivially available without a service call + 0, // embeddedChunks same + (int) pagesWithLinks, + null, // lastIngest + null // embeddingModel + )); + } + + // ── 9. GET /pages — list pages ──────────────────────────────────────── + + @RequireKbScope("kb:list") + @GetMapping("/{kbId}/pages") + @Operation(summary = "List pages (lightweight)") + public R listPages( + @PathVariable Long kbId, + @RequestParam(required = false) String pageType) { + List pages = pageService.listByKbId(kbId); + if (pageType != null && !pageType.isBlank()) { + pages = pages.stream() + .filter(p -> pageType.equals(p.getPageType())) + .collect(Collectors.toList()); + } + List items = pages.stream() + .map(p -> new PageListItem(p.getSlug(), p.getTitle(), p.getSummary(), + p.getPageType(), p.getKnowledgeLayer())) + .collect(Collectors.toList()); + return R.ok(new PageList(kbId, items.size(), items)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenResearchController.java b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenResearchController.java new file mode 100644 index 00000000..89565fe5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenResearchController.java @@ -0,0 +1,230 @@ +package vip.mate.kbopen.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.channel.web.Utf8SseEmitter; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.kbopen.auth.KbApiKeyContext; +import vip.mate.kbopen.auth.RequireKbScope; +import vip.mate.kbopen.research.KbResearchSessionRegistry; +import vip.mate.kbopen.research.KbResearchSessionRegistry.Session; +import vip.mate.kbopen.research.KbResearchSessionRegistry.Status; +import vip.mate.kbopen.research.KbResearchSessionRegistry.TooManyConcurrentException; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiResearchService; +import vip.mate.wiki.service.WikiResearchService.ResearchResult; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * KB Open API — Deep Research endpoints. + * + *

    Unlike the synchronous read endpoints, research is async (multi-step LLM + * pipeline) with SSE progress. The start endpoint returns a sessionId; the + * caller subscribes to SSE for progress, or polls status for the final result. + * + *

    The SSE endpoint uses a {@code ?token=} query param because browser + * EventSource cannot set Authorization headers. The {@code KbOpenApiAuthFilter} + * already falls back to query param tokens. + * + *

    Cost & lifecycle controls

    + *
      + *
    • Cancel is cooperative: it calls {@link ChatStreamTracker#requestStop} + * so {@link WikiResearchService} bails at the next stage boundary — the + * draft fan-out and compose LLM calls are skipped, not run to completion.
    • + *
    • CANCELLED is a sticky terminal: a late complete() after cancel is + * a no-op in the registry, so the user never sees a COMPLETED report.
    • + *
    • Per-key concurrency cap ({@code mate.kbopen.research.max-concurrent-per-key}) + * stops one key from spawning unbounded parallel pipelines → 429.
    • + *
    + */ +@Slf4j +@Tag(name = "KB Open API — Deep Research") +@RestController +@RequestMapping("/api/v1/open/kb") +@RequiredArgsConstructor +public class KbOpenResearchController { + + private final WikiResearchService researchService; + private final WikiKnowledgeBaseService kbService; + private final ChatStreamTracker streamTracker; + private final KbResearchSessionRegistry sessionRegistry; + + private static final ExecutorService RESEARCH_EXEC = Executors.newVirtualThreadPerTaskExecutor(); + + // ── POST /{kbId}/research — start ───────────────────────────────────── + + @RequireKbScope("kb:search") + @PostMapping("/{kbId}/research") + @Operation(summary = "Start Deep Research (async, returns sessionId)") + public R> startResearch( + @PathVariable Long kbId, + @RequestBody ResearchRequest req, + HttpServletRequest request) { + KbApiKeyContext ctx = requireContext(request); + String topic = req.topic(); + if (topic == null || topic.isBlank()) { + throw new MateClawException(400, "topic is required"); + } + if (kbService.getById(kbId) == null) { + throw new MateClawException(404, "Knowledge base not found: " + kbId); + } + + String sessionId = "open-research-" + UUID.randomUUID(); + streamTracker.register(sessionId); + streamTracker.incrementFlux(sessionId); + // Per-key concurrency cap (DoS / runaway-cost guard on top of the + // per-minute rate limiter). Throws → 429 below. + try { + sessionRegistry.startIfAllowed(sessionId, ctx.keyId(), kbId, topic); + } catch (TooManyConcurrentException e) { + try { streamTracker.complete(sessionId); } catch (Exception ignored) {} + throw new MateClawException(429, e.getMessage()); + } + + RESEARCH_EXEC.submit(() -> { + try { + ResearchResult result = researchService.research(kbId, topic, sessionId, req.topKPerQuestion()); + // complete() is a no-op if the user already cancelled — the + // sticky CANCELLED terminal wins over a late COMPLETED. + sessionRegistry.complete(sessionId, result); + } catch (Exception e) { + log.error("[KbOpenResearch] Failed sessionId={}: {}", sessionId, e.getMessage(), e); + sessionRegistry.fail(sessionId, e.getMessage()); + } finally { + try { streamTracker.broadcast(sessionId, "done", "{}"); } catch (Exception ignored) {} + try { streamTracker.complete(sessionId); } catch (Exception ignored) {} + } + }); + + return R.ok(Map.of( + "sessionId", sessionId, + "kbId", kbId, + "topic", topic, + "streamUrl", "/api/v1/open/kb/" + kbId + "/research/" + sessionId + "/stream")); + } + + public record ResearchRequest(String topic, Integer topKPerQuestion) {} + + // ── GET /{kbId}/research/{sessionId}/stream — SSE ───────────────────── + + @RequireKbScope("kb:search") + @GetMapping(value = "/{kbId}/research/{sessionId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "Subscribe to research SSE progress (use ?token= for EventSource)") + public SseEmitter stream( + @PathVariable Long kbId, + @PathVariable String sessionId, + HttpServletRequest request) { + requireSessionOwnership(request, kbId, sessionId); + + SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L); + boolean attached = streamTracker.attach(sessionId, emitter); + if (!attached) { + try { + emitter.send(SseEmitter.event().name("error") + .data("{\"message\":\"session not found or already ended\"}")); + emitter.complete(); + } catch (Exception ignored) {} + } + emitter.onCompletion(() -> streamTracker.detach(sessionId, emitter)); + emitter.onTimeout(() -> streamTracker.detach(sessionId, emitter)); + emitter.onError(err -> streamTracker.detach(sessionId, emitter)); + return emitter; + } + + // ── GET /{kbId}/research/{sessionId}/status — query result ──────────── + + @RequireKbScope("kb:search") + @GetMapping("/{kbId}/research/{sessionId}/status") + @Operation(summary = "Query research status / final result") + public R> status( + @PathVariable Long kbId, + @PathVariable String sessionId, + HttpServletRequest request) { + Session session = requireSessionOwnership(request, kbId, sessionId); + Status status = session.status(); + ResearchResult result = session.result(); + + Map data = new LinkedHashMap<>(); + data.put("sessionId", sessionId); + data.put("status", status.name().toLowerCase()); + data.put("topic", session.topic()); + if (result != null) { + data.put("report", result.report()); + data.put("sections", result.sections().size()); + } + if (session.error() != null) { + data.put("error", session.error()); + } + return R.ok(data); + } + + // ── POST /{kbId}/research/{sessionId}/cancel — cancel ───────────────── + + @RequireKbScope("kb:search") + @PostMapping("/{kbId}/research/{sessionId}/cancel") + @Operation(summary = "Cancel a running research session") + public R> cancel( + @PathVariable Long kbId, + @PathVariable String sessionId, + HttpServletRequest request) { + Session session = requireSessionOwnership(request, kbId, sessionId); + if (session.status() != Status.RUNNING) { + throw new MateClawException(409, "Session is not running (status: " + session.status() + ")"); + } + // Cooperative cancellation: signal the running pipeline to bail at the + // next stage boundary (plan→draft, draft→compose, and inside the draft + // fan-out) rather than running LLM calls to completion. + streamTracker.requestStop(sessionId); + sessionRegistry.cancel(sessionId); + // Close the SSE stream so subscribers detach immediately. + try { + streamTracker.broadcast(sessionId, "cancelled", "{\"message\":\"cancelled by user\"}"); + streamTracker.broadcast(sessionId, "done", "{}"); + streamTracker.complete(sessionId); + } catch (Exception ignored) {} + return R.ok(Map.of("sessionId", sessionId, "status", "cancelled")); + } + + // ── Auth helpers ────────────────────────────────────────────────────── + + private KbApiKeyContext requireContext(HttpServletRequest request) { + KbApiKeyContext ctx = (KbApiKeyContext) request.getAttribute(KbApiKeyContext.ATTR); + if (ctx == null) { + throw new MateClawException(401, "Authentication required"); + } + return ctx; + } + + private Session requireSessionOwnership(HttpServletRequest request, Long kbId, String sessionId) { + KbApiKeyContext ctx = requireContext(request); + Optional session = sessionRegistry.get(sessionId); + if (session.isEmpty()) { + throw new MateClawException(404, "Research session not found: " + sessionId); + } + // A caller can only access sessions they started + if (!session.get().keyId().equals(ctx.keyId())) { + throw new MateClawException(403, "Session does not belong to this API key"); + } + // The session must also belong to the KB named in the path, so a session + // started under one KB cannot be addressed via another — even when the + // caller's key happens to be bound to both. + if (!session.get().kbId().equals(kbId)) { + throw new MateClawException(404, "Research session not found: " + sessionId); + } + return session.get(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/dto/KbOpenApiDtos.java b/mateclaw-server/src/main/java/vip/mate/kbopen/dto/KbOpenApiDtos.java new file mode 100644 index 00000000..bd60b084 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/dto/KbOpenApiDtos.java @@ -0,0 +1,142 @@ +package vip.mate.kbopen.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Explicit response DTOs for the KB Open API. + * + *

    A5 constraint: the open API never serializes raw + * entities (WikiPageEntity, etc.) — every endpoint returns an explicit DTO + * to prevent accidental field leakage (IDOR / internal column exposure). + * + *

    All DTOs are records for immutability and clean JSON serialization. + */ +public final class KbOpenApiDtos { + + private KbOpenApiDtos() {} + + /** GET /pages/{slug} — entity card / page detail. */ + public record PageCard( + String slug, + String canonicalName, + String pageType, + String knowledgeLayer, + String title, + String summary, + Map fields, + String content, + SourceRef source, + Integer version, + LocalDateTime updatedAt + ) {} + + /** GET /pages/{slug}/trace — provenance chain. */ + public record TraceResult( + String slug, + String pageType, + String knowledgeLayer, + List sources, + LocalDateTime extractedAt, + Integer pageVersion + ) {} + + public record SourceGroup( + Long rawId, + String rawTitle, + List citations + ) {} + + public record CitationDetail( + Long chunkId, + String snippet, + Double confidence, + Integer pageNumber + ) {} + + /** GET /taxonomy — type/scope enumeration map. */ + public record TaxonomyResult( + List pageTypes, + List entityTypes, + List relationTypes + ) {} + + public record TypeCount(String type, int count) {} + + /** GET /stats — KB metadata. */ + public record KbStats( + Long kbId, + String name, + int pageCount, + int rawCount, + int chunkCount, + int embeddedChunks, + int pagesWithLinks, + LocalDateTime lastIngest, + String embeddingModel + ) {} + + /** GET /whats-new — freshness/change query. */ + public record WhatsNewResult( + Long kbId, + LocalDateTime since, + List changedPages, + List stalePages + ) {} + + public record ChangedPage( + String slug, + String title, + String knowledgeLayer, + LocalDateTime updatedAt, + String staleReason + ) {} + + /** POST /pages/{slug}/traverse — entity relation subgraph. */ + public record TraverseResult( + TraverseNode root, + List edges, + List nodes + ) {} + + public record TraverseNode( + Long entityId, + String name, + String type, + String slug + ) {} + + public record TraverseEdge( + String predicate, + Long fromId, + Long toId, + String fromName, + String toName, + String evidence, + Double confidence, + String sourceHandle + ) {} + + /** GET /pages — page list item (lightweight). */ + public record PageListItem( + String slug, + String title, + String summary, + String pageType, + String knowledgeLayer + ) {} + + public record PageList( + Long kbId, + int count, + List pages + ) {} + + /** Shared source reference. */ + public record SourceRef( + Set rawIds, + List rawTitles + ) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/research/KbResearchSessionRegistry.java b/mateclaw-server/src/main/java/vip/mate/kbopen/research/KbResearchSessionRegistry.java new file mode 100644 index 00000000..130272fd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/research/KbResearchSessionRegistry.java @@ -0,0 +1,188 @@ +package vip.mate.kbopen.research; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vip.mate.wiki.service.WikiResearchService.ResearchResult; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tracks active Deep Research sessions started via the Open API. + * + *

    Each session records the owning API key id + kbId so that status/cancel + * endpoints can authorize access (a caller can only query/cancel their own + * sessions). Results are stored on completion for the status endpoint to + * return synchronously. + * + *

    This is an in-memory registry (single-node). For multi-node, sessions + * would need to live in a shared store — but research is short-lived (< 1 min + * typical) and the SSE stream must connect to the node running the job, so + * sticky routing is a prerequisite anyway. + * + *

    Lifecycle invariants

    + *
      + *
    • {@link Status#CANCELLED} is a sticky terminal state: a late + * {@link #complete}/{@link #fail} arriving after cancel is a no-op, so + * the user who cancelled never sees a COMPLETED report.
    • + *
    • Terminal sessions are evicted by {@link #evictExpired} after + * {@code mate.kbopen.research.session-ttl} (default 30 min) so the map + * cannot grow without bound.
    • + *
    • {@link #startIfAllowed} enforces a per-key concurrency cap + * ({@code mate.kbopen.research.max-concurrent-per-key}, default 3) as a + * DoS / runaway-cost guard on top of the per-minute rate limiter.
    • + *
    + */ +@Slf4j +@Component +public class KbResearchSessionRegistry { + + public enum Status { RUNNING, COMPLETED, FAILED, CANCELLED } + + /** Adds {@code updatedAt} so the eviction sweep can find stale terminals. */ + public record Session(String sessionId, Long keyId, Long kbId, String topic, Status status, + ResearchResult result, String error, Instant updatedAt) { + + /** Convenience for {@link #register} (status=RUNNING, no result). */ + static Session running(String sessionId, Long keyId, Long kbId, String topic) { + return new Session(sessionId, keyId, kbId, topic, Status.RUNNING, null, null, Instant.now()); + } + + private Session with(Status newStatus, ResearchResult res, String err) { + return new Session(sessionId, keyId, kbId, topic, newStatus, res, err, Instant.now()); + } + } + + /** Exception thrown by {@link #startIfAllowed} when the per-key cap is hit. */ + public static class TooManyConcurrentException extends RuntimeException { + public TooManyConcurrentException(String msg) { super(msg); } + } + + private final Map sessions = new ConcurrentHashMap<>(); + + /** + * Per-key count of RUNNING sessions, kept in lock-step with the + * {@code status==RUNNING} sessions in {@link #sessions}. Maintained + * atomically so {@link #startIfAllowed} can enforce the cap without a + * check-then-act race (two concurrent starts could both pass a stream-based + * count and both put). Incremented on start, decremented on each + * RUNNING→terminal transition (complete/fail/cancel). + */ + private final Map runningPerKey = new ConcurrentHashMap<>(); + + private final int maxConcurrentPerKey; + private final Duration sessionTtl; + + public KbResearchSessionRegistry( + @Value("${mate.kbopen.research.max-concurrent-per-key:3}") int maxConcurrentPerKey, + @Value("${mate.kbopen.research.session-ttl:PT30M}") Duration sessionTtl) { + this.maxConcurrentPerKey = maxConcurrentPerKey; + this.sessionTtl = sessionTtl; + } + + /** + * Reserve a slot for a new session, enforcing the per-key concurrency cap. + * + *

    Atomic: {@code incrementAndGet} + rollback on overflow, so concurrent + * starts for the same key cannot both slip past the cap. The previous + * stream-and-count impl had a check-then-act race. + * + * @throws TooManyConcurrentException if {@code keyId} already has + * {@code maxConcurrentPerKey} RUNNING sessions. + */ + public void startIfAllowed(String sessionId, Long keyId, Long kbId, String topic) { + AtomicInteger count = runningPerKey.computeIfAbsent(keyId, k -> new AtomicInteger()); + int now = count.incrementAndGet(); + if (now > maxConcurrentPerKey) { + count.decrementAndGet(); // rollback — slot was not granted + throw new TooManyConcurrentException( + "API key already has " + maxConcurrentPerKey + + " running research session(s); limit is " + maxConcurrentPerKey); + } + sessions.put(sessionId, Session.running(sessionId, keyId, kbId, topic)); + } + + public Optional get(String sessionId) { + return Optional.ofNullable(sessions.get(sessionId)); + } + + /** RUNNING → COMPLETED. No-op on a session that was already CANCELLED (sticky terminal). */ + public void complete(String sessionId, ResearchResult result) { + sessions.computeIfPresent(sessionId, (k, s) -> { + if (s.status() == Status.CANCELLED) { + return s; // sticky terminal — no transition, no counter change + } + decrementRunning(s.keyId()); // RUNNING → COMPLETED releases the slot + return s.with(Status.COMPLETED, result, null); + }); + } + + /** RUNNING → FAILED. No-op on a session that was already CANCELLED (sticky terminal). */ + public void fail(String sessionId, String error) { + sessions.computeIfPresent(sessionId, (k, s) -> { + if (s.status() == Status.CANCELLED) { + return s; + } + decrementRunning(s.keyId()); + return s.with(Status.FAILED, null, error); + }); + } + + /** RUNNING → CANCELLED. Returns false if the session is missing or already terminal. */ + public boolean cancel(String sessionId) { + Session[] before = new Session[1]; + sessions.computeIfPresent(sessionId, (k, s) -> { + before[0] = s; + if (s.status() == Status.RUNNING) { + decrementRunning(s.keyId()); + return s.with(Status.CANCELLED, null, null); + } + return s; + }); + return before[0] != null && before[0].status() == Status.RUNNING; + } + + /** Release one running-slot for {@code keyId}, floored at 0. */ + private void decrementRunning(Long keyId) { + AtomicInteger count = runningPerKey.get(keyId); + if (count != null) { + // getAndDeccrement would go negative; clamp instead so repeated + // terminal transitions (e.g. complete after cancel) can't drift. + while (true) { + int cur = count.get(); + if (cur <= 0) break; + if (count.compareAndSet(cur, cur - 1)) break; + } + } + } + + /** + * Drop terminal sessions older than {@code sessionTtl}. Called periodically + * by {@link #evictExpired}; public for testing. + */ + public int evictExpired(Instant now) { + int removed = 0; + for (Map.Entry e : sessions.entrySet()) { + Session s = e.getValue(); + if (s.status() != Status.RUNNING && now.isAfter(s.updatedAt().plus(sessionTtl))) { + if (sessions.remove(e.getKey()) != null) removed++; + } + } + if (removed > 0) { + log.info("[KbResearchSessionRegistry] Evicted {} terminal session(s) older than {}", removed, sessionTtl); + } + return removed; + } + + /** Scheduled sweep — runs every 5 min. */ + @Scheduled(fixedDelay = 5 * 60 * 1000L) + public void evictExpired() { + evictExpired(Instant.now()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/service/KbOpenApiService.java b/mateclaw-server/src/main/java/vip/mate/kbopen/service/KbOpenApiService.java new file mode 100644 index 00000000..81623fbf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/service/KbOpenApiService.java @@ -0,0 +1,298 @@ +package vip.mate.kbopen.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.exception.MateClawException; +import vip.mate.kbopen.dto.KbOpenApiDtos; +import vip.mate.kbopen.dto.KbOpenApiDtos.*; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiEntityEntity; +import vip.mate.wiki.model.WikiEntityMentionEntity; +import vip.mate.wiki.model.WikiEntityRelationEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiChunkMapper; +import vip.mate.wiki.repository.WikiEntityMapper; +import vip.mate.wiki.repository.WikiEntityMentionMapper; +import vip.mate.wiki.repository.WikiEntityRelationMapper; +import vip.mate.wiki.service.WikiPageService; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Assembly layer for KB Open API endpoints that require multi-table joins + * or aggregation logic not covered by a single existing service method. + * + *

    A6 constraint: returns pure DTOs, never touches HttpServletRequest/R. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KbOpenApiService { + + private final WikiPageService pageService; + private final WikiEntityMapper entityMapper; + private final WikiEntityRelationMapper relationMapper; + private final WikiEntityMentionMapper mentionMapper; + private final WikiChunkMapper chunkMapper; + private final ObjectMapper objectMapper; + + // ── Page card assembly ──────────────────────────────────────────────── + + /** + * Assemble a PageCard from a WikiPageEntity, honoring the mode parameter. + * + * @param page the resolved page entity + * @param mode summary (default) / full / section:{heading} + * @param fields optional comma-separated field filter (only for summary mode) + */ + public PageCard assembleCard(WikiPageEntity page, String mode, String fields) { + String content = resolveContent(page, mode); + + Map metadata = parseMetadata(page.getMetadataJson()); + if (fields != null && !fields.isBlank() && "summary".equals(mode)) { + Set wanted = Arrays.stream(fields.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + metadata = metadata.entrySet().stream() + .filter(e -> wanted.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + return new PageCard( + page.getSlug(), + page.getTitle(), + page.getPageType(), + page.getKnowledgeLayer(), + page.getTitle(), + page.getSummary(), + metadata.isEmpty() ? null : metadata, + content, + buildSourceRef(page), + page.getVersion(), + page.getUpdateTime() + ); + } + + private String resolveContent(WikiPageEntity page, String mode) { + if (mode == null || mode.isBlank() || "summary".equals(mode)) { + return null; // summary mode: no content, caller uses summary+fields + } + if ("full".equals(mode)) { + return page.getContent(); + } + if (mode.startsWith("section:")) { + String heading = mode.substring("section:".length()).trim(); + return extractSection(page.getContent(), heading); + } + return null; + } + + /** Extract the content under a markdown heading (## or ###). */ + private String extractSection(String content, String heading) { + if (content == null || heading == null) return null; + String[] lines = content.split("\n"); + int start = -1; + int headingLevel = 0; + for (int i = 0; i < lines.length; i++) { + String trimmed = lines[i].trim(); + if (trimmed.startsWith("#")) { + int level = 0; + while (level < trimmed.length() && trimmed.charAt(level) == '#') level++; + String text = trimmed.substring(level).trim(); + if (start == -1 && text.equalsIgnoreCase(heading)) { + start = i; + headingLevel = level; + continue; + } + if (start != -1 && level <= headingLevel) { + // next heading at same or higher level → end of section + return joinLines(lines, start, i); + } + } + } + return start != -1 ? joinLines(lines, start, lines.length) : null; + } + + private String joinLines(String[] lines, int from, int to) { + return String.join("\n", Arrays.copyOfRange(lines, from, to)).trim(); + } + + private SourceRef buildSourceRef(WikiPageEntity page) { + Set rawIds = parseRawIds(page.getSourceRawIds()); + if (rawIds.isEmpty()) return null; + // rawTitles are in sourceEntries (JSON array of {rawId, rawTitle}) if available + List titles = parseSourceTitles(page.getSourceEntries()); + return new SourceRef(rawIds, titles); + } + + // ── Traverse ────────────────────────────────────────────────────────── + + /** + * Resolve the primary entity for a page (salience-highest mention) and + * traverse the entity relation graph. + */ + public TraverseResult traverse(Long kbId, String slug, String relation, + int depth, String direction, int limit) { + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) { + throw new MateClawException(404, "Page not found: " + slug); + } + + // Resolve primary entity: the salience-highest entity mentioned by this page + Long primaryEntityId = resolvePrimaryEntity(page.getId()); + if (primaryEntityId == null) { + // No entity mentions → return empty graph with root as page-level info + return new TraverseResult( + new TraverseNode(null, page.getTitle(), + page.getPageType(), slug), + List.of(), List.of()); + } + + WikiEntityEntity root = entityMapper.selectById(primaryEntityId); + TraverseNode rootNode = new TraverseNode( + root.getId(), root.getCanonicalName(), root.getType(), slug); + + // BFS traversal + Set visited = new LinkedHashSet<>(); + visited.add(primaryEntityId); + List allEdges = new ArrayList<>(); + Set neighborIds = new LinkedHashSet<>(); + + collectEdges(kbId, primaryEntityId, relation, direction, limit, allEdges, neighborIds, visited); + + if (depth >= 2) { + // Second hop: traverse each first-hop neighbor + for (Long neighborId : new ArrayList<>(neighborIds)) { + if (visited.size() > limit * 3) break; // explosion guard + collectEdges(kbId, neighborId, relation, direction, limit, allEdges, neighborIds, visited); + } + } + + // Assemble neighbor nodes + neighborIds.remove(primaryEntityId); + List nodes = new ArrayList<>(); + if (!neighborIds.isEmpty()) { + for (WikiEntityEntity e : entityMapper.selectBatchIds(neighborIds)) { + String entitySlug = resolveSlugForEntity(e.getId()); + nodes.add(new TraverseNode(e.getId(), e.getCanonicalName(), e.getType(), entitySlug)); + } + } + + return new TraverseResult(rootNode, allEdges, nodes); + } + + private void collectEdges(Long kbId, Long entityId, String relation, + String direction, int limit, + List out, Set neighborIds, Set visited) { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(WikiEntityRelationEntity::getKbId, kbId); + + boolean outgoing = !"incoming".equals(direction); + boolean incoming = !"outgoing".equals(direction); + if (outgoing && incoming) { + q.and(w -> w.eq(WikiEntityRelationEntity::getSubjectEntityId, entityId) + .or().eq(WikiEntityRelationEntity::getObjectEntityId, entityId)); + } else if (outgoing) { + q.eq(WikiEntityRelationEntity::getSubjectEntityId, entityId); + } else { + q.eq(WikiEntityRelationEntity::getObjectEntityId, entityId); + } + + if (relation != null && !relation.isBlank()) { + q.like(WikiEntityRelationEntity::getPredicate, relation); + } + q.last("LIMIT " + Math.max(1, Math.min(limit, 50))); + + for (WikiEntityRelationEntity r : relationMapper.selectList(q)) { + TraverseEdge edge = new TraverseEdge( + r.getPredicate(), + r.getSubjectEntityId(), + r.getObjectEntityId(), + null, null, // names filled by caller via batch lookup + r.getEvidence(), + r.getConfidence() != null ? r.getConfidence().doubleValue() : null, + resolveSourceHandle(r.getEvidenceChunkId()) + ); + out.add(edge); + if (!r.getSubjectEntityId().equals(entityId)) neighborIds.add(r.getSubjectEntityId()); + if (!r.getObjectEntityId().equals(entityId)) neighborIds.add(r.getObjectEntityId()); + visited.add(r.getSubjectEntityId()); + visited.add(r.getObjectEntityId()); + } + } + + private Long resolvePrimaryEntity(Long pageId) { + List mentions = mentionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getPageId, pageId) + .orderByDesc(WikiEntityMentionEntity::getConfidence) + .last("LIMIT 1")); + return mentions.isEmpty() ? null : mentions.get(0).getEntityId(); + } + + private String resolveSlugForEntity(Long entityId) { + List mentions = mentionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getEntityId, entityId) + .isNotNull(WikiEntityMentionEntity::getPageId) + .last("LIMIT 1")); + if (mentions.isEmpty()) return null; + WikiPageEntity page = pageService.getById(mentions.get(0).getPageId()); + return page != null ? page.getSlug() : null; + } + + private String resolveSourceHandle(Long chunkId) { + if (chunkId == null) return null; + // Find the first page that cites this chunk + List mentions = mentionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getChunkId, chunkId) + .isNotNull(WikiEntityMentionEntity::getPageId) + .last("LIMIT 1")); + if (mentions.isEmpty()) return null; + return "p:" + mentions.get(0).getPageId(); + } + + // ── JSON parsing helpers ────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private Map parseMetadata(String json) { + if (json == null || json.isBlank()) return Map.of(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + @SuppressWarnings("unchecked") + private Set parseRawIds(String json) { + if (json == null || json.isBlank()) return Set.of(); + try { + List ids = objectMapper.readValue(json, new TypeReference>() {}); + return ids.stream().map(Integer::longValue).collect(Collectors.toCollection(LinkedHashSet::new)); + } catch (Exception e) { + return Set.of(); + } + } + + @SuppressWarnings("unchecked") + private List parseSourceTitles(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + List> entries = objectMapper.readValue(json, new TypeReference>>() {}); + return entries.stream() + .map(e -> String.valueOf(e.getOrDefault("rawTitle", ""))) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java index dc94eea2..3e1bb195 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java @@ -7,17 +7,26 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; /** - * 从 Spring AI {@link Usage} 中提取 Anthropic 的 prompt cache token 计数。 + * 从 Spring AI {@link Usage} 中提取各 provider 的 prompt cache / reasoning token 计数。 * *

    spring-ai 的高层 {@code Usage} 接口只暴露 {@code promptTokens} / {@code completionTokens}, - * 没有 cache 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生 usage 对象。 - * 对 Anthropic 而言是 {@code AnthropicApi.Usage} record,含 {@code cacheCreationInputTokens} - * 与 {@code cacheReadInputTokens}。

    + * 没有 cache / reasoning 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生 + * usage 对象。各 provider 的字段位置:

    + *
      + *
    • Anthropic({@code AnthropicApi.Usage}):顶层 {@code cacheReadInputTokens} / + * {@code cacheCreationInputTokens}。注意其 {@code inputTokens} 不含缓存部分 + * (加法口径)。无 reasoning 计数。
    • + *
    • OpenAI 兼容({@code OpenAiApi.Usage}):嵌套 {@code promptTokensDetails.cachedTokens} + * 与 {@code completionTokenDetails.reasoningTokens};{@code promptTokens} 已含 + * 缓存命中部分(包含口径)。无 cache 写入计数。
    • + *
    • DashScope({@code DashScopeApi.TokenUsage}):嵌套 + * {@code promptTokenDetailed.cachedTokens};包含口径,无写入/reasoning 计数。
    • + *
    * *

    采用反射调用以避免: *

      *
    • 对 spring-ai 内部 record 形态的硬编码(未来字段重命名风险小)
    • - *
    • 对其它 provider(OpenAI 兼容、DashScope)的 ClassCastException
    • + *
    • 对其它 provider 原生类型的编译期依赖与 ClassCastException
    • *
    * 反射结果按类缓存,热路径性能可接受。

    * @@ -25,11 +34,13 @@ import java.util.concurrent.ConcurrentMap; */ public final class CacheUsageExtractor { - /** {@code (cacheReadTokens, cacheWriteTokens)};任一字段不可得时为 0。 */ - public record CacheTokens(int cacheReadTokens, int cacheWriteTokens) { - public static final CacheTokens EMPTY = new CacheTokens(0, 0); + /** {@code (cacheReadTokens, cacheWriteTokens, reasoningTokens)};任一字段不可得时为 0。 */ + public record CacheTokens(int cacheReadTokens, int cacheWriteTokens, int reasoningTokens) { + public static final CacheTokens EMPTY = new CacheTokens(0, 0, 0); - public boolean isEmpty() { return cacheReadTokens == 0 && cacheWriteTokens == 0; } + public boolean isEmpty() { + return cacheReadTokens == 0 && cacheWriteTokens == 0 && reasoningTokens == 0; + } } /** 缓存 (Class, methodName) → reflected Method(命中失败时为标记 NULL_METHOD)。 */ @@ -45,28 +56,58 @@ public final class CacheUsageExtractor { private CacheUsageExtractor() {} - /** 从 spring-ai Usage 中尽力抽取 cache token;不支持的 provider 返回 EMPTY。 */ + /** 从 spring-ai Usage 中尽力抽取 cache / reasoning token;不支持的 provider 返回 EMPTY。 */ public static CacheTokens extract(Usage usage) { if (usage == null) return CacheTokens.EMPTY; Object native_ = usage.getNativeUsage(); if (native_ == null) return CacheTokens.EMPTY; + // Anthropic: top-level accessors on AnthropicApi.Usage int read = invokeIntAccessor(native_, "cacheReadInputTokens"); int write = invokeIntAccessor(native_, "cacheCreationInputTokens"); - return (read == 0 && write == 0) ? CacheTokens.EMPTY : new CacheTokens(read, write); + + // OpenAI-compatible: promptTokensDetails.cachedTokens + if (read == 0) { + read = invokeNestedIntAccessor(native_, "promptTokensDetails", "cachedTokens"); + } + // DashScope: promptTokenDetailed.cachedTokens + if (read == 0) { + read = invokeNestedIntAccessor(native_, "promptTokenDetailed", "cachedTokens"); + } + + // OpenAI-compatible: completionTokenDetails.reasoningTokens + int reasoning = invokeNestedIntAccessor(native_, "completionTokenDetails", "reasoningTokens"); + if (reasoning == 0) { + // Some OpenAI-compatible gateways pluralize the field name. + reasoning = invokeNestedIntAccessor(native_, "completionTokensDetails", "reasoningTokens"); + } + + return (read == 0 && write == 0 && reasoning == 0) + ? CacheTokens.EMPTY + : new CacheTokens(read, write, reasoning); + } + + /** 两级访问:先取嵌套 detail 对象,再取其 int 字段;任一级缺失返回 0。 */ + private static int invokeNestedIntAccessor(Object target, String detailAccessor, String intAccessor) { + Object detail = invokeAccessor(target, detailAccessor); + if (detail == null) return 0; + return invokeIntAccessor(detail, intAccessor); } private static int invokeIntAccessor(Object target, String accessor) { + Object v = invokeAccessor(target, accessor); + return v instanceof Number n ? n.intValue() : 0; + } + + private static Object invokeAccessor(Object target, String accessor) { Class cls = target.getClass(); String key = cls.getName() + "#" + accessor; Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor)); - if (m == NULL_METHOD) return 0; + if (m == NULL_METHOD) return null; try { - Object v = m.invoke(target); - if (v instanceof Number n) return n.intValue(); - return 0; + return m.invoke(target); } catch (ReflectiveOperationException ignored) { - return 0; + return null; } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextLimitErrorParser.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextLimitErrorParser.java new file mode 100644 index 00000000..c2244b25 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextLimitErrorParser.java @@ -0,0 +1,64 @@ +package vip.mate.llm.probe; + +import java.util.OptionalInt; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Extracts the model's context-window size from a "prompt too long" error + * message. Serves as the reconciliation fallback when probing is unavailable: + * the serving stack itself states its limit in the rejection text (e.g. vLLM + * reports {@code max_model_len}), so one failed call teaches the resolver the + * true window for every subsequent turn. + */ +public final class ContextLimitErrorParser { + + /** Reject absurd parses — anything below one model page or above 10M tokens. */ + private static final int MIN_PLAUSIBLE = 512; + private static final int MAX_PLAUSIBLE = 10_000_000; + + /** + * Ordered from most specific to most generic. Each pattern anchors the + * number on the limit-keyword side so "requested 50000 tokens, maximum + * context length is 32768" yields 32768, not 50000. + */ + private static final Pattern[] LIMIT_PATTERNS = { + // vLLM: "... exceeds the max_model_len 32768" / "max_model_len=32768" + Pattern.compile("max_model_len\\D{0,20}?(\\d{3,8})", Pattern.CASE_INSENSITIVE), + // OpenAI-style: "This model's maximum context length is 4096 tokens" + Pattern.compile("maximum context length is\\s*(\\d{3,8})", Pattern.CASE_INSENSITIVE), + // vLLM alt: "maximum model length 32768" + Pattern.compile("maximum model length\\D{0,20}?(\\d{3,8})", Pattern.CASE_INSENSITIVE), + // Ollama-style knob in the rejection text: "num_ctx 8192" + Pattern.compile("num_ctx\\D{0,10}?(\\d{3,8})", Pattern.CASE_INSENSITIVE), + // Generic: "context length of only 8192" / "context length limit: 8192" + Pattern.compile("context length (?:of only|limit)\\D{0,10}?(\\d{3,8})", Pattern.CASE_INSENSITIVE), + }; + + private ContextLimitErrorParser() { + } + + /** + * @return the context window the server reported in the error text, or + * empty when no pattern matches or the number is implausible. + */ + public static OptionalInt extractLimit(String errorMessage) { + if (errorMessage == null || errorMessage.isBlank()) { + return OptionalInt.empty(); + } + for (Pattern pattern : LIMIT_PATTERNS) { + Matcher matcher = pattern.matcher(errorMessage); + if (matcher.find()) { + try { + int value = Integer.parseInt(matcher.group(1)); + if (value >= MIN_PLAUSIBLE && value <= MAX_PLAUSIBLE) { + return OptionalInt.of(value); + } + } catch (NumberFormatException ignored) { + // fall through to the next pattern + } + } + } + return OptionalInt.empty(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java new file mode 100644 index 00000000..ed32d81f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java @@ -0,0 +1,25 @@ +package vip.mate.llm.probe; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration for local-model context-window probing. + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.context.probe") +public class ContextProbeProperties { + + /** Master switch. When false, {@code resolveMaxInputTokens} only honors explicit config. */ + private boolean enabled = true; + + /** Per-request read timeout. Probing must never hold up chat startup. */ + private int timeoutMs = 1000; + + /** + * How long a probe result (positive or negative) stays cached. Local + * servers like LM Studio allow hot-swapping models, so results must not + * be persisted — a short in-memory TTL keeps them honest. + */ + private int cacheTtlSeconds = 600; +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/LocalContextProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/LocalContextProbe.java new file mode 100644 index 00000000..08205186 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/LocalContextProbe.java @@ -0,0 +1,41 @@ +package vip.mate.llm.probe; + +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; + +import java.util.Optional; + +/** + * SPI for probing the real context-window size of a locally hosted model + * (Ollama, vLLM, LM Studio, MLX and other self-hosted OpenAI-compatible + * servers). + * + *

    Motivation: {@code ModelConfigEntity.maxInputTokens} is optional and + * rarely filled in for local deployments, so the conversation window budget + * silently falls back to the global default (128k). A local 8k/16k model then + * never triggers any trimming and the first oversized request fails. Probing + * the serving endpoint recovers the true window without user configuration. + * + *

    Contract: implementations must be cheap to call (single short HTTP + * request), must never throw for routine failures (return + * {@link Optional#empty()} instead), and must not be invoked for cloud + * providers — {@link #supports} gates that. + */ +public interface LocalContextProbe { + + /** + * @return true when this probe knows how to query the given provider. + * Implementations must return false for cloud providers so no + * probe traffic ever leaves the local network. + */ + boolean supports(ModelProviderEntity provider, ModelConfigEntity model); + + /** + * Query the serving endpoint for the model's maximum context length. + * + * @return the context window in tokens, or empty when the endpoint is + * unreachable, the model is unknown, or the response carries no + * usable length field. + */ + Optional probeContextLength(ModelProviderEntity provider, ModelConfigEntity model); +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/LocalEndpoints.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/LocalEndpoints.java new file mode 100644 index 00000000..1bc81fd3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/LocalEndpoints.java @@ -0,0 +1,85 @@ +package vip.mate.llm.probe; + +import java.net.InetAddress; +import java.net.URI; + +/** + * Heuristics for deciding whether a base URL points at a locally hosted / + * self-hosted inference server. Probing is restricted to such endpoints so no + * probe traffic ever reaches a cloud provider. + */ +final class LocalEndpoints { + + private LocalEndpoints() { + } + + /** + * @return true when the URL's host is loopback, a private / link-local + * IPv4 range, an mDNS {@code .local} name, or a well-known + * container-host alias. + */ + static boolean isLocal(String baseUrl) { + if (baseUrl == null || baseUrl.isBlank()) { + return false; + } + String host; + try { + host = URI.create(baseUrl.trim()).getHost(); + } catch (IllegalArgumentException e) { + return false; + } + if (host == null || host.isBlank()) { + return false; + } + String lower = host.toLowerCase(); + if (lower.equals("localhost") || lower.endsWith(".local") + || lower.equals("host.docker.internal") || lower.equals("host.containers.internal")) { + return true; + } + // Literal IP addresses only — never resolve DNS here: a probe gate + // must not add name-resolution latency or leak lookups for cloud hosts. + byte[] addr = parseLiteralAddress(lower); + if (addr == null) { + return false; + } + try { + InetAddress inet = InetAddress.getByAddress(addr); + return inet.isLoopbackAddress() || inet.isSiteLocalAddress() || inet.isLinkLocalAddress(); + } catch (Exception e) { + return false; + } + } + + /** Parse an IPv4/IPv6 literal without triggering DNS. Returns null for hostnames. */ + private static byte[] parseLiteralAddress(String host) { + String h = host; + if (h.startsWith("[") && h.endsWith("]")) { + h = h.substring(1, h.length() - 1); + } + if (h.contains(":")) { + // IPv6 literal — only loopback matters in practice for local servers. + try { + return InetAddress.getByName(h).getAddress(); + } catch (Exception e) { + return null; + } + } + String[] parts = h.split("\\."); + if (parts.length != 4) { + return null; + } + byte[] out = new byte[4]; + for (int i = 0; i < 4; i++) { + try { + int v = Integer.parseInt(parts[i]); + if (v < 0 || v > 255) { + return null; + } + out[i] = (byte) v; + } catch (NumberFormatException e) { + return null; + } + } + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java new file mode 100644 index 00000000..70549909 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java @@ -0,0 +1,125 @@ +package vip.mate.llm.probe; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Service; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; + +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Resolves the effective context window (max input tokens) for a runtime + * model, so downstream window budgeting works from the model's real limit + * instead of the 128k global default. + * + *

    Priority: + *

      + *
    1. explicit {@code ModelConfigEntity.maxInputTokens} — user configuration + * always wins;
    2. + *
    3. a probed value from a {@link LocalContextProbe} (runtime-cached with a + * short TTL, never persisted — local servers hot-swap models);
    4. + *
    5. {@code null} — caller falls back to the global default, exactly the + * pre-probe behavior.
    6. + *
    + * + *

    Reconciliation: when a provider rejects a request for being over the + * context limit, {@link #noteContextLimitError} parses the limit out of the + * error text and seeds the same cache, so the very next turn budgets against + * the true window even where probing is unsupported. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@EnableConfigurationProperties(ContextProbeProperties.class) +public class ModelContextWindowResolver { + + private record CacheEntry(Integer value, long expiresAtMs) { + } + + private final List probes; + private final ContextProbeProperties properties; + + /** Key: providerId + "/" + modelName. Value may hold null (negative cache). */ + private final Map cache = new ConcurrentHashMap<>(); + + /** + * @return the effective max input tokens, or {@code null} when neither + * explicit config nor probing yields a value (caller keeps its + * existing global-default fallback). + */ + public Integer resolveMaxInputTokens(ModelProviderEntity provider, ModelConfigEntity model) { + if (model == null) { + return null; + } + if (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0) { + return model.getMaxInputTokens(); + } + if (!properties.isEnabled()) { + return null; + } + String key = cacheKey(provider != null ? provider.getProviderId() : null, model.getModelName()); + CacheEntry cached = cache.get(key); + long now = System.currentTimeMillis(); + if (cached != null && cached.expiresAtMs() > now) { + return cached.value(); + } + Integer probed = null; + for (LocalContextProbe probe : probes) { + try { + if (!probe.supports(provider, model)) { + continue; + } + probed = probe.probeContextLength(provider, model).orElse(null); + if (probed != null) { + break; + } + } catch (Exception e) { + log.debug("[ContextProbe] probe {} threw for {}: {}", + probe.getClass().getSimpleName(), key, e.getMessage()); + } + } + cache.put(key, new CacheEntry(probed, now + ttlMs())); + if (probed != null) { + log.info("[ContextProbe] 探测到模型 {} 的上下文窗口为 {} tokens(未配置 maxInputTokens,窗口预算将使用探测值)", + key, probed); + } + return probed; + } + + /** + * Feed a "prompt too long" rejection back into the cache. The parsed limit + * only takes effect for models without explicit configuration, because + * {@link #resolveMaxInputTokens} checks explicit config first. + */ + public void noteContextLimitError(String providerId, String modelName, String errorMessage) { + if (!properties.isEnabled() || modelName == null || modelName.isBlank()) { + return; + } + OptionalInt parsed = ContextLimitErrorParser.extractLimit(errorMessage); + if (parsed.isEmpty()) { + return; + } + String key = cacheKey(providerId, modelName); + int value = parsed.getAsInt(); + cache.put(key, new CacheEntry(value, System.currentTimeMillis() + ttlMs())); + log.info("[ContextProbe] 从上下文超限报错中解析到模型 {} 的窗口为 {} tokens,已记入运行期缓存", key, value); + } + + /** Test hook: drop all cached probe results. */ + void clearCache() { + cache.clear(); + } + + private long ttlMs() { + return Math.max(1, properties.getCacheTtlSeconds()) * 1000L; + } + + private static String cacheKey(String providerId, String modelName) { + return (providerId == null ? "" : providerId) + "/" + (modelName == null ? "" : modelName); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/OllamaContextProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/OllamaContextProbe.java new file mode 100644 index 00000000..c4b8c570 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/OllamaContextProbe.java @@ -0,0 +1,141 @@ +package vip.mate.llm.probe; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Iterator; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Context-window probe for Ollama servers via the native model-metadata + * endpoint ({@code POST /api/show}). + * + *

    Resolution order within the response: + *

      + *
    1. {@code num_ctx} from the modelfile parameters — the window the server + * actually serves with;
    2. + *
    3. the architecture's {@code *.context_length} from {@code model_info} — + * an upper bound when no explicit {@code num_ctx} is set.
    4. + *
    + * Explicit per-model configuration always wins upstream in the resolver; this + * probe only fills the gap when the user configured nothing. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OllamaContextProbe implements LocalContextProbe { + + static final String DEFAULT_BASE_URL = "http://127.0.0.1:11434"; + + private static final Pattern NUM_CTX_PATTERN = Pattern.compile("num_ctx\\s+(\\d{3,8})"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ContextProbeProperties properties; + + @Override + public boolean supports(ModelProviderEntity provider, ModelConfigEntity model) { + return provider != null && model != null + && "ollama".equalsIgnoreCase(provider.getProviderId()); + } + + @Override + public Optional probeContextLength(ModelProviderEntity provider, ModelConfigEntity model) { + String baseUrl = normalizeBaseUrl(provider.getBaseUrl()); + try { + RestClient client = RestClient.builder() + .requestFactory(requestFactory()) + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + // Newer Ollama accepts "model", older releases used "name" — send both. + String body = client.post() + .uri("/api/show") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("model", model.getModelName(), "name", model.getModelName())) + .retrieve() + .body(String.class); + OptionalInt parsed = parseShowResponse(body); + return parsed.isPresent() ? Optional.of(parsed.getAsInt()) : Optional.empty(); + } catch (Exception e) { + log.debug("[ContextProbe] Ollama probe failed for {} at {}: {}", + model.getModelName(), baseUrl, e.getMessage()); + return Optional.empty(); + } + } + + /** + * Parse an {@code /api/show} response body. Package-private for tests. + */ + static OptionalInt parseShowResponse(String body) { + if (body == null || body.isBlank()) { + return OptionalInt.empty(); + } + try { + JsonNode root = OBJECT_MAPPER.readTree(body); + // Serving-time knob wins: it is what the server actually allocates. + Matcher numCtx = NUM_CTX_PATTERN.matcher(root.path("parameters").asText("")); + if (numCtx.find()) { + int value = Integer.parseInt(numCtx.group(1)); + if (value > 0) { + return OptionalInt.of(value); + } + } + JsonNode modelInfo = root.path("model_info"); + if (modelInfo.isObject()) { + for (Iterator it = modelInfo.fieldNames(); it.hasNext(); ) { + String field = it.next(); + if (field.endsWith(".context_length")) { + int value = modelInfo.path(field).asInt(0); + if (value > 0) { + return OptionalInt.of(value); + } + } + } + } + } catch (Exception e) { + return OptionalInt.empty(); + } + return OptionalInt.empty(); + } + + private JdkClientHttpRequestFactory requestFactory() { + // HTTP/1.1 pinned: Uvicorn-style local stacks reject the h2c upgrade. + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofMillis(properties.getTimeoutMs())) + .build(); + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient); + factory.setReadTimeout(Duration.ofMillis(properties.getTimeoutMs())); + return factory; + } + + /** Ollama providers are often saved with the OpenAI-compatible {@code /v1} suffix — strip it. */ + static String normalizeBaseUrl(String baseUrl) { + if (baseUrl == null || baseUrl.isBlank()) { + return DEFAULT_BASE_URL; + } + String normalized = baseUrl.trim(); + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (normalized.endsWith("/v1")) { + normalized = normalized.substring(0, normalized.length() - 3); + } + return normalized; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/OpenAiCompatibleContextProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/OpenAiCompatibleContextProbe.java new file mode 100644 index 00000000..3b5b5ae4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/OpenAiCompatibleContextProbe.java @@ -0,0 +1,130 @@ +package vip.mate.llm.probe; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProtocol; +import vip.mate.llm.model.ModelProviderEntity; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalInt; + +/** + * Context-window probe for self-hosted OpenAI-compatible servers (vLLM, + * LM Studio, llama.cpp server, MLX, …) via {@code GET /v1/models}. + * + *

    vLLM exposes {@code max_model_len} per model entry; other stacks expose + * {@code context_length} or {@code max_context_length}. Only endpoints whose + * host is local / private are probed — {@link LocalEndpoints#isLocal} gates + * that, so no probe traffic is ever sent to a cloud provider. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenAiCompatibleContextProbe implements LocalContextProbe { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ContextProbeProperties properties; + + @Override + public boolean supports(ModelProviderEntity provider, ModelConfigEntity model) { + if (provider == null || model == null) { + return false; + } + // Ollama has a richer native endpoint handled by its dedicated probe. + if ("ollama".equalsIgnoreCase(provider.getProviderId())) { + return false; + } + if (ModelProtocol.fromChatModel(provider.getChatModel()) != ModelProtocol.OPENAI_COMPATIBLE) { + return false; + } + return LocalEndpoints.isLocal(provider.getBaseUrl()); + } + + @Override + public Optional probeContextLength(ModelProviderEntity provider, ModelConfigEntity model) { + String baseUrl = normalizeBaseUrl(provider.getBaseUrl()); + try { + RestClient client = RestClient.builder() + .requestFactory(requestFactory()) + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + RestClient.RequestHeadersSpec spec = client.get().uri("/v1/models"); + String apiKey = provider.getApiKey(); + if (apiKey != null && !apiKey.isBlank()) { + spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim()); + } + String body = spec.retrieve().body(String.class); + OptionalInt parsed = parseModelsResponse(body, model.getModelName()); + return parsed.isPresent() ? Optional.of(parsed.getAsInt()) : Optional.empty(); + } catch (Exception e) { + log.debug("[ContextProbe] OpenAI-compatible probe failed for {} at {}: {}", + model.getModelName(), baseUrl, e.getMessage()); + return Optional.empty(); + } + } + + /** + * Find the entry matching {@code modelName} in a {@code /v1/models} + * response and read its context-length field. Package-private for tests. + */ + static OptionalInt parseModelsResponse(String body, String modelName) { + if (body == null || body.isBlank() || modelName == null || modelName.isBlank()) { + return OptionalInt.empty(); + } + try { + JsonNode data = OBJECT_MAPPER.readTree(body).path("data"); + if (!data.isArray()) { + return OptionalInt.empty(); + } + for (JsonNode node : data) { + if (!modelName.equals(node.path("id").asText(""))) { + continue; + } + for (String field : new String[]{"max_model_len", "context_length", "max_context_length"}) { + int value = node.path(field).asInt(0); + if (value > 0) { + return OptionalInt.of(value); + } + } + return OptionalInt.empty(); + } + } catch (Exception e) { + return OptionalInt.empty(); + } + return OptionalInt.empty(); + } + + private JdkClientHttpRequestFactory requestFactory() { + // HTTP/1.1 pinned: Uvicorn-style local stacks reject the h2c upgrade. + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofMillis(properties.getTimeoutMs())) + .build(); + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient); + factory.setReadTimeout(Duration.ofMillis(properties.getTimeoutMs())); + return factory; + } + + private static String normalizeBaseUrl(String baseUrl) { + String normalized = baseUrl.trim(); + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (normalized.endsWith("/v1")) { + normalized = normalized.substring(0, normalized.length() - 3); + } + return normalized; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java index a1b85407..4b6180cc 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java @@ -21,9 +21,13 @@ public interface AgentBindingResolver { Set getBoundSkillIds(Long agentId); /** - * Provider ids the agent prefers, in priority order; empty when none. + * Ordered preferred-model chain for the agent: each entry is a provider + * plus an optional pinned model ({@code modelId == null} = the provider's + * default chat model). The same provider may repeat with different models, + * so an agent can express a chain like {@code A/modelX → A/modelY → + * B/modelZ}. Empty when the agent has no preferences. */ - List getPreferredProviderIds(Long agentId); + List getPreferredProviderModels(Long agentId); /** * Wiki knowledge-base ids bound to the agent, or {@code null} when the diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderModelRef.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderModelRef.java new file mode 100644 index 00000000..8a23eb03 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderModelRef.java @@ -0,0 +1,18 @@ +package vip.mate.llm.routing; + +/** + * One entry in an agent's preferred-model chain: a provider plus an optional + * specific chat model. + * + *

    {@code modelId == null} means "use the provider's default chat model" — + * backward compatible with provider-only preferences. The same + * {@code providerId} may appear in multiple entries, each pinning a different + * model, so an agent can express a chain like {@code A/modelX → A/modelY → + * B/modelZ}. + * + * @param providerId provider id (matches {@code mate_model_provider.provider_id}) + * @param modelId pinned model id (matches {@code mate_model_config.id}), or + * {@code null} for the provider's default chat model + */ +public record ProviderModelRef(String providerId, Long modelId) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java index 19180b53..b744c6a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java @@ -189,14 +189,14 @@ public class ProviderRouter { public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) { if (agentId == null) return globalDefault; - List preferred = bindingService.getPreferredProviderIds(agentId); + List preferred = bindingService.getPreferredProviderModels(agentId); Set requiredModalities = resolveRequiredModalities(agentId); - // Pass 1: capability-satisfying providers (preferred first, global fallback) + // Pass 1: capability-satisfying entries (preferred first, global fallback) if (requiredModalities != null) { - // 1a. preferred providers satisfying capabilities - for (String providerId : preferred) { - ModelConfigEntity candidate = pickProviderDefault(providerId); + // 1a. preferred (provider, model) entries satisfying capabilities + for (ProviderModelRef ref : preferred) { + ModelConfigEntity candidate = pickPreferredModel(ref); if (candidate == null) continue; if (satisfies(candidate, requiredModalities)) { log.info("[ProviderRouter] agent={} primary={}/{} (preferred, satisfies {})", @@ -213,9 +213,9 @@ public class ProviderRouter { } // Pass 2: unconstrained (capability ignored — last resort) - // 2a. any available preferred provider - for (String providerId : preferred) { - ModelConfigEntity candidate = pickProviderDefault(providerId); + // 2a. any available preferred entry + for (ProviderModelRef ref : preferred) { + ModelConfigEntity candidate = pickPreferredModel(ref); if (candidate == null) continue; log.info("[ProviderRouter] agent={} primary={}/{} (preferred, unconstrained)", agentId, candidate.getProvider(), candidate.getModelName()); @@ -231,6 +231,35 @@ public class ProviderRouter { return null; } + /** + * Resolve a preference entry to a usable primary model. A pinned model + * ({@code modelId != null}) is honoured when its provider is configured and + * the model is enabled; otherwise we fall back to the provider's default + * chat model so a deleted/disabled pin does not silently drop the provider. + */ + private ModelConfigEntity pickPreferredModel(ProviderModelRef ref) { + if (ref == null) return null; + if (ref.modelId() == null) return pickProviderDefault(ref.providerId()); + if (ref.providerId() == null || ref.providerId().isBlank()) return null; + try { + if (!modelProviderService.isProviderConfigured(ref.providerId())) return null; + ModelConfigEntity m = modelConfigService.getModel(ref.modelId()); + // Honour the pin only when it is a usable chat model that actually + // belongs to this entry's provider; otherwise fall back to the + // provider's default chat model. + if (m != null && Boolean.TRUE.equals(m.getEnabled()) + && ref.providerId().equals(m.getProvider()) + && (m.getModelType() == null || "chat".equals(m.getModelType()))) { + return m; + } + } catch (Exception e) { + // getModel throws when the pinned model id no longer exists. + log.info("[ProviderRouter] pinned model {} for provider {} unresolved ({}), using provider default", + ref.modelId(), ref.providerId(), e.getMessage()); + } + return pickProviderDefault(ref.providerId()); + } + /** Returns null when no capabilities are required (skips Pass 1). */ private Set resolveRequiredModalities(Long agentId) { Set needs = aggregateModelNeeds(agentId); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java index 3eaba40e..ca1ee69c 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java @@ -97,9 +97,12 @@ public class ModelCapabilityService { m.put("claude-haiku", EnumSet.of(Modality.VISION)); // ===== DeepSeek ===== - // V4 (Apr 2026) is the first DeepSeek line with native multimodal — image + video. - // V3 and earlier are text-only (no entry → defaults to text only). - m.put("deepseek-v4", EnumSet.of(Modality.VISION, Modality.VIDEO)); + // All released DeepSeek chat models (deepseek-chat / deepseek-reasoner / + // deepseek-v3.x) are text-only, so there is no entry and they default to + // text. Do NOT assume a future line is multimodal here: a wrong vision + // assumption makes the router send image_url to a text model, which the + // provider rejects with a 400. A genuinely multimodal model should declare + // its modalities on the model config instead. // ===== ByteDance Doubao / Seed ===== // Seed 2.0 Pro (Feb 2026) handles hour-long videos. Seed1.5-VL also supports video. diff --git a/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchService.java b/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchService.java index da03c709..2523a32a 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchService.java @@ -58,13 +58,17 @@ public class SessionSearchService { /** * List recent conversations for the given agent. + * Excludes the current conversation and any still-running sessions to prevent + * cross-conversation context leakage in concurrent multi-conversation scenarios. */ - public List> listRecent(Long agentId, int limit) { + public List> listRecent(Long agentId, String currentConversationId, int limit) { int effectiveLimit = Math.min(Math.max(limit, 1), 50); String sql = """ SELECT conversation_id, title, message_count, last_active_time, create_time FROM mate_conversation WHERE agent_id = ? AND deleted = 0 + AND conversation_id != ? + AND (stream_status IS NULL OR stream_status != 'running') ORDER BY last_active_time DESC LIMIT ? """; @@ -77,7 +81,7 @@ public class SessionSearchService { row.put("lastActiveTime", toLocalDateTime(rs.getTimestamp("last_active_time"))); row.put("createTime", toLocalDateTime(rs.getTimestamp("create_time"))); return row; - }, agentId, effectiveLimit); + }, agentId, currentConversationId != null ? currentConversationId : "", effectiveLimit); } // ==================== MySQL FULLTEXT ==================== @@ -93,6 +97,7 @@ public class SessionSearchService { WHERE c.agent_id = ? AND m.conversation_id != ? AND m.role IN ('user', 'assistant') AND m.deleted = 0 AND c.deleted = 0 + AND (c.stream_status IS NULL OR c.stream_status != 'running') AND MATCH(m.content) AGAINST(? IN NATURAL LANGUAGE MODE) ORDER BY relevance DESC LIMIT ? @@ -116,6 +121,7 @@ public class SessionSearchService { WHERE c.agent_id = ? AND m.conversation_id != ? AND m.role IN ('user', 'assistant') AND m.deleted = 0 AND c.deleted = 0 + AND (c.stream_status IS NULL OR c.stream_status != 'running') AND LOWER(m.content) LIKE LOWER(CONCAT('%', ?, '%')) ORDER BY m.create_time DESC LIMIT ? diff --git a/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchTool.java b/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchTool.java index 792b1b8b..dfc22605 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchTool.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/search/SessionSearchTool.java @@ -4,9 +4,11 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; 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.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; import java.util.List; import java.util.Map; @@ -33,13 +35,14 @@ public class SessionSearchTool { - "recent":列出最近的会话(标题、时间、消息数),不需要 query 参数 - "search":按关键词全文搜索消息内容,返回匹配的消息片段 适用于回忆之前讨论过的话题、查找历史决策、检索之前的上下文。 + 注意:只会搜索已完成的会话,不会返回当前正在运行中的其他会话内容。 """) public String session_search( @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "当前会话 ID(用于排除当前会话)") String currentConversationId, @ToolParam(description = "搜索模式:recent 或 search") String mode, @ToolParam(description = "搜索关键词(mode=search 时必填)", required = false) String query, - @ToolParam(description = "返回结果数量上限,默认 10", required = false) Integer limit) { + @ToolParam(description = "返回结果数量上限,默认 10", required = false) Integer limit, + ToolContext toolContext) { if (agentId == null) { return error("agentId 不能为空"); @@ -48,11 +51,21 @@ public class SessionSearchTool { mode = "recent"; } + // Read the current conversation id from the ToolContext rather than trusting + // the LLM to pass it, so concurrent sessions of the same agent stay isolated. + String currentConversationId = ""; + if (toolContext != null) { + String fromOrigin = ChatOrigin.from(toolContext).conversationId(); + if (fromOrigin != null && !fromOrigin.isEmpty()) { + currentConversationId = fromOrigin; + } + } + int effectiveLimit = limit != null && limit > 0 ? limit : 10; try { if ("recent".equalsIgnoreCase(mode.trim())) { - return handleRecent(agentId, effectiveLimit); + return handleRecent(agentId, currentConversationId, effectiveLimit); } else if ("search".equalsIgnoreCase(mode.trim())) { if (query == null || query.isBlank()) { return error("mode=search 时 query 不能为空"); @@ -67,8 +80,8 @@ public class SessionSearchTool { } } - private String handleRecent(Long agentId, int limit) { - List> sessions = sessionSearchService.listRecent(agentId, limit); + private String handleRecent(Long agentId, String currentConversationId, int limit) { + List> sessions = sessionSearchService.listRecent(agentId, currentConversationId, limit); JSONObject result = new JSONObject(); result.set("mode", "recent"); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java index 6cf287f7..df10190d 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java @@ -35,6 +35,22 @@ public class MemoryRecallService { private static final int MAX_QUERY_HASHES = 32; + /** mate_memory_recall.filename is VARCHAR(256). Bound the value here so an + * over-long section key (path + '#' + H2 heading slug, see #461) can never + * blow past the column. Truncating at the entry point keeps the select / + * insert / update branches below operating on the same value, so the + * dup-key concurrency fallback still matches. */ + static final int MAX_FILENAME_LENGTH = 255; + + /** Truncate {@code filename} to {@link #MAX_FILENAME_LENGTH}. Package-private + * for direct unit testing. CJK chars live in the BMP, so {@code substring} + * cannot split a surrogate pair. */ + static String truncateFilename(String filename) { + return filename.length() > MAX_FILENAME_LENGTH + ? filename.substring(0, MAX_FILENAME_LENGTH) + : filename; + } + /** * 记录一次文件召回 */ @@ -42,6 +58,9 @@ public class MemoryRecallService { if (agentId == null || filename == null || filename.isBlank()) { return; } + // 写库前硬截断:覆盖所有调用路径(含 trackActiveRetrieval 透传的外部 filename), + // 防 filename 突破 VARCHAR(256) 导致写入失败(#461) + filename = truncateFilename(filename); // snippet preview 只取前 200 字符(避免对大文件做完整 SHA-256) String preview = snippetText != null && snippetText.length() > 200 diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java index 0eaae68a..49449f20 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java @@ -126,12 +126,20 @@ public class MemoryRecallTracker { return count; } - private String sanitizeSectionKey(String heading) { + /** Max length of a section slug — keeps the full key (path + '#' + slug) + * well under the mate_memory_recall.filename VARCHAR(256) ceiling even + * when an LLM writes an over-long H2 heading. CJK chars live in the BMP, + * so {@code substring} can never split a surrogate pair here. */ + static final int MAX_SECTION_SLUG = 200; + + /** Package-private for direct unit testing of the slug/truncation logic. */ + static String sanitizeSectionKey(String heading) { // "## Some Title" -> "some-title" - return heading.replaceFirst("^#+\\s*", "") + String slug = heading.replaceFirst("^#+\\s*", "") .toLowerCase() .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-") .replaceAll("^-|-$", ""); + return slug.length() > MAX_SECTION_SLUG ? slug.substring(0, MAX_SECTION_SLUG) : slug; } /** 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 e0ba36ff..bbbcf0b7 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 @@ -3,6 +3,7 @@ package vip.mate.memory.spi; import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.agent.context.TokenEstimator; import vip.mate.memory.MemoryProperties; import vip.mate.memory.spi.decorator.MetricsMemoryProvider; import vip.mate.memory.spi.decorator.RetryableMemoryProvider; @@ -81,13 +82,41 @@ public class MemoryManager { * Called once at agent build time (snapshot frozen for session). */ public String buildSystemPromptBlock(Long agentId) { + return buildSystemPromptBlock(agentId, Integer.MAX_VALUE); + } + + /** + * Budgeted variant: providers keep their own per-block caps, but the + * combined output additionally may not exceed {@code budgetTokens} + * (estimated). Provider order is priority order — once the budget is + * spent, later providers are dropped whole and a partially fitting block + * is truncated at a line boundary. Small local context windows need this: + * the individual caps are sized for large cloud models and stack up past + * an 8k/16k window on their own. + */ + public String buildSystemPromptBlock(Long agentId, int budgetTokens) { List blocks = new ArrayList<>(); + int usedTokens = 0; for (MemoryProvider provider : providers) { try { String block = provider.systemPromptBlock(agentId); - if (block != null && !block.isBlank()) { - blocks.add(block); + if (block == null || block.isBlank()) { + continue; } + int blockTokens = TokenEstimator.estimateTokens(block); + if (usedTokens + blockTokens <= budgetTokens) { + blocks.add(block); + usedTokens += blockTokens; + continue; + } + int remaining = budgetTokens - usedTokens; + String truncated = truncateToTokenBudget(block, remaining); + if (!truncated.isBlank()) { + blocks.add(truncated + "\n\n[memory truncated to fit the model context window]"); + } + log.info("[MemoryManager] Memory block budget {} tokens reached at provider '{}' — " + + "remaining providers dropped from the system prompt", budgetTokens, provider.id()); + break; } catch (Exception e) { log.warn("[MemoryManager] Provider '{}' systemPromptBlock() failed: {}", provider.id(), e.getMessage()); @@ -96,6 +125,27 @@ public class MemoryManager { return String.join("\n\n", blocks); } + /** Trim to the last full line that fits the token budget; empty when nothing fits. */ + private static String truncateToTokenBudget(String block, int budgetTokens) { + if (budgetTokens <= 0) { + return ""; + } + StringBuilder kept = new StringBuilder(); + int usedTokens = 0; + for (String line : block.split("\n", -1)) { + int lineTokens = TokenEstimator.estimateTokens(line) + 1; + if (usedTokens + lineTokens > budgetTokens) { + break; + } + if (kept.length() > 0) { + kept.append('\n'); + } + kept.append(line); + usedTokens += lineTokens; + } + return kept.toString(); + } + // ==================== Prefetch / Recall ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java index 1f1396ae..4b031e11 100644 --- a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java +++ b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java @@ -13,6 +13,7 @@ import vip.mate.agent.runtime.AgentRuntimeAggregator; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; +import vip.mate.wiki.service.WikiRawMaterialService; import java.util.LinkedHashMap; import java.util.Map; @@ -36,6 +37,7 @@ public class NotificationController { private final ApprovalWorkflowService approvalWorkflowService; private final AgentRuntimeAggregator agentRuntimeAggregator; + private final WikiRawMaterialService wikiRawMaterialService; @Operation(summary = "Aggregated counts for the sidebar attention badges") @GetMapping("/summary") @@ -49,10 +51,16 @@ public class NotificationController { int stuckAgents = admin ? agentRuntimeAggregator.snapshot().summary().stuck() : 0; + // Cross-KB Wiki ingest failures/degradations — admin-only, mirroring + // stuckAgents (the list view it links to spans every workspace). + int failedWikiJobs = admin + ? (int) Math.min(Integer.MAX_VALUE, wikiRawMaterialService.countFailures()) + : 0; Map payload = new LinkedHashMap<>(); payload.put("pendingApprovals", pendingApprovals); payload.put("stuckAgents", stuckAgents); + payload.put("failedWikiJobs", failedWikiJobs); // Reserved fields — wire shape stays stable so the frontend doesn't // need a fan-out when these get real semantics later. payload.put("failedCrons", 0); diff --git a/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java b/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java new file mode 100644 index 00000000..3f8934a4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java @@ -0,0 +1,112 @@ +package vip.mate.operational.controller; + +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import vip.mate.operational.model.ExportTask; +import vip.mate.operational.service.OperationalDataExportService; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; + +import java.io.IOException; +import java.nio.file.Files; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 运营数据导出 Controller — 仅全局管理员可访问。 + *

    + * 不暴露标准 REST API 端点,下载通过一次性 token 保护。 + */ +@RestController +@RequestMapping("/api/v1/operational-data") +public class OperationalDataController { + + private final OperationalDataExportService exportService; + + public OperationalDataController(OperationalDataExportService exportService) { + this.exportService = exportService; + } + + @PostMapping("/generate") + @RequireGlobalAdmin + public ResponseEntity> generate( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate) { + try { + ExportTask task = exportService.generate(startDate, endDate); + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getTaskId()); + result.put("status", task.getStatus()); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map err = new LinkedHashMap<>(); + err.put("code", 500); + err.put("msg", e.getClass().getSimpleName() + ": " + e.getMessage()); + return ResponseEntity.internalServerError().body(err); + } + } + + /** + * 查询生成进度(驱动圆形进度条) + */ + @GetMapping("/progress") + @RequireGlobalAdmin + public ResponseEntity> progress(@RequestParam String taskId) { + ExportTask task = exportService.getProgress(taskId); + if (task == null) { + Map err = new LinkedHashMap<>(); + err.put("code", 404); + err.put("msg", "任务不存在或已过期"); + return ResponseEntity.notFound().build(); + } + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getTaskId()); + result.put("step", task.getStep()); + result.put("total", task.getTotal()); + result.put("status", task.getStatus()); + if ("completed".equals(task.getStatus())) { + result.put("downloadToken", task.getDownloadToken()); + } + if ("failed".equals(task.getStatus())) { + result.put("errorMessage", task.getErrorMessage()); + } + return ResponseEntity.ok(result); + } + + /** + * 下载已生成的文件(一次有效,需 downloadToken) + */ + @GetMapping("/download") + @RequireGlobalAdmin + public ResponseEntity download( + @RequestParam String taskId, + @RequestParam String token) { + ExportTask task = exportService.confirmDownload(taskId, token); + if (task == null) { + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.TEXT_PLAIN) + .body(null); + } + + try { + if (!Files.exists(task.getFilePath())) { + return ResponseEntity.status(HttpStatus.GONE).build(); + } + + // The one-time token was already atomically claimed in confirmDownload(). + InputStreamResource resource = new InputStreamResource(Files.newInputStream(task.getFilePath())); + + String encodedName = new String(task.getFileName().getBytes("UTF-8"), "ISO-8859-1"); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + encodedName + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(resource); + } catch (IOException e) { + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java new file mode 100644 index 00000000..275bd2e9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java @@ -0,0 +1,10 @@ +package vip.mate.operational.model; + +/** + * 导出任务并发冲突异常——{@code AtomicBoolean} 已被占用。 + */ +public class ExportInProgressException extends RuntimeException { + public ExportInProgressException(String message) { + super(message); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java new file mode 100644 index 00000000..86c3eb37 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java @@ -0,0 +1,72 @@ +package vip.mate.operational.model; + +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 运营数据导出任务——异步生成 + 一次下载模型。 + */ +public class ExportTask { + private String taskId; + private volatile int step; + private int total = 9; + private volatile String status; // generating | completed | failed | timeout | oom + private volatile Path filePath; + private volatile long completedAt; + private volatile String downloadToken; + private final AtomicBoolean downloaded = new AtomicBoolean(false); + private volatile String errorMessage; + + public ExportTask() { + this.taskId = UUID.randomUUID().toString().substring(0, 8); + this.status = "generating"; + } + + public void setCompleted(Path filePath) { + this.status = "completed"; + this.filePath = filePath; + this.completedAt = System.currentTimeMillis(); + this.downloadToken = UUID.randomUUID().toString().substring(0, 12); + } + + public void setFailed(String errorMessage) { + this.status = "failed"; + this.errorMessage = errorMessage; + } + + public String getFileName() { + if (filePath == null) return null; + return filePath.getFileName().toString(); + } + + // ── Manual getters/setters (avoid Lombok/Java25 issue) ── + + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + + public int getStep() { return step; } + public void setStep(int step) { this.step = step; } + + public int getTotal() { return total; } + public void setTotal(int total) { this.total = total; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Path getFilePath() { return filePath; } + public void setFilePath(Path filePath) { this.filePath = filePath; } + + public long getCompletedAt() { return completedAt; } + public void setCompletedAt(long completedAt) { this.completedAt = completedAt; } + + public String getDownloadToken() { return downloadToken; } + public void setDownloadToken(String downloadToken) { this.downloadToken = downloadToken; } + + public boolean isDownloaded() { return downloaded.get(); } + /** Atomically claim the one-time download; returns false if already claimed. */ + public boolean claimDownload() { return downloaded.compareAndSet(false, true); } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } +} diff --git a/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java b/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java new file mode 100644 index 00000000..2a56ae54 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java @@ -0,0 +1,1352 @@ +package vip.mate.operational.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.agent.binding.repository.AgentSkillBindingMapper; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.approval.grant.entity.ApprovalGrant; +import vip.mate.approval.grant.repository.ApprovalGrantMapper; +import vip.mate.approval.model.ToolApprovalEntity; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.audit.model.AuditEventEntity; +import vip.mate.audit.repository.AuditEventMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.repository.UserMapper; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.repository.ChannelMapper; +import vip.mate.cron.model.CronJobEntity; +import vip.mate.cron.repository.CronJobMapper; +import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.dashboard.repository.CronJobRunMapper; +import vip.mate.dashboard.service.DashboardService; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.repository.ModelConfigMapper; +import vip.mate.llm.repository.ModelProviderMapper; +import vip.mate.operational.model.ExportTask; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.repository.SkillUsageStatMapper; +import vip.mate.skill.usage.SkillUsageStatEntity; +import vip.mate.tool.guard.model.ToolGuardAuditLogEntity; +import vip.mate.tool.guard.model.ToolGuardConfigEntity; +import vip.mate.tool.guard.model.ToolGuardRuleEntity; +import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper; +import vip.mate.tool.guard.repository.ToolGuardConfigMapper; +import vip.mate.tool.guard.repository.ToolGuardRuleMapper; +import vip.mate.workspace.conversation.TokenUsageService; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.conversation.vo.TokenUsageSummaryVO; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.repository.WorkspaceMapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Operational data export service: asynchronously builds a 9-sheet Excel report + * and serves it through a one-time download. + *

    + * Data-sourcing strategy: prefer reusing existing service methods, fall back to + * direct mapper queries, then aggregate in memory. + */ +@Service +public class OperationalDataExportService { + + private static final Logger log = LoggerFactory.getLogger(OperationalDataExportService.class); + + // ── 现成 Service ── + private final TokenUsageService tokenUsageService; + private final DashboardService dashboardService; + private final AuditEventService auditEventService; + + // ── Mapper(按需注入)── + private final MessageMapper messageMapper; + private final ConversationMapper conversationMapper; + private final UserMapper userMapper; + private final WorkspaceMapper workspaceMapper; + private final SkillMapper skillMapper; + private final SkillUsageStatMapper skillUsageStatMapper; + private final AgentSkillBindingMapper agentSkillBindingMapper; + private final AgentMapper agentMapper; + private final ChannelMapper channelMapper; + private final ModelConfigMapper modelConfigMapper; + private final ModelProviderMapper modelProviderMapper; + private final AuditEventMapper auditEventMapper; + private final ToolGuardRuleMapper toolGuardRuleMapper; + private final ToolGuardConfigMapper toolGuardConfigMapper; + private final ToolGuardAuditLogMapper toolGuardAuditLogMapper; + private final ToolApprovalMapper toolApprovalMapper; + private final ApprovalGrantMapper approvalGrantMapper; + private final CronJobMapper cronJobMapper; + private final CronJobRunMapper cronJobRunMapper; + + // ── 并发控制 ── + private final ObjectMapper objectMapper; + private final AtomicBoolean generating = new AtomicBoolean(false); + private final ConcurrentHashMap tasks = new ConcurrentHashMap<>(); + + public OperationalDataExportService(TokenUsageService tokenUsageService, + DashboardService dashboardService, + AuditEventService auditEventService, + ObjectMapper objectMapper, + MessageMapper messageMapper, + ConversationMapper conversationMapper, + UserMapper userMapper, + WorkspaceMapper workspaceMapper, + SkillMapper skillMapper, + SkillUsageStatMapper skillUsageStatMapper, + AgentSkillBindingMapper agentSkillBindingMapper, + AgentMapper agentMapper, + ChannelMapper channelMapper, + ModelConfigMapper modelConfigMapper, + ModelProviderMapper modelProviderMapper, + AuditEventMapper auditEventMapper, + ToolGuardRuleMapper toolGuardRuleMapper, + ToolGuardConfigMapper toolGuardConfigMapper, + ToolGuardAuditLogMapper toolGuardAuditLogMapper, + ToolApprovalMapper toolApprovalMapper, + ApprovalGrantMapper approvalGrantMapper, + CronJobMapper cronJobMapper, + CronJobRunMapper cronJobRunMapper) { + this.tokenUsageService = tokenUsageService; + this.dashboardService = dashboardService; + this.auditEventService = auditEventService; + this.objectMapper = objectMapper; + this.messageMapper = messageMapper; + this.conversationMapper = conversationMapper; + this.userMapper = userMapper; + this.workspaceMapper = workspaceMapper; + this.skillMapper = skillMapper; + this.skillUsageStatMapper = skillUsageStatMapper; + this.agentSkillBindingMapper = agentSkillBindingMapper; + this.agentMapper = agentMapper; + this.channelMapper = channelMapper; + this.modelConfigMapper = modelConfigMapper; + this.modelProviderMapper = modelProviderMapper; + this.auditEventMapper = auditEventMapper; + this.toolGuardRuleMapper = toolGuardRuleMapper; + this.toolGuardConfigMapper = toolGuardConfigMapper; + this.toolGuardAuditLogMapper = toolGuardAuditLogMapper; + this.toolApprovalMapper = toolApprovalMapper; + this.approvalGrantMapper = approvalGrantMapper; + this.cronJobMapper = cronJobMapper; + this.cronJobRunMapper = cronJobRunMapper; + } + + // ── 存储目录 ── + + // ── 常量 ── + private static final int MAX_DAYS_FRONTEND = 90; + private static final int LIMIT_USER_MSGS = 50_000; + private static final int LIMIT_AUDIT = 10_000; + private static final long DEADLINE_MS = 300_000; // 5 min + private static final Path EXPORT_DIR = Path.of(".", "data", "exports"); + private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // ── 值转译字典 ── + private static final Map LABEL_MAP = Map.ofEntries( + Map.entry("TRUE", "启用"), Map.entry("1", "启用"), + Map.entry("FALSE", "禁用"), Map.entry("0", "禁用"), + Map.entry("builtin", "内置"), Map.entry("mcp", "MCP"), + Map.entry("dynamic", "动态"), Map.entry("acp", "ACP"), + Map.entry("active", "活跃"), Map.entry("stale", "待归档"), Map.entry("archived", "已归档"), + Map.entry("CREATE", "创建"), Map.entry("UPDATE", "更新"), Map.entry("DELETE", "删除"), + Map.entry("AGENT", "Agent"), Map.entry("SKILL", "Skill"), Map.entry("TOOL", "工具"), + Map.entry("CHANNEL", "渠道"), Map.entry("WORKSPACE", "工作区"), Map.entry("USER", "用户"), + Map.entry("rule", "防护规则"), Map.entry("audit", "防护审计"), + Map.entry("approval", "工具审批"), Map.entry("grant", "自动授权"), + Map.entry("config", "全局配置"), Map.entry("business_audit", "业务审计"), + Map.entry("critical", "严重"), Map.entry("high", "高"), + Map.entry("medium", "中"), Map.entry("low", "低"), Map.entry("info", "提示"), + Map.entry("ALLOW", "允许"), Map.entry("BLOCK", "阻止"), Map.entry("NEEDS_APPROVAL", "需审批"), + Map.entry("PENDING", "待处理"), Map.entry("APPROVED", "已批准"), + Map.entry("DENIED", "已拒绝"), Map.entry("EXPIRED", "已过期"), + Map.entry("revoked", "已撤销"), Map.entry("enabled", "启用"), Map.entry("disabled", "禁用"), + Map.entry("chat", "对话"), Map.entry("embedding", "嵌入"), Map.entry("image", "图像") + ); + + // ════════════════════════════════════════════════════ + // 公开方法 + // ════════════════════════════════════════════════════ + + /** 前端触发生成(≤90 天、有死线) */ + public ExportTask generate(LocalDate start, LocalDate end) { + final LocalDate from = start.isAfter(end) ? end : start; + final LocalDate to = start.isAfter(end) ? start : end; + if (ChronoUnit.DAYS.between(from, to) > MAX_DAYS_FRONTEND) { + throw new IllegalArgumentException("时间跨度不能超过 " + MAX_DAYS_FRONTEND + " 天"); + } + if (!generating.compareAndSet(false, true)) { + throw new IllegalStateException("正在生成中,请等待"); + } + ExportTask task = new ExportTask(); + try { + tasks.put(task.getTaskId(), task); + CompletableFuture.runAsync(() -> runExport(task, from, to, true)); + } catch (RuntimeException e) { + // Async submission failed, so runExport's finally will never release + // the lock — release it here to avoid wedging the flag permanently. + generating.set(false); + throw e; + } + return task; + } + + /** 后台直接调用(无限制) */ + public ExportTask exportBackend(LocalDate start, LocalDate end) { + if (!generating.compareAndSet(false, true)) { + throw new IllegalStateException("前端或后台已有生成任务在运行"); + } + ExportTask task = new ExportTask(); + tasks.put(task.getTaskId(), task); + runExport(task, start, end, false); + return task; + } + + /** + * Backend-only export entry point for the CLI: no 90-day cap and no timeout, + * returns the ZIP bytes directly instead of writing to disk. Reachable only + * from the operator CLI, never over HTTP. + */ + public byte[] exportBackendBytes(LocalDate start, LocalDate end) { + if (!generating.compareAndSet(false, true)) { + throw new IllegalStateException("前端或后台已有生成任务在运行"); + } + try { + long t0 = System.currentTimeMillis(); + byte[] zip = doExport(start, end, step -> {}, false); + log.info("CLI export completed: {} KB, {}ms", zip.length / 1024, + System.currentTimeMillis() - t0); + try { + auditEventService.recordSync("EXPORT", "OPERATIONAL_DATA", + start + "~" + end, "运营数据导出 9 sheets (CLI)", null); + } catch (Exception e) { + log.warn("Failed to write audit for CLI export: {}", e.getMessage()); + } + return zip; + } catch (Exception e) { + log.error("CLI export failed", e); + throw new RuntimeException("导出失败: " + e.getMessage(), e); + } finally { + generating.set(false); + } + } + + /** 查询任务进度 */ + public ExportTask getProgress(String taskId) { + return tasks.get(taskId); + } + + /** 下载(一次性) */ + public ExportTask confirmDownload(String taskId, String token) { + ExportTask task = tasks.get(taskId); + if (task == null) return null; + if (!token.equals(task.getDownloadToken())) return null; + if (!"completed".equals(task.getStatus())) return null; + // Atomically claim the one-time download so concurrent requests cannot + // both succeed; a second caller gets null (treated as 410 Gone). + if (!task.claimDownload()) return null; + return task; + } + + // ════════════════════════════════════════════════════ + // 核心生成 + // ════════════════════════════════════════════════════ + + private void runExport(ExportTask task, LocalDate start, LocalDate end, boolean enforceLimit) { + long startedAt = System.currentTimeMillis(); + try { + byte[] zip = doExport(start, end, step -> { + task.setStep(step); + if (enforceLimit && System.currentTimeMillis() - startedAt > DEADLINE_MS) { + throw new RuntimeException("生成超时"); + } + }, enforceLimit); + + Files.createDirectories(EXPORT_DIR); + String fileName = "ops_data_" + start + "_" + end + ".zip"; + Path file = EXPORT_DIR.resolve(fileName); + Files.write(file, zip); + task.setCompleted(file); + log.info("Export completed: {}, size={}KB", task.getTaskId(), zip.length / 1024); + + try { + auditEventService.recordSync("EXPORT", "OPERATIONAL_DATA", + start + "~" + end, "运营数据导出 9 sheets", null); + } catch (Exception e) { + log.warn("Failed to write audit for export: {}", e.getMessage()); + } + } catch (Exception e) { + log.error("Export failed: {}", task.getTaskId(), e); + task.setFailed(e.getMessage()); + } finally { + generating.set(false); + } + } + + byte[] doExport(LocalDate start, LocalDate end, Consumer onStep, + boolean enforceLimit) throws IOException { + LocalDateTime startTime = start.atStartOfDay(); + LocalDateTime endTime = end.atTime(LocalTime.MAX); + + try (XSSFWorkbook wb = new XSSFWorkbook(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + + // CellStyles + CellStyle headerStyle = createHeaderStyle(wb); + CellStyle numStyle = createNumStyle(wb); + CellStyle dateStyle = createDateStyle(wb); + + // Sheet 1: 概览汇总 + onStep.accept(1); + buildOverviewSheet(wb, headerStyle, startTime, endTime); + + // Sheet 2: Token用量 + onStep.accept(2); + buildTokenSheet(wb, headerStyle, numStyle, start, end); + + // Sheet 3: 技能统计 + onStep.accept(3); + buildSkillSheet(wb, headerStyle, numStyle, dateStyle, startTime, endTime); + + // Sheet 4: 用户统计 + onStep.accept(4); + buildUserStatSheet(wb, headerStyle, numStyle, dateStyle, startTime, endTime); + + // Sheet 5: 用户对话 + onStep.accept(5); + buildUserDetailSheet(wb, headerStyle, numStyle, dateStyle, startTime, endTime, enforceLimit); + + // Sheet 6: 安全与审计 + onStep.accept(6); + buildSecuritySheet(wb, headerStyle, dateStyle, startTime, endTime, enforceLimit); + + // Sheet 7: 渠道统计 + onStep.accept(7); + buildChannelSheet(wb, headerStyle, numStyle, startTime, endTime); + + // Sheet 8: 模型配置 + onStep.accept(8); + buildModelSheet(wb, headerStyle, numStyle); + + // Sheet 9: 定时任务 + onStep.accept(9); + buildCronSheet(wb, headerStyle, dateStyle, startTime, endTime); + + wb.write(bos); + byte[] xlsx = bos.toByteArray(); + + // ZIP + ByteArrayOutputStream zbos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(zbos)) { + ZipEntry entry = new ZipEntry("ops_data_" + start + "_" + end + ".xlsx"); + zos.putNextEntry(entry); + zos.write(xlsx); + zos.closeEntry(); + } + return zbos.toByteArray(); + } + } + + // ══════════════════════════════════════════════════ + // Sheet 1: 概览汇总 + // ══════════════════════════════════════════════════ + + private void buildOverviewSheet(XSSFWorkbook wb, CellStyle headerStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("概览汇总"); + sheet.createFreezePane(0, 1); + int r = 0; + + // A: 运营指标(区间) + Row aTitle = sheet.createRow(r++); + createCell(aTitle, 0, "A: 运营指标(区间)", headerStyle); + Map stats = queryOverviewStats(null, startTime, endTime); + long activeUsers = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getUsername)) + .stream().map(ConversationEntity::getUsername).filter(Objects::nonNull).distinct().count(); + addKvRow(sheet, r++, "对话数", stats.getOrDefault("conversations", 0L).toString(), headerStyle); + addKvRow(sheet, r++, "消息数", stats.getOrDefault("messages", 0L).toString(), headerStyle); + addKvRow(sheet, r++, "工具调用次数", stats.getOrDefault("toolCalls", 0L).toString(), headerStyle); + long totalTokensVal = ((Number) stats.getOrDefault("totalTokens", 0L)).longValue(); + long conversationsVal = ((Number) stats.getOrDefault("conversations", 0L)).longValue(); + addKvRow(sheet, r++, "Token 消耗", String.valueOf(totalTokensVal), headerStyle); + addKvRow(sheet, r++, "平均Token/对话", conversationsVal > 0 ? String.format("%.0f", (double) totalTokensVal / conversationsVal) : "-", headerStyle); + addKvRow(sheet, r++, "活跃用户数", String.valueOf(activeUsers), headerStyle); + r++; + + // B: 系统快照 + Row bTitle = sheet.createRow(r++); + createCell(bTitle, 0, "B: 系统快照(当前)", headerStyle); + long skillCount = skillMapper.selectCount(new LambdaQueryWrapper().eq(SkillEntity::getDeleted, 0)); + long agentTotal = agentMapper.selectCount(new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0)); + long agentEnabled = agentMapper.selectCount(new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0).eq(AgentEntity::getEnabled, true)); + addKvRow(sheet, r++, "技能总数", String.valueOf(skillCount), headerStyle); + addKvRow(sheet, r++, "Agent 总数(已启用/总数)", agentEnabled + " / " + agentTotal, headerStyle); + addKvRow(sheet, r++, "定时任务数(已启用)", + String.valueOf(cronJobMapper.selectCount(new LambdaQueryWrapper().eq(CronJobEntity::getEnabled, true).eq(CronJobEntity::getDeleted, 0))), headerStyle); + r++; + + // C: 最近 7 天活跃度 + Row cTitle = sheet.createRow(r++); + createCell(cTitle, 0, "C: 最近 7 天活跃度", headerStyle); + // Sum all 7 days from trend + List> weekTrend = dashboardService.getTrend(null, 7); + long weekConv = 0, weekMsg = 0, weekTok = 0; + for (Map d : weekTrend) { + weekConv += toLong(d.getOrDefault("conversations", 0L)); + weekMsg += toLong(d.getOrDefault("messages", 0L)); + weekTok += toLong(d.getOrDefault("totalTokens", 0L)); + } + addKvRow(sheet, r++, "最近7天对话数", String.valueOf(weekConv), headerStyle); + addKvRow(sheet, r++, "最近7天消息数", String.valueOf(weekMsg), headerStyle); + addKvRow(sheet, r++, "最近7天 Token 消耗", String.valueOf(weekTok), headerStyle); + r++; + + // D: 周期对比 + Map overview = dashboardService.getOverview(null); + Map todayOv = (Map) overview.get("today"); + Map weekOv = (Map) overview.get("thisWeek"); + Map monthOv = (Map) overview.get("thisMonth"); + Row dTitle = sheet.createRow(r++); + createCell(dTitle, 0, "D: 周期对比", headerStyle); + createCell(dTitle, 1, "今日 / 本周 / 本月", headerStyle); + addKvRow(sheet, r++, "对话数", + todayOv.getOrDefault("conversations", 0) + " / " + weekOv.getOrDefault("conversations", 0) + " / " + monthOv.getOrDefault("conversations", 0) + " 次对话", headerStyle); + addKvRow(sheet, r++, "消息数", + todayOv.getOrDefault("messages", 0) + " / " + weekOv.getOrDefault("messages", 0) + " / " + monthOv.getOrDefault("messages", 0) + " 条消息", headerStyle); + addKvRow(sheet, r++, "Token 消耗", + todayOv.getOrDefault("totalTokens", 0) + " / " + weekOv.getOrDefault("totalTokens", 0) + " / " + monthOv.getOrDefault("totalTokens", 0) + " tokens", headerStyle); + addKvRow(sheet, r++, "工具调用", + todayOv.getOrDefault("toolCalls", 0) + " / " + weekOv.getOrDefault("toolCalls", 0) + " / " + monthOv.getOrDefault("toolCalls", 0) + " 次", headerStyle); + r++; + + // E: 当前模型详情(仅已配置的 Provider) + Row eTitle = sheet.createRow(r++); + createCell(eTitle, 0, "E: 当前模型详情", headerStyle); + List allProviders = modelProviderMapper.selectList( + new LambdaQueryWrapper()); + Set configuredProvIds = allProviders.stream() + .filter(p -> Boolean.TRUE.equals(p.getEnabled())) + .filter(p -> p.getApiKey() != null && !p.getApiKey().isBlank()) + .map(ModelProviderEntity::getProviderId) + .collect(Collectors.toSet()); + Map provIdToName = allProviders.stream().collect(Collectors.toMap(ModelProviderEntity::getProviderId, ModelProviderEntity::getName)); + List activeConfigs = modelConfigMapper.selectList( + new LambdaQueryWrapper().eq(ModelConfigEntity::getDeleted, 0)); + boolean hasAny = false; + for (ModelConfigEntity cfg : activeConfigs) { + if (!configuredProvIds.contains(cfg.getProvider())) continue; + hasAny = true; + String provName = provIdToName.getOrDefault(cfg.getProvider(), "-"); + addKvRow(sheet, r++, "Provider 名称", provName, headerStyle); + addKvRow(sheet, r++, "模型名称", cfg.getModelName() != null ? cfg.getModelName() : "-", headerStyle); + addKvRow(sheet, r++, "模型类型", label(cfg.getModelType() != null ? cfg.getModelType() : "-"), headerStyle); + addKvRow(sheet, r++, "Max Tokens", cfg.getMaxTokens() != null ? String.valueOf(cfg.getMaxTokens()) : "-", headerStyle); + addKvRow(sheet, r++, "Temperature", cfg.getTemperature() != null ? String.valueOf(cfg.getTemperature()) : "-", headerStyle); + if (cfg.getIsDefault() != null && cfg.getIsDefault()) { + addKvRow(sheet, r++, "默认模型", "是", headerStyle); + } + r++; + } + r++; + + // F: Agent 活跃排名 — by conversation.agentId → agent.name + Row fTitle = sheet.createRow(r++); + createCell(fTitle, 0, "F: Agent 活跃排名 (Top 10)", headerStyle); + String[] rankingCols = {"Agent", "对话数", "消息数", "Token消耗"}; + Row rHead = sheet.createRow(r++); + for (int i = 0; i < rankingCols.length; i++) createCell(rHead, i, rankingCols[i], headerStyle); + + // Load conversations to map convId → agentId + List rankConvs = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId, ConversationEntity::getAgentId)); + Map convAgentMap = rankConvs.stream() + .filter(c -> c.getAgentId() != null) + .collect(Collectors.toMap(ConversationEntity::getConversationId, ConversationEntity::getAgentId, (a, b) -> a)); + Map agentIdToName = agentMapper.selectList( + new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0)) + .stream().collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName, (a, b) -> a)); + + LambdaQueryWrapper rankW = new LambdaQueryWrapper() + .eq(MessageEntity::getRole, "assistant") + .ge(MessageEntity::getCreateTime, startTime) + .le(MessageEntity::getCreateTime, endTime) + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, MessageEntity::getConversationId); + List rankMsgs = messageMapper.selectList(rankW); + + Map agentMap = new LinkedHashMap<>(); + Map> agentConvSet = new LinkedHashMap<>(); + for (MessageEntity m : rankMsgs) { + Long agId = convAgentMap.get(m.getConversationId()); + String agentName = agId != null ? agentIdToName.getOrDefault(agId, "Agent#" + agId) : "-"; + long[] acc = agentMap.computeIfAbsent(agentName, k -> new long[3]); + acc[0]++; // message count + acc[1] += m.getPromptTokens() != null ? m.getPromptTokens() : 0; + acc[1] += m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; + agentConvSet.computeIfAbsent(agentName, k -> new HashSet<>()).add(m.getConversationId()); + } + final int[] rr = {r}; + agentMap.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])) + .limit(10) + .forEach(e -> { + Row row = sheet.createRow(rr[0]++); + createCell(row, 0, e.getKey(), null); + createCell(row, 1, agentConvSet.getOrDefault(e.getKey(), Set.of()).size(), null); + createCell(row, 2, e.getValue()[0], null); + createCell(row, 3, e.getValue()[1], null); + }); + r = rr[0]; + + // Auto-size + sheet.setColumnWidth(0, 30 * 256); + sheet.setColumnWidth(1, 30 * 256); + } + + // ══════════════════════════════════════════════════ + // Sheet 2: Token用量 + // ══════════════════════════════════════════════════ + + private void buildTokenSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + LocalDate start, LocalDate end) { + Sheet sheet = wb.createSheet("Token用量"); + sheet.createFreezePane(0, 1); + String[] cols = {"日期", "Provider", "Prompt Tokens", "Completion Tokens", "总Tokens", "消息数", "平均Tokens/消息"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + // 按日聚合 byModel + LocalDateTime startTime = start.atStartOfDay(); + LocalDateTime endTime = end.atTime(LocalTime.MAX); + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(MessageEntity::getRole, "assistant") + .ge(MessageEntity::getCreateTime, startTime) + .le(MessageEntity::getCreateTime, endTime) + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, + MessageEntity::getRuntimeProvider, MessageEntity::getCreateTime, MessageEntity::getRuntimeModel); + List msgs = messageMapper.selectList(w); + + Map byDateProv = new TreeMap<>(); + Map byDateMsg = new TreeMap<>(); + for (MessageEntity m : msgs) { + String date = m.getCreateTime().toLocalDate().toString(); + String prov = (m.getRuntimeProvider() != null && !m.getRuntimeProvider().isBlank()) + ? m.getRuntimeProvider() : "-"; + String key = date + "|" + prov; + long[] acc = byDateProv.computeIfAbsent(key, k -> new long[3]); + acc[0] += m.getPromptTokens() != null ? m.getPromptTokens() : 0; + acc[1] += m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; + acc[2]++; + byDateMsg.merge(date, 1L, Long::sum); + } + + int r = 1; + for (Map.Entry e : byDateProv.entrySet()) { + // split with limit -1 keeps a trailing empty field (provider "-" guards + // blanks, but never rely on split dropping trailing segments). + String[] parts = e.getKey().split("\\|", -1); + long[] v = e.getValue(); + Row row = sheet.createRow(r++); + createCell(row, 0, parts[0], null); + createCell(row, 1, parts[1], null); + createCell(row, 2, v[0], numStyle); + createCell(row, 3, v[1], numStyle); + createCell(row, 4, v[0] + v[1], numStyle); + createCell(row, 5, v[2], numStyle); + createCell(row, 6, v[2] > 0 ? String.format("%.0f", (double)(v[0] + v[1]) / v[2]) : "-", null); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 3: 技能统计 + // ══════════════════════════════════════════════════ + + private void buildSkillSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + CellStyle dateStyle, LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("技能统计"); + sheet.createFreezePane(0, 1); + String[] cols = {"技能ID", "技能名称", "类型", "生命周期", "工作区ID", "工作区名称", "启用", "描述", "最近调用时间", "调用次数", "绑定Agent"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List skills = skillMapper.selectList( + new LambdaQueryWrapper().eq(SkillEntity::getDeleted, 0)); + + // usage stats + List usageRows = skillUsageStatMapper.selectList( + new LambdaQueryWrapper() + .ge(SkillUsageStatEntity::getLastLoadedAt, startTime) + .le(SkillUsageStatEntity::getLastLoadedAt, endTime)); + Map usageByName = new HashMap<>(); + for (SkillUsageStatEntity u : usageRows) { + long[] acc = usageByName.computeIfAbsent(u.getSkillName(), k -> new long[]{0, 0}); + acc[0] += u.getLoadCount() != null ? u.getLoadCount() : 0; + acc[1] = Math.max(acc[1], u.getLastLoadedAt() != null + ? u.getLastLoadedAt().toEpochSecond(ZoneOffset.UTC) : 0); + } + + // agent bindings + List bindings = agentSkillBindingMapper.selectList( + new LambdaQueryWrapper().eq(AgentSkillBinding::getEnabled, true)); + Map> agentsBySkill = new HashMap<>(); + for (AgentSkillBinding b : bindings) { + agentsBySkill.computeIfAbsent(b.getSkillId(), k -> new ArrayList<>()) + .add(String.valueOf(b.getAgentId())); + } + // resolve agent names + List allAgentIds = bindings.stream().map(AgentSkillBinding::getAgentId).distinct().toList(); + Map agentNames = new HashMap<>(); + if (!allAgentIds.isEmpty()) { + agentMapper.selectList(new LambdaQueryWrapper().in(AgentEntity::getId, allAgentIds)) + .forEach(a -> agentNames.put(a.getId(), a.getName())); + } + + Map wsNames = workspaceMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(WorkspaceEntity::getId, WorkspaceEntity::getName, (a, b) -> a)); + + int r = 1; + for (SkillEntity s : skills) { + Row row = sheet.createRow(r++); + createCell(row, 0, s.getId(), null); + createCell(row, 1, s.getName(), null); + createCell(row, 2, label(s.getSkillType()), null); + createCell(row, 3, label(s.getLifecycleState()), null); + createCell(row, 4, s.getWorkspaceId(), null); + createCell(row, 5, wsNames.getOrDefault(s.getWorkspaceId(), ""), null); + createCell(row, 6, label(Boolean.TRUE.equals(s.getEnabled()) ? "TRUE" : "FALSE"), null); + createCell(row, 7, s.getDescription(), null); + long[] usage = usageByName.get(s.getName()); + if (usage != null && usage[1] > 0) { + createCell(row, 8, LocalDateTime.ofEpochSecond(usage[1], 0, ZoneOffset.UTC), dateStyle); + createCell(row, 9, usage[0], numStyle); + } else { + createCell(row, 8, "", null); + createCell(row, 9, 0, numStyle); + } + List boundAgents = agentsBySkill.getOrDefault(s.getId(), List.of()); + createCell(row, 10, boundAgents.isEmpty() ? "" : + boundAgents.stream().map(id -> agentNames.getOrDefault(Long.valueOf(id), id.toString())).collect(Collectors.joining(", ")), null); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 4: 用户统计 + // ══════════════════════════════════════════════════ + + private void buildUserStatSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, CellStyle dateStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("用户统计"); + sheet.createFreezePane(0, 1); + String[] cols = {"工作区ID", "工作区名称", "用户ID", "用户名", "角色", "对话数", "Prompt Tokens", "Completion Tokens", "总Tokens", "总耗时(s)", "用户消息数", "平均会话时长(s)", "最后活跃时间"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + // Query conversations in range + List convs = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId, ConversationEntity::getWorkspaceId, + ConversationEntity::getUsername, ConversationEntity::getCreateTime, + ConversationEntity::getLastActiveTime)); + + // Group by (workspace_id, username) + Map userMap = new LinkedHashMap<>(); + Map> userConvIds = new LinkedHashMap<>(); + Map userDuration = new LinkedHashMap<>(); + Map userLastActive = new LinkedHashMap<>(); + for (ConversationEntity c : convs) { + String key = (c.getWorkspaceId() != null ? c.getWorkspaceId() : 0) + "|" + + (c.getUsername() != null && !c.getUsername().isBlank() ? c.getUsername() : "-"); + userConvIds.computeIfAbsent(key, k -> new HashSet<>()).add(c.getConversationId()); + if (c.getCreateTime() != null && c.getLastActiveTime() != null) { + userDuration.merge(key, (long) Duration.between(c.getCreateTime(), c.getLastActiveTime()).getSeconds(), Long::sum); + } + if (c.getLastActiveTime() != null) { + userLastActive.merge(key, c.getLastActiveTime(), (a, b) -> a.isAfter(b) ? a : b); + } + } + + // Load workspaces and users + Map wsNames = workspaceMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(WorkspaceEntity::getId, WorkspaceEntity::getName, (a, b) -> a)); + Map users = userMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(UserEntity::getUsername, u -> u, (a, b) -> a)); + + // Query messages for token sums + if (!convs.isEmpty()) { + List allConvIds = convs.stream().map(ConversationEntity::getConversationId).distinct().toList(); + List msgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, allConvIds) + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getConversationId, MessageEntity::getPromptTokens, + MessageEntity::getCompletionTokens, MessageEntity::getRole)); + // Aggregate by (ws, username) + // Re-query to get the user mapping... For simplicity, aggregate in memory + Map convWsMap = convs.stream().collect(Collectors.toMap( + ConversationEntity::getConversationId, c -> (c.getWorkspaceId() != null ? c.getWorkspaceId() : 0L) + "|" + + (c.getUsername() != null && !c.getUsername().isBlank() ? c.getUsername() : "-"), (a, b) -> a)); + for (MessageEntity m : msgs) { + String key = convWsMap.get(m.getConversationId()); + if (key == null) continue; + long[] acc = userMap.computeIfAbsent(key, k -> new long[5]); + if ("assistant".equals(m.getRole())) { + acc[0] += m.getPromptTokens() != null ? m.getPromptTokens() : 0; + acc[1] += m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; + acc[2]++; + } else if ("user".equals(m.getRole())) { + acc[3]++; + } + } + } + + int r = 1; + for (String key : userConvIds.keySet()) { + String[] parts = key.split("\\|", -1); + long wsId = Long.parseLong(parts[0]); + String uname = parts[1]; + long[] acc = userMap.getOrDefault(key, new long[5]); + Row row = sheet.createRow(r++); + createCell(row, 0, wsId, null); + createCell(row, 1, wsNames.getOrDefault(wsId, ""), null); + UserEntity u = users.get(uname); + createCell(row, 2, u != null ? u.getId() : 0, null); + createCell(row, 3, uname, null); + createCell(row, 4, u != null ? u.getRole() : "", null); + createCell(row, 5, userConvIds.get(key).size(), numStyle); + createCell(row, 6, acc[0], numStyle); + createCell(row, 7, acc[1], numStyle); + createCell(row, 8, acc[0] + acc[1], numStyle); + createCell(row, 9, userDuration.getOrDefault(key, 0L), numStyle); + createCell(row, 10, acc[3], numStyle); + long durSec = userDuration.getOrDefault(key, 0L); + long convCount = userConvIds.get(key).size(); + createCell(row, 11, convCount > 0 ? String.format("%.1f", (double) durSec / convCount) : "-", null); + createCell(row, 12, userLastActive.get(key) != null ? userLastActive.get(key) : "", dateStyle); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 5: 用户对话(明细) + // ══════════════════════════════════════════════════ + + private void buildUserDetailSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + CellStyle dateStyle, LocalDateTime startTime, LocalDateTime endTime, + boolean enforceLimit) { + Sheet sheet = wb.createSheet("用户对话"); + sheet.createFreezePane(0, 1); + String[] cols = {"工作区ID", "工作区名称", "用户ID", "用户名", "角色", "Agent名称", "会话ID", "消息时间", "用户内容", "响应Tokens", "响应模型", "响应Provider", "响应耗时(s)"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + // Load convs in range + List convs = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId, ConversationEntity::getWorkspaceId, + ConversationEntity::getUsername, ConversationEntity::getAgentId)); + if (convs.isEmpty()) { for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); return; } + + Map convWs = convs.stream().collect(Collectors.toMap( + ConversationEntity::getConversationId, c -> c.getWorkspaceId() != null ? c.getWorkspaceId() : 0, (a, b) -> a)); + Map convUser = convs.stream().collect(Collectors.toMap( + ConversationEntity::getConversationId, c -> c.getUsername() != null ? c.getUsername() : "-", (a, b) -> a)); + Map convAgent = convs.stream().filter(c -> c.getAgentId() != null).collect(Collectors.toMap( + ConversationEntity::getConversationId, ConversationEntity::getAgentId, (a, b) -> a)); + Map wsNames = workspaceMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(WorkspaceEntity::getId, WorkspaceEntity::getName, (a, b) -> a)); + Map userMap = userMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(UserEntity::getUsername, u -> u, (a, b) -> a)); + Map agentNames = agentMapper.selectList(new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0)) + .stream().collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName, (a, b) -> a)); + + List convIds = convs.stream().map(ConversationEntity::getConversationId).toList(); + List msgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, convIds) + .eq(MessageEntity::getDeleted, 0) + .orderByAsc(MessageEntity::getConversationId) + .orderByAsc(MessageEntity::getCreateTime) + .select(MessageEntity::getConversationId, MessageEntity::getRole, MessageEntity::getCreateTime, + MessageEntity::getContent, MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, + MessageEntity::getRuntimeModel, MessageEntity::getRuntimeProvider)); + + // Pair user messages with next assistant + int r = 1; + for (int i = 0; i < msgs.size() - 1 && (!enforceLimit || r <= LIMIT_USER_MSGS); i++) { + MessageEntity m = msgs.get(i); + if (!"user".equals(m.getRole())) continue; + MessageEntity next = msgs.get(i + 1); + boolean isAssistant = "assistant".equals(next.getRole()); + Row row = sheet.createRow(r++); + Long wsId = convWs.get(m.getConversationId()); + String uname = convUser.get(m.getConversationId()); + UserEntity u = userMap.get(uname); + createCell(row, 0, wsId != null ? wsId : 0, null); + createCell(row, 1, wsNames.getOrDefault(wsId, ""), null); + createCell(row, 2, u != null ? u.getId() : 0, null); + createCell(row, 3, uname, null); + createCell(row, 4, u != null ? u.getRole() : "", null); + Long agId = convAgent.get(m.getConversationId()); + createCell(row, 5, agId != null ? agentNames.getOrDefault(agId, "-") : "-", null); + createCell(row, 6, m.getConversationId(), null); + createCell(row, 7, m.getCreateTime(), dateStyle); + createCell(row, 8, m.getContent(), null); + if (isAssistant) { + createCell(row, 9, + (long)(next.getCompletionTokens() != null ? next.getCompletionTokens() : 0), numStyle); + createCell(row, 10, next.getRuntimeModel(), null); + createCell(row, 11, next.getRuntimeProvider(), null); + createCell(row, 12, Duration.between(m.getCreateTime(), next.getCreateTime()).toMillis() / 1000.0, numStyle); + } + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 6: 安全与审计 + // ══════════════════════════════════════════════════ + + private void buildSecuritySheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle dateStyle, + LocalDateTime startTime, LocalDateTime endTime, boolean enforceLimit) { + Sheet sheet = wb.createSheet("安全与审计"); + sheet.createFreezePane(0, 1); + String[] cols = {"来源", "ID", "工作区ID", "工具名/资源类型", "严重级别", "决策", "状态", "操作人", "分类", "授权范围类型", "授权范围ID", "描述", "时间", "资源类型", "操作", "详情"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + int r = 1; + r = appendGuardRules(sheet, r, cols.length, dateStyle); + r = appendGuardAuditLog(sheet, r, cols.length, startTime, endTime, dateStyle); + r = appendApprovals(sheet, r, cols.length, startTime, endTime, dateStyle); + r = appendGrants(sheet, r, cols.length, dateStyle); + r = appendGuardConfig(sheet, r, cols.length, dateStyle); + if (enforceLimit) r = Math.min(r, LIMIT_AUDIT + 1); + // business audit events + List auditEvents = auditEventMapper.selectList( + new LambdaQueryWrapper() + .ge(AuditEventEntity::getCreateTime, startTime) + .le(AuditEventEntity::getCreateTime, endTime) + .orderByDesc(AuditEventEntity::getCreateTime) + .last(enforceLimit ? "LIMIT " + LIMIT_AUDIT : "")); + for (AuditEventEntity ae : auditEvents) { + Row row = sheet.createRow(r++); + createCell(row, 0, "business_audit", null); + createCell(row, 1, ae.getId(), null); + createCell(row, 2, ae.getWorkspaceId(), null); + createCell(row, 3, "", null); + createCell(row, 4, "", null); + createCell(row, 5, "", null); + createCell(row, 6, "", null); + createCell(row, 7, ae.getUsername(), null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, "", null); + createCell(row, 12, ae.getCreateTime(), dateStyle); + createCell(row, 13, label(ae.getResourceType()), null); + createCell(row, 14, label(ae.getAction()), null); + createCell(row, 15, ae.getDetailJson(), null); + } + + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + private int appendGuardRules(Sheet sheet, int r, int maxCols, CellStyle dateStyle) { + List rules = toolGuardRuleMapper.selectList( + new LambdaQueryWrapper().eq(ToolGuardRuleEntity::getDeleted, 0)); + for (ToolGuardRuleEntity rl : rules) { + Row row = sheet.createRow(r++); + createCell(row, 0, "rule", null); + createCell(row, 1, rl.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, rl.getToolName(), null); + createCell(row, 4, rl.getSeverity() != null ? rl.getSeverity().toLowerCase() : "", null); + createCell(row, 5, rl.getDecision(), null); + createCell(row, 6, Boolean.TRUE.equals(rl.getEnabled()) ? "enabled" : "disabled", null); + createCell(row, 7, "", null); + createCell(row, 8, rl.getCategory(), null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, rl.getDescription(), null); + createCell(row, 12, rl.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "", null); + } + return r; + } + + private int appendGuardAuditLog(Sheet sheet, int r, int maxCols, LocalDateTime start, LocalDateTime end, CellStyle dateStyle) { + List logs = toolGuardAuditLogMapper.selectList( + new LambdaQueryWrapper() + .ge(ToolGuardAuditLogEntity::getCreateTime, start) + .le(ToolGuardAuditLogEntity::getCreateTime, end)); + Map userIdToName = userMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(UserEntity::getId, UserEntity::getUsername, (a, b) -> a)); + for (ToolGuardAuditLogEntity l : logs) { + Row row = sheet.createRow(r++); + createCell(row, 0, "audit", null); + createCell(row, 1, l.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, l.getToolName(), null); + createCell(row, 4, l.getMaxSeverity() != null ? l.getMaxSeverity().toLowerCase() : "", null); + createCell(row, 5, l.getDecision(), null); + createCell(row, 6, "", null); + createCell(row, 7, l.getUserId() != null ? userIdToName.getOrDefault(l.getUserId(), String.valueOf(l.getUserId())) : "", null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, "", null); + createCell(row, 12, l.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, l.getFindingsJson(), null); + } + return r; + } + + private int appendApprovals(Sheet sheet, int r, int maxCols, LocalDateTime start, LocalDateTime end, CellStyle dateStyle) { + List approvals = toolApprovalMapper.selectList( + new LambdaQueryWrapper() + .ge(ToolApprovalEntity::getCreateTime, start) + .le(ToolApprovalEntity::getCreateTime, end)); + for (ToolApprovalEntity a : approvals) { + Row row = sheet.createRow(r++); + createCell(row, 0, "approval", null); + createCell(row, 1, a.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, a.getToolName(), null); + createCell(row, 4, a.getMaxSeverity() != null ? a.getMaxSeverity().toLowerCase() : "", null); + createCell(row, 5, "", null); + createCell(row, 6, a.getStatus(), null); + createCell(row, 7, a.getRequesterName(), null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, a.getSummary(), null); + createCell(row, 12, a.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "", null); + } + return r; + } + + private int appendGrants(Sheet sheet, int r, int maxCols, CellStyle dateStyle) { + List grants = approvalGrantMapper.selectList( + new LambdaQueryWrapper().eq(ApprovalGrant::getDeleted, 0)); + for (ApprovalGrant g : grants) { + Row row = sheet.createRow(r++); + createCell(row, 0, "grant", null); + createCell(row, 1, g.getId(), null); + createCell(row, 2, g.getWorkspaceId(), null); + createCell(row, 3, g.getToolName(), null); + createCell(row, 4, g.getMaxSeverity(), null); + createCell(row, 5, "", null); + createCell(row, 6, g.getRevoked() != null && g.getRevoked() == 0 ? "active" : "revoked", null); + createCell(row, 7, g.getGrantedBy() != null ? String.valueOf(g.getGrantedBy()) : "", null); + createCell(row, 8, "", null); + createCell(row, 9, g.getScopeType(), null); + createCell(row, 10, g.getScopeId(), null); + createCell(row, 11, g.getNote(), null); + createCell(row, 12, g.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "", null); + } + return r; + } + + private int appendGuardConfig(Sheet sheet, int r, int maxCols, CellStyle dateStyle) { + List configs = toolGuardConfigMapper.selectList(new LambdaQueryWrapper<>()); + for (ToolGuardConfigEntity c : configs) { + Row row = sheet.createRow(r++); + createCell(row, 0, "config", null); + createCell(row, 1, c.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, "", null); + createCell(row, 4, "", null); + createCell(row, 5, "", null); + createCell(row, 6, Boolean.TRUE.equals(c.getEnabled()) ? "enabled" : "disabled", null); + createCell(row, 7, "", null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, "", null); + createCell(row, 12, c.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "guard_scope=" + (c.getGuardScope() != null ? c.getGuardScope() : "-") + + "; file_guard=" + (c.getFileGuardEnabled() != null ? c.getFileGuardEnabled() : "-") + + "; retention=" + (c.getAuditRetentionDays() != null ? c.getAuditRetentionDays() : "-") + "d", null); + } + return r; + } + + // ══════════════════════════════════════════════════ + // Sheet 7: 渠道统计 + // ══════════════════════════════════════════════════ + + private void buildChannelSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("渠道统计"); + sheet.createFreezePane(0, 1); + String[] cols = {"渠道ID", "渠道名称", "渠道类型", "工作区ID", "启用", "绑定Agent", "对话数", "总Tokens", "独立用户数"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List channels = channelMapper.selectList( + new LambdaQueryWrapper().eq(ChannelEntity::getDeleted, 0)); + Map agentNames = new HashMap<>(); + for (ChannelEntity ch : channels) { + if (ch.getAgentId() != null) { + AgentEntity ag = agentMapper.selectById(ch.getAgentId()); + if (ag != null) agentNames.put(ch.getAgentId(), ag.getName()); + } + } + + int r = 1; + for (ChannelEntity ch : channels) { + long conversations = 0, tokens = 0, uniqueUsers = 0; + if (ch.getAgentId() != null) { + // count conversations for this agent + conversations = conversationMapper.selectCount( + new LambdaQueryWrapper() + .eq(ConversationEntity::getAgentId, ch.getAgentId()) + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime)); + // sum tokens + List agentConvIds = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getAgentId, ch.getAgentId()) + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId)) + .stream().map(ConversationEntity::getConversationId).toList(); + if (!agentConvIds.isEmpty()) { + List msgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, agentConvIds) + .eq(MessageEntity::getRole, "assistant") + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens)); + for (MessageEntity m : msgs) { + tokens += (m.getPromptTokens() != null ? m.getPromptTokens() : 0) + + (m.getCompletionTokens() != null ? m.getCompletionTokens() : 0); + } + uniqueUsers = conversationMapper.selectList( + new LambdaQueryWrapper() + .in(ConversationEntity::getConversationId, agentConvIds) + .select(ConversationEntity::getUsername)) + .stream().map(ConversationEntity::getUsername).filter(Objects::nonNull).distinct().count(); + } + } + Row row = sheet.createRow(r++); + createCell(row, 0, ch.getId(), null); + createCell(row, 1, ch.getName(), null); + createCell(row, 2, ch.getChannelType(), null); + createCell(row, 3, ch.getWorkspaceId(), null); + createCell(row, 4, label(Boolean.TRUE.equals(ch.getEnabled()) ? "TRUE" : "FALSE"), null); + createCell(row, 5, agentNames.getOrDefault(ch.getAgentId(), ""), null); + createCell(row, 6, conversations, numStyle); + createCell(row, 7, tokens, numStyle); + createCell(row, 8, uniqueUsers, numStyle); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 8: 模型配置 + // ══════════════════════════════════════════════════ + + private void buildModelSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle) { + Sheet sheet = wb.createSheet("模型配置"); + sheet.createFreezePane(0, 1); + String[] cols = {"Provider ID", "Provider名称", "模型名称", "模型类型", "启用", "Temperature", "Max Tokens", "默认模型", "最大输入Tokens", "描述"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List configs = modelConfigMapper.selectList( + new LambdaQueryWrapper().eq(ModelConfigEntity::getDeleted, 0)); + List allProvs = modelProviderMapper.selectList(new LambdaQueryWrapper<>()); + Map provNames = allProvs.stream().collect(Collectors.toMap(ModelProviderEntity::getProviderId, ModelProviderEntity::getName, (a, b) -> a)); + Set configuredProvIds = allProvs.stream() + .filter(p -> Boolean.TRUE.equals(p.getEnabled())) + .filter(p -> p.getApiKey() != null && !p.getApiKey().isBlank()) + .map(ModelProviderEntity::getProviderId) + .collect(Collectors.toSet()); + + int r = 1; + for (ModelConfigEntity c : configs) { + if (!configuredProvIds.contains(c.getProvider())) continue; + Row row = sheet.createRow(r++); + createCell(row, 0, c.getProvider(), null); + createCell(row, 1, provNames.getOrDefault(c.getProvider(), ""), null); + createCell(row, 2, c.getModelName(), null); + createCell(row, 3, label(c.getModelType() != null ? c.getModelType() : "-"), null); + createCell(row, 4, label(Boolean.TRUE.equals(c.getEnabled()) ? "TRUE" : "FALSE"), null); + if (c.getTemperature() != null) createCell(row, 5, c.getTemperature().doubleValue(), numStyle); + else createCell(row, 5, "", null); + if (c.getMaxTokens() != null) createCell(row, 6, c.getMaxTokens(), numStyle); + else createCell(row, 6, "", null); + createCell(row, 7, Boolean.TRUE.equals(c.getIsDefault()) ? "是" : "否", null); + createCell(row, 8, c.getMaxInputTokens() != null ? c.getMaxInputTokens() : "", numStyle); + createCell(row, 9, c.getDescription(), null); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 9: 定时任务 + // ══════════════════════════════════════════════════ + + private void buildCronSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle dateStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("定时任务"); + sheet.createFreezePane(0, 1); + String[] cols = {"任务ID", "任务名称", "触发方式", "状态", "耗时(秒)", "Token消耗", "执行时间"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List runs = cronJobRunMapper.selectList( + new LambdaQueryWrapper() + .ge(CronJobRunEntity::getStartedAt, startTime) + .le(CronJobRunEntity::getStartedAt, endTime) + .orderByDesc(CronJobRunEntity::getStartedAt)); + + Map jobNames = cronJobMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(CronJobEntity::getId, CronJobEntity::getName, (a, b) -> a)); + + int r = 1; + for (CronJobRunEntity run : runs) { + Row row = sheet.createRow(r++); + createCell(row, 0, run.getCronJobId(), null); + createCell(row, 1, jobNames.getOrDefault(run.getCronJobId(), ""), null); + createCell(row, 2, run.getTriggerType(), null); + createCell(row, 3, run.getStatus(), null); + if (run.getStartedAt() != null && run.getFinishedAt() != null) { + createCell(row, 4, Duration.between(run.getStartedAt(), run.getFinishedAt()).toMillis() / 1000.0, null); + } else { + createCell(row, 4, "", null); + } + createCell(row, 5, run.getTokenUsage() != null ? run.getTokenUsage() : "", null); + createCell(row, 6, run.getStartedAt(), dateStyle); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // 工具方法 + // ══════════════════════════════════════════════════ + + private Map queryOverviewStats(Long workspaceId, LocalDateTime start, LocalDateTime end) { + // Reuse DashboardService logic but with custom time range + // Simplified: just query counts and tokens + LambdaQueryWrapper cw = new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, start) + .le(ConversationEntity::getCreateTime, end); + long conversations = conversationMapper.selectCount(cw); + + List convIds = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, start) + .le(ConversationEntity::getCreateTime, end) + .select(ConversationEntity::getConversationId)) + .stream().map(ConversationEntity::getConversationId).toList(); + + Map stats = new LinkedHashMap<>(); + stats.put("conversations", conversations); + if (convIds.isEmpty()) { + stats.put("messages", 0L); + stats.put("totalTokens", 0L); + stats.put("toolCalls", 0L); + return stats; + } + + long messages = messageMapper.selectCount( + new LambdaQueryWrapper().in(MessageEntity::getConversationId, convIds).eq(MessageEntity::getDeleted, 0)); + stats.put("messages", messages); + + List assistantMsgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, convIds) + .eq(MessageEntity::getRole, "assistant") + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, MessageEntity::getMetadata)); + long totalTokens = 0, toolCalls = 0; + for (MessageEntity m : assistantMsgs) { + totalTokens += (m.getPromptTokens() != null ? m.getPromptTokens() : 0) + + (m.getCompletionTokens() != null ? m.getCompletionTokens() : 0); + toolCalls += countToolCallsFromMetadata(m.getMetadata()); + } + stats.put("totalTokens", totalTokens); + stats.put("toolCalls", toolCalls); + return stats; + } + + private String label(String raw) { + if (raw == null) return ""; + return LABEL_MAP.getOrDefault(raw, raw); + } + + private static long toLong(Object val) { + if (val instanceof Number n) return n.longValue(); + if (val instanceof String s) { + try { return Long.parseLong(s); } catch (NumberFormatException ignored) { } + } + return 0L; + } + + private long countToolCallsFromMetadata(String metadataJson) { + if (metadataJson == null || metadataJson.isBlank() || "{}".equals(metadataJson.trim())) { + return 0; + } + try { + String json = metadataJson.trim(); + if (json.startsWith("\"") && json.endsWith("\"")) { + json = objectMapper.readValue(json, String.class); + } + if (json.isBlank() || "{}".equals(json)) return 0; + JsonNode root = objectMapper.readTree(json); + JsonNode toolCalls = root.get("toolCalls"); + if (toolCalls != null && toolCalls.isArray() && !toolCalls.isEmpty()) { + return toolCalls.size(); + } + JsonNode segments = root.get("segments"); + if (segments != null && segments.isArray()) { + long count = 0; + for (JsonNode seg : segments) { + if ("tool_call".equals(seg.path("type").asText())) count++; + } + return count; + } + } catch (Exception ignored) { } + return 0; + } + + private void addKvRow(Sheet sheet, int r, String key, String value, CellStyle style) { + Row row = sheet.createRow(r); + Cell k = row.createCell(0); + k.setCellValue(key); + if (style != null) k.setCellStyle(style); + row.createCell(1).setCellValue(value); + } + + private CellStyle createHeaderStyle(XSSFWorkbook wb) { + CellStyle style = wb.createCellStyle(); + Font font = wb.createFont(); + font.setBold(true); + style.setFont(font); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setBorderBottom(BorderStyle.THIN); + return style; + } + + private CellStyle createNumStyle(XSSFWorkbook wb) { + CellStyle style = wb.createCellStyle(); + style.setDataFormat(wb.createDataFormat().getFormat("#,##0")); + return style; + } + + private CellStyle createDateStyle(XSSFWorkbook wb) { + CellStyle style = wb.createCellStyle(); + style.setDataFormat(wb.createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss")); + return style; + } + + private void createCell(Row row, int col, Object value, CellStyle style) { + Cell cell = row.createCell(col); + if (value == null) { + cell.setCellValue(""); + } else if (value instanceof String s) { + cell.setCellValue(s); + } else if (value instanceof Long l) { + // Snowflake IDs exceed the 2^53-1 exact-integer range of Excel's + // numeric (double) cells and would be rounded or shown in scientific + // notation; write out-of-range longs as text to preserve precision. + if (l > 9007199254740991L || l < -9007199254740991L) { + cell.setCellValue(String.valueOf(l)); + } else { + cell.setCellValue((double) l); + } + } else if (value instanceof Integer i) { + cell.setCellValue((double) i); + } else if (value instanceof Double d) { + cell.setCellValue(d); + } else if (value instanceof Boolean b) { + cell.setCellValue(b.toString()); + } else if (value instanceof LocalDateTime ldt) { + cell.setCellValue(ldt.format(DT_FMT)); + } else { + cell.setCellValue(value.toString()); + } + if (style != null) cell.setCellStyle(style); + } + + // ══════════════════════════════════════════════════ + // 定时清理 + // ══════════════════════════════════════════════════ + + @Scheduled(fixedRate = 3600000) + public void cleanExpiredTasks() { + long cutoff = System.currentTimeMillis() - 86400000; + tasks.values().removeIf(task -> { + if (task.getCompletedAt() > 0 && task.getCompletedAt() < cutoff) { + try { Files.deleteIfExists(task.getFilePath()); } catch (IOException ignored) {} + return true; + } + return false; + }); + } +} 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 9ef9ed6f..fac3ac82 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 @@ -224,12 +224,22 @@ public class PlanningService { /** * 审批 replay 上下文:找到最近一条 running 且含 awaiting_approval 步骤的计划, * 返回恢复图执行所需的全部状态。 + *

    + * 按 conversationId 过滤,防止并发会话误取兄弟会话的计划。 */ - public PlanResumeContext findAwaitingApprovalContext() { - PlanEntity plan = planMapper.selectOne(new LambdaQueryWrapper() + public PlanResumeContext findAwaitingApprovalContext(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + log.warn("[PlanningService] findAwaitingApprovalContext called without conversationId, " + + "risk of cross-conversation plan pickup in concurrent scenarios"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(PlanEntity::getStatus, "running") .orderByDesc(PlanEntity::getCreateTime) - .last("LIMIT 1")); + .last("LIMIT 1"); + if (conversationId != null && !conversationId.isBlank()) { + wrapper.eq(PlanEntity::getConversationId, conversationId); + } + PlanEntity plan = planMapper.selectOne(wrapper); if (plan == null) return null; List subPlans = subPlanMapper.selectList( diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java index 2c455883..6f2b9605 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/LoadedPlugin.java @@ -29,6 +29,9 @@ public class LoadedPlugin { /** Channel types registered by this plugin */ private final List registeredChannels = new ArrayList<>(); + /** Search provider ids registered by this plugin */ + private final List registeredSearchProviders = new ArrayList<>(); + /** Provider ID registered by this plugin (null if none) */ private String registeredProvider; diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java index 554f4db5..78f1e889 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginContextImpl.java @@ -12,9 +12,12 @@ import vip.mate.plugin.api.PluginException; import vip.mate.plugin.api.PluginManifest; import vip.mate.plugin.api.channel.PluginChannelAdapter; import vip.mate.plugin.api.memory.PluginMemoryProvider; +import vip.mate.plugin.api.search.PluginSearchProvider; import vip.mate.plugin.bridge.PluginChannelBridge; import vip.mate.plugin.bridge.PluginMemoryBridge; +import vip.mate.plugin.bridge.PluginSearchBridge; import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; import com.fasterxml.jackson.databind.ObjectMapper; @@ -35,7 +38,14 @@ public class PluginContextImpl implements PluginContext { private final ChannelManager channelManager; private final MemoryManager memoryManager; private final ModelProviderService modelProviderService; - private final Map configMap; + private final SearchProviderRegistry searchProviderRegistry; + /** + * Live config view: replaced wholesale by {@link #refreshConfig} when an admin + * saves new values, so {@link #getConfig} reflects updates without a plugin + * restart. Volatile reference to an immutable map — readers either see the old + * snapshot or the new one, never a torn state. + */ + private volatile Map configMap; private final Logger logger; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -45,6 +55,7 @@ public class PluginContextImpl implements PluginContext { ChannelManager channelManager, MemoryManager memoryManager, ModelProviderService modelProviderService, + SearchProviderRegistry searchProviderRegistry, String configJson) { this.loadedPlugin = loadedPlugin; this.manifest = manifest; @@ -52,10 +63,20 @@ public class PluginContextImpl implements PluginContext { this.channelManager = channelManager; this.memoryManager = memoryManager; this.modelProviderService = modelProviderService; + this.searchProviderRegistry = searchProviderRegistry; this.logger = LoggerFactory.getLogger("plugin." + manifest.getName()); this.configMap = parseConfig(configJson); } + /** + * Re-parse and swap the live config after {@code PluginManager.updateConfig} + * persists new values, so the running plugin's {@code getConfig} calls pick up + * the change immediately instead of serving load-time values until a restart. + */ + void refreshConfig(String configJson) { + this.configMap = parseConfig(configJson); + } + @SuppressWarnings("unchecked") private Map parseConfig(String configJson) { if (configJson == null || configJson.isBlank()) { @@ -105,6 +126,19 @@ public class PluginContextImpl implements PluginContext { loadedPlugin.setRegisteredMemoryProvider(provider.id()); } + @Override + public void registerSearchProvider(PluginSearchProvider provider) { + if (provider == null || provider.id() == null || provider.id().isBlank()) { + throw new PluginException("Search provider id must not be blank"); + } + try { + searchProviderRegistry.registerPluginProvider(new PluginSearchBridge(provider)); + } catch (IllegalArgumentException e) { + throw new PluginException(e.getMessage(), e); + } + loadedPlugin.getRegisteredSearchProviders().add(provider.id()); + } + @Override @SuppressWarnings("unchecked") public T getConfig(String key, Class type) { diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java index 0976c26f..9ef69cc5 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java @@ -19,6 +19,7 @@ import vip.mate.plugin.model.PluginEntity; import vip.mate.plugin.model.PluginInfo; import vip.mate.plugin.repository.PluginMapper; import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.core.service.WorkspaceService; @@ -57,6 +58,7 @@ public class PluginManager { private final ChannelManager channelManager; private final MemoryManager memoryManager; private final ModelProviderService modelProviderService; + private final SearchProviderRegistry searchProviderRegistry; private final Optional workspaceService; private final Map plugins = new ConcurrentHashMap<>(); @@ -211,6 +213,7 @@ public class PluginManager { PluginContextImpl context = new PluginContextImpl( loadedPlugin, manifest, toolRegistry, channelManager, memoryManager, modelProviderService, + searchProviderRegistry, configJson ); loadedPlugin.setContext(context); @@ -261,6 +264,9 @@ public class PluginManager { if (loaded.getRegisteredProvider() != null) { try { modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); } catch (Exception e) { /* best effort */ } } + for (String searchId : loaded.getRegisteredSearchProviders()) { + try { searchProviderRegistry.unregisterPluginProvider(searchId); } catch (Exception e) { /* best effort */ } + } } /** @@ -300,14 +306,20 @@ public class PluginManager { providerRemoved = loaded.getRegisteredProvider(); } + for (String searchId : loaded.getRegisteredSearchProviders()) { + searchProviderRegistry.unregisterPluginProvider(searchId); + } + int searchRemoved = loaded.getRegisteredSearchProviders().size(); + loaded.setEnabled(false); plugins.remove(name); updateStatus(name, false, "DISABLED", null); - log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={})", + log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={}, search={})", name, toolsRemoved, channelsRemoved, providerRemoved != null ? providerRemoved : "none", - memoryRemoved != null ? memoryRemoved : "none"); + memoryRemoved != null ? memoryRemoved : "none", + searchRemoved); } /** @@ -365,6 +377,7 @@ public class PluginManager { .registeredChannels(List.copyOf(loaded.getRegisteredChannels())) .registeredProvider(loaded.getRegisteredProvider()) .registeredMemoryProvider(loaded.getRegisteredMemoryProvider()) + .registeredSearchProviders(List.copyOf(loaded.getRegisteredSearchProviders())) .configSchema(buildConfigSchema(m)) .currentConfig(buildRedactedConfig(loaded)) .build()); @@ -387,6 +400,7 @@ public class PluginManager { .jarPath(entity.getJarPath()) .registeredTools(List.of()) .registeredChannels(List.of()) + .registeredSearchProviders(List.of()) .build()); } } @@ -405,14 +419,49 @@ public class PluginManager { } /** - * Update a plugin's configuration. + * 反查某个 search provider id 是由哪个已加载插件注册的(供设置页 catalog 用)。 + * + * @return 插件名(manifest 的 name),找不到返回 {@code null} */ + public String getPluginNameForSearchProvider(String searchProviderId) { + return plugins.values().stream() + .filter(p -> p.getRegisteredSearchProviders().contains(searchProviderId)) + .map(p -> p.getManifest().getName()) + .findFirst() + .orElse(null); + } + + /** + * Update a plugin's configuration. + *

    + * Merges the incoming (possibly partial) {@code config} over the existing stored + * config rather than replacing it wholesale. The plugin config dialog intentionally + * omits unchanged secret fields from its save payload — the frontend never receives + * plaintext secret values back from the backend (they're redacted), so it has no way + * to "resubmit unchanged"; omission is the only privacy-safe way to say "leave this + * as-is". If we treated the incoming map as the complete new config, every omitted + * field — including previously-configured secrets — would be silently deleted on + * every save. + */ + @SuppressWarnings("unchecked") public void updateConfig(String name, Map config) { PluginEntity entity = findByName(name); if (entity == null) { throw new PluginException("Plugin not found: " + name); } + // Parse the existing stored config so omitted keys can be carried forward. + Map mergedConfig = new LinkedHashMap<>(); + try { + if (entity.getConfigJson() != null && !entity.getConfigJson().isBlank()) { + mergedConfig.putAll(objectMapper.readValue(entity.getConfigJson(), Map.class)); + } + } catch (Exception e) { + log.warn("Plugin {} has unparsable stored config, discarding it: {}", name, e.getMessage()); + } + // Incoming values win for provided keys; everything else survives from the old config. + mergedConfig.putAll(config); + // Validate config keys against manifest if plugin is loaded LoadedPlugin loaded = plugins.get(name); if (loaded != null && loaded.getManifest().getConfig() != null) { @@ -422,17 +471,35 @@ public class PluginManager { log.warn("Plugin {} config: unknown key '{}' (not in manifest schema)", name, key); } } - // Check required fields + // Check required fields against the MERGED result — a required field that was + // already configured and is simply omitted from this save must not be treated + // as missing. Blank strings count as "not actually set", consistent with how + // SystemSettingService treats blank secret values as absent. for (Map.Entry schemaEntry : schema.entrySet()) { - if (schemaEntry.getValue().isRequired() && !config.containsKey(schemaEntry.getKey())) { - throw new PluginException("Missing required config field: " + schemaEntry.getKey()); + if (schemaEntry.getValue().isRequired()) { + Object value = mergedConfig.get(schemaEntry.getKey()); + boolean missing = !mergedConfig.containsKey(schemaEntry.getKey()) + || value == null + || (value instanceof String s && s.isBlank()); + if (missing) { + throw new PluginException("Missing required config field: " + schemaEntry.getKey()); + } } } } try { - entity.setConfigJson(objectMapper.writeValueAsString(config)); + String mergedJson = objectMapper.writeValueAsString(mergedConfig); + entity.setConfigJson(mergedJson); pluginMapper.updateById(entity); + // Push the new values into the RUNNING plugin's context too — configMap is + // parsed once at load time, so without this refresh the plugin would keep + // serving stale values from getConfig() until a disable/enable cycle, + // making the config dialog's "save" silently ineffective. + if (loaded != null && loaded.getContext() != null) { + loaded.getContext().refreshConfig(mergedJson); + log.info("Plugin config refreshed in running instance: {}", name); + } log.info("Plugin config updated: {}", name); } catch (PluginException e) { throw e; diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java new file mode 100644 index 00000000..5d8bb1bb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginSearchBridge.java @@ -0,0 +1,112 @@ +package vip.mate.plugin.bridge; + +import lombok.extern.slf4j.Slf4j; +import vip.mate.plugin.api.search.PluginSearchProvider; +import vip.mate.plugin.api.search.PluginSearchQuery; +import vip.mate.plugin.api.search.PluginSearchResult; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.tool.search.SearchProvider; +import vip.mate.tool.search.SearchQuery; +import vip.mate.tool.search.SearchResult; + +import java.util.ArrayList; +import java.util.List; + +/** + * Bridge that wraps a plugin's {@link PluginSearchProvider} into the platform's + * internal {@link SearchProvider} interface. + *

    + * The platform-side {@link SystemSettingsDTO} is intentionally ignored — plugin + * providers read their own config via {@code PluginContext#getConfig}, keeping + * the SDK free of server types. + *

    + * Fault isolation: {@code search()} may throw (the caller's provider-fallback + * chain handles that), but everything consulted on unguarded paths is insulated + * from plugin code here — metadata ({@code id/label/requiresCredential/autoDetectOrder}) + * is snapshotted once at registration time (where a throw is caught and rolled + * back by the plugin loader), because it is later read inside {@code allSorted()}'s + * sort comparator and the settings catalog with no per-provider guard; and + * {@code isAvailable()} degrades to {@code false} on any plugin exception, because + * it runs inside {@code resolve()} on every web_search call — a throwing + * availability check must not take every other provider down with it. + * + * @author MateClaw Team + */ +@Slf4j +public class PluginSearchBridge implements SearchProvider { + + private final PluginSearchProvider delegate; + private final String id; + private final String label; + private final boolean requiresCredential; + private final int autoDetectOrder; + + public PluginSearchBridge(PluginSearchProvider delegate) { + this.delegate = delegate; + this.id = delegate.id(); + this.label = delegate.label(); + this.requiresCredential = delegate.requiresCredential(); + this.autoDetectOrder = delegate.autoDetectOrder(); + } + + @Override + public String id() { + return id; + } + + @Override + public String label() { + return label; + } + + @Override + public boolean requiresCredential() { + return requiresCredential; + } + + @Override + public int autoDetectOrder() { + return autoDetectOrder; + } + + @Override + public boolean isAvailable(SystemSettingsDTO config) { + try { + return delegate.isAvailable(); + } catch (Exception e) { + log.warn("插件搜索提供商 {} 的 isAvailable() 抛出异常,按不可用处理: {}", id, e.getMessage()); + return false; + } + } + + @Override + public List search(String query, SystemSettingsDTO config) { + return search(SearchQuery.of(query), config); + } + + @Override + public List search(SearchQuery searchQuery, SystemSettingsDTO config) { + PluginSearchQuery pluginQuery = new PluginSearchQuery( + searchQuery.query(), + searchQuery.freshness(), + searchQuery.language(), + searchQuery.resolvedCount() + ); + List pluginResults = delegate.search(pluginQuery); + if (pluginResults == null) { + return List.of(); + } + List results = new ArrayList<>(pluginResults.size()); + for (PluginSearchResult r : pluginResults) { + results.add(SearchResult.builder() + .title(r.title()) + .url(r.url()) + .snippet(r.snippet()) + .source(r.source()) + .date(r.date()) + .providerId(id) + .build()); + } + return results; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java index 817d5592..b576bb5e 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginInfo.java @@ -38,6 +38,9 @@ public class PluginInfo { /** Memory provider ID registered by this plugin (null if none) */ private String registeredMemoryProvider; + /** Search provider ids registered by this plugin */ + private List registeredSearchProviders; + /** Plugin config schema (from manifest) */ private Map configSchema; 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 ce3c0243..bbee1d22 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 @@ -895,6 +895,15 @@ public class SkillController { return R.ok(skillCuratorJob.status()); } + @Operation(summary = "开启/关闭 curator 合并去重 pass") + @PostMapping("/curator/consolidate") + @RequireWorkspaceRole("admin") + public R> curatorConsolidate( + @RequestParam(defaultValue = "true") boolean enabled) { + skillCuratorJob.setConsolidate(enabled); + return R.ok(skillCuratorJob.status()); + } + @Operation(summary = "curator 控制面状态") @GetMapping("/curator/status") @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java index ac81767c..0f6e7b7d 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java @@ -3,6 +3,7 @@ package vip.mate.skill.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -33,6 +34,15 @@ public class SkillInstallController { private final SkillInstaller skillInstaller; private final SkillFrontmatterParser frontmatterParser; + /** Per-entry cap inside an uploaded skill ZIP (MB). */ + @Value("${mateclaw.skill.upload.max-entry-size-mb:1}") + private long maxEntrySizeMb = 1; + + /** Total size cap for an uploaded skill ZIP (MB). The archive is buffered + * in memory during extraction, so this also bounds peak heap usage. */ + @Value("${mateclaw.skill.upload.max-total-size-mb:50}") + private long maxTotalSizeMb = 50; + @Operation(summary = "搜索 ClawHub 市场") @GetMapping("/hub/search") @RequireWorkspaceRole("admin") @@ -90,7 +100,8 @@ public class SkillInstallController { return R.fail(400, "Only .zip files are accepted"); } try { - SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser); + SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser, + ZipSkillFetcher.Limits.ofMb(maxEntrySizeMb, maxTotalSizeMb)); Map result = skillInstaller.installFromBundle( bundle, enable, overwrite, targetName, workspaceId); return R.ok(result); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java index baf1d102..487305f6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java @@ -3,6 +3,7 @@ package vip.mate.skill.installer; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import vip.mate.skill.installer.model.HubSkillInfo; import vip.mate.skill.installer.model.SkillBundle; @@ -37,6 +38,13 @@ import java.util.Map; @Service public class SkillHubClient { + /** Per-entry / total caps for marketplace bundle ZIPs — same knobs as upload. */ + @Value("${mateclaw.skill.upload.max-entry-size-mb:1}") + private long maxEntrySizeMb = 1; + + @Value("${mateclaw.skill.upload.max-total-size-mb:50}") + private long maxTotalSizeMb = 50; + private final SkillHubProperties properties; private final ObjectMapper objectMapper; private final SkillFrontmatterParser frontmatterParser; @@ -131,7 +139,9 @@ public class SkillHubClient { // Step 3: extract + assemble SkillBundle. try { - ZipSkillFetcher.ExtractedSkill extracted = ZipSkillFetcher.extract(new ByteArrayInputStream(zipBytes)); + ZipSkillFetcher.ExtractedSkill extracted = ZipSkillFetcher.extract( + new ByteArrayInputStream(zipBytes), + ZipSkillFetcher.Limits.ofMb(maxEntrySizeMb, maxTotalSizeMb)); var parsed = frontmatterParser.parse(extracted.skillMdContent()); Map fm = parsed.getFrontmatter(); 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 e1e8221f..d176f0ed 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 @@ -27,7 +27,9 @@ import java.util.zip.ZipInputStream; * path (downloaded ZIP bytes). Hardened against: *

      *
    • Zip Slip path traversal
    • - *
    • Per-file ≤1MB, total ≤50MB
    • + *
    • Per-file / total size caps (defaults 1MB / 50MB, configurable via + * {@code mateclaw.skill.upload.max-entry-size-mb} / + * {@code mateclaw.skill.upload.max-total-size-mb})
    • *
    • Only SKILL.md / references/ / scripts/ entries are kept
    • *
    • Binary entries are skipped with a WARN — bundle storage is text-only, * so decoding them as text would persist corrupted content
    • @@ -44,8 +46,23 @@ import java.util.zip.ZipInputStream; @Slf4j public class ZipSkillFetcher { - private static final long MAX_FILE_SIZE = 1_000_000; // 1MB per file - private static final long MAX_TOTAL_SIZE = 50_000_000; // 50MB total + /** + * Size caps applied while decompressing a bundle. Callers wire these from + * {@code mateclaw.skill.upload.max-entry-size-mb} / + * {@code mateclaw.skill.upload.max-total-size-mb}; the defaults preserve + * the historical 1MB-per-entry / 50MB-total behaviour. The whole archive + * is buffered in memory during extraction, so raising the total cap + * raises peak heap usage accordingly. + */ + public record Limits(long maxEntryBytes, long maxTotalBytes) { + public static final Limits DEFAULT = ofMb(1, 50); + + public static Limits ofMb(long entryMb, long totalMb) { + return new Limits(entryMb * 1_000_000L, totalMb * 1_000_000L); + } + + long totalMb() { return maxTotalBytes / 1_000_000L; } + } private static final String SKILL_MD = "SKILL.md"; private static final String SKILL_MD_LOWER = "skill.md"; @@ -87,16 +104,26 @@ public class ZipSkillFetcher { * and source URL is the original filename. */ public static SkillBundle parse(MultipartFile zipFile, SkillFrontmatterParser parser) throws IOException { + return parse(zipFile, parser, Limits.DEFAULT); + } + + /** + * Parse an uploaded ZIP file into a SkillBundle with explicit size caps + * (see {@link Limits}). + */ + public static SkillBundle parse(MultipartFile zipFile, SkillFrontmatterParser parser, + Limits limits) throws IOException { if (zipFile == null || zipFile.isEmpty()) { throw new IllegalArgumentException("ZIP file is empty"); } - if (zipFile.getSize() > MAX_TOTAL_SIZE) { - throw new IllegalArgumentException("ZIP file too large (max 50MB)"); + if (zipFile.getSize() > limits.maxTotalBytes()) { + throw new IllegalArgumentException("ZIP file too large (max " + limits.totalMb() + + "MB; adjust mateclaw.skill.upload.max-total-size-mb)"); } ExtractedSkill extracted; try (InputStream is = zipFile.getInputStream()) { - extracted = extract(is); + extracted = extract(is, limits); } var parsed = parser.parse(extracted.skillMdContent()); @@ -140,7 +167,12 @@ public class ZipSkillFetcher { * instead of being silently dropped. */ public static ExtractedSkill extract(InputStream zipStream) throws IOException { - return extract(zipStream.readAllBytes()); + return extract(zipStream.readAllBytes(), Limits.DEFAULT); + } + + /** Variant of {@link #extract(InputStream)} with explicit size caps. */ + public static ExtractedSkill extract(InputStream zipStream, Limits limits) throws IOException { + return extract(zipStream.readAllBytes(), limits); } /** Fallback charset for archives authored on Chinese Windows (entry names / content in GBK). */ @@ -154,12 +186,17 @@ public class ZipSkillFetcher { * a one-shot stream) is what makes the retry possible. */ public static ExtractedSkill extract(byte[] zipBytes) throws IOException { + return extract(zipBytes, Limits.DEFAULT); + } + + /** 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); + 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); + return extract(zipBytes, GBK, limits); } throw e; } @@ -182,7 +219,7 @@ public class ZipSkillFetcher { return false; } - private static ExtractedSkill extract(byte[] zipBytes, Charset charset) throws IOException { + private static ExtractedSkill extract(byte[] zipBytes, Charset charset, Limits limits) throws IOException { List raws = new ArrayList<>(); String skillMdContent = null; String skillMdPrefix = ""; @@ -214,21 +251,22 @@ public class ZipSkillFetcher { } long declaredSize = entry.getSize(); - if (declaredSize > MAX_FILE_SIZE) { + if (declaredSize > limits.maxEntryBytes()) { log.warn("[ZipSkillFetcher] Skipping oversized entry: {} ({}bytes)", entryName, declaredSize); zis.closeEntry(); continue; } byte[] bytes = zis.readAllBytes(); - if (bytes.length > MAX_FILE_SIZE) { + if (bytes.length > limits.maxEntryBytes()) { log.warn("[ZipSkillFetcher] Skipping oversized entry post-read: {} ({}bytes)", entryName, bytes.length); zis.closeEntry(); continue; } totalSize += bytes.length; - if (totalSize > MAX_TOTAL_SIZE) { - throw new IOException("Total extracted size exceeds 50MB limit"); + if (totalSize > limits.maxTotalBytes()) { + throw new IOException("Total extracted size exceeds " + limits.totalMb() + + "MB limit (adjust mateclaw.skill.upload.max-total-size-mb)"); } // Skill bundles persist file contents as text (mate_skill_file diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java new file mode 100644 index 00000000..e35f4ba6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java @@ -0,0 +1,255 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +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.model.ToolContext; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; +import vip.mate.tool.builtin.SkillManageTool; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Consolidation pass for the skill curator: merges near-duplicate + * agent-created skills into a broader umbrella skill, then archives the + * narrow ones it absorbed. Off by default — opt in via + * {@code mateclaw.skill.curator.consolidate}. + * + *

      The umbrella write is routed through {@link SkillManageTool} so it + * inherits the full security scan / validation pipeline; the absorbed skills + * are archived (not deleted) through {@link SkillLifecycleService} so they + * stay recoverable. The reviewer can only ever cause skills already in the + * curator's candidate set to be archived — names it invents are ignored. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillConsolidationService { + + private final SkillService skillService; + private final SkillManageTool skillManageTool; + private final SkillLifecycleService lifecycleService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final SkillLifecycleProperties properties; + private final ObjectMapper objectMapper; + + private static final int CATALOG_BODY_TRUNCATE_CHARS = 1500; + + /** + * Run a consolidation pass over the given candidate skills, recording + * outcomes into the sweep report. No-op when consolidation is disabled or + * there are too few candidates to bother. + */ + public void consolidate(List candidates, LocalDateTime now, + boolean dryRun, SkillCuratorReport.Builder report) { + if (!properties.isConsolidate()) { + return; + } + List withContent = candidates.stream() + .filter(s -> s.getSkillContent() != null && !s.getSkillContent().isBlank()) + .toList(); + if (withContent.size() < properties.getConsolidateMinSkills()) { + return; + } + + // Index by name so the reviewer can only ever archive in-scope skills. + Map byName = new LinkedHashMap<>(); + for (SkillEntity s : withContent) { + byName.put(s.getName(), s); + } + + JsonNode groups = askReviewer(withContent); + if (groups == null || !groups.isArray() || groups.isEmpty()) { + return; + } + + int applied = 0; + for (JsonNode group : groups) { + if (applied >= properties.getConsolidateMaxGroupsPerRun()) { + break; + } + if (applyGroup(group, byName, now, dryRun, report)) { + applied++; + } + } + } + + private boolean applyGroup(JsonNode group, Map byName, + LocalDateTime now, boolean dryRun, SkillCuratorReport.Builder report) { + String umbrellaName = group.path("umbrella_name").asText("").strip().toLowerCase(); + String umbrellaContent = group.path("umbrella_content").asText(null); + String reason = group.path("reason").asText(""); + if (umbrellaName.isBlank() || umbrellaContent == null || umbrellaContent.isBlank()) { + return false; + } + + // Restrict absorbed skills to the in-scope candidate set, excluding the + // umbrella itself — the reviewer cannot archive anything outside it. + List absorb = new ArrayList<>(); + for (JsonNode n : group.path("absorb")) { + String nm = n.asText("").strip().toLowerCase(); + if (!nm.isBlank() && !nm.equals(umbrellaName) && byName.containsKey(nm) && !absorb.contains(nm)) { + absorb.add(nm); + } + } + SkillEntity existingUmbrella = skillService.findByName(umbrellaName); + boolean willCreate = existingUmbrella == null; + // A real merge must touch at least two distinct skills: a brand-new + // umbrella needs >=2 absorbed; reusing an existing skill as the + // umbrella needs >=1 absorbed (the umbrella itself is the second). + boolean realMerge = willCreate ? absorb.size() >= 2 : !absorb.isEmpty(); + if (!realMerge) { + log.debug("[SkillConsolidate] Skipping group '{}' — not a real merge", umbrellaName); + return false; + } + + if (dryRun) { + report.consolidation(new SkillCuratorReport.ConsolidationRow( + umbrellaName, willCreate, absorb, false, reason)); + return true; + } + + // Stamp the umbrella with a source conversation from one absorbed skill + // so it stays curator-eligible under the AGENT_CREATED scope. + String lineageConv = absorb.stream() + .map(byName::get) + .map(SkillEntity::getSourceConversationId) + .filter(c -> c != null && !c.isBlank()) + .findFirst().orElse(null); + ToolContext ctx = toolContext(lineageConv); + + String act = willCreate ? "create" : "edit"; + String result = skillManageTool.skill_manage(act, umbrellaName, umbrellaContent, null, null, null, ctx); + boolean umbrellaOk = result != null + && !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED"); + if (!umbrellaOk) { + log.debug("[SkillConsolidate] Umbrella {} '{}' rejected: {}", act, umbrellaName, result); + return false; + } + + // Archive the absorbed narrow skills (recoverable, never deleted). + for (String nm : absorb) { + SkillEntity victim = byName.get(nm); + if (victim == null) { + continue; + } + try { + lifecycleService.applyManual(victim, LifecycleTransition.TO_ARCHIVED, now, + "consolidated into " + umbrellaName); + } catch (Exception e) { + log.warn("[SkillConsolidate] Failed to archive absorbed skill '{}': {}", nm, e.getMessage()); + } + } + + log.info("[SkillConsolidate] {} umbrella '{}' absorbing {} — {}", act, umbrellaName, absorb, reason); + report.consolidation(new SkillCuratorReport.ConsolidationRow( + umbrellaName, willCreate, absorb, true, reason)); + return true; + } + + private JsonNode askReviewer(List skills) { + try { + String systemPrompt = PromptLoader.loadPrompt("skill/consolidate-system"); + String userPrompt = PromptLoader.loadPrompt("skill/consolidate-user") + .replace("{skills}", buildCatalog(skills, properties.getConsolidateCatalogCharBudget())); + ChatModel chatModel = buildChatModel(); + Prompt prompt = new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt))); + ChatResponse response = chatModel.call(prompt); + if (response == null || response.getResult() == null + || response.getResult().getOutput() == null) { + return null; + } + return parseJsonResponse(response.getResult().getOutput().getText()); + } catch (Exception e) { + log.warn("[SkillConsolidate] Reviewer call failed: {}", e.getMessage()); + return null; + } + } + + private String buildCatalog(List skills, int charBudget) { + StringBuilder sb = new StringBuilder(); + for (SkillEntity skill : skills) { + String entry = "### " + skill.getName() + "\n" + + (skill.getDescription() == null ? "" : skill.getDescription().strip() + "\n") + + truncate(skill.getSkillContent(), CATALOG_BODY_TRUNCATE_CHARS) + "\n\n"; + if (sb.length() + entry.length() > charBudget) { + sb.append("... (catalog truncated)\n"); + break; + } + sb.append(entry); + } + return sb.toString().strip(); + } + + private ToolContext toolContext(String sourceConversationId) { + ChatOrigin origin = new ChatOrigin(null, sourceConversationId, "", null, null, + null, null, false, null, null, null, null, null); + return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin)); + } + + private ChatModel buildChatModel() { + ModelConfigEntity model = null; + if (properties.getConsolidateModelId() != null && !properties.getConsolidateModelId().isBlank()) { + try { + model = modelConfigService.getModel(Long.parseLong(properties.getConsolidateModelId())); + } catch (Exception e) { + log.warn("[SkillConsolidate] Invalid consolidateModelId '{}', using default", + properties.getConsolidateModelId()); + } + } + if (model == null) { + model = modelConfigService.getDefaultModel(); + } + return agentGraphBuilder.buildRuntimeChatModel(model); + } + + private JsonNode parseJsonResponse(String response) { + if (response == null || response.isBlank()) { + return null; + } + String cleaned = response.strip(); + if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7); + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.substring(3); + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.substring(0, cleaned.length() - 3); + } + try { + return objectMapper.readTree(cleaned.strip()); + } catch (Exception e) { + log.debug("[SkillConsolidate] JSON parse failed: {}", e.getMessage()); + return null; + } + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return ""; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "... [truncated]"; + } +} 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 28620ae0..882e2895 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 @@ -39,6 +39,8 @@ public class SkillCuratorJob { static final String FIRST_RUN_KEY = "skill.curator.firstRunCompleted"; /** Runtime kill switch — pauses the scheduled sweep without a redeploy. */ static final String PAUSED_KEY = "skill.curator.paused"; + /** Runtime override for the consolidation pass (falls back to config). */ + static final String CONSOLIDATE_KEY = "skill.curator.consolidate"; /** ISO-8601 timestamp of the last auto dry-run, for throttling. */ static final String LAST_DRY_RUN_KEY = "skill.curator.lastDryRunAt"; /** ISO-8601 timestamp of the first sweep observation after install. */ @@ -57,6 +59,7 @@ public class SkillCuratorJob { private final AgentBindingService agentBindingService; private final SkillWorkspaceManager workspaceManager; private final CuratorRunNotifier notifier; + private final SkillConsolidationService consolidationService; @Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}") @SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S") @@ -127,6 +130,16 @@ public class SkillCuratorJob { systemSettingService.saveBool(PAUSED_KEY, paused, "Skill curator paused"); } + /** Set the runtime consolidation flag (overrides the config default). */ + public void setConsolidate(boolean on) { + systemSettingService.saveBool(CONSOLIDATE_KEY, on, "Skill curator consolidation enabled"); + } + + /** Effective consolidation switch: runtime override, falling back to config. */ + private boolean effectiveConsolidate() { + return systemSettingService.getBool(CONSOLIDATE_KEY, properties.isConsolidate()); + } + /** Aggregated control-panel state for the admin UI. */ public Map status() { Map config = new LinkedHashMap<>(); @@ -139,6 +152,7 @@ public class SkillCuratorJob { Map control = new LinkedHashMap<>(); control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false)); control.put("paused", systemSettingService.getBool(PAUSED_KEY, false)); + control.put("consolidate", effectiveConsolidate()); control.put("lastObservedAt", systemSettingService.getString(LAST_OBSERVED_KEY, null)); control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null)); control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null)); @@ -212,6 +226,15 @@ public class SkillCuratorJob { .appliedCounts(appliedStale, appliedArchived, appliedReactivate) .blockedByBindings(agentBindingService.blockedByBindingCandidates(now)); + // Consolidation pass (opt-in). Reload candidates so it sees the state + // left by the aging pass above and never merges a just-archived skill. + if (effectiveConsolidate()) { + List mergeCandidates = loadCandidates().stream() + .filter(s -> !"archived".equals(s.getLifecycleState())) + .toList(); + consolidationService.consolidate(mergeCandidates, now, dryRun, report); + } + return reportStore.write(report.build()); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java index a2cb8b79..f03d5287 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java @@ -38,6 +38,7 @@ public class SkillCuratorReport { private final List transitions; private final List blockedByBindings; private final List reconciliations; + private final List consolidations; /** Set by the report store after the run directory is written. */ @JsonIgnore @@ -54,6 +55,7 @@ public class SkillCuratorReport { this.transitions = List.copyOf(b.transitions); this.blockedByBindings = List.copyOf(b.blockedByBindings); this.reconciliations = List.copyOf(b.reconciliations); + this.consolidations = List.copyOf(b.consolidations); } public void setPath(Path path) { @@ -81,6 +83,15 @@ public class SkillCuratorReport { public record TransitionRow(Long skillId, String name, String from, String to, long daysIdle) {} + /** + * One consolidation group: narrow skills in {@code absorbed} were folded + * into the {@code umbrella} skill. {@code umbrellaCreated} distinguishes a + * brand-new umbrella from an edit of an existing skill; {@code applied} is + * false for a dry-run preview. + */ + public record ConsolidationRow(String umbrella, boolean umbrellaCreated, + List absorbed, boolean applied, String reason) {} + public static Builder builder() { return new Builder(); } @@ -98,6 +109,7 @@ public class SkillCuratorReport { private final List transitions = new ArrayList<>(); private List blockedByBindings = new ArrayList<>(); private final List reconciliations = new ArrayList<>(); + private final List consolidations = new ArrayList<>(); public Builder runAt(LocalDateTime runAt) { this.runAt = runAt; @@ -166,6 +178,13 @@ public class SkillCuratorReport { return this; } + public Builder consolidation(ConsolidationRow row) { + if (row != null) { + this.consolidations.add(row); + } + return this; + } + public SkillCuratorReport build() { return new SkillCuratorReport(this); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java index 1843d2c3..31f24f0f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java @@ -192,6 +192,20 @@ public class SkillCuratorReportStore { sb.append('\n'); } + if (!r.getConsolidations().isEmpty()) { + sb.append("## Consolidations\n\n"); + sb.append("Narrow agent-created skills merged into a broader umbrella skill.\n\n"); + sb.append("| umbrella | new? | absorbed | applied | reason |\n|---|---|---|---|---|\n"); + for (SkillCuratorReport.ConsolidationRow c : r.getConsolidations()) { + sb.append("| ").append(c.umbrella()) + .append(" | ").append(c.umbrellaCreated() ? "create" : "edit") + .append(" | ").append(String.join(", ", c.absorbed())) + .append(" | ").append(c.applied() ? "yes" : "preview") + .append(" | ").append(c.reason() == null ? "" : c.reason()).append(" |\n"); + } + sb.append('\n'); + } + if (!r.getReconciliations().isEmpty()) { sb.append("## Reconciliations\n\n"); for (String line : r.getReconciliations()) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java index 9ed64593..7c49755d 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java @@ -42,4 +42,23 @@ public class SkillLifecycleProperties { /** Skills whose name starts with any of these prefixes are never touched. */ private List protectPrefixes = new ArrayList<>(List.of("sys-", "ops-")); + + /** + * Whether the daily sweep also runs a consolidation pass that merges + * near-duplicate agent-created skills into broader umbrella skills. + * Off by default — it spends an LLM call and rewrites skills, so opt-in. + */ + private boolean consolidate = false; + + /** Minimum candidate skills present before a consolidation pass runs. */ + private int consolidateMinSkills = 4; + + /** Hard cap on merge groups applied in a single consolidation pass. */ + private int consolidateMaxGroupsPerRun = 2; + + /** Character budget for the catalog handed to the consolidation reviewer. */ + private int consolidateCatalogCharBudget = 12000; + + /** Consolidation model ID ({@code null} = follow the system default model). */ + private String consolidateModelId; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionAutoConfiguration.java new file mode 100644 index 00000000..060c0d78 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionAutoConfiguration.java @@ -0,0 +1,14 @@ +package vip.mate.skill.reflection; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Registers configuration for the out-of-band skill reflection service. + * + * @author MateClaw Team + */ +@Configuration +@EnableConfigurationProperties(SkillReflectionProperties.class) +public class SkillReflectionAutoConfiguration { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionProperties.java new file mode 100644 index 00000000..5b5777a8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionProperties.java @@ -0,0 +1,49 @@ +package vip.mate.skill.reflection; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration for the out-of-band skill reflection service — the post-turn + * review that autonomously creates or improves skills from a finished + * conversation, without consuming the live turn's context. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.skill.reflection") +public class SkillReflectionProperties { + + /** Master switch. When {@code false} no post-turn skill review runs. */ + private boolean enabled = true; + + /** + * Review cadence: trigger a review every N conversation messages. The + * cooldown still applies on top, so a busy conversation reviews at most + * once per {@link #cooldownMinutes}. {@code 0} disables the cadence gate. + */ + private int reviewTurnInterval = 8; + + /** + * Minimum number of assistant turns in the reviewed window before a review + * is worth running — a one-shot exchange rarely contains a reusable + * workflow. (Tool calls are not persisted as separate messages, so turn + * count, not tool count, is the signal we can actually observe.) + */ + private int minAssistantTurns = 2; + + /** Most recent messages fed to the reviewer. */ + private int maxMessages = 24; + + /** Per-conversation cooldown between reviews, in minutes. */ + private int cooldownMinutes = 30; + + /** Hard cap on create/edit/patch actions applied in a single review. */ + private int maxActionsPerRun = 3; + + /** Character budget for the existing-skills catalog handed to the reviewer. */ + private int catalogCharBudget = 8000; + + /** Review model ID ({@code null} = follow the system default model). */ + private String modelId; +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java new file mode 100644 index 00000000..a3710cf4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java @@ -0,0 +1,352 @@ +package vip.mate.skill.reflection; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +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.model.ToolContext; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.event.ConversationCompletedEvent; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; +import vip.mate.tool.builtin.SkillManageTool; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Out-of-band skill reflection — after a conversation finishes, reviews the + * recent turns and autonomously creates or improves skills, mirroring the + * memory-nudge cadence but writing to the skill registry instead of memory. + * + *

      The review runs on an async thread so it never blocks the user response + * and never consumes the live turn's context window. Every write is routed + * back through {@link SkillManageTool#skill_manage} so it inherits the same + * security scan, name validation, builtin guard, fuzzy-patch matching, and + * workspace export as the in-band agent path — this service only decides + * what to write, never how. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillReflectionService { + + private final ConversationService conversationService; + private final SkillService skillService; + private final SkillManageTool skillManageTool; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final SkillReflectionProperties properties; + private final ObjectMapper objectMapper; + + /** Per-conversation cooldown tracking. */ + private final ConcurrentHashMap lastRunTimes = new ConcurrentHashMap<>(); + + /** Per-message truncation when building the review transcript. */ + private static final int MESSAGE_TRUNCATE_CHARS = 1200; + /** Per-skill body truncation when building the catalog. */ + private static final int CATALOG_BODY_TRUNCATE_CHARS = 1200; + + @Async + @EventListener + public void onConversationCompleted(ConversationCompletedEvent event) { + if (event == null) { + return; + } + maybeReflect(event.agentId(), event.conversationId(), event.messageCount()); + } + + /** + * Decide whether a review should run for this conversation and execute it + * if the cadence, tool-use floor, and cooldown gates all pass. + */ + @Async + public void maybeReflect(Long agentId, String conversationId, int messageCount) { + if (!properties.isEnabled() || agentId == null || conversationId == null) { + return; + } + // Cadence gate: review every N messages. + if (properties.getReviewTurnInterval() <= 0 + || messageCount % properties.getReviewTurnInterval() != 0) { + return; + } + if (isInCooldown(conversationId)) { + log.debug("[SkillReflect] conversation {} in cooldown, skipping", conversationId); + return; + } + try { + boolean ran = doReflect(agentId, conversationId); + if (ran) { + lastRunTimes.put(conversationId, Instant.now()); + } + } catch (Exception e) { + log.warn("[SkillReflect] Failed for agent={}, conv={}: {}", + agentId, conversationId, e.getMessage()); + } + } + + /** @return {@code true} when a review actually ran (cooldown should advance). */ + private boolean doReflect(Long agentId, String conversationId) { + // 1. Load the recent window of the conversation. + List messages = conversationService.listMessages(conversationId); + if (messages == null || messages.isEmpty()) { + return false; + } + int maxReview = properties.getMaxMessages(); + List recent = messages.size() > maxReview + ? messages.subList(messages.size() - maxReview, messages.size()) + : messages; + + // 2. Substance floor — a window with too few assistant turns rarely + // yields a reusable skill. Tool calls are not persisted as separate + // messages, so assistant-turn count is the observable signal. + long assistantTurns = recent.stream().filter(m -> "assistant".equals(m.getRole())).count(); + if (assistantTurns < properties.getMinAssistantTurns()) { + log.debug("[SkillReflect] conv {} below assistant-turn floor ({} < {}), skipping", + conversationId, assistantTurns, properties.getMinAssistantTurns()); + return false; + } + + String transcript = buildTranscript(recent); + if (transcript.isBlank()) { + return false; + } + String skillCatalog = buildSkillCatalog(properties.getCatalogCharBudget()); + + // 3. Ask the reviewer for a JSON action plan. + String llmResponse; + try { + String systemPrompt = PromptLoader.loadPrompt("skill/reflect-system"); + String userPrompt = PromptLoader.loadPrompt("skill/reflect-user") + .replace("{skills}", skillCatalog.isBlank() ? "(no skills yet)" : skillCatalog) + .replace("{transcript}", transcript); + ChatModel chatModel = buildChatModel(); + Prompt prompt = new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt))); + llmResponse = callLlmWithRetry(chatModel, prompt, 2); + } catch (Exception e) { + log.warn("[SkillReflect] LLM call failed for conv={}: {}", conversationId, e.getMessage()); + return true; + } + if (llmResponse == null) { + return true; + } + + // 4. Parse and apply the plan via the shared skill_manage safety pipeline. + JsonNode plan = parseJsonResponse(llmResponse); + if (plan == null || !plan.isArray() || plan.isEmpty()) { + log.debug("[SkillReflect] No actions proposed for conv={}", conversationId); + return true; + } + + ToolContext toolContext = buildToolContext(agentId, conversationId); + int applied = 0; + for (JsonNode action : plan) { + if (applied >= properties.getMaxActionsPerRun()) { + log.info("[SkillReflect] Hit maxActionsPerRun={} for conv={}, stopping", + properties.getMaxActionsPerRun(), conversationId); + break; + } + if (applyAction(action, toolContext)) { + applied++; + } + } + if (applied > 0) { + log.info("[SkillReflect] Applied {} skill action(s) from conv={}", applied, conversationId); + } + return true; + } + + /** Route one planned action through {@link SkillManageTool}. */ + private boolean applyAction(JsonNode action, ToolContext toolContext) { + String act = action.path("action").asText("").strip().toLowerCase(); + String name = action.path("name").asText("").strip(); + if (act.isBlank() || name.isBlank()) { + return false; + } + // Reflection never deletes — it only creates or improves. + if (!List.of("create", "edit", "patch").contains(act)) { + log.debug("[SkillReflect] Ignoring unsupported action '{}'", act); + return false; + } + String content = action.path("content").asText(null); + String oldText = action.path("oldText").asText(null); + String newText = action.path("newText").asText(null); + try { + String result = skillManageTool.skill_manage(act, name, content, oldText, newText, null, toolContext); + boolean ok = result != null && !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED"); + if (ok) { + log.info("[SkillReflect] {} '{}' — {}", act, name, + action.path("reason").asText("")); + } else { + log.debug("[SkillReflect] {} '{}' rejected: {}", act, name, result); + } + return ok; + } catch (Exception e) { + log.warn("[SkillReflect] Action {} '{}' threw: {}", act, name, e.getMessage()); + return false; + } + } + + /** + * Build a ToolContext carrying the agent's origin so created skills are + * stamped with their source conversation (making them curator-eligible + * under the {@code AGENT_CREATED} scope). + */ + private ToolContext buildToolContext(Long agentId, String conversationId) { + ChatOrigin origin = new ChatOrigin(agentId, conversationId, "", null, null, + null, null, false, null, null, null, null, null); + return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin)); + } + + /** Existing non-builtin skills with truncated bodies, capped to a char budget. */ + private String buildSkillCatalog(int charBudget) { + List skills = skillService.listEnabledSkills(); + if (skills == null || skills.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (SkillEntity skill : skills) { + if (Boolean.TRUE.equals(skill.getBuiltin())) { + continue; + } + String entry = "### " + skill.getName() + "\n" + + (skill.getDescription() == null ? "" : skill.getDescription().strip() + "\n") + + truncate(skill.getSkillContent(), CATALOG_BODY_TRUNCATE_CHARS) + "\n\n"; + if (sb.length() + entry.length() > charBudget) { + sb.append("... (catalog truncated)\n"); + break; + } + sb.append(entry); + } + return sb.toString().strip(); + } + + private String buildTranscript(List messages) { + StringBuilder sb = new StringBuilder(); + for (MessageEntity msg : messages) { + String role = msg.getRole(); + String content = msg.getContent(); + if (content == null || content.isBlank()) { + continue; + } + String label = switch (role == null ? "" : role) { + case "user" -> "User"; + case "assistant" -> "Assistant"; + case "tool" -> "Tool[" + (msg.getToolName() != null ? msg.getToolName() : "unknown") + "]"; + default -> null; + }; + if (label == null) { + continue; + } + sb.append(label).append(": ").append(truncate(content, MESSAGE_TRUNCATE_CHARS)).append("\n\n"); + } + return sb.toString().strip(); + } + + private ChatModel buildChatModel() { + ModelConfigEntity model = null; + if (properties.getModelId() != null && !properties.getModelId().isBlank()) { + try { + model = modelConfigService.getModel(Long.parseLong(properties.getModelId())); + } catch (Exception e) { + log.warn("[SkillReflect] Invalid modelId '{}', falling back to default", properties.getModelId()); + } + } + if (model == null) { + model = modelConfigService.getDefaultModel(); + } + return agentGraphBuilder.buildRuntimeChatModel(model); + } + + private JsonNode parseJsonResponse(String response) { + if (response == null || response.isBlank()) { + return null; + } + String cleaned = response.strip(); + if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7); + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.substring(3); + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.substring(0, cleaned.length() - 3); + } + cleaned = cleaned.strip(); + try { + return objectMapper.readTree(cleaned); + } catch (Exception e) { + log.debug("[SkillReflect] JSON parse failed: {}", e.getMessage()); + return null; + } + } + + private String callLlmWithRetry(ChatModel chatModel, Prompt prompt, int maxRetries) { + for (int attempt = 0; attempt <= maxRetries; attempt++) { + try { + ChatResponse response = chatModel.call(prompt); + if (response != null && response.getResult() != null + && response.getResult().getOutput() != null) { + return response.getResult().getOutput().getText(); + } + return null; + } catch (Exception e) { + if (attempt < maxRetries && isRateLimitError(e)) { + long delay = 5000L * (attempt + 1); + log.info("[SkillReflect] Rate limited, waiting {}ms before retry ({}/{})", + delay, attempt + 1, maxRetries); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return null; + } + } else { + throw e instanceof RuntimeException re ? re : new RuntimeException(e); + } + } + } + return null; + } + + private boolean isRateLimitError(Exception e) { + String msg = e.getMessage(); + return msg != null && (msg.contains("429") || msg.contains("rate_limit") + || msg.contains("Too Many Requests")); + } + + private boolean isInCooldown(String conversationId) { + Instant lastRun = lastRunTimes.get(conversationId); + if (lastRun == null) { + return false; + } + long cooldownSeconds = properties.getCooldownMinutes() * 60L; + return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds)); + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return ""; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "... [truncated]"; + } +} 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 69f17d40..4c662ccb 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 @@ -652,6 +652,8 @@ public class SkillService { catalog.append("When using a skill and finding it outdated, incomplete, or wrong, "); catalog.append("patch it immediately with `skill_manage(action='patch')` — don't wait to be asked. "); catalog.append("Skills that aren't maintained become liabilities.\n\n"); + catalog.append("Keep SKILL.md lean: move bulky reference material or re-runnable scripts into "); + catalog.append("`skill_manage(action='write_file')` under references/ or scripts/.\n\n"); // --- 第一层:技能目录(始终注入,消耗很少的 token) --- catalog.append("## Available Skills\n"); diff --git a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java index 35a5a452..ac1c1382 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java +++ b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java @@ -6,6 +6,7 @@ import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import vip.mate.system.model.SearchProviderCatalogResponse; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; import vip.mate.workspace.core.annotation.RequireGlobalAdmin; @@ -33,6 +34,13 @@ public class SystemSettingController { return R.ok(systemSettingService.saveSettings(dto)); } + @Operation(summary = "获取搜索 provider catalog(内置 + 插件),及当前实际生效的 provider") + @GetMapping("/search-providers") + @RequireWorkspaceRole("admin") + public R getSearchProviders() { + return R.ok(systemSettingService.getSearchProviderCatalog()); + } + @Operation(summary = "获取当前语言") @GetMapping("/language") public R getLanguage() { diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogEntry.java b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogEntry.java new file mode 100644 index 00000000..46ed7772 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogEntry.java @@ -0,0 +1,24 @@ +package vip.mate.system.model; + +/** + * One row in the search-provider catalog exposed to the settings UI. + * + * @param id provider id (matches {@code SearchProvider.id()}) + * @param label display label + * @param builtin {@code true} for the four shipped providers, {@code false} for plugin-registered ones + * @param requiresCredential whether the provider needs an API key/credential + * @param available whether it's currently usable under the active config + * @param pluginName owning plugin's manifest name for plugin-registered providers; + * {@code null} for builtin providers, and also possibly {@code null} + * for a plugin provider if the owning plugin was unregistered + * concurrently with this lookup + */ +public record SearchProviderCatalogEntry( + String id, + String label, + boolean builtin, + boolean requiresCredential, + boolean available, + String pluginName +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogResponse.java b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogResponse.java new file mode 100644 index 00000000..f28c8ab2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SearchProviderCatalogResponse.java @@ -0,0 +1,17 @@ +package vip.mate.system.model; + +import java.util.List; + +/** + * Response payload for {@code GET /api/v1/settings/search-providers}. + * + * @param providers all registered providers (builtin + plugin), sorted by autoDetectOrder + * @param resolvedId the id of the provider that would actually be used right now; {@code null} if none available + * @param resolvedSource why it was picked: "configured" / "auto-detect" / "keyless-fallback"; {@code null} when resolvedId is null + */ +public record SearchProviderCatalogResponse( + List providers, + String resolvedId, + String resolvedSource +) { +} 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 8384afa8..d21431fc 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,14 +1,20 @@ package vip.mate.system.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +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.search.SearchProvider; +import vip.mate.tool.search.SearchProviderRegistry; + +import java.util.List; @Service -@RequiredArgsConstructor public class SystemSettingService { private static final String LANGUAGE_KEY = "language"; @@ -77,6 +83,29 @@ public class SystemSettingService { private static final String MINIMAX_REGION_KEY = "minimaxRegion"; private final SystemSettingMapper systemSettingMapper; + private final SearchProviderRegistry searchProviderRegistry; + + /** + * {@code PluginManager} is injected lazily because the bean graph is + * cyclic: {@code pluginManager → toolRegistry → i18nService → + * systemSettingService}. It is only consulted from + * {@link #toEntry} at request time (never at construction), so a lazy + * proxy is safe and breaks the cycle cleanly. Note: {@code @Lazy} must be + * applied via an explicit constructor (not {@code @RequiredArgsConstructor}) + * — Lombok does not copy field-level annotations onto the generated + * constructor parameter, so a Lombok-only {@code @Lazy} silently has no + * effect and Spring still resolves the bean eagerly. + */ + @Lazy + private final PluginManager pluginManager; + + public SystemSettingService(SystemSettingMapper systemSettingMapper, + SearchProviderRegistry searchProviderRegistry, + @Lazy PluginManager pluginManager) { + this.systemSettingMapper = systemSettingMapper; + this.searchProviderRegistry = searchProviderRegistry; + this.pluginManager = pluginManager; + } /** * Resolve the SearXNG base URL: DB value takes priority; fall back to the @@ -206,6 +235,37 @@ public class SystemSettingService { return dto; } + /** + * 搜索 provider catalog:内置 + 插件注册的全部 provider,标注是否可用、 + * 属于哪个插件,以及当前实际会被 resolve() 选中的是哪一个。 + */ + public SearchProviderCatalogResponse getSearchProviderCatalog() { + SystemSettingsDTO config = getSearchSettings(); + + List entries = searchProviderRegistry.allSorted().stream() + .map(p -> toEntry(p, config)) + .toList(); + + SearchProviderRegistry.ResolvedProvider resolved = searchProviderRegistry.resolve(config); + String resolvedId = resolved != null ? resolved.provider().id() : null; + String resolvedSource = resolved != null ? resolved.source() : null; + + return new SearchProviderCatalogResponse(entries, resolvedId, resolvedSource); + } + + private SearchProviderCatalogEntry toEntry(SearchProvider provider, SystemSettingsDTO config) { + boolean builtin = !searchProviderRegistry.isPluginProvider(provider.id()); + String pluginName = builtin ? null : pluginManager.getPluginNameForSearchProvider(provider.id()); + return new SearchProviderCatalogEntry( + provider.id(), + provider.label(), + builtin, + provider.requiresCredential(), + provider.isAvailable(config), + pluginName + ); + } + public SystemSettingsDTO saveSettings(SystemSettingsDTO dto) { saveValue(LANGUAGE_KEY, dto.getLanguage(), "当前界面语言"); saveValue(STREAM_ENABLED_KEY, String.valueOf(Boolean.TRUE.equals(dto.getStreamEnabled())), "是否开启流式响应"); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java index ec63b592..ba1e16f3 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java @@ -1,13 +1,18 @@ package vip.mate.tool.browser; +import com.microsoft.playwright.APIResponse; import com.microsoft.playwright.Browser; import com.microsoft.playwright.BrowserContext; import com.microsoft.playwright.BrowserType; import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; import com.microsoft.playwright.PlaywrightException; +import com.microsoft.playwright.Route; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import vip.mate.common.net.SsrfProperties; + +import java.net.URI; import org.springframework.stereotype.Component; import java.io.BufferedReader; @@ -40,9 +45,11 @@ public class BrowserLauncher { .toLowerCase(Locale.ROOT).contains("mac"); private final BrowserProperties props; + private final SsrfProperties ssrfProperties; - public BrowserLauncher(BrowserProperties props) { + public BrowserLauncher(BrowserProperties props, SsrfProperties ssrfProperties) { this.props = props; + this.ssrfProperties = ssrfProperties; } public BrowserProperties properties() { @@ -126,6 +133,7 @@ public class BrowserLauncher { context = browser.newContext(); page = context.newPage(); } + applyContextDefaults(context); long elapsed = System.currentTimeMillis() - t0; trace.add(Attempt.ok(strategy, "connectOverCDP(" + normalized + ")", elapsed)); return Result.success(browser, context, page, true, normalized, strategy, trace); @@ -282,6 +290,7 @@ public class BrowserLauncher { ? browser.newContext() : browser.contexts().get(0); Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0); + applyContextDefaults(context); long elapsed = System.currentTimeMillis() - t0; trace.add(Attempt.ok(Strategy.EXTERNAL_CDP, browserBin + " + connectOverCDP(" + cdpBase + ")", elapsed)); // Hand ownership of `proc` and `userDataDir` to the caller — the session that @@ -336,6 +345,17 @@ public class BrowserLauncher { private BrowserType.LaunchOptions baseLaunchOptions(boolean headed) { BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions().setHeadless(!headed); List args = chromiumLaunchArgs(); + // When the deployment is LAN-isolated AND the operator opted into ignoring + // HTTPS errors, push the flag to the Chromium command line as well. This + // covers the EXTERNAL_CDP path where the browser is spawned by us but its + // contexts are created without NewContextOptions (so setIgnoreHTTPSErrors + // would not apply), and is also a stronger guarantee than the per-context + // flag for self-signed LAN CAs. Gated on allowPrivateNetwork so internet- + // facing deployments cannot accidentally disable cert validation globally. + if (props.isIgnoreHttpsErrors() && props.isAllowPrivateNetwork()) { + args.add("--ignore-certificate-errors"); + args.add("--allow-running-insecure-content"); + } if (!args.isEmpty()) { opts.setArgs(args); } @@ -344,14 +364,110 @@ public class BrowserLauncher { private Result wrapLocalBrowser(Browser browser, Strategy strategy, String desc, long elapsedMs, List trace) { - BrowserContext context = browser.newContext(new Browser.NewContextOptions() + Browser.NewContextOptions opts = new Browser.NewContextOptions() .setViewportSize(props.getViewportWidth(), props.getViewportHeight()) - .setLocale("zh-CN")); + .setLocale("zh-CN"); + // Gate the per-context TLS bypass on allowPrivateNetwork too (matching the + // command-line flags above), so ignoring HTTPS errors is scoped to LAN + // deployments and never silently disables cert validation for public traffic. + if (props.isIgnoreHttpsErrors() && props.isAllowPrivateNetwork()) { + opts.setIgnoreHTTPSErrors(true); + } + BrowserContext context = browser.newContext(opts); + applyContextDefaults(context); Page page = context.newPage(); trace.add(Attempt.ok(strategy, desc, elapsedMs)); return Result.success(browser, context, page, false, null, strategy, trace); } + /** + * Apply configurable Playwright timeouts to a freshly acquired context. + * Called on every context creation path (wrapLocalBrowser, tryCdp, + * tryExternalCdpLaunch) so {@code page.click / page.fill / page.navigate} + * inherit the configured limits without per-call boilerplate. + */ + private void applyContextDefaults(BrowserContext context) { + context.setDefaultTimeout(props.getDefaultTimeoutSeconds() * 1000L); + context.setDefaultNavigationTimeout(props.getDefaultNavigationTimeoutSeconds() * 1000L); + installSsrfInterceptor(context); + } + + /** + * Re-run the SSRF guard on every http(s) request the page makes, so subresources, + * script-initiated fetches AND server-side redirect targets cannot reach blocked + * hosts (above all cloud-metadata endpoints) after the initial navigation URL + * already passed the one-shot check in the tool layer. + *

      + * Redirects need special handling: Playwright follows 3xx responses internally on + * {@code route.resume()} without re-invoking this handler, so a public page that + * 302s to a metadata IP would slip through. For navigation requests we therefore + * fetch with {@code maxRedirects=0} and validate the {@code Location} of every hop + * before fulfilling, so each redirect target is checked. Non-network schemes + * (data:, blob:, about:, …) are not SSRF vectors and pass through untouched. + */ + private void installSsrfInterceptor(BrowserContext context) { + if (!props.isSsrfCheckEnabled()) { + return; + } + context.route("**/*", (Route route) -> { + String reqUrl = route.request().url(); + String lower = reqUrl == null ? "" : reqUrl.toLowerCase(Locale.ROOT); + if (!lower.startsWith("http://") && !lower.startsWith("https://")) { + route.resume(); + return; + } + // 1. Validate the request URL itself (covers subresources and fetches, + // which are each delivered to this handler as their own request). + try { + UrlSafetyChecker.check(reqUrl, ssrfProperties.getSsrfAllowlist(), + props.isAllowPrivateNetwork()); + } catch (SecurityException se) { + log.warn("[BrowserLauncher] SSRF interceptor blocked request url={}: {}", + reqUrl, se.getMessage()); + route.abort(); + return; + } catch (Exception e) { + route.resume(); + return; + } + // 2. Non-navigation requests carry no auto-followed redirect chain, so the + // URL check above is sufficient — resume normally. + if (!route.request().isNavigationRequest()) { + route.resume(); + return; + } + // 3. Navigation requests: follow redirects manually so each hop is checked. + try { + APIResponse resp = route.fetch(new Route.FetchOptions().setMaxRedirects(0)); + int status = resp.status(); + if (status >= 300 && status < 400) { + String location = resp.headers().get("location"); + if (location != null && !location.isBlank()) { + String target = URI.create(reqUrl).resolve(location.trim()).toString(); + UrlSafetyChecker.check(target, ssrfProperties.getSsrfAllowlist(), + props.isAllowPrivateNetwork()); + } + } + // Hand the (possibly-3xx) response back; the browser follows any safe + // redirect, whose next hop re-enters this handler and is re-validated. + route.fulfill(new Route.FulfillOptions().setResponse(resp)); + } catch (SecurityException se) { + log.warn("[BrowserLauncher] SSRF interceptor blocked redirect from url={}: {}", + reqUrl, se.getMessage()); + route.abort(); + } catch (Exception e) { + // Manual fetch/proxy failed (network, unsupported response, …). The + // request URL itself already passed the check, so fall back to normal + // handling rather than wedging the page. + try { + route.resume(); + } catch (Exception ignore) { + // route may already be consumed by the failed fetch; nothing to do. + } + } + }); + } + public static List chromiumLaunchArgs() { List args = new ArrayList<>(); boolean inContainer = isRunningInContainer(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java index c51c16bf..6836fd19 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java @@ -48,13 +48,73 @@ public class BrowserProperties { /** Maximum concurrent browser sessions across all agents. Prevents runaway memory usage. */ private int maxSessions = 5; - /** Block navigations to loopback, private, link-local and cloud-metadata hosts. */ + /** + * Block navigations to loopback, private, link-local and cloud-metadata hosts. + * Narrow exceptions are configured via {@code mateclaw.security.ssrf-allowlist}. + * Only takes effect when {@link #allowPrivateNetwork} is {@code false}. + */ private boolean ssrfCheckEnabled = true; + /** + * Permit the browser to reach loopback / private / link-local addresses + * (127.0.0.1, 10.x, 192.168.x, 172.16-31.x, fc00::/7, ::1, …). Cloud-metadata + * endpoints (169.254.169.254, fd00:ec2::254, …) stay blocked in every mode. + * + *

      Scope: browser tool only — webhook / image-download SSRF guards still + * enforce strict mode. Turn on for isolated LAN / on-prem deployments where + * the agent must drive internal services (e.g. {@code http://192.168.x.x:port}) + * and has no path to the public internet. Leave off for internet-facing + * deployments; the {@code ssrf-allowlist} is the narrower escape hatch there. + */ + private boolean allowPrivateNetwork = false; + + /** + * Whether Playwright should ignore HTTPS certificate errors when creating a + * browser context. Useful for LAN deployments where internal services use + * self-signed certificates. Defaults to {@code false} so the strict CA + * validation chain is preserved on internet-facing deployments. + * + *

      Effect: + *

        + *
      • Sets {@code Browser.NewContextOptions.ignoreHTTPSErrors = true} for + * contexts created by {@link BrowserLauncher} via Playwright launch.
      • + *
      • When {@link #allowPrivateNetwork} is also {@code true}, additionally + * passes {@code --ignore-certificate-errors} / {@code --allow-running-insecure-content} + * to the Chromium command line — this covers CDP-attached external browsers + * whose existing contexts cannot be re-configured at the NewContext layer.
      • + *
      + * No effect on contexts pre-existing on a user-managed Chrome (action=connect_cdp + * when Chrome already has tabs open) — those keep the Chrome process's own setting. + */ + private boolean ignoreHttpsErrors = false; + /** Viewport width (px) for launched browsers. */ private int viewportWidth = 1280; /** Viewport height (px) for launched browsers. */ private int viewportHeight = 800; + + /** + * Default Playwright action timeout in seconds. Applies to every + * {@code page.click / page.fill / page.waitForLoadState} call after the + * browser context is created. Increase for slow LAN / large-page scenarios. + */ + private int defaultTimeoutSeconds = 30; + + /** + * Default Playwright navigation timeout in seconds. Applies to + * {@code page.navigate} and load-state waits. Increase for slow networks. + */ + private int defaultNavigationTimeoutSeconds = 30; + + /** + * Hard cap on the textual snapshot returned by {@code action=snapshot}. + * Content beyond this length is dropped with a {@code truncated:true} flag + * and a hint suggesting the {@code selector} parameter. Note: results + * larger than the framework spill threshold (~8000 chars) are further + * spilt to disk by {@code ToolResultStorage}; keep this value reasonable + * to avoid forcing every snapshot through the spill-and-preview path. + */ + private int snapshotMaxLength = 20_000; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java index bed67935..af3b5757 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java @@ -1,15 +1,40 @@ package vip.mate.tool.browser; +import vip.mate.common.net.SsrfAllowlist; + +import java.net.Inet6Address; import java.net.InetAddress; import java.net.URI; +import java.util.Collection; +import java.util.List; import java.util.Set; /** * SSRF guard — rejects URLs that resolve to loopback, link-local, private, or - * known cloud-metadata endpoints. Mirrors openfang's {@code check_ssrf} behaviour. + * known cloud-metadata endpoints. * *

      Call this before passing any user-controlled URL to the browser or to an - * outbound HTTP client. + * outbound HTTP client. An optional allowlist lets administrators reach specific + * internal hosts/IPs/CIDR blocks while every other restricted address stays blocked. + * + *

      Modes: + *

        + *
      • Strict (default, {@code allowPrivateNetwork=false}) — blocks + * loopback, any-local, link-local, site-local (private), multicast, and + * all known cloud-metadata endpoints. Use when the agent may reach the + * public internet, where SSRF protection is required.
      • + *
      • Private-network allow ({@code allowPrivateNetwork=true}) — for + * isolated LAN / on-prem deployments. Loopback, private, and link-local + * addresses are allowed through; only cloud-metadata endpoints remain + * blocked so a misconfigured agent cannot exfiltrate cloud credentials.
      • + *
      + * + *

      Known limitation: DNS rebinding (TOCTOU). The check resolves the hostname + * once and validates every returned address, but the browser may re-resolve + * the same hostname later and obtain a different IP. Fully closing this requires + * hooking the browser's DNS layer, which Playwright does not expose; the + * private-network-allow mode makes this a non-issue because every private + * address is permitted anyway. */ public final class UrlSafetyChecker { @@ -22,17 +47,66 @@ public final class UrlSafetyChecker { "instance-data", "169.254.169.254", // AWS / Azure / GCP IMDS "100.100.100.200", // Alibaba Cloud IMDS - "192.0.0.192", // Azure IMDS alternative + "192.0.0.192", // Oracle Cloud IMDS "0.0.0.0", "::1" ); + /** + * Subset of {@link #BLOCKED_HOSTNAMES} that are cloud instance-metadata endpoints. + * These are blocked unconditionally — even an explicit allowlist entry must never + * open a path to instance-metadata credential theft. + */ + private static final Set METADATA_HOSTNAMES = Set.of( + "metadata.google.internal", + "metadata.aws.internal", + "instance-data", + "169.254.169.254", + "100.100.100.200", + "192.0.0.192" + ); + private UrlSafetyChecker() {} /** * Throw {@link SecurityException} if the URL is unsafe. Accepts http:// and https:// only. + * Equivalent to {@code check(url, List.of(), false)} (strict mode, no allowlist). */ public static void check(String url) { + check(url, List.of(), false); + } + + /** + * Throw {@link SecurityException} if the URL is unsafe (strict mode). + * + * @param allowlist hostnames, literal IPs, or IPv4 CIDR blocks that are permitted even + * when they would otherwise be blocked (loopback, private, metadata, …). + */ + public static void check(String url, Collection allowlist) { + check(url, allowlist, false); + } + + /** + * Throw {@link SecurityException} if the URL is unsafe (no allowlist). + * + * @param allowPrivateNetwork {@code true} permits loopback / private / link-local + * addresses; cloud-metadata endpoints stay blocked. + */ + public static void check(String url, boolean allowPrivateNetwork) { + check(url, List.of(), allowPrivateNetwork); + } + + /** + * Throw {@link SecurityException} if the URL is unsafe. + * + * @param allowlist hostnames, literal IPs, or IPv4 CIDR blocks that are + * permitted even when they would otherwise be blocked. + * @param allowPrivateNetwork {@code true} permits loopback / private / link-local + * addresses; cloud-metadata endpoints stay blocked. + * Use only for isolated LAN / on-prem deployments where + * the agent has no path to the public internet. + */ + public static void check(String url, Collection allowlist, boolean allowPrivateNetwork) { if (url == null || url.isBlank()) { throw new SecurityException("URL is required"); } @@ -53,14 +127,36 @@ public final class UrlSafetyChecker { String hostname = host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host; - if (BLOCKED_HOSTNAMES.contains(hostname.toLowerCase())) { + String lowerHost = hostname.toLowerCase(); + // Cloud-metadata endpoints are blocked unconditionally — an allowlist entry + // must never open a path to instance-metadata credential theft. + if (METADATA_HOSTNAMES.contains(lowerHost)) { + throw new SecurityException("SSRF blocked: " + hostname + " is a cloud-metadata endpoint"); + } + // An explicit allowlist entry for the literal host bypasses the loopback / + // private / restricted-hostname checks below — but never the metadata checks. + boolean hostAllowlisted = SsrfAllowlist.matchesHost(hostname, allowlist); + if (!hostAllowlisted && BLOCKED_HOSTNAMES.contains(lowerHost)) { throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname"); } try { for (InetAddress addr : InetAddress.getAllByName(hostname)) { + // Cloud-metadata IPs are blocked in every mode and regardless of the + // allowlist — never exfiltrate cloud credentials via the browser tool. + if (isMetadataIp(addr)) { + throw new SecurityException("SSRF blocked: " + hostname + + " resolves to cloud-metadata endpoint " + addr.getHostAddress()); + } + if (hostAllowlisted || SsrfAllowlist.matchesAddress(addr, allowlist)) { + continue; + } + if (allowPrivateNetwork) { + // Skip loopback / any-local / link-local / site-local / multicast checks. + continue; + } if (addr.isLoopbackAddress() || addr.isAnyLocalAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress() - || addr.isMulticastAddress() || isMetadataIp(addr)) { + || addr.isMulticastAddress()) { throw new SecurityException("SSRF blocked: " + hostname + " resolves to restricted address " + addr.getHostAddress()); } @@ -69,14 +165,35 @@ public final class UrlSafetyChecker { throw e; } catch (Exception e) { // DNS resolution failure — let the caller deal with it (browser will show its own error). + // Known limitation: a deliberately slow/timeout DNS server can use this to bypass the + // guard. Not fixable here without a hard fail policy; document as accepted risk. } } + /** + * Identify cloud instance-metadata endpoints by IP. Covers IPv4 literals used by + * AWS / Azure / GCP / Alibaba, and the AWS IPv6 IMDS prefix {@code fd00:ec2::/64} + * (the only documented IPv6 metadata range). IPv6 addresses outside this prefix + * but inside the broader ULA range {@code fc00::/7} are NOT treated as metadata. + */ private static boolean isMetadataIp(InetAddress addr) { String ip = addr.getHostAddress(); - return "169.254.169.254".equals(ip) + // IPv4 literals — kept as string compares for clarity and zero allocation on the hot path. + if ("169.254.169.254".equals(ip) || "100.100.100.200".equals(ip) - || "192.0.0.192".equals(ip) - || "fd00:ec2::254".equalsIgnoreCase(ip); + || "192.0.0.192".equals(ip)) { + return true; + } + // AWS IPv6 IMDS lives in fd00:ec2::/64 — match by 64-bit prefix to cover + // fd00:ec2::254 and any future variant under the same prefix. + if (addr instanceof Inet6Address) { + byte[] b = addr.getAddress(); + // 16 bytes; first 8 must equal fd 00 0e c2 00 00 00 00 + return b.length == 16 + && b[0] == (byte) 0xfd && b[1] == 0x00 + && b[2] == 0x0e && b[3] == (byte) 0xc2 + && b[4] == 0 && b[5] == 0 && b[6] == 0 && b[7] == 0; + } + return false; } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java index 25f017e4..6f80d092 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java @@ -6,6 +6,7 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.microsoft.playwright.Browser; import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.ElementHandle; import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; import com.microsoft.playwright.PlaywrightException; @@ -19,6 +20,7 @@ import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.browser.BrowserDiagnosticsService; import vip.mate.tool.browser.BrowserLauncher; +import vip.mate.common.net.SsrfProperties; import vip.mate.tool.browser.UrlSafetyChecker; import java.net.Socket; @@ -38,21 +40,89 @@ import java.util.regex.Pattern; public class BrowserUseTool { private static final long IDLE_TIMEOUT_MINUTES = 30; - private static final int MAX_SNAPSHOT_LENGTH = 20_000; private static final int CDP_SCAN_PORT_MIN = 9000; private static final int CDP_SCAN_PORT_MAX = 10000; + /** + * Snapshot extractor — runs as an ElementHandle.evaluate so {@code this} + * is the scoped root (document.body when no selector is passed). Uses a + * budget object so truncation stops at element boundaries rather than + * mid-TEXT_NODE, and surfaces a {@code truncated:true} flag to the caller + * so the LLM can be told to retry with a narrower selector. + */ + private static final String SNAPSHOT_JS = """ + (maxLen) => { + const budget = { remaining: maxLen, truncated: false }; + function getVisibleText(node, depth) { + if (depth > 10 || budget.remaining <= 0) return ''; + const results = []; + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent.trim(); + if (text) { + if (text.length > budget.remaining) { + const slice = text.substring(0, budget.remaining); + const lastSpace = slice.lastIndexOf(' '); + results.push(lastSpace > budget.remaining * 0.5 + ? slice.substring(0, lastSpace) : slice); + budget.remaining = 0; + budget.truncated = true; + } else { + results.push(text); + budget.remaining -= text.length; + } + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return ''; + const tag = el.tagName.toLowerCase(); + if (['a', 'button', 'input', 'select', 'textarea'].includes(tag)) { + const id = el.id ? '#' + el.id : ''; + const cls = el.className && typeof el.className === 'string' + ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') + : ''; + const text = el.textContent ? el.textContent.trim().substring(0, 80) : ''; + const href = el.getAttribute('href') || ''; + const placeholder = el.getAttribute('placeholder') || ''; + const desc = '[' + tag + id + cls + ']' + + (text ? ' "' + text + '"' : '') + + (href ? ' href=' + href : '') + + (placeholder ? ' placeholder=' + placeholder : ''); + if (desc.length > budget.remaining) { + budget.remaining = 0; + budget.truncated = true; + return results.join('\\n'); + } + results.push(desc); + budget.remaining -= desc.length; + } + for (const child of el.childNodes) { + if (budget.remaining <= 0) break; + const childText = getVisibleText(child, depth + 1); + if (childText) results.push(childText); + } + } + return results.join('\\n'); + } + const text = getVisibleText(this, 0); + return JSON.stringify({ text: text, truncated: budget.truncated }); + } + """; + /** SSE broadcaster for pushing browser actions to the frontend in real time. */ private final vip.mate.channel.web.ChatStreamTracker streamTracker; private final BrowserLauncher launcher; private final BrowserDiagnosticsService diagnostics; + private final SsrfProperties ssrfProperties; public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker, BrowserLauncher launcher, - BrowserDiagnosticsService diagnostics) { + BrowserDiagnosticsService diagnostics, + SsrfProperties ssrfProperties) { this.streamTracker = streamTracker; this.launcher = launcher; this.diagnostics = diagnostics; + this.ssrfProperties = ssrfProperties; } /** @@ -95,7 +165,10 @@ public class BrowserUseTool { - start: Launch a new browser (tries system Chrome, system Edge, then Playwright bundled). Optional headed=true. - stop: Close browser. If connected via CDP, only disconnects (Chrome keeps running). - open: Navigate to a URL. Requires url parameter. Auto-starts browser if not running. - - snapshot: Get page text content, interactive elements, and title. + - snapshot: Get page text content, interactive elements, and title. Optional `selector` + scopes to a subtree — USE IT when the page is large (big tables, long lists) to avoid + truncation. Without selector, content is capped and a `truncated:true` flag is returned + with a hint to retry using selector. - screenshot: Take a screenshot. Optional path to save file; returns base64 if no path. - click: Click an element. Requires selector (CSS selector). - type: Type text into an element. Requires selector and text. @@ -108,7 +181,7 @@ public class BrowserUseTool { public String browser_use( @ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action, @ToolParam(description = "URL to navigate to (for open), or CDP base URL (for connect_cdp, e.g. http://localhost:9222)", required = false) String url, - @ToolParam(description = "CSS selector for target element (for click/type)", required = false) String selector, + @ToolParam(description = "CSS selector. REQUIRED for click/type. OPTIONAL for snapshot: pass to scope to a subtree when previous snapshot returned truncated:true.", required = false) String selector, @ToolParam(description = "Text to type (for action=type)", required = false) String text, @ToolParam(description = "JavaScript code to execute (for action=eval). Top-level await is allowed; add `return` to return a value when the snippet uses await.", required = false) String code, @ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path, @@ -134,7 +207,7 @@ public class BrowserUseTool { case "start" -> doStart(sessionKey, Boolean.TRUE.equals(headed)); case "stop" -> doStop(sessionKey); case "open" -> doOpen(sessionKey, url); - case "snapshot" -> doSnapshot(sessionKey); + case "snapshot" -> doSnapshot(sessionKey, selector); case "screenshot" -> doScreenshot(sessionKey, path); case "click" -> doClick(sessionKey, selector); case "type" -> doType(sessionKey, selector, text); @@ -428,7 +501,9 @@ public class BrowserUseTool { if (launcher.properties().isSsrfCheckEnabled()) { try { - UrlSafetyChecker.check(normalizedUrl); + UrlSafetyChecker.check(normalizedUrl, + ssrfProperties.getSsrfAllowlist(), + launcher.properties().isAllowPrivateNetwork()); } catch (SecurityException se) { log.warn("[BrowserUse] SSRF check rejected url={}: {}", normalizedUrl, se.getMessage()); return error(se.getMessage()); @@ -486,7 +561,7 @@ public class BrowserUseTool { return JSONUtil.toJsonPrettyStr(result); } - private String doSnapshot(String sessionKey) { + private String doSnapshot(String sessionKey, String selector) { BrowserSession session = requireSession(sessionKey); if (session == null) { return error("No browser running. Use action=start first."); @@ -498,50 +573,46 @@ public class BrowserUseTool { String title = page.title(); String url = page.url(); - String textContent = page.evaluate(""" - (() => { - function getVisibleText(node, depth) { - if (depth > 10) return ''; - const results = []; - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent.trim(); - if (text) results.push(text); - } else if (node.nodeType === Node.ELEMENT_NODE) { - const el = node; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return ''; - const tag = el.tagName.toLowerCase(); - if (['a', 'button', 'input', 'select', 'textarea'].includes(tag)) { - const id = el.id ? '#' + el.id : ''; - const cls = el.className && typeof el.className === 'string' - ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') - : ''; - const text = el.textContent ? el.textContent.trim().substring(0, 80) : ''; - const href = el.getAttribute('href') || ''; - const placeholder = el.getAttribute('placeholder') || ''; - const selector = tag + id + cls; - let desc = '[' + selector + ']'; - if (text) desc += ' "' + text + '"'; - if (href) desc += ' href=' + href; - if (placeholder) desc += ' placeholder=' + placeholder; - results.push(desc); - } - for (const child of el.childNodes) { - const childText = getVisibleText(child, depth + 1); - if (childText) results.push(childText); - } - } - return results.join('\\n'); - } - const text = getVisibleText(document.body, 0); - return text.substring(0, %d); - })() - """.formatted(MAX_SNAPSHOT_LENGTH)).toString(); + // Resolve root element via ElementHandle — safer than string-concatenating + // the selector into JS (avoids selector-injection via crafted selectors). + // Falls back to body when no selector is provided. + ElementHandle root; + if (selector != null && !selector.isBlank()) { + root = page.querySelector(selector); + if (root == null) { + return error("Snapshot root not found for selector: " + selector); + } + } else { + root = page.querySelector("body"); + if (root == null) { + return error("Snapshot failed: document.body not available"); + } + } + + int maxLen = launcher.properties().getSnapshotMaxLength(); + String jsResult = (String) root.evaluate(SNAPSHOT_JS, maxLen); + + // JS returns { text: "...", truncated: true/false } + JSONObject parsed = JSONUtil.parseObj(jsResult); + String textContent = parsed.getStr("text"); + boolean truncated = parsed.getBool("truncated", false); JSONObject result = new JSONObject(); result.set("ok", true); result.set("title", title); result.set("url", url); + // IMPORTANT: truncated + hint MUST come before content. The framework's + // spill-preview keeps only the head ~800 chars of the JSON, so placing + // these flags first ensures the LLM still sees them after a spill. + result.set("truncated", truncated); + if (truncated) { + result.set("hint", "Content truncated. Re-call browser_use with action=snapshot" + + " and selector= to scope to a subtree (e.g. selector='#main'," + + " selector='table tbody tr')."); + } + if (selector != null && !selector.isBlank()) { + result.set("scopedTo", selector); + } result.set("content", textContent); return JSONUtil.toJsonPrettyStr(result); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java index 96a2abf2..cb713d39 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java @@ -1,15 +1,27 @@ package vip.mate.tool.builtin; import lombok.extern.slf4j.Slf4j; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; /** * Resolves a user-supplied file path against the current conversation's chat-upload - * directory ({@code data/chat-uploads/{conversationId}/}). + * directory ({@code {upload-root}/{conversationId}/}). + *

      + * The upload root is workspace/agent-aware. When the active agent has a resolved + * {@code workspaceBasePath} (carried on {@link ToolExecutionContext}), attachments + * live under {@code {workspaceBasePath}/chat-uploads/{conversationId}/}; otherwise + * they live under the configurable default root (legacy {@code data/chat-uploads}). + * Reads check both locations so attachments written before the workspace-aware + * relocation (under the default dir) still resolve. *

      * Chat attachments are stored as {@code {timestamp}_{safeFilename}} where * {@code safeFilename} replaces every non-{@code [a-zA-Z0-9._-]} character with @@ -20,14 +32,40 @@ import java.nio.file.Paths; *

      * This helper rescues such calls by matching basenames inside the conversation's * upload directory. Used by both {@link ReadFileTool} and {@link DocumentExtractTool}. + * + * @see ChatUploadLocationResolver the Spring-managed resolver that drives the + * same workspace/agent precedence from the off-request path (downloaders, + * cleanup, file-serving). */ @Slf4j -final class ChatUploadResolver { +public final class ChatUploadResolver { - static final Path CHAT_UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + /** + * Sub-directory appended under a workspace/agent base path. Kept in sync + * with {@link ChatUploadLocationResolver#UPLOAD_SUBDIR}. + */ + private static final String UPLOAD_SUBDIR = ChatUploadLocationResolver.UPLOAD_SUBDIR; + + /** + * Configurable default upload root, registered once at startup from + * {@code mateclaw.chat.upload.base-dir}. Defaults to the legacy + * {@code data/chat-uploads} until {@link #setDefaultRoot} is called. + */ + private static volatile Path defaultRoot = Paths.get("data", "chat-uploads"); private ChatUploadResolver() {} + /** + * Register the configurable default upload root. Called once at startup by + * {@code ChatUploadAutoConfiguration}. A {@code null}/blank path restores + * the legacy {@code data/chat-uploads}. + */ + public static void setDefaultRoot(Path path) { + defaultRoot = (path == null) + ? Paths.get("data", "chat-uploads") + : path.toAbsolutePath().normalize(); + } + /** * @return absolute path of the matched attachment, or {@code null} if no match */ @@ -39,11 +77,37 @@ final class ChatUploadResolver { if (conversationId == null || conversationId.isBlank()) { return null; } - Path uploadDir = CHAT_UPLOAD_ROOT.resolve(conversationId).toAbsolutePath().normalize(); + + for (Path uploadDir : candidateUploadDirs(conversationId)) { + Path matched = resolveIn(rawPath, uploadDir); + if (matched != null) { + return matched; + } + } + return null; + } + + /** + * Ordered candidate upload directories for a conversation: the + * workspace-scoped dir first (when a base path is active), then the default + * fallback dir. De-duplicated so the two coincide (no base path configured) + * is a single lookup. + */ + private static List candidateUploadDirs(String conversationId) { + Set dirs = new LinkedHashSet<>(); + String basePath = ToolExecutionContext.workspaceBasePath(); + if (basePath != null && !basePath.isBlank()) { + dirs.add(Paths.get(basePath).toAbsolutePath().normalize() + .resolve(UPLOAD_SUBDIR).resolve(conversationId)); + } + dirs.add(defaultRoot.resolve(conversationId).toAbsolutePath().normalize()); + return new ArrayList<>(dirs); + } + + private static Path resolveIn(String rawPath, Path uploadDir) { if (!Files.isDirectory(uploadDir)) { return null; } - String basename; try { Path requested = Paths.get(rawPath).getFileName(); 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 7919d67e..3aa174f4 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 @@ -19,6 +19,8 @@ import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillScriptExecutionService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.secret.SkillSecretService; +import vip.mate.tool.document.GeneratedFileCache; +import vip.mate.tool.document.WorkspaceArtifactSurfacer; import vip.mate.tool.guard.WorkspacePathGuard; import java.nio.file.Files; @@ -58,6 +60,7 @@ public class CodeExecuteTool { private final SkillScriptExecutionService executionService; private final SkillSecretService skillSecretService; private final ObjectMapper objectMapper; + private final GeneratedFileCache generatedFileCache; @Lazy @Autowired @@ -79,7 +82,9 @@ public class CodeExecuteTool { a JSON array for multiple args, or plain text for a single argument. - timeoutSeconds: optional, default 30, max 300. - Returns: JSON with exitCode, stdout, stderr. + Returns: JSON with exitCode, stdout, stderr, and (when the run wrote files) + a generatedFiles array of [name](url) download links. When present, echo + those links in your reply so the user can download the files you produced. Security: dangerous operations trigger security approval. The server's own secret environment variables are not exposed to the code. @@ -149,10 +154,15 @@ public class CodeExecuteTool { Long timeout = timeoutSeconds != null ? timeoutSeconds.longValue() : null; List argList = normalizeArgs(args); + long runStart = System.currentTimeMillis(); try { SkillScriptExecutionService.ScriptResult result = executionService.executeCode(language, code, workingDir, argList, envVars, timeout); - return formatResult(result); + // Surface any files the run wrote as one-click downloads so the user can + // grab generated artifacts (xlsx / csv / images / …) without the model + // having to call send_file or echo a server path. + List fileLinks = WorkspaceArtifactSurfacer.collect(generatedFileCache, workingDir, runStart, ctx); + return formatResult(result, fileLinks); } catch (Exception e) { log.error("[CodeExecute] Execution failed: {}", e.getMessage()); return formatError("Execution failed: " + e.getMessage()); @@ -193,12 +203,20 @@ public class CodeExecuteTool { return List.of(trimmed); } - private String formatResult(SkillScriptExecutionService.ScriptResult result) { + String formatResult(SkillScriptExecutionService.ScriptResult result, List fileLinks) { + // generatedFiles carries [name](url) markdown so the chat layer surfaces the + // artifacts as one-click downloads, and the model can echo them to the user. + // It's a single JSON *string* (links joined by newlines), not an array — a + // JSON array's own '[' sits adjacent to the markdown '[' and the link- + // extraction regex would then capture '"[name' as the filename. + String filesField = (fileLinks == null || fileLinks.isEmpty()) ? "" : + ",\n \"generatedFiles\": " + jsonEscape(String.join("\n", fileLinks)); return String.format( - "{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s\n}", + "{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s%s\n}", result.getExitCode(), jsonEscape(result.getStdout()), - jsonEscape(result.getStderr()) + jsonEscape(result.getStderr()), + filesField ); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index b12a0ec8..e3b56a82 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -13,7 +13,9 @@ import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import org.springframework.beans.factory.annotation.Value; import vip.mate.agent.AgentService; +import vip.mate.agent.AgentService.ChatResult; import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.delegation.DelegatedUsageAccumulator; import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; @@ -48,7 +50,9 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class DelegateAgentTool { - private static final int MAX_DELEGATION_DEPTH = 3; + // Package-private so sibling session tools (e.g. SessionSendTool, which + // re-enters a child run) share one source of truth for the recursion cap. + static final int MAX_DELEGATION_DEPTH = 3; private static final int MAX_RESULT_LENGTH = 4000; /** * Cap on children dispatched in a single delegateParallel call. Set to 8 @@ -114,6 +118,10 @@ public class DelegateAgentTool { "delegateToAgent", "delegateParallel", "listAvailableAgents", + // A child following up on its own grand-children via send would be + // horizontal dispatch that bypasses the spawn depth gate, so the + // continuation tool stays with the parent (same stance as delegate*). + "sendToSubagent", // Memory writes from children would pollute the parent's shared // long-term memory surface. "remember", @@ -145,6 +153,7 @@ public class DelegateAgentTool { private final SubagentRegistry subagentRegistry; private final AuditEventService auditEventService; private final AsyncTaskService asyncTaskService; + private final DelegatedUsageAccumulator delegatedUsageAccumulator; /** Max characters of the task description persisted in {@code request_json}. * Anything longer is truncated — full task is still inside the running @@ -236,6 +245,38 @@ public class DelegateAgentTool { return "[错误] 未找到名为「" + agentName + "」的已启用 Agent。" + availableAgentsHint(); } + // Execute via the shared single-task core (registry, relay, broadcast, + // child run). Returns the structured ChildResult plus the child's + // session handle so the parent can follow up on this exact sub-agent. + SingleDelegation sd = executeSingleDelegation(target, task, inheritParentContext, ctx); + ChildResult result = sd.result(); + + String response = result.toToolResponse(target.getName()); + // Surface the child's session handle so the parent can follow up on this + // exact sub-agent (its conversation persists past this call) via + // send_to_subagent, instead of re-spawning a fresh, context-less child. + if (result.success() && sd.childConversationId() != null) { + response += "\n\n[session_id: " + sd.childConversationId() + + " — to follow up with this sub-agent, call send_to_subagent(session_id, message)]"; + } + return response; + } + + /** Carrier for a single-task delegation: the structured child result plus + * the child conversation handle (null when spawning was short-circuited). */ + private record SingleDelegation(ChildResult result, String childConversationId) {} + + /** + * Shared execution core for single-task delegation, used by both the + * LLM-facing {@link #delegateToAgent} tool and the id-based + * {@link #delegateByAgentIdStructured} (per-step plan delegation). Handles + * spawn-pause, child conversation creation, optional parent-context + * inheritance, sub-agent registry, event relay/broadcast, and the child + * run — returning the structured {@link ChildResult} so callers decide how + * to format it (tool string vs. plan step bookkeeping). + */ + private SingleDelegation executeSingleDelegation(AgentEntity target, String task, + Boolean inheritParentContext, ToolContext ctx) { String parentConversationId = resolveParentConversationId(); // Root (human-facing) conversation at the top of the delegation tree. // At depth 0 the immediate parent IS the root; deeper layers carry it @@ -243,14 +284,15 @@ public class DelegateAgentTool { String rootConversationId = DelegationContext.rootConversationId(); if (rootConversationId == null) rootConversationId = parentConversationId; String parentSubagentId = DelegationContext.currentSubagentId(); - int childDepth = depth + 1; + int childDepth = DelegationContext.currentDepth() + 1; // Spawn-pause: short-circuit before creating child state when either the // immediate parent or the root tree is paused, so no conversation rows / // relays / registry entries leak. if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) || (rootConversationId != null && subagentRegistry.isSpawnPaused(rootConversationId))) { - return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"; + return new SingleDelegation(ChildResult.ofError(0, target.getName(), + "Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"), null); } String childConversationId = createChildConv(target, parentConversationId); @@ -269,12 +311,12 @@ public class DelegateAgentTool { } log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}", - depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId); + childDepth, target.getName(), target.getId(), childConversationId, parentConversationId); // Register the live sub-agent first so its stable id rides on every // event. Disposable is null in the synchronous single-task path because - // the executor blocks on AgentService#chat directly — there is no Flux - // subscription to dispose. Interrupts here are best-effort (status flip). + // the executor blocks on AgentService#chatWithUsage directly — there is + // no Flux subscription to dispose. Interrupts here are best-effort (status flip). String subagentId = parentConversationId != null ? subagentRegistry.register(parentConversationId, childConversationId, target.getId(), task, null, parentSubagentId, childDepth, rootConversationId) @@ -302,7 +344,7 @@ public class DelegateAgentTool { ChildResult result; try { result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, - parentOrigin, rootConversationId, subagentId, childDepth); + parentOrigin, rootConversationId, subagentId, childDepth, true); } finally { // Cleanup relay + registry regardless of how the child returned // (success / exception / interruption) so we never leak entries. @@ -320,8 +362,7 @@ public class DelegateAgentTool { broadcastEnd(rootConversationId, childConversationId, target.getName(), result, subagentId, parentSubagentId, childDepth); } - - return result.toToolResponse(target.getName()); + return new SingleDelegation(result, childConversationId); } /** @@ -333,12 +374,30 @@ public class DelegateAgentTool { * child's reply text, or an error string when the agent is missing/disabled. */ public String delegateByAgentId(Long agentId, String task, ChatOrigin parentOrigin) { + ChildResult result = delegateByAgentIdStructured(agentId, task, parentOrigin); + return result.toToolResponse(result.agentName()); + } + + /** + * Structured variant of {@link #delegateByAgentId}: runs the same isolated + * child execution but returns the full {@link ChildResult} instead of a + * formatted string. Used by per-step plan delegation so the plan graph can + * branch on {@code success()} / {@code isBlank()} / {@code outcome()} and + * read token usage, rather than pattern-matching an error prefix out of a + * string. Never throws — agent-resolution and depth-guard failures come + * back as {@code outcome="error"} results. + */ + public ChildResult delegateByAgentIdStructured(Long agentId, String task, ChatOrigin parentOrigin) { if (agentId == null) { - return "[错误] 未指定委派 Agent。"; + return ChildResult.ofError(0, "?", "未指定委派 Agent。"); } AgentEntity target = agentMapper.selectById(agentId); if (target == null || !Boolean.TRUE.equals(target.getEnabled())) { - return "[错误] 未找到 id=" + agentId + " 的已启用 Agent。"; + return ChildResult.ofError(0, "id=" + agentId, "未找到 id=" + agentId + " 的已启用 Agent。"); + } + if (DelegationContext.currentDepth() >= MAX_DELEGATION_DEPTH) { + return ChildResult.ofError(0, target.getName(), + "委派层级已达上限(" + MAX_DELEGATION_DEPTH + " 层)"); } ChatOrigin origin = parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY; ToolContext ctx = origin.toToolContext(); @@ -358,7 +417,7 @@ public class DelegateAgentTool { DelegationContext.enter(parentConvId, Set.of(), parentConvId, null, 0); } try { - return delegateToAgent(target.getName(), task, false, ctx); + return executeSingleDelegation(target, task, false, ctx).result(); } finally { if (seedContext) { DelegationContext.exit(); @@ -370,12 +429,16 @@ public class DelegateAgentTool { @vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize") @Tool(description = """ - Delegate multiple tasks to different Agents in parallel (max 3). \ + Delegate multiple tasks to different Agents in parallel (max 8). \ Each task runs concurrently in an independent child session. \ Use this when you have multiple independent sub-tasks that can run simultaneously. \ - Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""") + Input is a JSON array of objects with required "agentName" and "task", plus optional \ + "optional" (true = this task's failure must not abort the batch) and "timeout_seconds" \ + (widen the shared batch budget for a deliberately long task). When a required (non-optional) \ + task fails, remaining tasks are cancelled early instead of waiting out the full budget. \ + Example: [{"agentName":"X","task":"Y","optional":false,"timeout_seconds":120}, ...]""") public String delegateParallel( - @ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]") + @ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\",\"optional\":false,\"timeout_seconds\":120}, ...]") String tasksJson, // RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children. @Nullable ToolContext ctx) { @@ -419,9 +482,11 @@ public class DelegateAgentTool { // 2. Main thread: validate agents, create child conversations, register relays record PreparedChild(int index, AgentEntity agent, String task, String childConvId, - Runnable stopRelay, String subagentId) {} + Runnable stopRelay, String subagentId, boolean optional) {} List prepared = new ArrayList<>(); List errors = new ArrayList<>(); + // Highest per-task timeout override; widens the batch budget below. + int maxTaskTimeoutSeconds = 0; for (int i = 0; i < tasks.size(); i++) { Map t = tasks.get(i); @@ -439,6 +504,14 @@ public class DelegateAgentTool { continue; } + // Optional tasks never trigger fail-fast; per-task timeout_seconds + // (when present) widens the shared batch budget. + boolean optional = "true".equalsIgnoreCase(t.get("optional")); + Integer taskTimeout = parsePositiveIntOrNull(t.get("timeout_seconds")); + if (taskTimeout != null) { + maxTaskTimeoutSeconds = Math.max(maxTaskTimeoutSeconds, taskTimeout); + } + String childConvId = createChildConv(agent, parentConversationId); String subagentId = parentConversationId != null ? subagentRegistry.register(parentConversationId, childConvId, @@ -448,7 +521,7 @@ public class DelegateAgentTool { ? registerBatchedRelay(childConvId, rootConvFinal, agent.getName(), subagentId, parentSubagentId, childDepth) : null; - prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay, subagentId)); + prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay, subagentId, optional)); } if (prepared.isEmpty()) { @@ -470,6 +543,14 @@ public class DelegateAgentTool { "children", childrenInfo)); } + // Batch budget: the global default, widened by the largest per-task + // timeout_seconds override so a deliberately long child isn't cut off. + final int effectiveTimeoutSeconds = Math.max(parallelTimeoutSeconds, maxTaskTimeoutSeconds); + // Completes as soon as any REQUIRED child finishes unsuccessfully, so the + // wait below can collapse early (fail-fast) instead of burning the full + // budget while the parent already knows the batch can't succeed. + CompletableFuture requiredFailure = new CompletableFuture<>(); + // 4. Fan out — execute children in parallel long startTime = System.currentTimeMillis(); Map> futures = new LinkedHashMap<>(); @@ -482,7 +563,7 @@ public class DelegateAgentTool { for (PreparedChild p : prepared) { CompletableFuture future = CompletableFuture.supplyAsync( () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, - parentOriginParallel, rootConvFinal, p.subagentId, childDepth), + parentOriginParallel, rootConvFinal, p.subagentId, childDepth, true), DELEGATION_EXECUTOR); // Broadcast per-child completion as soon as each child finishes @@ -507,6 +588,8 @@ public class DelegateAgentTool { payload.put("trimmedLength", r.trimmedLength); payload.put("blank", r.isBlank()); payload.put("durationMs", r.durationMs); + payload.put("promptTokens", r.promptTokens); + payload.put("completionTokens", r.completionTokens); payload.put("resultPreview", r.success ? truncate(r.result, 400) : (r.error != null ? r.error : "error")); @@ -514,19 +597,31 @@ public class DelegateAgentTool { }); } + // Required children arm the fail-fast signal on unsuccessful completion. + if (!p.optional()) { + future.thenAccept(r -> { + if (r != null && !r.success) { + requiredFailure.complete(null); + } + }); + } + futures.put(p.index, future); } - // 5. Wait for all children (with timeout) + // 5. Wait for all children, or bail out early when a required child fails. List results = new ArrayList<>(); try { - CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])) - .get(parallelTimeoutSeconds, TimeUnit.SECONDS); + CompletableFuture allDone = CompletableFuture.allOf( + futures.values().toArray(new CompletableFuture[0])); + CompletableFuture.anyOf(allDone, requiredFailure) + .get(effectiveTimeoutSeconds, TimeUnit.SECONDS); } catch (TimeoutException e) { - log.warn("Parallel delegation timed out ({}s), collecting completed results", parallelTimeoutSeconds); + log.warn("Parallel delegation timed out ({}s), collecting completed results", effectiveTimeoutSeconds); } catch (Exception e) { log.error("Parallel delegation error: {}", e.getMessage()); } + boolean failFast = requiredFailure.isDone(); // Collect results — completed futures get their value; unfinished ones are cancelled and recorded as timeout for (var entry : futures.entrySet()) { @@ -551,8 +646,11 @@ public class DelegateAgentTool { streamTracker.requestStop(p.childConvId); } f.cancel(true); - // Use ofTimeout so outcome="timeout" is explicit and distinct from "error". - results.add(ChildResult.ofTimeout(idx, agentName, parallelTimeoutSeconds)); + // Distinguish fail-fast cancellation from a genuine timeout so the + // parent doesn't misread a cancelled sibling as a slow agent. + results.add(failFast + ? ChildResult.ofCancelled(idx, agentName) + : ChildResult.ofTimeout(idx, agentName, effectiveTimeoutSeconds)); } } @@ -586,6 +684,8 @@ public class DelegateAgentTool { m.put("trimmedLength", r.trimmedLength); m.put("blank", r.isBlank()); m.put("durationMs", r.durationMs); + m.put("promptTokens", r.promptTokens); + m.put("completionTokens", r.completionTokens); // childConversationId + subagentId for stable frontend tree lookup prepared.stream() .filter(p -> p.index == r.taskIndex) @@ -613,7 +713,10 @@ public class DelegateAgentTool { long successCount = results.stream().filter(r -> r.success && !r.isBlank()).count(); long blankCount = results.stream().filter(ChildResult::isBlank).count(); long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count(); + long cancelledCount = results.stream().filter(r -> "cancelled".equals(r.outcome)).count(); long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count(); + long tokensInTotal = results.stream().mapToLong(r -> r.promptTokens).sum(); + long tokensOutTotal = results.stream().mapToLong(r -> r.completionTokens).sum(); StringBuilder sb = new StringBuilder(); @@ -625,8 +728,11 @@ public class DelegateAgentTool { .append(" success=").append(successCount) .append(" blank_success=").append(blankCount) .append(" timeout=").append(timeoutCount) + .append(" cancelled=").append(cancelledCount) .append(" error=").append(errorCount) .append(" durationMs=").append(totalDurationMs) + .append(" tokensIn=").append(tokensInTotal) + .append(" tokensOut=").append(tokensOutTotal) .append("\n\n"); // Important: this result is from the current execution. Any timeout entries in the @@ -647,6 +753,8 @@ public class DelegateAgentTool { .append(" | contentLength=").append(r.trimmedLength).append("chars") .append(" | rawLength=").append(r.rawLength).append("chars") .append(" | duration=").append(r.durationMs / 1000).append("s") + .append(" | tokensIn=").append(r.promptTokens) + .append(" | tokensOut=").append(r.completionTokens) .append("\n\n"); switch (r.outcome) { @@ -659,7 +767,7 @@ public class DelegateAgentTool { .append(",trim 后 0 字符)。请勿将此误报为超时或失败——子 Agent 已正常完成,只是本次无输出。\n"); } case "timeout" -> - sb.append("❌ 超时(").append(parallelTimeoutSeconds).append("s 内未返回)\n"); + sb.append("❌ 超时(").append(effectiveTimeoutSeconds).append("s 内未返回)\n"); default -> sb.append("❌ 失败:").append(r.error).append("\n"); } @@ -763,9 +871,12 @@ public class DelegateAgentTool { currentUser, () -> { try { + // Detached async child: its usage belongs to the later + // task_output retrieval, not the spawning turn, so do not + // roll it into the parent's _usage_final. ChildResult childResult = runSingleChild(0, target, task, parentConversationId, childConversationId, parentOrigin, - rootConvAsync, subagentId, childDepth); + rootConvAsync, subagentId, childDepth, false); return childResult.toToolResponse(target.getName()); } finally { subagentRegistry.get(subagentId).ifPresent(rec -> { @@ -975,7 +1086,8 @@ public class DelegateAgentTool { private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task, String parentConversationId, String childConversationId, ChatOrigin parentOrigin, - String rootConversationId, String subagentId, int childDepth) { + String rootConversationId, String subagentId, int childDepth, + boolean accumulateToParent) { boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId); if (relayChildEvents) { streamTracker.register(childConversationId); @@ -995,11 +1107,25 @@ public class DelegateAgentTool { ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY) .withAgent(target.getId()) .withConversationId(childConversationId); - String rawResult = agentService.chat(target.getId(), task, childConversationId, childOrigin); + // chatWithUsage runs the same StateGraph as chat() (so the child + // message is persisted identically) but also surfaces the child's + // token usage from the graph's _usage_final event, so the parent can + // see what each sub-agent cost. + ChatResult chatResult = agentService.chatWithUsage( + target.getId(), task, childConversationId, childOrigin); long durationMs = System.currentTimeMillis() - startTime; + String rawResult = chatResult.content(); + // Roll this child's usage up to the root (user-facing) turn so the + // parent's _usage_final reflects the whole delegation sub-tree. + // Skipped for detached async children, whose result belongs to a + // later task_output retrieval, not the spawning turn. + if (accumulateToParent) { + delegatedUsageAccumulator.add(rootConversationId, + chatResult.promptTokens(), chatResult.completionTokens()); + } // Measure lengths before truncation so ChildResult carries accurate metadata. return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs, - MAX_RESULT_LENGTH); + MAX_RESULT_LENGTH, chatResult.promptTokens(), chatResult.completionTokens()); } catch (Exception e) { log.error("Child agent failed: taskIndex={}, agent={}, error={}", taskIndex, target.getName(), e.getMessage()); @@ -1026,21 +1152,25 @@ public class DelegateAgentTool { *

      {@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the * true content length. */ - private record ChildResult( + public record ChildResult( int taskIndex, String agentName, boolean success, String result, String error, long durationMs, - /** "success" | "blank_success" | "timeout" | "error" */ + /** "success" | "blank_success" | "timeout" | "cancelled" | "error" */ String outcome, - int rawLength, int trimmedLength) { + int rawLength, int trimmedLength, + /** Child token usage captured from the graph's _usage_final event; + * 0 for non-success outcomes (timeout / error / cancelled). */ + int promptTokens, int completionTokens) { /** Whether the child returned no usable content (blank_success). */ - boolean isBlank() { return "blank_success".equals(outcome); } + public boolean isBlank() { return "blank_success".equals(outcome); } /** * Factory for a successful child execution. * Measures lengths from the raw result before applying the truncation limit. */ - static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen) { + static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen, + int promptTokens, int completionTokens) { String safe = rawResult != null ? rawResult : ""; String trimmed = safe.trim(); boolean blank = trimmed.isEmpty(); @@ -1049,7 +1179,8 @@ public class DelegateAgentTool { truncate(safe, maxLen), null, ms, blank ? "blank_success" : "success", - safe.length(), trimmed.length()); + safe.length(), trimmed.length(), + Math.max(0, promptTokens), Math.max(0, completionTokens)); } /** @@ -1060,14 +1191,24 @@ public class DelegateAgentTool { String msg = err != null ? err : "Unknown error"; boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout"); return new ChildResult(idx, name, false, null, msg, 0, - isTimeout ? "timeout" : "error", 0, 0); + isTimeout ? "timeout" : "error", 0, 0, 0, 0); } /** Factory for an explicit timeout (parallel window exceeded). */ static ChildResult ofTimeout(int idx, String name, int timeoutSec) { String msg = "超时 (" + timeoutSec + "s)"; return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L, - "timeout", 0, 0); + "timeout", 0, 0, 0, 0); + } + + /** + * Factory for a child cancelled by fail-fast — a required sibling failed, + * so this still-running child was stopped before finishing. Distinct from + * a timeout (it was not slow; the batch was abandoned). + */ + static ChildResult ofCancelled(int idx, String name) { + return new ChildResult(idx, name, false, null, + "已取消(必需子任务失败,触发提前收束)", 0, "cancelled", 0, 0, 0, 0); } // Legacy shims — kept for callers that pre-date the factory methods @@ -1077,15 +1218,19 @@ public class DelegateAgentTool { String trimmed = safe.trim(); boolean blank = trimmed.isEmpty(); return new ChildResult(idx, name, true, safe, null, ms, - blank ? "blank_success" : "success", safe.length(), trimmed.length()); + blank ? "blank_success" : "success", safe.length(), trimmed.length(), 0, 0); } static ChildResult error(int idx, String name, String err) { return ofError(idx, name, err); } String toToolResponse(String agentName) { - if (success) return "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : ""); - return "[错误] Agent「" + agentName + "」执行失败: " + error; + if (!success) return "[错误] Agent「" + agentName + "」执行失败: " + error; + String body = "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : ""); + if (promptTokens > 0 || completionTokens > 0) { + body += "\n\n[usage: tokensIn=" + promptTokens + " tokensOut=" + completionTokens + "]"; + } + return body; } private static String truncate(String text, int maxLength) { @@ -1309,6 +1454,8 @@ public class DelegateAgentTool { Map ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName); ev.put("success", result.success); ev.put("durationMs", result.durationMs); + ev.put("promptTokens", result.promptTokens); + ev.put("completionTokens", result.completionTokens); ev.put("resultPreview", result.success ? truncate(result.result, 200) : (result.error != null ? result.error : "")); streamTracker.broadcastObject(rootConvId, "delegation_end", ev); @@ -1322,6 +1469,17 @@ public class DelegateAgentTool { return DelegationContext.parentConversationId(); } + /** Parse a positive integer (seconds) or return null for blank / invalid / non-positive input. */ + private static Integer parsePositiveIntOrNull(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + int v = Integer.parseInt(raw.trim()); + return v > 0 ? v : null; + } catch (NumberFormatException e) { + return null; + } + } + private String availableAgentsHint() { List agents = agentMapper.selectList(new LambdaQueryWrapper() .eq(AgentEntity::getEnabled, true).select(AgentEntity::getName)); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java index 85481626..5153bb54 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java @@ -1,38 +1,45 @@ package vip.mate.tool.builtin; +import vip.mate.agent.delegation.SubagentRunContext; + import java.util.ArrayDeque; import java.util.Deque; import java.util.Set; /** * Tracks Agent delegation call context to prevent infinite recursion and carry parent session info. - *

      - * Uses a ThreadLocal stack so that nested delegations correctly restore the previous layer's - * parentConversationId and childDeniedTools on exit. - * Each {@link DelegateAgentTool} delegation calls enter() before and exit() after execution. + * + *

      Thin thread-local adapter over {@link SubagentRunContext}: each layer's + * identity is an immutable {@link SubagentRunContext}, and this class keeps a + * per-thread stack of them so nested delegations correctly restore the previous + * layer's context on {@link #exit()}. Each {@link DelegateAgentTool} delegation + * calls {@link #enter} before and {@link #exit} after execution. + * + *

      The value object is the canonical carrier; this adapter only manages its + * thread-confined lifecycle. Call sites that already hold a + * {@link SubagentRunContext} should prefer passing it explicitly — a thread-local + * stack does not survive virtual-thread / reactive hops, which is why the + * explicit-depth {@link #enter(String, Set, String, String, int)} overload + * exists for async / parallel children that start on a fresh executor thread. * * @author MateClaw Team */ public final class DelegationContext { - /** - * Snapshot of one delegation layer's state. - * - *

      {@code rootConversationId} is the human-facing conversation at the top - * of the delegation tree — every layer carries it unchanged so that a - * grandchild's progress events can be broadcast to the same stream the user - * is watching, rather than to its immediate (machine-only) parent. - * {@code currentSubagentId} is the id of the subagent running THIS layer; a - * deeper child reads it as its own {@code parentSubagentId} to reconstruct - * the spawn tree. - */ - private record Frame(String parentConversationId, Set childDeniedTools, - String rootConversationId, String currentSubagentId, int depth) {} - - private static final ThreadLocal> STACK = ThreadLocal.withInitial(ArrayDeque::new); + private static final ThreadLocal> STACK = + ThreadLocal.withInitial(ArrayDeque::new); private DelegationContext() {} + /** + * The context of the layer currently executing on this thread, or + * {@link SubagentRunContext#ROOT} when not inside any delegation. + */ + public static SubagentRunContext current() { + SubagentRunContext top = STACK.get().peek(); + return top != null ? top : SubagentRunContext.ROOT; + } + /** * Current delegation depth (0 = top-level call, not inside any delegation). *

      Read from the TOP frame's recorded depth, NOT the thread-local stack @@ -42,38 +49,32 @@ public final class DelegationContext { * depth is carried in via {@link #enter(String, Set, String, String, int)}. */ public static int currentDepth() { - Frame top = STACK.get().peek(); - return top != null ? top.depth : 0; + return current().depth(); } /** Depth for the next layer when the caller doesn't pass one explicitly. */ private static int nextDepth() { - Frame top = STACK.get().peek(); - return (top != null ? top.depth : 0) + 1; + return current().depth() + 1; } /** Parent conversation ID for event relay (from the current frame) */ public static String parentConversationId() { - Frame top = STACK.get().peek(); - return top != null ? top.parentConversationId : null; + return current().parentConversationId(); } /** Denied tools set for the child Agent (from the current frame) */ public static Set childDeniedTools() { - Frame top = STACK.get().peek(); - return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of(); + return current().deniedTools(); } /** Root (human-facing) conversation ID for the whole tree, or null at top level. */ public static String rootConversationId() { - Frame top = STACK.get().peek(); - return top != null ? top.rootConversationId : null; + return current().rootConversationId(); } /** Subagent id of the layer currently executing, or null at top level. */ public static String currentSubagentId() { - Frame top = STACK.get().peek(); - return top != null ? top.currentSubagentId : null; + return current().currentSubagentId(); } /** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */ @@ -100,8 +101,8 @@ public final class DelegationContext { */ public static void enter(String parentConversationId, Set deniedTools, String rootConversationId, String currentSubagentId, int depth) { - STACK.get().push(new Frame(parentConversationId, deniedTools, - rootConversationId, currentSubagentId, depth)); + push(new SubagentRunContext(depth, parentConversationId, rootConversationId, + currentSubagentId, deniedTools)); } /** Enter the next delegation layer (backward-compatible overload) */ @@ -109,9 +110,19 @@ public final class DelegationContext { enter(null, null, null, null, nextDepth()); } + /** + * Push a pre-built context onto this thread's stack. Preferred when the + * caller already holds an explicit {@link SubagentRunContext} (e.g. one + * reconstructed on a fresh executor thread), so the identity is threaded + * as a value rather than reassembled from positional arguments. + */ + public static void push(SubagentRunContext context) { + STACK.get().push(context); + } + /** Exit the current delegation layer, restoring the previous layer's context */ public static void exit() { - Deque stack = STACK.get(); + Deque stack = STACK.get(); if (!stack.isEmpty()) { stack.pop(); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java index 50cb474a..d483ea5f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java @@ -10,7 +10,11 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -35,6 +39,34 @@ public class MateClawDocService { /** VitePress 首页,无正文,从用户可见列表中排除。 */ private static final String INDEX_SLUG = "index"; + /** 一个文档分组:组标题(中/英)+ 该组内文档的有序 slug 列表。 */ + private record DocGroup(String zhLabel, String enLabel, List slugs) {} + + /** + * 帮助文档的分组与顺序,镜像 VitePress 文档站侧栏 (docs/.vitepress/config.ts): + * 开始 → 使用 → 扩展 → 运维 → 开发 → 参考。 + * 磁盘上存在但未登记于此的文档会被归入末尾的「更多 / More」分组——不会丢失, + * 也提示维护者把它补进对应分组。改了 VitePress 侧栏时,同步更新这里即可保持一致。 + */ + private static final List STRUCTURE = List.of( + new DocGroup("开始", "Start", + List.of("intro", "quickstart", "desktop")), + new DocGroup("使用", "Use", + List.of("chat", "agents", "goals", "wiki", "memory", "multimodal", "model3d", + "channels", "webchat", "wecom-tuning", "ambient-ai", "workflow", "triggers")), + new DocGroup("扩展", "Extend", + List.of("tools", "skills", "mcp", "acp")), + new DocGroup("运维", "Operate", + List.of("console", "backstage", "docker-deploy", "workspaces", "security", "models", "doctor", "config")), + new DocGroup("开发", "Develop", + List.of("api", "architecture", "contributing")), + new DocGroup("参考", "Reference", + List.of("releases", "roadmap", "faq"))); + + /** 未登记文档的兜底分组标题。 */ + private static final String OTHER_ZH = "更多"; + private static final String OTHER_EN = "More"; + /** 开头的 YAML frontmatter 块:`---\n ... \n---`。 */ private static final Pattern FRONTMATTER = Pattern.compile("^---\\s*\\n.*?\\n---\\s*\\n", Pattern.DOTALL); /** frontmatter 里的 `title:` 字段。 */ @@ -42,17 +74,18 @@ public class MateClawDocService { /** 正文里的首个 ATX 一级标题 `# xxx`。 */ private static final Pattern H1 = Pattern.compile("(?m)^#\\s+(.+?)\\s*$"); - public record DocMeta(String slug, String title) {} + public record DocMeta(String slug, String title, String group) {} /** - * 列出某语言下的全部文档(排除 index.md),按 slug 排序, - * 每篇带一个用于展示的标题。 + * 列出某语言下的全部文档(排除 index.md),按 {@link #STRUCTURE} 的分组与顺序输出, + * 每篇带展示标题和所属分组。磁盘上存在但未登记的文档归入末尾「更多」分组,按字母序。 */ public List list(String lang) { if (lang == null || !VALID_LANG.matcher(lang).matches()) { return List.of(); } - List docs = new ArrayList<>(); + // 先扫描磁盘上实际存在的文档:slug -> Resource(保序,作为兜底分组的输入)。 + Map available = new LinkedHashMap<>(); try { PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); Resource[] resources = resolver.getResources("classpath:docs/" + lang + "/*.md"); @@ -65,13 +98,34 @@ public class MateClawDocService { if (INDEX_SLUG.equals(slug)) { continue; } - docs.add(new DocMeta(slug, resolveTitle(r, slug))); + available.put(slug, r); } } catch (IOException e) { log.debug("No {} docs found: {}", lang, e.getMessage()); } - docs.sort((a, b) -> a.slug().compareTo(b.slug())); - return docs; + + boolean en = "en".equals(lang); + List ordered = new ArrayList<>(); + Set placed = new HashSet<>(); + // 1) 按结构分组、按顺序输出已存在的文档。 + for (DocGroup g : STRUCTURE) { + String label = en ? g.enLabel() : g.zhLabel(); + for (String slug : g.slugs()) { + Resource r = available.get(slug); + if (r == null) { + continue; + } + ordered.add(new DocMeta(slug, resolveTitle(r, slug), label)); + placed.add(slug); + } + } + // 2) 未登记于结构的文档归入「更多」分组,按字母序,避免遗漏。 + String otherLabel = en ? OTHER_EN : OTHER_ZH; + available.entrySet().stream() + .filter(e -> !placed.contains(e.getKey())) + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> ordered.add(new DocMeta(e.getKey(), resolveTitle(e.getValue(), e.getKey()), otherLabel))); + return ordered; } /** @@ -132,6 +186,12 @@ public class MateClawDocService { if (raw == null) { return slug; } + // 侧栏要简洁标题:优先正文首个 H1(如「LLM Wiki 知识库」), + // 再退回 frontmatter 的 title(可能是较长的 SEO 标题),最后退回 slug。 + Matcher h1 = H1.matcher(stripFrontmatter(raw)); + if (h1.find()) { + return h1.group(1).trim(); + } Matcher fm = FRONTMATTER.matcher(raw); if (fm.find()) { Matcher title = TITLE_FIELD.matcher(fm.group()); @@ -139,10 +199,6 @@ public class MateClawDocService { return unquote(title.group(1)); } } - Matcher h1 = H1.matcher(stripFrontmatter(raw)); - if (h1.find()) { - return h1.group(1).trim(); - } return slug; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java new file mode 100644 index 00000000..83e899f2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java @@ -0,0 +1,144 @@ +package vip.mate.tool.builtin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.lang.Nullable; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.stereotype.Component; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.delegation.SubagentRegistry.SubagentRecord; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Read-only session listing tool: enumerates the sub-agents the current + * conversation has delegated to — both the ones still running and the ones that + * have finished and can be followed up on. + * + *

      Completes the spawn / send / list triad alongside {@link DelegateAgentTool} + * (spawn) and {@link SessionSendTool} (send). It is the discovery surface for + * send: each row carries the {@code session_id} (the child's persisted + * conversation id) so the parent can pick a finished child and continue it, + * which the live in-memory registry alone cannot show (it drops a child the + * moment it completes). + * + *

      Source of truth is the persisted direct children of the caller's + * conversation (so finished sessions stay discoverable), overlaid with live + * status from {@link SubagentRegistry} for any child still running. Resolves the + * caller conversation the same way the delegation relay does. Read-only, so it + * is safe for children to call. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SessionListTool { + + /** Cap on rows so a conversation with a long delegation history stays readable. */ + private static final int MAX_ROWS = 30; + + private final SubagentRegistry subagentRegistry; + private final ConversationMapper conversationMapper; + + @Tool(description = """ + List the sub-agents this conversation has delegated to — both running and finished — + with each one's session_id, target agent, status, and goal/title. Use it to discover a + session_id to follow up on via send_to_subagent, or to check whether a child you + delegated is still running before deciding to wait or proceed. Read-only.""") + public String listSubagents(@Nullable ToolContext ctx) { + String callerConversationId = resolveCallerConversationId(); + if (callerConversationId == null || callerConversationId.isBlank()) { + return "No sub-agent sessions (no conversation context)."; + } + + // Persisted direct children — the sendable sessions, including finished ones. + List children = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getParentConversationId, callerConversationId) + .eq(ConversationEntity::getDeleted, 0) + .orderByDesc(ConversationEntity::getLastActiveTime) + .last("LIMIT " + MAX_ROWS)); + + // Live status overlay, keyed by the child conversation id. + Map liveByConv = new LinkedHashMap<>(); + for (SubagentRecord r : subagentRegistry.snapshot(callerConversationId)) { + if (r.childConversationId() != null) { + liveByConv.put(r.childConversationId(), r); + } + } + + if (children.isEmpty() && liveByConv.isEmpty()) { + return "No sub-agent sessions for this conversation."; + } + + long now = System.currentTimeMillis(); + StringBuilder sb = new StringBuilder(); + sb.append("Sub-agent sessions for this conversation (").append( + Math.max(children.size(), liveByConv.size())).append("):\n"); + + for (ConversationEntity child : children) { + String convId = child.getConversationId(); + SubagentRecord live = liveByConv.remove(convId); + sb.append(live != null ? formatLive(convId, live, now) : formatPersisted(child)).append('\n'); + } + // Any live record whose persisted row wasn't returned (e.g. just spawned, + // outside the LIMIT window) still gets listed so nothing in flight hides. + for (Map.Entry e : liveByConv.entrySet()) { + sb.append(formatLive(e.getKey(), e.getValue(), now)).append('\n'); + } + + sb.append("Follow up with send_to_subagent(session_id, message)."); + return sb.toString(); + } + + /** + * Caller conversation to scope the listing to: the relay-carried root when + * this runs inside a delegated layer, otherwise the current conversation. + */ + private String resolveCallerConversationId() { + String root = DelegationContext.rootConversationId(); + if (root != null && !root.isBlank()) { + return root; + } + try { + return ToolExecutionContext.conversationId(); + } catch (Exception ignored) { + return null; + } + } + + private String formatLive(String convId, SubagentRecord r, long now) { + long elapsedSec = Math.max(0, (now - r.startedAt()) / 1000); + return "- session_id=" + convId + + " | agent=" + r.agentId() + + " | " + r.status().get() + + " | phase=" + r.currentPhase().get() + + " | tools=" + r.toolCount().get() + + " | elapsed=" + elapsedSec + "s" + + " | goal=\"" + clip(r.goal(), 80) + "\""; + } + + private String formatPersisted(ConversationEntity child) { + String title = child.getTitle(); + String when = child.getLastActiveTime() != null ? child.getLastActiveTime().toString() : ""; + return "- session_id=" + child.getConversationId() + + " | agent=" + child.getAgentId() + + " | idle" + + (when.isEmpty() ? "" : " | last active " + when) + + (title == null || title.isBlank() ? "" : " | \"" + clip(title, 80) + "\""); + } + + private static String clip(String text, int max) { + if (text == null) { + return ""; + } + return text.length() <= max ? text : text.substring(0, max) + "…"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java new file mode 100644 index 00000000..363aef20 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java @@ -0,0 +1,142 @@ +package vip.mate.tool.builtin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +/** + * Multi-turn "send" leg of the spawn / send / list delegation triad: continues a + * sub-agent's existing session with a follow-up message instead of spawning a + * fresh, context-less child. + * + *

      The session handle is the child's own (persisted) {@code conversationId}, + * surfaced by {@link DelegateAgentTool#delegateToAgent} in its result. Because + * the child conversation persists past the original delegation call, the parent + * can later ask it to refine / expand / correct its earlier output and the child + * still sees its own prior context. + * + *

      Guards mirror the spawn path: the recursion depth cap is shared with + * {@link DelegateAgentTool}, a child may only be continued by the conversation + * that spawned it, and the continued run is re-entered under the standard child + * deny set so it cannot delegate or send onward. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SessionSendTool { + + private static final int MAX_RESULT_LENGTH = 4000; + + private final AgentService agentService; + private final ConversationMapper conversationMapper; + private final SubagentRegistry subagentRegistry; + + @Tool(description = """ + Send a follow-up message to a sub-agent you previously delegated to, continuing its + existing session (so it still remembers the earlier task) rather than starting fresh. + Pass the session_id that delegateToAgent returned. Use it to ask a child to refine, + expand, or correct its earlier result.""") + public String sendToSubagent( + @ToolParam(description = "The session_id returned by a prior delegateToAgent call") String sessionId, + @ToolParam(description = "Follow-up message / instruction for the sub-agent") String message, + @Nullable ToolContext ctx) { + + if (sessionId == null || sessionId.isBlank()) { + return "[Error] session_id is required."; + } + if (message == null || message.isBlank()) { + return "[Error] message is required."; + } + + // Depth guard: a send re-enters a child run one level below the caller, + // so refuse if that would breach the shared recursion cap. + int callerDepth = DelegationContext.currentDepth(); + if (callerDepth >= DelegateAgentTool.MAX_DELEGATION_DEPTH) { + return "[Error] Delegation depth limit (" + DelegateAgentTool.MAX_DELEGATION_DEPTH + + ") reached; cannot follow up on a sub-agent from here."; + } + + ConversationEntity child = conversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, sessionId)); + if (child == null) { + return "[Error] Unknown session_id: " + sessionId; + } + if (child.getParentConversationId() == null) { + return "[Error] " + sessionId + " is not a sub-agent session."; + } + + // Tenant / ownership: only the conversation that spawned the child may + // continue it, so a sibling or another tenant cannot drive someone + // else's sub-agent. + String callerConversationId = resolveCallerConversationId(); + if (callerConversationId == null || !callerConversationId.equals(child.getParentConversationId())) { + return "[Error] session " + sessionId + " does not belong to this conversation."; + } + if (child.getAgentId() == null) { + return "[Error] session " + sessionId + " has no bound agent."; + } + + Long agentId = child.getAgentId(); + ChatOrigin origin = ChatOrigin.from(ctx).withAgent(agentId).withConversationId(sessionId); + + // Register the continuation so an in-flight follow-up is visible to + // SessionListTool and interruptible via the subagent control API, then + // re-enter the delegation context one level below the caller so the + // continued child stays gated (cannot delegate / send onward) and the + // depth cap keeps holding for anything it tries to spawn. + String subagentId = subagentRegistry.register(callerConversationId, sessionId, agentId, message, null); + DelegationContext.enter(callerConversationId, DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS, + resolveRootConversationId(callerConversationId), null, callerDepth + 1); + try { + String raw = agentService.chat(agentId, message, sessionId, origin); + return "[Sub-agent reply | session " + sessionId + "]\n\n" + + truncate(raw != null ? raw : "", MAX_RESULT_LENGTH); + } catch (Exception e) { + log.error("send_to_subagent failed: session={}, error={}", sessionId, e.getMessage()); + return "[Error] Sub-agent follow-up failed: " + e.getMessage(); + } finally { + DelegationContext.exit(); + subagentRegistry.unregister(subagentId); + } + } + + private String resolveCallerConversationId() { + try { + String c = ToolExecutionContext.conversationId(); + if (c != null && !c.isBlank()) { + return c; + } + } catch (Exception ignored) { + // fall through to the delegation frame below + } + return DelegationContext.parentConversationId(); + } + + private String resolveRootConversationId(String callerConversationId) { + String root = DelegationContext.rootConversationId(); + return (root != null && !root.isBlank()) ? root : callerConversationId; + } + + private static String truncate(String text, int maxLength) { + if (text == null) { + return ""; + } + if (text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength) + "\n... [truncated, original " + text.length() + " chars]"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java index aff0298a..c8224a2a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java @@ -8,6 +8,8 @@ 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.tool.document.GeneratedFileCache; +import vip.mate.tool.document.WorkspaceArtifactSurfacer; import java.io.IOException; import java.io.InputStream; @@ -15,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.util.List; import java.util.Locale; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; @@ -41,6 +44,7 @@ import java.util.function.Predicate; public class ShellExecuteTool { private final vip.mate.i18n.I18nService i18n; + private final GeneratedFileCache generatedFileCache; private static final int DEFAULT_TIMEOUT_SECONDS = 60; private static final int MAX_OUTPUT_BYTES = 10_000; @@ -50,7 +54,8 @@ public class ShellExecuteTool { @vip.mate.tool.ConcurrencyUnsafe("shell command execution can mutate global state in ways the executor can't reason about") @Tool(description = "Execute a shell command on the local server. For running system commands, viewing files, running scripts. " + "Uses cmd.exe on Windows, /bin/sh on Linux/macOS. " - + "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.") + + "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut, " + + "and (when the command wrote files) a generatedFiles string of [name](url) download links — echo them so the user can download what you produced.") public String execute_shell_command( @ToolParam(description = "Shell command to execute") String command, @ToolParam(description = "Timeout in seconds, default 60", required = false) Integer timeoutSeconds, @@ -107,6 +112,7 @@ public class ShellExecuteTool { pb.redirectOutput(stdoutFile.toFile()); pb.redirectError(stderrFile.toFile()); + long runStart = System.currentTimeMillis(); Process process = pb.start(); boolean completed = process.waitFor(timeout, TimeUnit.SECONDS); @@ -130,6 +136,14 @@ public class ShellExecuteTool { result.set("stdout", stdout); result.set("stderr", stderr); result.set("timedOut", false); + // Surface files the command wrote as one-click downloads (same path + // as execute_code). A single newline-joined string, not a JSON array, + // so the link-extraction regex captures the clean filename. + java.nio.file.Path workingDir = vip.mate.tool.guard.WorkspacePathGuard.getWorkingDirectory(ctx); + List fileLinks = WorkspaceArtifactSurfacer.collect(generatedFileCache, workingDir, runStart, ctx); + if (!fileLinks.isEmpty()) { + result.set("generatedFiles", String.join("\n", fileLinks)); + } } } catch (Exception e) { 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 d590ddbd..46bcbc2b 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 @@ -82,6 +82,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/. - delete: Remove a skill SKILL.md format example: @@ -125,6 +129,10 @@ public class SkillManageTool { @JsonPropertyDescription("For patch action: the new text to replace with") String newText, + @JsonProperty + @JsonPropertyDescription("For write_file action: relative path under references/ or scripts/ (e.g. 'references/api.md', 'scripts/run.sh'). No '..' allowed.") + String filePath, + // RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden // from the LLM by JsonSchemaGenerator. Used to stamp the new // skill with the agent's owning workspace. @@ -143,20 +151,23 @@ public class SkillManageTool { + "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)"; } - Long workspaceId = ChatOrigin.from(toolContext).workspaceId(); + ChatOrigin origin = ChatOrigin.from(toolContext); + Long workspaceId = origin.workspaceId(); + String sourceConversationId = origin.conversationId(); return switch (action.strip().toLowerCase()) { - case "create" -> doCreate(normalizedName, content, workspaceId); - case "edit" -> doEdit(normalizedName, content); - case "patch" -> doPatch(normalizedName, oldText, newText); - case "delete" -> doDelete(normalizedName); - default -> "Error: unknown action '" + action + "'. Use: create | edit | patch | delete"; + case "create" -> doCreate(normalizedName, content, workspaceId, sourceConversationId); + case "edit" -> doEdit(normalizedName, content); + case "patch" -> doPatch(normalizedName, oldText, newText); + case "write_file" -> doWriteFile(normalizedName, filePath, content); + case "delete" -> doDelete(normalizedName); + default -> "Error: unknown action '" + action + "'. Use: create | edit | patch | write_file | delete"; }; } // ==================== Create ==================== - private String doCreate(String name, String content, Long workspaceId) { + private String doCreate(String name, String content, Long workspaceId, String sourceConversationId) { if (content == null || content.isBlank()) { return "Error: content is required for create action. Provide full SKILL.md content."; } @@ -186,6 +197,12 @@ public class SkillManageTool { skill.setVersion(extractVersion(content)); skill.setSecurityScanStatus("PASSED"); skill.setWorkspaceId(workspaceId); + // Stamp the originating conversation so the lifecycle curator can + // age this skill under its AGENT_CREATED scope. Without it, + // agent-authored skills are invisible to the curator's default sweep. + if (sourceConversationId != null && !sourceConversationId.isBlank()) { + skill.setSourceConversationId(sourceConversationId); + } skillService.createSkill(skill); @@ -331,6 +348,54 @@ public class SkillManageTool { } } + // ==================== Write supporting file ==================== + + /** + * Write a supporting file under the skill's {@code references/} or + * {@code scripts/} directory. The path is validated and confined to the + * skill workspace by {@link SkillWorkspaceManager#writeWorkspaceFile}; the + * content is security-scanned just like SKILL.md so an agent can't drop a + * dangerous script alongside an otherwise-clean skill. + */ + 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')."; + } + if (content == null) { + return "Error: content is required for write_file action."; + } + if (content.length() > MAX_CONTENT_CHARS) { + return "Error: content too large (" + content.length() + " chars, max " + MAX_CONTENT_CHARS + ")"; + } + + SkillEntity existing = skillService.findByName(name); + if (existing == null) { + return "Error: skill '" + name + "' not found. Create it first with action='create'."; + } + if (Boolean.TRUE.equals(existing.getBuiltin())) { + return "Error: cannot write files into builtin skill '" + name + "'."; + } + + // Security scan the file body — scripts especially must be screened. + String scanError = runSecurityScan(content, name); + if (scanError != null) { + return scanError; + } + + try { + workspaceManager.writeWorkspaceFile(name, filePath, content); + } catch (IllegalArgumentException e) { + return "Error: " + e.getMessage() + + " (paths must start with references/ or scripts/, 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(); + } + + log.info("[SkillManage] Agent wrote skill file: skill={}, path={}", name, filePath); + return "File '" + filePath + "' written to skill '" + name + "' (security scan: PASSED)."; + } + // ==================== Delete ==================== private String doDelete(String name) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java index 81120b40..b10345cb 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java @@ -6,6 +6,7 @@ import org.springframework.ai.tool.ToolCallback; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import vip.mate.agent.AgentToolSet; +import vip.mate.agent.context.TokenEstimator; import vip.mate.tool.ToolRegistry; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.service.McpServerService; @@ -15,6 +16,7 @@ import vip.mate.tool.service.AvailableToolService; import vip.mate.tool.service.ToolService; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -53,6 +55,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService { private final McpServerService mcpServerService; private final AvailableToolService availableToolService; private final ToolRegistry toolRegistry; + private final ToolUsageRecencyTracker usageRecencyTracker; @Value("${mateclaw.tools.disclosure.mode:progressive}") private String disclosureMode; @@ -97,17 +100,25 @@ public class DefaultToolDisclosureService implements ToolDisclosureService { @Override public ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions) { + return split(baseSet, enabledExtensions, Set.of()); + } + + @Override + public ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions, + Set autoDemoted) { List all = baseSet == null ? List.of() : baseSet.callbacks(); if (legacyMode()) { return new ToolDisclosureSplit(all, List.of()); } Set enabled = enabledExtensions == null ? Set.of() : enabledExtensions; + Set demoted = autoDemoted == null ? Set.of() : autoDemoted; List active = new ArrayList<>(all.size()); List extensionCatalog = new ArrayList<>(); for (ToolCallback cb : all) { - if (resolveTier(cb) == DisclosureTier.EXTENSION) { + String name = cb.getToolDefinition().name(); + if (resolveTier(cb) == DisclosureTier.EXTENSION || demoted.contains(name)) { extensionCatalog.add(cb); - if (enabled.contains(cb.getToolDefinition().name())) { + if (enabled.contains(name)) { active.add(cb); } } else { @@ -117,12 +128,79 @@ public class DefaultToolDisclosureService implements ToolDisclosureService { return new ToolDisclosureSplit(active, extensionCatalog); } + /** + * {@inheritDoc} + * + *

      Protection set: {@link #ALWAYS_CORE} meta-tools and builtin tools + * with an explicit {@code disclosure_tier = core} row. MCP tools remain + * demotable — the server-level tier cannot distinguish an explicit core + * choice from the default, and MCP schemas are typically the heaviest + * part of the advertisement. + */ + @Override + public Set computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) { + if (legacyMode() || baseSet == null || budgetTokens == null + || budgetTokens <= 0 || budgetTokens == Integer.MAX_VALUE) { + return Set.of(); + } + List core = split(baseSet, Set.of()).activeCallbacks(); + int coreTokens = TokenEstimator.estimateToolsTokens(core); + if (coreTokens <= budgetTokens) { + return Set.of(); + } + Snapshot snap = snapshot(); + List candidates = core.stream() + .filter(cb -> isDemotable(cb.getToolDefinition().name(), snap)) + .sorted(demotionOrder()) + .toList(); + Set demoted = new LinkedHashSet<>(); + int remainingTokens = coreTokens; + for (ToolCallback cb : candidates) { + if (remainingTokens <= budgetTokens) { + break; + } + remainingTokens -= TokenEstimator.estimateToolsTokens(List.of(cb)); + demoted.add(cb.getToolDefinition().name()); + } + if (!demoted.isEmpty()) { + log.info("[ToolDisclosure] 工具 schema 估算 {} tokens 超出预算 {}——已将 {} 个最少使用的工具" + + "降级到扩展目录(enable_tool 可找回): {}", + coreTokens, budgetTokens, demoted.size(), demoted); + } + return demoted; + } + + private boolean isDemotable(String toolName, Snapshot snap) { + if (toolName == null || ALWAYS_CORE.contains(toolName)) { + return false; + } + // An explicit core row is an operator decision — never override it. + return snap.builtinTierByName.get(toolName) != DisclosureTier.CORE; + } + + /** Never-used tools demote first, then least recently used; name-tiebreak keeps builds deterministic. */ + private Comparator demotionOrder() { + return Comparator + .comparing(cb -> { + Long lastUsed = usageRecencyTracker == null + ? null : usageRecencyTracker.lastUsedAt(cb.getToolDefinition().name()); + return lastUsed == null ? Long.MIN_VALUE : lastUsed; + }) + .thenComparing(cb -> cb.getToolDefinition().name()); + } + @Override public String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens) { + return renderExtensionCatalog(baseSet, maxInputTokens, Set.of()); + } + + @Override + public String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens, + Set autoDemoted) { if (legacyMode() || baseSet == null) { return ""; } - List extension = split(baseSet, Set.of()).extensionCatalog(); + List extension = split(baseSet, Set.of(), autoDemoted).extensionCatalog(); if (extension.isEmpty()) { return ""; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java index 9ad06d83..c1b16b84 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java @@ -31,6 +31,29 @@ public interface ToolDisclosureService { */ ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions); + /** + * Budget-aware variant: tools in {@code autoDemoted} are treated as + * extension tier for this split even when their resolved tier is core. + * The demotion set is decided once per agent build (see + * {@link #computeAutoDemotions}) so the runtime split, the baked catalog + * and the prompt-cache prefix stay consistent with each other. + */ + default ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions, + Set autoDemoted) { + return split(baseSet, enabledExtensions); + } + + /** + * Decide which core-tier tools to auto-demote so the advertised tool + * schemas fit {@code budgetTokens} (estimated). Ranking: never-used tools + * first, then least recently used; meta-tools and explicitly configured + * core tools are never demoted. Empty when the set already fits, when + * {@code budgetTokens} is null, or in legacy disclosure mode. + */ + default Set computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) { + return Set.of(); + } + /** * Render the {@code ## Extension Tools} system-prompt segment for the * agent's extension tools, or an empty string when there are none / when @@ -38,6 +61,15 @@ public interface ToolDisclosureService { */ String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens); + /** + * Budget-aware variant: auto-demoted tools are listed in the catalog too, + * so the model can discover and {@code enable_tool} them back. + */ + default String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens, + Set autoDemoted) { + return renderExtensionCatalog(baseSet, maxInputTokens); + } + /** Drop the cached tier snapshot so the next resolve re-reads the DB. */ void invalidate(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolUsageRecencyTracker.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolUsageRecencyTracker.java new file mode 100644 index 00000000..f01f6a6a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolUsageRecencyTracker.java @@ -0,0 +1,33 @@ +package vip.mate.tool.disclosure; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory recency signal for tool usage. Feeds the budget-driven + * auto-demotion ranking: never-used tools demote first, then the least + * recently used ones. + * + *

      Deliberately process-local and unpersisted — this is an advisory + * ranking, not an audit trail. A restart resets everything to "never used", + * which merely makes the first demotion pass alphabetical. + */ +@Component +public class ToolUsageRecencyTracker { + + private final Map lastUsedAtMs = new ConcurrentHashMap<>(); + + /** Record a successful execution of {@code toolName}. */ + public void recordUse(String toolName) { + if (toolName != null && !toolName.isBlank()) { + lastUsedAtMs.put(toolName, System.currentTimeMillis()); + } + } + + /** @return epoch millis of the last recorded use, or null when never used. */ + public Long lastUsedAt(String toolName) { + return toolName == null ? null : lastUsedAtMs.get(toolName); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index 97dffcbc..40e2abb8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -349,4 +349,49 @@ public class GeneratedFileCache { m.appendTail(out); return out.toString(); } + + /** + * Wrap bare {@code /api/v1/files/generated/{id}} URLs whose id is live + * into a {@code [filename](url)} markdown link, so chat surfaces render + * the file name instead of the raw id URL. Models frequently echo the + * download URL as plain text ("下载链接:http://…/{uuid}") even though the + * tool result hands them a ready-made markdown link; autolink rendering + * then displays the UUID to the user. + * + *

      URLs already serving as a markdown link destination (directly + * preceded by {@code ](}) are left untouched, whatever their link text — + * the model may have chosen a legitimate custom label. Cache misses are + * also left untouched; {@link #scrubMissingReferences} owns that case. + */ + public String linkifyBareReferences(String text) { + if (text == null || text.isEmpty()) return text; + Matcher m = GENERATED_URL_PATTERN.matcher(text); + if (!m.find()) return text; + StringBuilder out = new StringBuilder(); + m.reset(); + while (m.find()) { + String replacement = m.group(0); + int s = m.start(); + boolean isLinkDestination = s >= 2 + && text.charAt(s - 1) == '(' + && text.charAt(s - 2) == ']'; + // Angle-bracket autolinks () must not be wrapped either — + // "[name]()" with the brackets kept inline breaks rendering. + boolean isAngleAutolink = s >= 1 && text.charAt(s - 1) == '<'; + if (!isLinkDestination && !isAngleAutolink) { + String filename = get(m.group(1)) + .map(Entry::filename) + .filter(n -> n != null && !n.isBlank()) + // Square brackets would terminate the link text early. + .map(n -> n.replaceAll("[\\[\\]]", "")) + .orElse(null); + if (filename != null) { + replacement = "[" + filename + "](" + m.group(0) + ")"; + } + } + m.appendReplacement(out, Matcher.quoteReplacement(replacement)); + } + m.appendTail(out); + return out.toString(); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java new file mode 100644 index 00000000..9e679aa1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java @@ -0,0 +1,121 @@ +package vip.mate.tool.document; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Turns files that a tool run wrote into the working directory into one-click + * download links, so a user can grab generated artifacts (xlsx / csv / images / + * …) without the model having to call {@code send_file} or echo a server path. + * + *

      Each returned entry is a {@code [name](url)} markdown link backed by + * {@link GeneratedFileCache} (7-day, disk-persisted, served by + * {@code GeneratedFileController}). The chat layer already scans tool results for + * exactly this shape and surfaces them as downloads, so callers just need to put + * the links somewhere in their result payload. + * + *

      Shared by {@code execute_code} and {@code execute_shell_command}. Best-effort + * throughout — surfacing a download must never fail the tool run. + */ +@Slf4j +public final class WorkspaceArtifactSurfacer { + + private static final int SCAN_DEPTH = 4; + private static final int MAX_ARTIFACTS = 8; + private static final int MAX_SCAN_CANDIDATES = 200; + private static final long MAX_ARTIFACT_BYTES = 20L * 1024 * 1024; + private static final long MAX_TOTAL_ARTIFACT_BYTES = 48L * 1024 * 1024; + + private WorkspaceArtifactSurfacer() {} + + /** + * Register files created or modified in {@code workingDir} at or after + * {@code sinceMillis} into the cache and return their download links. + * Returns an empty list when there is no persistent working dir (e.g. a + * private scratch dir that gets deleted after the run). + */ + public static List collect(GeneratedFileCache cache, @Nullable Path workingDir, + long sinceMillis, @Nullable ToolContext ctx) { + if (cache == null || workingDir == null || !Files.isDirectory(workingDir)) { + return List.of(); + } + List links = new ArrayList<>(); + long totalBytes = 0L; + try (Stream walk = Files.walk(workingDir, SCAN_DEPTH)) { + List candidates = walk + .filter(Files::isRegularFile) + .filter(p -> !isNoise(p)) + .filter(p -> modifiedSince(p, sinceMillis)) + .limit(MAX_SCAN_CANDIDATES) + .toList(); + for (Path p : candidates) { + if (links.size() >= MAX_ARTIFACTS) { + break; + } + try { + long size = Files.size(p); + if (size <= 0 || size > MAX_ARTIFACT_BYTES || totalBytes + size > MAX_TOTAL_ARTIFACT_BYTES) { + continue; + } + byte[] bytes = Files.readAllBytes(p); + totalBytes += size; + String name = p.getFileName().toString(); + String id = cache.put(bytes, name, probeMime(p, name)); + links.add("[" + name + "](" + cache.downloadUrl(id, ctx) + ")"); + } catch (Exception perFile) { + log.debug("[ArtifactSurfacer] skip {}: {}", p, perFile.getMessage()); + } + } + } catch (Exception e) { + log.debug("[ArtifactSurfacer] scan failed for {}: {}", workingDir, e.getMessage()); + } + return links; + } + + private static boolean modifiedSince(Path p, long sinceMillis) { + try { + // 1s slack absorbs filesystem mtime granularity. + return Files.getLastModifiedTime(p).toMillis() >= sinceMillis - 1000L; + } catch (Exception e) { + return false; + } + } + + /** Skip hidden files, dependency/cache dirs, and obvious scratch/log files. */ + private static boolean isNoise(Path p) { + for (Path seg : p) { + String s = seg.toString(); + if (s.startsWith(".") || s.equals("__pycache__") || s.equals("node_modules")) { + return true; + } + } + String name = p.getFileName().toString().toLowerCase(); + return name.endsWith(".pyc") || name.endsWith(".tmp") || name.endsWith(".lock") + || name.endsWith(".log") || name.endsWith(".class"); + } + + private static String probeMime(Path p, String name) { + try { + String mime = Files.probeContentType(p); + if (mime != null && !mime.isBlank()) { + return mime; + } + } catch (Exception ignore) { + // fall through to extension default + } + String lower = name.toLowerCase(); + if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".pdf")) return "application/pdf"; + return "application/octet-stream"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java index d0b329ec..59cb8346 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java @@ -87,12 +87,84 @@ public final class WorkspacePathGuard { return defaultRoot; } + /** + * Additional always-trusted roots that sit outside any workspace + * boundary yet must remain readable by the agent. The tool-result spill + * store registers its base directories here: when a tool produces an + * oversized result it is written to disk and the agent is handed back a + * path with the instruction to {@code read_file} it on demand. That spill + * directory may live outside the workspace (a central + * {@code storage-base-dir} or the {@code ${java.io.tmpdir}} fallback), so + * without this allow-list the very read the agent is told to perform would + * be rejected as a boundary escape. Registered roots are matched exactly + * like {@link #skillRoot} — by {@code startsWith} on the normalized path. + */ + private static final Set trustedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + /** + * Register an additional always-trusted root (e.g. a tool-result spill + * directory). A {@code null} or blank path is ignored. Idempotent. + */ + public static void addTrustedRoot(@Nullable String path) { + if (path == null || path.isBlank()) { + return; + } + Path normalized = Paths.get(path).toAbsolutePath().normalize(); + if (trustedRoots.add(normalized)) { + log.info("[WorkspacePathGuard] Trusted root added: {}", normalized); + } + } + + /** Clear every registered trusted root. Intended for test teardown. */ + public static void clearTrustedRoots() { + trustedRoots.clear(); + } + /** True when {@code normalized} lives under the shared skill root (if one is set). */ private static boolean isUnderSkillRoot(Path normalized) { Path sr = skillRoot; return sr != null && normalized.startsWith(sr); } + /** + * True when {@code normalized} (or its symlink-resolved real path) lives + * under the shared skill root or any registered {@link #trustedRoots}. + * Bundles the skill-root and trusted-root checks so every boundary check + * site stays a single call. + */ + private static boolean isExempt(Path normalized) { + if (isUnderSkillRoot(normalized)) { + return true; + } + for (Path root : trustedRoots) { + if (normalized.startsWith(root)) { + return true; + } + } + return false; + } + + /** Symlink-resolved variant of {@link #isExempt}. */ + private static boolean isExemptReal(Path realPath) { + if (isUnderSkillRootReal(realPath)) { + return true; + } + for (Path root : trustedRoots) { + if (realPath.startsWith(root)) { + return true; + } + try { + Path realRoot = root.toFile().exists() ? root.toRealPath() : root; + if (realPath.startsWith(realRoot)) { + return true; + } + } catch (IOException e) { + // fall through — the plain startsWith above already ran + } + } + return false; + } + /** * Symlink-resolved variant of {@link #isUnderSkillRoot}. Resolves the skill * root's real path so a path whose real location lands inside the skill @@ -142,7 +214,7 @@ public final class WorkspacePathGuard { Path root = Paths.get(basePath).toAbsolutePath().normalize(); // 先用 normalize 检查,再尝试 toRealPath 防符号链接逃逸 - if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) { + if (!normalized.startsWith(root) && !isExempt(normalized)) { throw new IllegalArgumentException( "Path is outside workspace boundary: " + normalized + ", allowed root: " + root); } @@ -152,7 +224,7 @@ public final class WorkspacePathGuard { if (normalized.toFile().exists()) { Path realPath = normalized.toRealPath(); Path realRoot = root.toFile().exists() ? root.toRealPath() : root; - if (!realPath.startsWith(realRoot) && !isUnderSkillRootReal(realPath)) { + if (!realPath.startsWith(realRoot) && !isExemptReal(realPath)) { throw new IllegalArgumentException( "Path escapes workspace via symlink: " + realPath + ", allowed root: " + realRoot); } @@ -262,7 +334,7 @@ public final class WorkspacePathGuard { Path root = basePathToRoot(basePath); if (root == null) return null; Path normalized = Paths.get(rawPath).toAbsolutePath().normalize(); - if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) { + if (!normalized.startsWith(root) && !isExempt(normalized)) { return "Path is outside workspace boundary: " + normalized + ", allowed root: " + root; } return null; @@ -340,7 +412,7 @@ public final class WorkspacePathGuard { if (destructive && normalized.equals(root)) { throw rootDeletionError(root); } - if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) { + if (!normalized.startsWith(root) && !isExempt(normalized)) { throw new IllegalArgumentException( "Shell command references path outside workspace boundary: " + normalized + ", allowed root: " + root); @@ -364,7 +436,7 @@ public final class WorkspacePathGuard { if (destructive && resolved.equals(root)) { throw rootDeletionError(root); } - if (!resolved.startsWith(root) && !isUnderSkillRoot(resolved)) { + if (!resolved.startsWith(root) && !isExempt(resolved)) { throw new IllegalArgumentException( "Shell command uses parent-directory traversal that escapes the workspace: '" + candidate + "' would resolve to " + resolved diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java index 8afb0c22..5cfcd920 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java @@ -8,6 +8,7 @@ import vip.mate.tool.guard.WorkspacePathGuard; import vip.mate.tool.guard.model.*; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -36,6 +37,25 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { "execute_shell_command", "shell_execute", "run_command" ); + /** + * Inline code-execution tool. Its shell content lives in the {@code code} + * JSON parameter (selected by a sibling {@code language} parameter), not in + * a {@code command} parameter, so it needs its own extraction path. Only + * shell-language code is screened for boundary escapes — see + * {@link #SHELL_LANGUAGES} and {@link #evaluate}. + */ + private static final String CODE_TOOL_NAME = "execute_code"; + + /** + * {@code language} values whose {@code code} is shell script and can be + * scanned with the shell-syntax boundary scanner. Mirrors the {@code .sh} + * aliases accepted by the code-execution runtime. Python/Node code is not + * scanned: the shell scanner would false-positive on absolute-path string + * literals while missing interpreter-specific file access, so applying it + * there is both noisy and incomplete. + */ + private static final Set SHELL_LANGUAGES = Set.of("bash", "sh", "shell"); + /** File tools and their JSON path-parameter name. */ private static final Map FILE_PATH_PARAMS = Map.of( "read_file", "filePath", @@ -48,7 +68,9 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { @Override public boolean supports(ToolInvocationContext context) { String tool = context.toolName(); - return tool != null && (SHELL_TOOL_NAMES.contains(tool) || FILE_PATH_PARAMS.containsKey(tool)); + return tool != null && (SHELL_TOOL_NAMES.contains(tool) + || CODE_TOOL_NAME.equals(tool) + || FILE_PATH_PARAMS.containsKey(tool)); } /** Run before the DB-rule guardians so a boundary escape blocks early. */ @@ -76,6 +98,26 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { return List.of(); } + if (CODE_TOOL_NAME.equals(tool)) { + // The shell content lives in the `code` param, gated by `language`. + // Scan only shell-language code, and extract `code` explicitly + // rather than scanning the whole rawArgs JSON — the latter would + // false-positive on Python/Node source that merely contains an + // absolute-path string literal. + if (!isShellLanguage(extractJsonParam(rawArgs, "language"))) { + return List.of(); + } + String code = extractJsonParam(rawArgs, "code"); + if (code == null) { + return List.of(); + } + String violation = WorkspacePathGuard.findShellBoundaryViolation(code, basePath); + if (violation != null) { + return List.of(boundaryFinding(tool, "code", code, violation)); + } + return List.of(); + } + String paramName = FILE_PATH_PARAMS.get(tool); if (paramName != null) { String path = extractJsonParam(rawArgs, paramName); @@ -102,6 +144,11 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { GuardDecision.BLOCK); } + /** True when {@code language} names a shell interpreter (bash/sh/shell). */ + private static boolean isShellLanguage(String language) { + return language != null && SHELL_LANGUAGES.contains(language.trim().toLowerCase(Locale.ROOT)); + } + private String extractJsonParam(String rawArgs, String paramName) { try { Map params = objectMapper.readValue(rawArgs, new TypeReference<>() {}); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java index b008e4d1..5cace0df 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java @@ -1,15 +1,16 @@ package vip.mate.tool.image; import cn.hutool.http.HttpUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Base64; /** @@ -19,9 +20,10 @@ import java.util.Base64; */ @Slf4j @Component +@RequiredArgsConstructor public class ImageFileDownloader { - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + private final ChatUploadLocationResolver uploadLocationResolver; /** * Persist an image referenced by either a {@code data:} URL (inline base64 / @@ -38,7 +40,7 @@ public class ImageFileDownloader { if (imageUrl == null) { throw new IOException("imageUrl is null"); } - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); if (imageUrl.startsWith("data:")) { @@ -110,7 +112,7 @@ public class ImageFileDownloader { * 将 Base64 编码的图片保存到本地 */ public Path saveBase64(String base64Data, String conversationId, String taskId, int index) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String fileName = "image_" + taskId + "_" + index + ".png"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java index 613d23ab..1d178b0b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java @@ -4,6 +4,8 @@ import cn.hutool.http.HttpUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.common.net.SsrfAllowlist; +import vip.mate.common.net.SsrfProperties; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; @@ -48,6 +50,7 @@ public class ImageReferenceLoader { private static final int HTTP_TIMEOUT_MS = 30_000; private final ConversationService conversationService; + private final SsrfProperties ssrfProperties; /** * Resolve a list of input strings; null / blank entries are skipped. @@ -144,15 +147,8 @@ public class ImageReferenceLoader { if (host == null) { throw new IOException("URL has no host: " + url); } - // Conservative SSRF guard: reject obvious internal targets. Refine later - // if the project gains a dedicated SsrFPolicy module. - String lowered = host.toLowerCase(); - if (lowered.equals("localhost") - || lowered.equals("127.0.0.1") - || lowered.startsWith("10.") - || lowered.startsWith("192.168.") - || lowered.startsWith("169.254.") - || lowered.startsWith("172.")) { + // SSRF guard: reject obvious internal targets unless explicitly allowlisted. + if (isInternalHost(host) && !SsrfAllowlist.matchesHost(host, ssrfProperties.getSsrfAllowlist())) { throw new IOException("Refusing to download image from internal host: " + host); } try { @@ -171,6 +167,32 @@ public class ImageReferenceLoader { } } + /** Match common private / loopback / link-local hosts by string form (no DNS lookup). */ + private static boolean isInternalHost(String host) { + if (host == null) { + return true; + } + String h = host.toLowerCase(); + if (h.equals("localhost") || h.equals("127.0.0.1") || h.equals("::1")) { + return true; + } + if (h.startsWith("10.") || h.startsWith("192.168.") || h.startsWith("169.254.")) { + return true; + } + if (h.startsWith("172.")) { + String[] parts = h.split("\\."); + if (parts.length >= 2) { + try { + int second = Integer.parseInt(parts[1]); + return second >= 16 && second <= 31; // 172.16.0.0 – 172.31.255.255 + } catch (NumberFormatException ignore) { + return false; + } + } + } + return false; + } + // ==================== form: msg:: ==================== private ImageReference loadConversationMessageRef(String ref, String conversationId) throws IOException { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeController.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeController.java new file mode 100644 index 00000000..e1d7e405 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeController.java @@ -0,0 +1,44 @@ +package vip.mate.tool.local; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * Exposes whether the current user has a live desktop tunnel, so the admin UI + * can show local-tool availability and the connection state. + * + * @author MateClaw Team + */ +@RestController +@RequestMapping("/api/v1/desktop") +@RequiredArgsConstructor +public class DesktopBridgeController { + + private final DesktopBridgeRegistry registry; + + @GetMapping("/status") + public Map status() { + Map body = new HashMap<>(); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = auth != null ? auth.getName() : null; + + DesktopBridgeRegistry.DesktopSession session = + username != null ? registry.getSession(username) : null; + boolean online = session != null && session.session().isOpen(); + + body.put("online", online); + if (online) { + body.put("platform", session.platform()); + body.put("protocolVersion", session.protocolVersion()); + body.put("capabilities", session.capabilities()); + } + return body; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeException.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeException.java new file mode 100644 index 00000000..9cc0cf69 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeException.java @@ -0,0 +1,32 @@ +package vip.mate.tool.local; + +/** + * Raised when a {@code local_*} tool cannot reach the user's desktop tunnel. + * The {@link Code} drives the friendly message surfaced back to the agent. + * + * @author MateClaw Team + */ +public class DesktopBridgeException extends RuntimeException { + + public enum Code { + /** No desktop tunnel is connected for the requesting user. */ + OFFLINE, + /** The connected desktop is too old to honor the requested capability. */ + UNSUPPORTED, + /** The desktop did not reply within the call timeout. */ + TIMEOUT, + /** The requesting user could not be resolved from the tool context. */ + NO_USER + } + + private final Code code; + + public DesktopBridgeException(Code code, String message) { + super(message); + this.code = code; + } + + public Code code() { + return code; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeHandshakeInterceptor.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeHandshakeInterceptor.java new file mode 100644 index 00000000..c0d6fce0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeHandshakeInterceptor.java @@ -0,0 +1,86 @@ +package vip.mate.tool.local; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.pat.PersonalAccessTokenEntity; +import vip.mate.auth.pat.PersonalAccessTokenService; +import vip.mate.auth.service.AuthService; + +import java.util.Map; +import java.util.Optional; + +/** + * Authenticates the desktop tunnel WebSocket handshake. + *

      + * The desktop cannot send custom headers on a browser-style WebSocket open, so + * the token is passed as a {@code ?token=} query parameter (same convention the + * SSE endpoints use). Both JWT and Personal Access Token forms are accepted. + * On success the resolved username is stashed in the session attributes under + * {@link #USERNAME_ATTR} for the handler to read; on failure the handshake is + * rejected so an unauthenticated socket never reaches the tool bridge. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DesktopBridgeHandshakeInterceptor implements HandshakeInterceptor { + + public static final String USERNAME_ATTR = "mateclaw.desktopUser"; + + private final AuthService authService; + private final PersonalAccessTokenService patService; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + String token = UriComponentsBuilder.fromUri(request.getURI()) + .build().getQueryParams().getFirst("token"); + if (token == null || token.isBlank()) { + log.warn("[DesktopBridge] Handshake rejected: missing token"); + return false; + } + + String username = resolveUsername(token); + if (username == null) { + log.warn("[DesktopBridge] Handshake rejected: invalid token"); + return false; + } + + attributes.put(USERNAME_ATTR, username); + log.info("[DesktopBridge] Handshake accepted for user={}", username); + return true; + } + + private String resolveUsername(String token) { + try { + if (token.startsWith(PersonalAccessTokenService.PAT_PREFIX)) { + Optional maybe = patService.findActiveByPlaintext(token); + if (maybe.isEmpty()) return null; + UserEntity user = authService.findById(maybe.get().getUserId()); + return (user != null && Boolean.TRUE.equals(user.getEnabled())) ? user.getUsername() : null; + } + Claims claims = authService.parseClaims(token); + if (claims == null) return null; + String username = claims.getSubject(); + UserEntity user = authService.findByUsername(username); + return (user != null && Boolean.TRUE.equals(user.getEnabled())) ? username : null; + } catch (Exception e) { + return null; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + // no-op + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeRegistry.java new file mode 100644 index 00000000..bb3183d4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeRegistry.java @@ -0,0 +1,181 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registry of connected desktop tunnels, keyed by the authenticated username. + *

      + * A desktop client opens a WebSocket to the server and registers itself here. + * When a cloud agent invokes a {@code local_*} tool, the tool resolves the + * requesting user, looks up that user's live desktop session, and forwards an + * RPC call. The desktop executes the file/shell operation locally and replies, + * which completes the pending future the caller is blocked on. + *

      + * Concurrency: a single {@link WebSocketSession} is not safe for concurrent + * sends, so every frame written to a session is guarded by a monitor on that + * session. Pending RPC futures live in a flat map keyed by request id; the + * handler completes them when the matching {@code result} frame arrives. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DesktopBridgeRegistry { + + private final ObjectMapper objectMapper; + + /** + * A live desktop tunnel. {@code protocolVersion} and {@code capabilities} + * are negotiated in the {@code hello} handshake so {@code local_*} tools + * can degrade gracefully against older clients (older desktops advertise a + * smaller capability set — e.g. read/list only — and never receive + * write/edit/shell calls). + */ + public record DesktopSession( + WebSocketSession session, + String username, + int protocolVersion, + Set capabilities, + String platform) { + + public boolean supports(String capability) { + return capabilities != null && capabilities.contains(capability); + } + } + + /** username -> live desktop session (latest connection wins). */ + private final ConcurrentHashMap sessionsByUser = new ConcurrentHashMap<>(); + + /** wsSessionId -> username, for cleanup on disconnect. */ + private final ConcurrentHashMap userByWsSession = new ConcurrentHashMap<>(); + + /** requestId -> caller awaiting the desktop's reply. */ + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + + /** requestId -> id of the ws session it was routed to, so a disconnect only fails its own calls. */ + private final ConcurrentHashMap pendingOwner = new ConcurrentHashMap<>(); + + /** Register a freshly handshaken desktop session, replacing any prior one for the user. */ + public void register(DesktopSession desktop) { + DesktopSession prior = sessionsByUser.put(desktop.username(), desktop); + userByWsSession.put(desktop.session().getId(), desktop.username()); + if (prior != null && !prior.session().getId().equals(desktop.session().getId())) { + // Same user reconnected from another window — drop the stale one. + userByWsSession.remove(prior.session().getId()); + closeQuietly(prior.session()); + } + log.info("[DesktopBridge] Registered desktop for user={}, protocol={}, caps={}, platform={}", + desktop.username(), desktop.protocolVersion(), desktop.capabilities(), desktop.platform()); + } + + /** Remove a session on disconnect/error and fail any of its in-flight calls. */ + public void unregister(WebSocketSession session) { + String wsId = session.getId(); + String username = userByWsSession.remove(wsId); + if (username != null) { + DesktopSession current = sessionsByUser.get(username); + if (current != null && current.session().getId().equals(wsId)) { + sessionsByUser.remove(username); + } + log.info("[DesktopBridge] Unregistered desktop for user={}", username); + } + // Fail only the pending calls that were routed to this socket — other + // users' desktops keep their in-flight calls. + pendingOwner.forEach((id, ownerWsId) -> { + if (ownerWsId.equals(wsId)) { + CompletableFuture future = pending.remove(id); + pendingOwner.remove(id); + if (future != null && !future.isDone()) { + future.completeExceptionally(new DesktopBridgeException( + DesktopBridgeException.Code.OFFLINE, "Desktop disconnected before replying")); + } + } + }); + } + + public boolean isOnline(String username) { + if (username == null) return false; + DesktopSession s = sessionsByUser.get(username); + return s != null && s.session().isOpen(); + } + + public DesktopSession getSession(String username) { + return username == null ? null : sessionsByUser.get(username); + } + + /** Complete a pending call with the desktop's {@code result} payload. */ + public void complete(String requestId, JsonNode resultEnvelope) { + CompletableFuture future = pending.remove(requestId); + pendingOwner.remove(requestId); + if (future != null) { + future.complete(resultEnvelope); + } else { + log.debug("[DesktopBridge] No pending call for id={} (timed out or duplicate)", requestId); + } + } + + /** + * Send a {@code call} frame to the user's desktop and return a future that + * completes when the matching {@code result} frame arrives. Throws + * {@link DesktopBridgeException} with {@code OFFLINE} when the user has no + * live tunnel, or {@code UNSUPPORTED} when the desktop is too old to honor + * the requested capability. + */ + public CompletableFuture call(String username, String method, String capability, ObjectNode params) { + DesktopSession desktop = sessionsByUser.get(username); + if (desktop == null || !desktop.session().isOpen()) { + throw new DesktopBridgeException(DesktopBridgeException.Code.OFFLINE, + "No desktop is connected for this user"); + } + if (capability != null && !desktop.supports(capability)) { + throw new DesktopBridgeException(DesktopBridgeException.Code.UNSUPPORTED, + "The connected desktop does not support '" + capability + + "' (upgrade the MateClaw desktop app)"); + } + + String requestId = UUID.randomUUID().toString(); + ObjectNode frame = objectMapper.createObjectNode(); + frame.put("type", "call"); + frame.put("id", requestId); + frame.put("method", method); + frame.set("params", params != null ? params : objectMapper.createObjectNode()); + + CompletableFuture future = new CompletableFuture<>(); + pending.put(requestId, future); + pendingOwner.put(requestId, desktop.session().getId()); + try { + WebSocketSession session = desktop.session(); + synchronized (session) { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(frame))); + } + } catch (IOException e) { + pending.remove(requestId); + pendingOwner.remove(requestId); + throw new DesktopBridgeException(DesktopBridgeException.Code.OFFLINE, + "Failed to reach desktop: " + e.getMessage()); + } + return future; + } + + private void closeQuietly(WebSocketSession session) { + try { + if (session.isOpen()) session.close(); + } catch (IOException ignored) { + // best-effort + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeWebSocketHandler.java b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeWebSocketHandler.java new file mode 100644 index 00000000..b99467ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/DesktopBridgeWebSocketHandler.java @@ -0,0 +1,117 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.AbstractWebSocketHandler; + +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Server endpoint for the desktop local-tool tunnel ({@code /api/v1/desktop/ws}). + *

      + * Protocol (JSON text frames): + *

        + *
      • desktop → server {@code {"type":"hello","protocolVersion":1,"capabilities":[...],"platform":"darwin"}}
      • + *
      • server → desktop {@code {"type":"hello-ack","minProtocol":1}}
      • + *
      • server → desktop {@code {"type":"call","id":"","method":"read_file","params":{...}}}
      • + *
      • desktop → server {@code {"type":"result","id":"","ok":true,"data":{...}}} + * or {@code {"type":"result","id":"","ok":false,"error":"...","code":"DENIED"}}
      • + *
      • desktop → server {@code {"type":"ping"}} → server replies {@code {"type":"pong"}}
      • + *
      + * The authenticated username is injected into the session attributes by + * {@link DesktopBridgeHandshakeInterceptor}; an unauthenticated socket never + * reaches this handler. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DesktopBridgeWebSocketHandler extends AbstractWebSocketHandler { + + /** Lowest desktop protocol version the server still accepts. */ + private static final int MIN_PROTOCOL = 1; + + private final DesktopBridgeRegistry registry; + private final ObjectMapper objectMapper; + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + log.info("[DesktopBridge] WebSocket connected: {} (user={})", + session.getId(), session.getAttributes().get(DesktopBridgeHandshakeInterceptor.USERNAME_ATTR)); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + JsonNode data; + try { + data = objectMapper.readTree(message.getPayload()); + } catch (Exception e) { + log.warn("[DesktopBridge] Invalid JSON frame: {}", e.getMessage()); + return; + } + + String type = data.path("type").asText(""); + switch (type) { + case "hello" -> handleHello(session, data); + case "result" -> registry.complete(data.path("id").asText(), data); + case "ping" -> send(session, "{\"type\":\"pong\"}"); + default -> log.debug("[DesktopBridge] Ignoring frame type='{}'", type); + } + } + + private void handleHello(WebSocketSession session, JsonNode data) throws IOException { + String username = (String) session.getAttributes().get(DesktopBridgeHandshakeInterceptor.USERNAME_ATTR); + if (username == null) { + // Defense in depth — interceptor should have rejected already. + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + + int protocolVersion = data.path("protocolVersion").asInt(1); + if (protocolVersion < MIN_PROTOCOL) { + send(session, "{\"type\":\"hello-ack\",\"ok\":false,\"error\":\"protocol too old\"}"); + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + + Set capabilities = new LinkedHashSet<>(); + JsonNode caps = data.path("capabilities"); + if (caps.isArray()) { + caps.forEach(c -> capabilities.add(c.asText())); + } + String platform = data.path("platform").asText("unknown"); + + registry.register(new DesktopBridgeRegistry.DesktopSession( + session, username, protocolVersion, Set.copyOf(capabilities), platform)); + send(session, "{\"type\":\"hello-ack\",\"ok\":true,\"minProtocol\":" + MIN_PROTOCOL + "}"); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + registry.unregister(session); + log.info("[DesktopBridge] WebSocket disconnected: {} (status={})", session.getId(), status); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + registry.unregister(session); + log.warn("[DesktopBridge] Transport error: {} - {}", session.getId(), exception.getMessage()); + } + + private void send(WebSocketSession session, String json) throws IOException { + if (session.isOpen()) { + synchronized (session) { + session.sendMessage(new TextMessage(json)); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalFileTools.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalFileTools.java new file mode 100644 index 00000000..3ebca584 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalFileTools.java @@ -0,0 +1,132 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.context.ChatOrigin; +import vip.mate.tool.ConcurrencyUnsafe; + +/** + * Tools that operate on files on the user's local desktop machine (not + * the server). Each call is forwarded over the desktop WebSocket tunnel to the + * MateClaw desktop app, which enforces a directory whitelist, prompts the user + * for approval on writes/edits, and executes the operation locally. + *

      + * These tools require a connected desktop tunnel for the requesting user; when + * none is connected they return a friendly {@code OFFLINE} error so the agent + * can fall back to server-side tools or tell the user to open the desktop app. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalFileTools { + + /** Read calls are cheap; allow the desktop a short window to reply. */ + private static final int READ_TIMEOUT_SECONDS = 30; + /** Writes/edits may trigger an approval dialog the user must read first. */ + private static final int APPROVAL_TIMEOUT_SECONDS = 180; + + private final LocalToolBridgeService bridge; + private final ObjectMapper objectMapper; + + @Tool(description = """ + LOCAL: Read a file on the USER'S LOCAL DESKTOP machine (not the server). \ + Supports an optional 1-based line range. Output is truncated to ~30KB. \ + Requires the MateClaw desktop app to be connected and the path to be \ + inside the user's configured local directory whitelist. Use the plain \ + read_file tool for server-side files.""") + public String local_read_file( + @ToolParam(description = "Absolute path on the user's local machine") String filePath, + @ToolParam(description = "Start line number (1-based, inclusive). Omit to start at line 1", required = false) Integer startLine, + @ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("filePath", filePath); + if (startLine != null) params.put("startLine", startLine); + if (endLine != null) params.put("endLine", endLine); + return call(ctx, "local_read_file", "read_file", + LocalToolBridgeService.CAP_READ, params, READ_TIMEOUT_SECONDS, false); + } + + @ConcurrencyUnsafe("local file write — must serialize with reads/writes on overlapping paths") + @Tool(description = """ + LOCAL: Write content to a file on the USER'S LOCAL DESKTOP machine (not \ + the server). Overwrites if it exists, creates parent directories as \ + needed. ALWAYS prompts the user for native approval on the desktop \ + before writing. Requires the path to be inside the local directory \ + whitelist.""") + public String local_write_file( + @ToolParam(description = "Absolute path on the user's local machine") String filePath, + @ToolParam(description = "Full content to write to the file") String content, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("filePath", filePath); + params.put("content", content); + return call(ctx, "local_write_file", "write_file", + LocalToolBridgeService.CAP_WRITE, params, APPROVAL_TIMEOUT_SECONDS, true); + } + + @ConcurrencyUnsafe("local in-place edit — must not race with reads/writes on the same path") + @Tool(description = """ + LOCAL: Edit a file on the USER'S LOCAL DESKTOP machine via find-and-replace. \ + Replaces the first exact match of oldText with newText (set replaceAll=true \ + for all). ALWAYS prompts the user for native approval on the desktop before \ + editing. Requires the path to be inside the local directory whitelist.""") + public String local_edit_file( + @ToolParam(description = "Absolute path on the user's local machine") String filePath, + @ToolParam(description = "Original text to find (exact match)") String oldText, + @ToolParam(description = "Replacement text") String newText, + @ToolParam(description = "Replace all occurrences, default false (first only)", required = false) Boolean replaceAll, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("filePath", filePath); + params.put("oldText", oldText); + params.put("newText", newText); + params.put("replaceAll", Boolean.TRUE.equals(replaceAll)); + return call(ctx, "local_edit_file", "edit_file", + LocalToolBridgeService.CAP_EDIT, params, APPROVAL_TIMEOUT_SECONDS, true); + } + + @Tool(description = """ + LOCAL: List entries in a directory on the USER'S LOCAL DESKTOP machine \ + (not the server). Returns each entry with a name and a type marker \ + (file/dir). Requires the directory to be inside the local directory \ + whitelist.""") + public String local_list_dir( + @ToolParam(description = "Absolute directory path on the user's local machine") String dirPath, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("dirPath", dirPath); + return call(ctx, "local_list_dir", "list_dir", + LocalToolBridgeService.CAP_LIST, params, READ_TIMEOUT_SECONDS, false); + } + + @Tool(description = """ + LOCAL: Get metadata for a path on the USER'S LOCAL DESKTOP machine: size \ + in bytes, last-modified time, and whether it is a directory. Requires the \ + path to be inside the local directory whitelist.""") + public String local_stat( + @ToolParam(description = "Absolute path on the user's local machine") String path, + @Nullable ToolContext ctx) { + ObjectNode params = objectMapper.createObjectNode(); + params.put("path", path); + return call(ctx, "local_stat", "stat", + LocalToolBridgeService.CAP_STAT, params, READ_TIMEOUT_SECONDS, false); + } + + private String call(@Nullable ToolContext ctx, String toolName, String method, String capability, + ObjectNode params, int timeoutSeconds, boolean mutating) { + ChatOrigin origin = ChatOrigin.from(ctx); + LocalToolBridgeService.BridgeResult result = + bridge.invoke(origin, toolName, method, capability, params, timeoutSeconds, mutating); + return LocalToolFormat.render(result, objectMapper); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalShellTool.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalShellTool.java new file mode 100644 index 00000000..58c55bdc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalShellTool.java @@ -0,0 +1,63 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.context.ChatOrigin; +import vip.mate.tool.ConcurrencyUnsafe; + +/** + * Executes a shell command on the user's local desktop machine (not the + * server) over the desktop tunnel. The desktop app prompts the user for native + * approval before running, uses {@code cmd.exe} on Windows and {@code /bin/sh} + * on macOS/Linux, and truncates stdout/stderr to ~10KB each — mirroring the + * server-side shell tool's limits. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalShellTool { + + private static final int DEFAULT_TIMEOUT_SECONDS = 60; + private static final int MAX_TIMEOUT_SECONDS = 300; + /** Extra slack on top of the command timeout so the user can read the approval dialog. */ + private static final int APPROVAL_SLACK_SECONDS = 180; + + private final LocalToolBridgeService bridge; + private final ObjectMapper objectMapper; + + @ConcurrencyUnsafe("local shell command execution can mutate global state on the user's machine") + @Tool(description = """ + LOCAL: Execute a shell command on the USER'S LOCAL DESKTOP machine (not \ + the server). Uses cmd.exe on Windows, /bin/sh on macOS/Linux. ALWAYS \ + prompts the user for native approval on the desktop before running. \ + Returns structured JSON with exitCode, stdout, stderr, timedOut \ + (stdout/stderr truncated to ~10KB each). Use execute_shell_command for \ + server-side execution.""") + public String local_execute_shell( + @ToolParam(description = "Shell command to execute on the user's local machine") String command, + @ToolParam(description = "Timeout in seconds, default 60, hard cap 300", required = false) Integer timeoutSeconds, + @Nullable ToolContext ctx) { + int cmdTimeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS; + cmdTimeout = Math.min(cmdTimeout, MAX_TIMEOUT_SECONDS); + + ObjectNode params = objectMapper.createObjectNode(); + params.put("command", command); + params.put("timeoutSeconds", cmdTimeout); + + ChatOrigin origin = ChatOrigin.from(ctx); + LocalToolBridgeService.BridgeResult result = bridge.invoke( + origin, "local_execute_shell", "execute_shell", + LocalToolBridgeService.CAP_SHELL, params, + cmdTimeout + APPROVAL_SLACK_SECONDS, true); + return LocalToolFormat.render(result, objectMapper); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolBridgeService.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolBridgeService.java new file mode 100644 index 00000000..87cc46db --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolBridgeService.java @@ -0,0 +1,145 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.guard.model.ToolGuardAuditLogEntity; +import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * High-level entry point the {@code local_*} tools use to execute an operation + * on the requesting user's desktop. + *

      + * It resolves the requester from the {@link ChatOrigin} carried in the tool + * context, forwards the call over {@link DesktopBridgeRegistry}, blocks for the + * desktop's reply (bounded by a timeout), and writes an audit record to + * {@code mate_tool_guard_audit_log} regardless of outcome. Approval itself is + * performed natively on the desktop (where the user can see the full path / + * command / content), so this layer only records the decision the desktop made. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LocalToolBridgeService { + + /** Capability tokens advertised by the desktop in its handshake. */ + public static final String CAP_READ = "read"; + public static final String CAP_LIST = "list"; + public static final String CAP_STAT = "stat"; + public static final String CAP_WRITE = "write"; + public static final String CAP_EDIT = "edit"; + public static final String CAP_SHELL = "shell"; + + private final DesktopBridgeRegistry registry; + private final ObjectMapper objectMapper; + private final ToolGuardAuditLogMapper auditLogMapper; + + /** Whether a user has a live desktop tunnel right now. */ + public boolean isOnline(@Nullable ChatOrigin origin) { + return origin != null && registry.isOnline(origin.requesterId()); + } + + /** + * Result envelope returned to the tools. {@code data} is the desktop's + * payload on success; on failure {@code error}/{@code code} describe why. + */ + public record BridgeResult(boolean ok, @Nullable JsonNode data, + @Nullable String error, @Nullable String code) { + + public static BridgeResult success(JsonNode data) { + return new BridgeResult(true, data, null, null); + } + + public static BridgeResult failure(String code, String error) { + return new BridgeResult(false, null, error, code); + } + } + + /** + * Forward a tool call to the user's desktop and wait for the reply. + * + * @param origin chat origin carrying the requester identity + * @param toolName the {@code local_*} tool name (for audit) + * @param method the desktop RPC method (e.g. {@code read_file}) + * @param capability capability the desktop must advertise, or null + * @param params the call parameters + * @param timeoutSeconds how long to wait for the desktop reply + * @param mutating true for write/edit/shell (drives the audit decision label) + */ + public BridgeResult invoke(@Nullable ChatOrigin origin, String toolName, String method, + @Nullable String capability, ObjectNode params, + int timeoutSeconds, boolean mutating) { + String username = origin != null ? origin.requesterId() : null; + if (username == null || username.isBlank()) { + audit(origin, toolName, params, "ERROR"); + return BridgeResult.failure("NO_USER", + "Cannot determine which desktop to reach for this request"); + } + + try { + CompletableFuture future = registry.call(username, method, capability, params); + JsonNode envelope = future.get(timeoutSeconds, TimeUnit.SECONDS); + boolean ok = envelope.path("ok").asBoolean(false); + if (ok) { + audit(origin, toolName, params, mutating ? "APPROVED" : "ALLOW"); + return BridgeResult.success(envelope.path("data")); + } + String code = envelope.path("code").asText("ERROR"); + String error = envelope.path("error").asText("Operation failed on desktop"); + audit(origin, toolName, params, "DENIED".equalsIgnoreCase(code) ? "DENIED" : "BLOCK"); + return BridgeResult.failure(code, error); + + } catch (DesktopBridgeException e) { + audit(origin, toolName, params, "OFFLINE"); + return BridgeResult.failure(e.code().name(), e.getMessage()); + } catch (TimeoutException e) { + audit(origin, toolName, params, "TIMEOUT"); + return BridgeResult.failure("TIMEOUT", + "Desktop did not respond within " + timeoutSeconds + "s"); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + audit(origin, toolName, params, "ERROR"); + return BridgeResult.failure("ERROR", cause.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + audit(origin, toolName, params, "ERROR"); + return BridgeResult.failure("ERROR", "Interrupted while waiting for desktop"); + } + } + + /** Write an audit row reusing the tool-guard audit table. Best-effort. */ + private void audit(@Nullable ChatOrigin origin, String toolName, ObjectNode params, String decision) { + try { + ToolGuardAuditLogEntity entity = new ToolGuardAuditLogEntity(); + if (origin != null) { + entity.setConversationId(origin.conversationId()); + entity.setAgentId(origin.agentId() != null ? String.valueOf(origin.agentId()) : null); + entity.setUserId(origin.requesterId()); + entity.setChannelType(origin.channelType() != null ? origin.channelType() : "desktop"); + } + entity.setToolName(toolName); + entity.setToolParamsJson(truncate(params != null ? params.toString() : null)); + entity.setDecision(decision); + auditLogMapper.insert(entity); + } catch (Exception e) { + log.warn("[LocalToolBridge] Failed to record audit: {}", e.getMessage()); + } + } + + private static String truncate(String s) { + if (s == null) return null; + return s.length() > 2000 ? s.substring(0, 2000) : s; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolFormat.java b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolFormat.java new file mode 100644 index 00000000..dec2390a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/local/LocalToolFormat.java @@ -0,0 +1,40 @@ +package vip.mate.tool.local; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Renders a {@link LocalToolBridgeService.BridgeResult} into the JSON string a + * {@code local_*} tool returns to the agent. Successful calls pass the desktop + * payload through verbatim; failures become a uniform error object the LLM can + * reason about ({@code error}, {@code code}, {@code message}). + * + * @author MateClaw Team + */ +final class LocalToolFormat { + + private LocalToolFormat() { + } + + static String render(LocalToolBridgeService.BridgeResult result, ObjectMapper om) { + try { + if (result.ok()) { + JsonNode data = result.data(); + if (data == null || data.isNull() || data.isMissingNode()) { + ObjectNode ok = om.createObjectNode(); + ok.put("ok", true); + return om.writerWithDefaultPrettyPrinter().writeValueAsString(ok); + } + return om.writerWithDefaultPrettyPrinter().writeValueAsString(data); + } + ObjectNode err = om.createObjectNode(); + err.put("error", true); + err.put("code", result.code()); + err.put("message", result.error()); + return om.writerWithDefaultPrettyPrinter().writeValueAsString(err); + } catch (Exception e) { + return "{\"error\":true,\"message\":\"Failed to render local tool result\"}"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerRemovedEvent.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerRemovedEvent.java new file mode 100644 index 00000000..105d900e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerRemovedEvent.java @@ -0,0 +1,20 @@ +package vip.mate.tool.mcp.event; + +/** + * Fires after an MCP server row has been deleted from {@code mate_mcp_server}. + * + *

      Downstream listeners scrub records that reference the server's tools — most + * importantly the agent-tool binding rows in {@code mate_agent_tool}, whose tool + * names are {@code mcp___}. Without this cleanup, deleting + * an MCP server leaves orphan bindings the agent edit page still shows and the + * user can no longer clear (the tool no longer exists in the live set, so the + * picker can't render a row to uncheck). + * + *

      Distinct from {@link McpServerChangedEvent}, which signals a connection-state + * change (the agent cache is rebuilt) but does not imply the server row is gone. + * + * @param serverId DB id of the removed {@code mate_mcp_server} row + * @param serverName name the row carried, for log lines + */ +public record McpServerRemovedEvent(Long serverId, String serverName) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallback.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallback.java new file mode 100644 index 00000000..f3445097 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallback.java @@ -0,0 +1,112 @@ +package vip.mate.tool.mcp.runtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +/** + * Wraps an MCP {@link ToolCallback} for a trusted server (opt-in via + * {@link McpIdentityForwardProperties}) and injects the caller's identity into + * the call arguments, so the STDIO MCP server can forward it on-behalf-of to its + * downstream REST backend. + * + *

      What gets injected is decided by {@link McpIdentityForwardService}: either + * the plaintext username (under {@code __mateclaw_user__}) or a short-lived + * signed JWT (under {@code __mateclaw_token__}). + * + *

      STDIO has no per-request header channel and the subprocess is shared by all + * users, so identity must ride in-band, per call. The identity is + * derived from the trusted server-side {@link ToolContext}, never from the model + * — any LLM-supplied value of the reserved key is overwritten, so the model + * cannot spoof identity. + * + *

      The wrapper is transparent in every other respect: tool definition, + * metadata, schema, and (when there is no identity to inject) the call itself are + * forwarded verbatim. It sits inside {@link PrefixedNameToolCallback} + * so name prefixing and return-direct detection still see the raw delegate. + * + * @author MateClaw Team + */ +@Slf4j +public final class IdentityForwardingToolCallback implements ToolCallback { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final ToolCallback delegate; + private final McpIdentityForwardService identityService; + private final String audience; + + public IdentityForwardingToolCallback(ToolCallback delegate, + McpIdentityForwardService identityService, + String audience) { + if (delegate == null || identityService == null) { + throw new IllegalArgumentException("delegate and identityService must not be null"); + } + this.delegate = delegate; + this.identityService = identityService; + this.audience = audience; + } + + @Override + public ToolDefinition getToolDefinition() { + return delegate.getToolDefinition(); + } + + @Override + public ToolMetadata getToolMetadata() { + return delegate.getToolMetadata(); + } + + @Override + public String call(String toolInput) { + return delegate.call(inject(toolInput, null)); + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + return delegate.call(inject(toolInput, toolContext), toolContext); + } + + /** Exposed for diagnostic / wrapping detection. */ + public ToolCallback getDelegate() { + return delegate; + } + + private String inject(String toolInput, ToolContext toolContext) { + return identityService.resolve(toolContext, audience) + .map(i -> withClaim(toolInput, i.key(), i.value())) + .orElse(toolInput); + } + + /** + * Merge {@code (key, value)} into the JSON arguments, overwriting any value + * the model supplied for that key. Returns the input unchanged when the + * arguments are not a JSON object (nothing to merge into) or are malformed + * (let the call surface the error rather than mask it by rewriting). + */ + static String withClaim(String toolInput, String key, String value) { + try { + ObjectNode node; + if (toolInput == null || toolInput.isBlank()) { + node = MAPPER.createObjectNode(); + } else { + JsonNode parsed = MAPPER.readTree(toolInput); + if (!parsed.isObject()) { + log.warn("[McpIdentity] tool input is not a JSON object; forwarding without identity"); + return toolInput; + } + node = (ObjectNode) parsed; + } + node.put(key, value); + return MAPPER.writeValueAsString(node); + } catch (Exception e) { + log.warn("[McpIdentity] failed to inject identity into tool input: {}", e.getMessage()); + return toolInput; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java index a83024d7..f4187f48 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java @@ -65,8 +65,15 @@ public class McpClientManager { private final ApplicationEventPublisher eventPublisher; - public McpClientManager(ApplicationEventPublisher eventPublisher) { + private final McpIdentityForwardService identityForwardService; + + /** serverId -> server name, captured at build time for identity-forward opt-in matching. */ + private final ConcurrentHashMap serverNames = new ConcurrentHashMap<>(); + + public McpClientManager(ApplicationEventPublisher eventPublisher, + McpIdentityForwardService identityForwardService) { this.eventPublisher = eventPublisher; + this.identityForwardService = identityForwardService; } /** serverId -> connection result info */ @@ -190,7 +197,11 @@ public class McpClientManager { SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(entry.getValue()); ToolCallback[] cbs = provider.getToolCallbacks(); if (cbs != null && cbs.length > 0) { - List wrapped = wrapServerCallbacks(serverId, cbs); + String serverName = serverNames.get(serverId); + McpIdentityForwardService idSvc = + identityForwardService.forwardsTo(serverId, serverName) ? identityForwardService : null; + String audience = idSvc != null ? identityForwardService.audienceFor(serverId, serverName) : null; + List wrapped = wrapServerCallbacks(serverId, cbs, idSvc, audience); lastGoodCallbacks.put(serverId, wrapped); allCallbacks.addAll(wrapped); continue; @@ -242,6 +253,19 @@ public class McpClientManager { * real {@link McpSyncClient}. */ static List wrapServerCallbacks(long serverId, ToolCallback[] cbs) { + return wrapServerCallbacks(serverId, cbs, null, null); + } + + /** + * @param identitySvc when non-null, each callback is additionally wrapped in + * {@link IdentityForwardingToolCallback} (inside the prefix wrapper) so + * the caller's identity rides along with the call. Driven by + * {@link McpIdentityForwardService} opt-in per server; {@code null} + * means this server does not forward identity. + * @param audience the token audience for this server (ignored in plaintext mode). + */ + static List wrapServerCallbacks(long serverId, ToolCallback[] cbs, + McpIdentityForwardService identitySvc, String audience) { List rawNames = new ArrayList<>(cbs.length); for (ToolCallback cb : cbs) { rawNames.add(cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null); @@ -267,7 +291,10 @@ public class McpClientManager { serverId, raw, d.prefixedName(), d.unavailableReason()); continue; } - out.add(new PrefixedNameToolCallback(d.prefixedName(), cb)); + ToolCallback inner = identitySvc != null + ? new IdentityForwardingToolCallback(cb, identitySvc, audience) + : cb; + out.add(new PrefixedNameToolCallback(d.prefixedName(), inner)); } return out; } @@ -362,6 +389,11 @@ public class McpClientManager { * side-effect free. */ private McpSyncClient buildClient(McpServerEntity server, boolean managed) { + if (server.getId() != null && server.getName() != null) { + // Remember the name so identity-forward opt-in can be matched by name + // (not just numeric id) when callbacks are wrapped. + serverNames.put(server.getId(), server.getName()); + } McpClientTransport transport = switch (server.getTransport()) { case "stdio" -> buildStdioTransport(server, managed); case "sse" -> buildSseTransport(server); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardProperties.java new file mode 100644 index 00000000..32979408 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardProperties.java @@ -0,0 +1,151 @@ +package vip.mate.tool.mcp.runtime; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Opt-in configuration for forwarding the calling user's identity to MCP servers. + * + *

      When a server is listed in {@link #servers}, every tool call routed to it is + * wrapped by {@link IdentityForwardingToolCallback}, which injects the caller's + * identity into the call arguments. Two trust models: + * + *

        + *
      • Plaintext (default, {@code token.enabled=false}) — injects the + * username under {@link #USER_ARG}. The REST backend trusts whatever the + * MCP service forwards; only fits a trusted network where the backend + * authenticates the MCP service by API key.
      • + *
      • Signed token ({@code token.enabled=true}) — injects a short-lived + * RS256 JWT under {@link #TOKEN_ARG} (sub=user, aud=server, short exp), + * minted by {@link McpIdentityForwardService} with MateClaw's private key. + * The REST backend verifies it with the public key, so it trusts + * the signature — not the MCP service, the Python script, or the transport. + * This is the cross-trust-boundary baseline.
      • + *
      + * + *

      Why opt-in, and per server. A single STDIO subprocess is shared by + * every user (the client pool is keyed by server id), so identity can only + * travel in-band per call — never via the process environment, which is + * fixed at spawn. And forwarding identity to every MCP server would leak + * it to any third-party server an operator adds. So it is off by default and + * enabled per trusted server. + * + *

      Configuration ({@code application.yml}): + *

      + * mateclaw:
      + *   mcp:
      + *     identity-forward:
      + *       servers:
      + *         - my-internal-api          # MCP server name (mate_mcp_server) or numeric id
      + *       token:
      + *         enabled: true              # off => plaintext username (back-compat)
      + *         issuer: mateclaw
      + *         ttl-seconds: 60
      + *         key-id: mateclaw-mcp-1
      + *         private-key-pem: ${MCP_IDFWD_PRIVATE_KEY_PEM:}   # PKCS#8 PEM, RS256
      + *         audiences:                 # optional name/id -> aud; default aud = server name
      + *           my-internal-api: my-internal-api
      + * 
      + * + * @author MateClaw Team + */ +@Component +@ConfigurationProperties(prefix = "mateclaw.mcp.identity-forward") +public class McpIdentityForwardProperties { + + /** + * Reserved tool-argument key carrying the plaintext username (plaintext + * trust model). Collision-unlikely with real tool parameters; the MCP + * server reads and strips it. + */ + public static final String USER_ARG = "__mateclaw_user__"; + + /** + * Reserved tool-argument key carrying the signed JWT (token trust model). + * The MCP server forwards it as a bearer token; the REST backend verifies. + */ + public static final String TOKEN_ARG = "__mateclaw_token__"; + + /** Server names (as in mate_mcp_server) or numeric ids that opt in. */ + private Set servers = Collections.emptySet(); + + private Token token = new Token(); + + public Set getServers() { + return servers; + } + + public void setServers(Set servers) { + this.servers = servers != null ? new LinkedHashSet<>(servers) : Collections.emptySet(); + } + + public Token getToken() { + return token; + } + + public void setToken(Token token) { + this.token = token != null ? token : new Token(); + } + + /** + * @return {@code true} iff the configured set contains either the server's + * numeric id (as a string) or its name. Either argument may be + * {@code null}; the other is still checked. + */ + public boolean forwardsTo(Long serverId, String serverName) { + if (servers.isEmpty()) { + return false; + } + if (serverId != null && servers.contains(String.valueOf(serverId))) { + return true; + } + return serverName != null && servers.contains(serverName); + } + + /** + * Audience claim for a server's minted tokens: an explicit mapping (by name + * or id) when configured, otherwise the server name (or id as string). Lets + * the backend reject a token minted for a different server. + */ + public String audienceFor(Long serverId, String serverName) { + Map aud = token.getAudiences(); + if (serverName != null && aud.containsKey(serverName)) { + return aud.get(serverName); + } + if (serverId != null && aud.containsKey(String.valueOf(serverId))) { + return aud.get(String.valueOf(serverId)); + } + return serverName != null ? serverName : String.valueOf(serverId); + } + + /** Signed-token (JWT) settings for the token trust model. */ + public static class Token { + private boolean enabled = false; + private String issuer = "mateclaw"; + private long ttlSeconds = 60; + private String keyId = "mateclaw-mcp-1"; + /** PKCS#8 PEM of the RS256 private key. Required when {@link #enabled}. */ + private String privateKeyPem = ""; + private Map audiences = Collections.emptyMap(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getIssuer() { return issuer; } + public void setIssuer(String issuer) { this.issuer = issuer; } + public long getTtlSeconds() { return ttlSeconds; } + public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; } + public String getKeyId() { return keyId; } + public void setKeyId(String keyId) { this.keyId = keyId; } + public String getPrivateKeyPem() { return privateKeyPem; } + public void setPrivateKeyPem(String privateKeyPem) { this.privateKeyPem = privateKeyPem; } + public Map getAudiences() { return audiences; } + public void setAudiences(Map audiences) { + this.audiences = audiences != null ? audiences : Collections.emptyMap(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardService.java new file mode 100644 index 00000000..7bf3d875 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpIdentityForwardService.java @@ -0,0 +1,255 @@ +package vip.mate.tool.mcp.runtime; + +import io.jsonwebtoken.Jwts; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.stereotype.Service; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.builtin.ToolExecutionContext; + +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +/** + * Resolves what identity to inject into an opt-in MCP server's tool call, and + * (in token mode) mints the signed assertion. + * + *

      Identity typing. Not every requester is a MateClaw-authenticated + * account. The resolved identity is typed so the REST backend can tell + * "MateClaw authenticated this user" apart from "this is an external/anonymous + * identifier" (see RFC: on-behalf-of identity typing): + *

        + *
      • authenticated — web-console login (JWT/PAT). {@code sub} = the + * user's immutable numeric id (carried on {@link ChatOrigin#requesterUserId()}). + * The backend may authorize on-behalf-of freely.
      • + *
      • anonymous — webchat visitor / third-party {@code endUserId}. No + * MateClaw account backs it; {@code sub} = the visitor id. The backend must + * treat this as unauthenticated and decide for itself whether/how to serve.
      • + *
      • external — IM sender (feishu/wecom/…). {@code sub} = the platform + * sender id; same caveat as anonymous.
      • + *
      • none — cron / system / unattributed. Nothing is injected (fail-closed): + * we never assert identity on behalf of a non-user.
      • + *
      + * + *

      Two transport modes carry the typed identity, per {@link McpIdentityForwardProperties}: + *

        + *
      • plaintext — injects {@code :} under {@link McpIdentityForwardProperties#USER_ARG}.
      • + *
      • token — injects an RS256 JWT under {@link McpIdentityForwardProperties#TOKEN_ARG} + * with {@code sub}, {@code trust}, {@code channel_type}, {@code aud}, short {@code exp}. + * The REST backend verifies the signature with the matching public key, so it + * need not trust the MCP service or the transport.
      • + *
      + * + *

      Fail-closed: when token mode is enabled but the signing key is missing or + * unparseable, nothing is injected (the call goes out without identity and the + * backend rejects it) rather than silently downgrading to plaintext. + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class McpIdentityForwardService { + + /** Trust levels carried in the JWT {@code trust} claim / plaintext prefix. */ + static final String TRUST_AUTHENTICATED = "authenticated"; + static final String TRUST_ANONYMOUS = "anonymous"; + static final String TRUST_EXTERNAL = "external"; + + private final McpIdentityForwardProperties properties; + + /** + * Lazily parsed signing key; {@code null} until first successful parse or + * while a parse is pending. Guarded by {@code this} (the parse block) and + * safe to read outside the lock because the field is volatile and the + * {@link PrivateKey} is published safely after construction. + */ + private volatile PrivateKey signingKey; + + /** + * PEM content last handed to the parser. Lets the cache self-heal when the + * operator fixes the config (or a config reload pushes a new key) without an + * app restart: if the PEM changed since the last attempt we retry instead of + * sticking with a permanent null. {@code null} = never attempted. + */ + private volatile String lastAttemptedPem; + + public McpIdentityForwardService(McpIdentityForwardProperties properties) { + this.properties = properties; + } + + public boolean forwardsTo(Long serverId, String serverName) { + return properties.forwardsTo(serverId, serverName); + } + + public String audienceFor(Long serverId, String serverName) { + return properties.audienceFor(serverId, serverName); + } + + /** The (key, value) to merge into the call arguments, or empty to inject nothing. */ + public record Injection(String key, String value) {} + + /** A typed identity resolved from the request origin. Empty = inject nothing. */ + record ResolvedIdentity(String subject, String trust, String channelType) { + static final ResolvedIdentity NONE = new ResolvedIdentity(null, null, null); + boolean present() { return subject != null && !subject.isBlank(); } + } + + /** + * Classify the requester behind a tool call into a typed identity. Returns + * {@link ResolvedIdentity#NONE} for cron / system / unattributed origins + * (never assert identity on behalf of a non-user). + * + *

      Trust is keyed off {@link ChatOrigin#channelType()}: only the explicit + * {@code "web"} channel (built by {@code ChatOrigin.web()}) may resolve to + * {@code authenticated} — every other channel is typed as {@code anonymous} + * / {@code external}, and an unknown (null/blank) channel resolves + * to {@link ResolvedIdentity#NONE} (fail-closed). The ThreadLocal + * {@link ToolExecutionContext} username is consulted only inside the web + * branch and only when no immutable user id is present. + */ + ResolvedIdentity classify(ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + // Cron / system / unattributed: never forward identity. + if (origin.cronOrigin() || "system".equals(origin.requesterId())) { + return ResolvedIdentity.NONE; + } + String channel = origin.channelType(); + // Unknown / unattributed channel: never assert identity. An absent + // channelType must NOT be promoted to authenticated — only the explicit + // "web" channel (built by ChatOrigin.web()) carries MateClaw's assertion + // that a real account backs this request. Treating null/blank as web + // would silently stamp an untrusted ThreadLocal value with authenticated + // trust, violating the fail-closed contract this service guarantees. + if (channel == null || channel.isBlank()) { + return ResolvedIdentity.NONE; + } + // Authenticated web account: MateClaw vouches for this user. Prefer the + // immutable numeric id when available (web-console login); fall back to + // the username when only the ThreadLocal path supplied identity. + if ("web".equals(channel)) { + if (origin.requesterUserId() != null) { + return new ResolvedIdentity(String.valueOf(origin.requesterUserId()), + TRUST_AUTHENTICATED, "web"); + } + String user = ToolExecutionContext.username(ctx); + return user != null && !user.isBlank() + ? new ResolvedIdentity(user, TRUST_AUTHENTICATED, "web") + : ResolvedIdentity.NONE; + } + // webchat visitor ("api") or IM sender (feishu/wecom/…): external id, + // no MateClaw account — forward with an explicit trust downgrade so the + // backend knows it is NOT an authenticated MateClaw user. + String requester = origin.requesterId(); + if (requester == null || requester.isBlank()) { + return ResolvedIdentity.NONE; + } + String trust = "api".equals(channel) ? TRUST_ANONYMOUS : TRUST_EXTERNAL; + return new ResolvedIdentity(requester, trust, channel); + } + + /** + * Resolve the identity injection for a call. Empty when the origin carries + * no usable identity (cron / system / anonymous-without-id), or when token + * mode is enabled but the key is unavailable (fail-closed). + */ + public Optional resolve(ToolContext ctx, String audience) { + ResolvedIdentity id = classify(ctx); + if (!id.present()) { + return Optional.empty(); + } + if (!properties.getToken().isEnabled()) { + // Plaintext: prefix the value with the trust level so the backend + // can tell authenticated from anonymous/external without a JWT. + return Optional.of(new Injection(McpIdentityForwardProperties.USER_ARG, + id.trust() + ":" + id.subject())); + } + String jwt = mint(id, audience); + if (jwt == null) { + return Optional.empty(); // fail-closed: token mode but no key + } + return Optional.of(new Injection(McpIdentityForwardProperties.TOKEN_ARG, jwt)); + } + + /** Mint a short-lived RS256 JWT, or {@code null} if the key is unavailable. */ + private String mint(ResolvedIdentity id, String audience) { + PrivateKey key = signingKey(); + if (key == null) { + return null; + } + McpIdentityForwardProperties.Token t = properties.getToken(); + Instant now = Instant.now(); + try { + return Jwts.builder() + .header().keyId(t.getKeyId()).and() + .issuer(t.getIssuer()) + .subject(id.subject()) + .audience().add(audience).and() + .claim("trust", id.trust()) + .claim("channel_type", id.channelType()) + .id(UUID.randomUUID().toString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(Duration.ofSeconds(Math.max(1, t.getTtlSeconds()))))) + .signWith(key, Jwts.SIG.RS256) + .compact(); + } catch (Exception e) { + log.error("[McpIdentity] failed to mint identity token: {}", e.getMessage()); + return null; + } + } + + /** + * Resolve the signing key, parsing it lazily. Self-heals when the configured + * PEM changes (e.g. an operator fixes a malformed key or a config reload + * pushes a new one) — a prior failed parse is retried on the next call once + * {@code private-key-pem} differs from what was last attempted, so recovery + * no longer needs an app restart. Stays fail-closed otherwise. + */ + private PrivateKey signingKey() { + PrivateKey k = signingKey; + if (k != null) { + return k; + } + String pem = properties.getToken().getPrivateKeyPem(); + // Skip only while the PEM is unchanged since the last attempt — that + // avoids re-parsing (and re-logging) on every call. A changed PEM clears + // the way for a fresh parse, which is the self-healing path. + if (lastAttemptedPem != null && lastAttemptedPem.equals(pem)) { + return null; + } + synchronized (this) { + if (signingKey != null) { + return signingKey; + } + // Re-check under the lock: another thread may have just attempted + // the same (unchanged) PEM. + if (lastAttemptedPem != null && lastAttemptedPem.equals(pem)) { + return null; + } + lastAttemptedPem = pem; + if (pem == null || pem.isBlank()) { + log.error("[McpIdentity] token mode enabled but mateclaw.mcp.identity-forward.token.private-key-pem is empty; " + + "identity tokens will NOT be issued (fail-closed)"); + return null; + } + try { + String body = pem.replaceAll("-----BEGIN (.*)-----", "") + .replaceAll("-----END (.*)-----", "") + .replaceAll("\\s", ""); + byte[] der = Base64.getDecoder().decode(body); + signingKey = KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(der)); + log.info("[McpIdentity] loaded RS256 signing key (kid={})", properties.getToken().getKeyId()); + } catch (Exception e) { + log.error("[McpIdentity] failed to parse private-key-pem (expect PKCS#8 RSA): {}", e.getMessage()); + } + return signingKey; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java index e79bf753..615207bd 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import vip.mate.exception.MateClawException; import vip.mate.tool.mcp.event.McpConnectionLostEvent; import vip.mate.tool.mcp.event.McpServerChangedEvent; +import vip.mate.tool.mcp.event.McpServerRemovedEvent; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.repository.McpServerMapper; import vip.mate.tool.mcp.runtime.McpClientManager; @@ -221,6 +222,9 @@ public class McpServerService { mcpClientManager.remove(id); mcpServerMapper.deleteById(id); publishChanged("server-deleted"); + // Cascade-clean agent-tool bindings for this server's tools so the agent + // edit page doesn't keep showing orphan bindings the user can't clear. + eventPublisher.publishEvent(new McpServerRemovedEvent(id, entity.getName())); log.info("MCP server deleted: name={}, id={}", entity.getName(), id); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java index bd77eddc..326a9f14 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java @@ -1,13 +1,14 @@ package vip.mate.tool.model3d; import cn.hutool.http.HttpUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; /** * Downloads generated 3D-model assets (.glb / .obj / .fbx) from the provider's @@ -16,13 +17,14 @@ import java.nio.file.Paths; */ @Slf4j @Component +@RequiredArgsConstructor public class Model3dFileDownloader { - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + private final ChatUploadLocationResolver uploadLocationResolver; public Path download(String modelUrl, String conversationId, String taskId, String preferredExtension) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String ext = guessExtension(modelUrl, preferredExtension); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java index ad72b750..0a2ddef8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java @@ -12,12 +12,12 @@ import vip.mate.task.AsyncTaskService; import vip.mate.task.model.AsyncTaskEntity; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import jakarta.annotation.PreDestroy; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -55,8 +55,8 @@ public class MusicGenerationService { * audio as a native attachment. Web-class channels keep using SSE only. */ private final AsyncTaskMediaDispatcher asyncTaskMediaDispatcher; + private final ChatUploadLocationResolver uploadLocationResolver; - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); private static final String TASK_TYPE = "music_generation"; /** Dedicated virtual-thread worker. Music generation blocks on a single @@ -191,7 +191,7 @@ public class MusicGenerationService { private PersistedAudio persistAudio(String conversationId, String taskId, MusicGenerationResult result) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String fileName = "music_" + taskId + "." + result.getFormat(); Path filePath = dir.resolve(fileName); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchCache.java b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchCache.java index 8d274bb6..e1b981aa 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchCache.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.concurrent.ConcurrentHashMap; /** - * 搜索结果内存缓存 — 借鉴 openclaw 的 SEARCH_CACHE 设计 + * 搜索结果内存缓存 * *

      避免 Agent 同一对话中多次搜索相同/相似 query 时重复调用搜索 API。 *

        diff --git a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java index 3ef10131..fb624797 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java @@ -4,16 +4,19 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.system.model.SystemSettingsDTO; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; /** * 搜索提供商注册表 — 收集所有 {@link SearchProvider} 实现,提供优先级排序与自动探测 * - *

        借鉴 openclaw 的 provider auto-detect 机制: + *

        provider 自动探测机制: *

          *
        1. 用户显式配置的 primary provider → 直接使用
        2. *
        3. 按 autoDetectOrder 遍历,优先选有 credential 的 provider
        4. @@ -29,6 +32,16 @@ public class SearchProviderRegistry { private final List sortedProviders; private final Map providerMap; + /** 插件注册的 provider(运行时可变),与 Spring 注入的内置 provider 合并成完整视图 */ + private final ConcurrentHashMap pluginProviders = new ConcurrentHashMap<>(); + + /** + * 注册写锁:大小写不敏感的冲突检测是"先检查后插入",两个并发注册大小写变体 + * ("Foo"/"foo")可能双双通过检查后各自落入不同 key——写路径必须原子化。 + * 读路径(getById/allSorted/resolve)仍走无锁的 ConcurrentHashMap。 + */ + private final Object registrationLock = new Object(); + public SearchProviderRegistry(List providers) { this.sortedProviders = providers.stream() .sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder)) @@ -39,20 +52,78 @@ public class SearchProviderRegistry { sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList()); } - /** 按 ID 获取指定 provider */ - public SearchProvider getById(String id) { - return providerMap.get(id); + /** + * 注册一个插件提供的 provider。 + * + *

          id 规则:不允许为空或含首尾空白(拒绝而非 trim——注册键必须与 + * {@code provider.id()} 完全一致,反注册才能对得上);存储与查找大小写敏感, + * 但冲突检测大小写不敏感,防止 "Serper" 这类变体在 UI 上与内置 "serper" 混淆。 + * + * @throws IllegalArgumentException id 为空、含首尾空白,或与内置/已注册插件 provider 冲突 + */ + public void registerPluginProvider(SearchProvider provider) { + String id = provider.id(); + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Search provider id must not be blank"); + } + if (!id.equals(id.trim())) { + throw new IllegalArgumentException( + "Search provider id must not contain leading/trailing whitespace: '" + id + "'"); + } + synchronized (registrationLock) { + if (containsIgnoreCase(providerMap.keySet(), id)) { + throw new IllegalArgumentException( + "Search provider id conflicts with a built-in provider: " + id); + } + if (containsIgnoreCase(pluginProviders.keySet(), id)) { + throw new IllegalArgumentException( + "Search provider id already registered by another plugin: " + id); + } + pluginProviders.put(id, provider); + } + log.info("插件搜索提供商已注册: {} (order={})", id, provider.autoDetectOrder()); } - /** 获取按 autoDetectOrder 排序的全部 provider 列表 */ + private static boolean containsIgnoreCase(Set ids, String candidate) { + return ids.stream().anyMatch(existing -> existing.equalsIgnoreCase(candidate)); + } + + /** 反注册插件 provider(disable / rollback 路径调用;id 不存在时静默) */ + public void unregisterPluginProvider(String id) { + if (pluginProviders.remove(id) != null) { + log.info("插件搜索提供商已反注册: {}", id); + } + } + + /** 判断某个 id 是否由插件注册(而非内置 Spring bean) */ + public boolean isPluginProvider(String id) { + return pluginProviders.containsKey(id); + } + + /** 按 ID 获取指定 provider(内置优先,其次插件注册区) */ + public SearchProvider getById(String id) { + SearchProvider builtin = providerMap.get(id); + return builtin != null ? builtin : pluginProviders.get(id); + } + + /** + * 获取按 autoDetectOrder 排序的全部 provider(内置 + 插件)。 + *

          有插件注册时每次调用重新合并排序——provider 总数 <10,无需缓存。 + */ public List allSorted() { - return sortedProviders; + if (pluginProviders.isEmpty()) { + return sortedProviders; + } + List merged = new ArrayList<>(sortedProviders); + merged.addAll(pluginProviders.values()); + merged.sort(Comparator.comparingInt(SearchProvider::autoDetectOrder)); + return merged; } /** * 根据当前配置,解析应使用的 provider。 * - *

          解析策略(借鉴 openclaw resolveWebSearchProviderId): + *

          解析策略: *

            *
          1. 如果用户配置了 primary provider 且该 provider 可用 → 选中
          2. *
          3. 否则按 autoDetectOrder 遍历,跳过 keyless,先找有 credential 的
          4. @@ -65,7 +136,7 @@ public class SearchProviderRegistry { // 1. 用户显式配置的 primary provider String configuredId = config.getSearchProvider(); if (configuredId != null && !configuredId.isBlank()) { - SearchProvider configured = providerMap.get(configuredId); + SearchProvider configured = getById(configuredId); if (configured != null && configured.isAvailable(config)) { return new ResolvedProvider(configured, "configured"); } @@ -73,7 +144,7 @@ public class SearchProviderRegistry { // 2. 按优先级遍历,先找有 credential 的 SearchProvider keylessFallback = null; - for (SearchProvider p : sortedProviders) { + for (SearchProvider p : allSorted()) { if (!p.requiresCredential()) { // 记住第一个可用的 keyless provider if (keylessFallback == null && p.isAvailable(config)) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchQuery.java b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchQuery.java index 19c3e7b4..aef13eb3 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/search/SearchQuery.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/search/SearchQuery.java @@ -1,7 +1,7 @@ package vip.mate.tool.search; /** - * 搜索查询参数封装 — 借鉴 openclaw 的丰富工具参数设计 + * 搜索查询参数封装 * * @param query 搜索关键词(必须) * @param freshness 时间范围过滤:day / week / month / year(可选) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java index 61bfded6..63e00632 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java @@ -1,13 +1,14 @@ package vip.mate.tool.video; import cn.hutool.http.HttpUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; /** * 视频文件下载器 — 从 provider CDN 下载视频到本地存储 @@ -16,9 +17,10 @@ import java.nio.file.Paths; */ @Slf4j @Component +@RequiredArgsConstructor public class VideoFileDownloader { - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + private final ChatUploadLocationResolver uploadLocationResolver; /** * 下载视频到本地 @@ -29,7 +31,7 @@ public class VideoFileDownloader { * @return 本地文件路径 */ public Path download(String videoUrl, String conversationId, String taskId) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String extension = guessExtension(videoUrl); diff --git a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java index b5f91520..ee1a8fd4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java +++ b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java @@ -6,11 +6,11 @@ import org.springframework.stereotype.Service; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -28,8 +28,8 @@ public class TtsService { private final SystemSettingService systemSettingService; private final TtsProviderRegistry providerRegistry; private final ChatStreamTracker streamTracker; + private final ChatUploadLocationResolver uploadLocationResolver; - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); private static final int MAX_TEXT_LENGTH = 4096; /** 用于自动 TTS 的异步线程池 */ @@ -204,7 +204,7 @@ public class TtsService { private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format) throws IOException { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId); Files.createDirectories(dir); String fileName = "tts_" + fileId + "." + format; Path filePath = dir.resolve(fileName); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java index f836025e..edaea65b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java @@ -6,17 +6,25 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.wiki.dto.WikiFailureItem; import vip.mate.wiki.job.WikiChunkTokenBackfillJob; import vip.mate.wiki.service.WikiOverviewService; import vip.mate.wiki.service.WikiPageService; +import vip.mate.wiki.service.WikiRawMaterialService; import vip.mate.wiki.service.WikiScaffoldService; import java.util.HashMap; +import java.util.List; import java.util.Map; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @@ -37,6 +45,7 @@ public class WikiAdminController { private final WikiScaffoldService scaffoldService; private final WikiPageService pageService; + private final WikiRawMaterialService rawService; /** Optional so the controller can boot in environments where the rebuilder isn't wired (e.g. minimal tests). */ @Autowired(required = false) @@ -100,4 +109,34 @@ public class WikiAdminController { Map report = pageService.mergeDuplicateTitles(kbId, dryRun, concatenate); return ResponseEntity.ok(report); } + + /** + * Centralized, cross-knowledge-base list of materials needing operator + * attention (failed / partial / completed-but-degraded). Lets an admin + * triage background ingest problems without opening each KB in turn — + * the count behind the sidebar attention badge resolves here. + * + *

            Platform-admin only: it deliberately spans every workspace, so it is + * gated on {@code ROLE_ADMIN} rather than a per-workspace role. + */ + @Operation(summary = "跨知识库列出需要关注的处理失败/降级材料(管理员)") + @GetMapping("/failures") + public R> listFailures( + @RequestParam(defaultValue = "100") int limit, + Authentication auth) { + requireAdmin(auth); + return R.ok(rawService.listFailures(limit)); + } + + private void requireAdmin(Authentication auth) { + if (auth == null) { + throw new MateClawException(401, "authentication required"); + } + boolean admin = auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch("ROLE_ADMIN"::equals); + if (!admin) { + throw new MateClawException(403, "admin only"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 86ee37a6..e4c1947b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -524,7 +524,10 @@ public class WikiController { item.put("title", raw.getTitle()); item.put("sourceType", raw.getSourceType()); item.put("processingStatus", raw.getProcessingStatus()); + item.put("errorCode", raw.getErrorCode()); item.put("errorMessage", raw.getErrorMessage()); + item.put("warningCode", raw.getWarningCode()); + item.put("warningMessage", raw.getWarningMessage()); item.put("progressPhase", raw.getProgressPhase()); item.put("progressDone", raw.getProgressDone()); item.put("progressTotal", raw.getProgressTotal()); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java index fcac1042..523288e1 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java @@ -5,14 +5,19 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import vip.mate.exception.MateClawException; import vip.mate.wiki.dto.WikiEntityGraphView; import vip.mate.wiki.dto.WikiEntityView; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.service.WikiEntityExtractionService; import vip.mate.wiki.service.WikiEntityGraphService; +import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiProcessingService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; import java.util.Map; @@ -30,27 +35,37 @@ public class WikiEntityController { private final WikiEntityGraphService graphService; private final WikiEntityExtractionService extractionService; + private final WikiKnowledgeBaseService kbService; /** List entities in a KB, optionally filtered by type, ranked by salience. */ + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/entities") public List listEntities(@PathVariable Long kbId, @RequestParam(required = false) String type, - @RequestParam(defaultValue = "100") int limit) { + @RequestParam(defaultValue = "100") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return graphService.listEntities(kbId, type, limit); } /** Whole-KB entity graph: top entities by salience plus the edges among them. */ + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/entity-graph") public WikiEntityGraphView kbEntityGraph(@PathVariable Long kbId, - @RequestParam(defaultValue = "150") int limit) { + @RequestParam(defaultValue = "150") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return graphService.graph(kbId, limit); } /** Ego-graph around a single entity: neighbors, edges, and mentioning pages. */ + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/entities/{entityId}/graph") public WikiEntityGraphView entityGraph(@PathVariable Long kbId, @PathVariable Long entityId, - @RequestParam(defaultValue = "50") int limit) { + @RequestParam(defaultValue = "50") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return graphService.ego(kbId, entityId, limit); } @@ -60,9 +75,12 @@ public class WikiEntityController { * * @param force when true, re-extract chunks that already have mentions */ + @RequireWorkspaceRole("member") @PostMapping("/kb/{kbId}/entities/extract") public Map extract(@PathVariable Long kbId, - @RequestParam(defaultValue = "false") boolean force) { + @RequestParam(defaultValue = "false") boolean force, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); WikiProcessingService.WIKI_EXECUTOR.submit(() -> { try { int count = extractionService.extractForKb(kbId, force); @@ -73,4 +91,17 @@ public class WikiEntityController { }); return Map.of("status", "started", "kbId", kbId); } + + // ==================== Workspace Verification ==================== + + private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null) { + throw new MateClawException(404, "Knowledge base not found"); + } + long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L; + if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) { + throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java index d309e598..5048f2d8 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java @@ -6,14 +6,20 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.bind.annotation.*; +import vip.mate.exception.MateClawException; import vip.mate.wiki.dto.*; import vip.mate.wiki.job.WikiProcessingJobService; import vip.mate.wiki.job.event.WikiJobCreatedEvent; import vip.mate.wiki.repository.WikiProcessingJobMapper; import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiChunkMapper; import vip.mate.wiki.repository.WikiPageCitationMapper; import vip.mate.wiki.service.*; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.HashMap; import java.util.List; @@ -37,52 +43,88 @@ public class WikiRelationController { private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final WikiEmbeddingService embeddingService; + private final WikiKnowledgeBaseService kbService; + private final WikiRawMaterialService rawService; + private final WikiChunkMapper chunkMapper; // ==================== RFC-029: Relations ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/pages/{slug}/related") public List relatedPages( @PathVariable Long kbId, @PathVariable String slug, - @RequestParam(defaultValue = "5") int topK) { + @RequestParam(defaultValue = "5") int topK, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return relationService.relatedPages(kbId, slug, Math.min(topK, 20)); } + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/pages/{slugA}/relation/{slugB}") public RelationExplanation explainRelation( @PathVariable Long kbId, @PathVariable String slugA, - @PathVariable String slugB) { + @PathVariable String slugB, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); return relationService.explain(kbId, slugA, slugB); } + @RequireWorkspaceRole("viewer") @GetMapping("/raw/{rawId}/pages") - public List pagesByRawId(@PathVariable Long rawId) { + public List pagesByRawId( + @PathVariable Long rawId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyRawWorkspace(rawId, workspaceId); return relationService.pagesByRawId(rawId); } + @RequireWorkspaceRole("viewer") @GetMapping("/chunks/{chunkId}/pages") - public List pagesByChunkId(@PathVariable Long chunkId) { + public List pagesByChunkId( + @PathVariable Long chunkId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyChunkWorkspace(chunkId, workspaceId); return relationService.pagesByChunkId(chunkId); } // ==================== RFC-029: Citations ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/pages/{pageId}/citations") public List pageCitations( @PathVariable Long kbId, - @PathVariable Long pageId) { + @PathVariable Long pageId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + // Guard against a partial IDOR: kbId is workspace-checked above, but + // pageId is an independent path variable that could point at a page in + // another KB. Require the resolved page to actually belong to this kbId + // (same pattern as the getJobs(rawId) cross-KB filter). + WikiPageEntity page = pageService.getById(pageId); + if (page == null || !kbId.equals(page.getKbId())) { + return List.of(); + } return citationMapper.listWithRawByPageId(pageId); } // ==================== RFC-030: Jobs ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/jobs") public List getJobs( @PathVariable Long kbId, - @RequestParam(required = false) Long rawId) { + @RequestParam(required = false) Long rawId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); if (rawId != null) { return jobMapper.findLatestByRawId(rawId) + // Guard against a partial IDOR: kbId is workspace-checked + // above, but rawId is an independent query param that could + // point at another KB's material. Require the resolved job + // to actually belong to this kbId. + .filter(j -> kbId.equals(j.getKbId())) .map(List::of).orElse(List.of()); } return jobMapper.listQueued(kbId, 20); @@ -90,8 +132,12 @@ public class WikiRelationController { // ==================== RFC-030/033: KB Stats ==================== + @RequireWorkspaceRole("viewer") @GetMapping("/kb/{kbId}/stats") - public Map kbStats(@PathVariable Long kbId) { + public Map kbStats( + @PathVariable Long kbId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); int pageCount = pageService.countByKbId(kbId); // Count enriched pages (those containing [[wikilinks]]) long enrichedCount = pageService.listByKbIdWithContent(kbId).stream() @@ -128,8 +174,13 @@ public class WikiRelationController { // ==================== RFC-031: Enrichment & Repair ==================== + @RequireWorkspaceRole("member") @PostMapping("/kb/{kbId}/pages/{slug}/enrich") - public Map enrichPage(@PathVariable Long kbId, @PathVariable String slug) { + public Map enrichPage( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); WikiPageEntity page = pageService.getBySlug(kbId, slug); if (page == null) return Map.of("error", "Page not found: " + slug); @@ -146,8 +197,13 @@ public class WikiRelationController { return Map.of("jobId", job.getId()); } + @RequireWorkspaceRole("member") @PostMapping("/kb/{kbId}/pages/{slug}/repair") - public Map repairPage(@PathVariable Long kbId, @PathVariable String slug) { + public Map repairPage( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); WikiPageEntity page = pageService.getBySlug(kbId, slug); if (page == null) return Map.of("error", "Page not found: " + slug); @@ -166,13 +222,54 @@ public class WikiRelationController { // ==================== RFC-032: Search preview ==================== + @RequireWorkspaceRole("viewer") @PostMapping("/kb/{kbId}/search-preview") public List searchPreview( @PathVariable Long kbId, - @RequestBody Map body) { + @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); String query = (String) body.getOrDefault("query", ""); String mode = (String) body.getOrDefault("mode", "hybrid"); int topK = body.containsKey("topK") ? ((Number) body.get("topK")).intValue() : 5; return hybridRetriever.search(kbId, query, mode, Math.min(topK, 20)); } + + // ==================== Workspace Verification ==================== + + private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null) { + throw new MateClawException(404, "Knowledge base not found"); + } + long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L; + if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) { + throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区"); + } + } + + /** + * Resolve the owning KB of a raw material and check it belongs to the + * caller's workspace. Raw materials don't carry workspaceId directly; + * they reference a KB which does. + */ + private void verifyRawWorkspace(Long rawId, Long headerWorkspaceId) { + WikiRawMaterialEntity raw = rawService.getById(rawId); + if (raw == null || raw.getKbId() == null) { + throw new MateClawException(404, "Raw material not found"); + } + verifyKBWorkspace(raw.getKbId(), headerWorkspaceId); + } + + /** + * Resolve the owning KB of a chunk and check it belongs to the caller's + * workspace. Like raw materials, chunks reference a KB, not a workspace. + */ + private void verifyChunkWorkspace(Long chunkId, Long headerWorkspaceId) { + WikiChunkEntity chunk = chunkMapper.selectById(chunkId); + if (chunk == null || chunk.getKbId() == null) { + throw new MateClawException(404, "Chunk not found"); + } + verifyKBWorkspace(chunk.getKbId(), headerWorkspaceId); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java new file mode 100644 index 00000000..340124dc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java @@ -0,0 +1,23 @@ +package vip.mate.wiki.dto; + +import java.time.LocalDateTime; + +/** + * Cross-KB projection of a raw material that needs operator attention — + * failed, partial, or completed-but-degraded (a warning was recorded). Powers + * the centralized Wiki failure list so operators can triage background + * processing problems without opening each knowledge base in turn. + */ +public record WikiFailureItem( + Long rawId, + Long kbId, + String kbName, + Long workspaceId, + String title, + String processingStatus, + String errorCode, + String errorMessage, + String warningCode, + String warningMessage, + LocalDateTime updateTime +) {} 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 5230de2b..e7dcada2 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 @@ -31,6 +31,16 @@ public class WikiKbConfig { */ private Long wikiDefaultModelId; + /** + * KB-level lightweight chat model for the cheap, high-volume steps + * (route / enrich / summary / entity extraction). When set, those steps + * run on this cheaper model instead of the KB/system default, cutting token + * spend without touching page generation quality. {@code null} falls back to + * the system-level light model, then to normal routing. Strong steps + * (create_page / merge_page) ignore this field. + */ + private Long wikiLightModelId; + /** Per-step model overrides: "heavy_ingest.create_page" → modelId */ private Map stepModels; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java index d9300ce5..a1acab0e 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java @@ -52,6 +52,9 @@ public final class WikiKbConfigParser { } else if ("wikiDefaultModelId".equals(key)) { Long parsed = parseLong(value); if (parsed != null) config.setWikiDefaultModelId(parsed); + } else if ("wikiLightModelId".equals(key)) { + Long parsed = parseLong(value); + if (parsed != null) config.setWikiLightModelId(parsed); } else if (key.startsWith("stepModels.")) { Long parsed = parseLong(value); if (parsed != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java index 3fd190a9..ec9914cd 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java @@ -9,12 +9,13 @@ import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; /** - * RFC-030: Final fallback strategy — uses the system default model. - * Cheap steps (ROUTE, ENRICH, SUMMARY) prefer a lighter/cheaper model; - * strong steps (CREATE_PAGE, MERGE_PAGE) use the default model. + * Final fallback strategy — uses the system default model for every step. + * Cheap steps that want a lighter model are handled earlier by + * {@link WikiLightModelStrategy} (Order 2) when a light model is configured; + * this strategy is the last resort and keeps all steps on the system default. */ @Component -@Order(3) +@Order(4) @RequiredArgsConstructor public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy { @@ -25,9 +26,6 @@ public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy { @Override public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { - // RFC-030: cheap steps (ROUTE, ENRICH, SUMMARY) should ideally use a lighter model, - // but ModelConfigService has no "cheapest chat model" concept yet. - // When per-step pricing metadata is added, this switch can differentiate. return modelConfigService.getDefaultModel().getId(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java index ae03657c..3edc0706 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java @@ -12,14 +12,14 @@ import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; /** - * RFC-051 PR-1a: middle-priority strategy that resolves the KB-level default - * chat model ({@link WikiKbConfig#getWikiDefaultModelId()}). Sits between the - * per-step override ({@link KbConfigStepModelStrategy}, Order 1) and the - * system-wide default ({@link GlobalDefaultStepModelStrategy}, Order 3), - * yielding the chain prescribed by RFC-051 §10.1: + * RFC-051 PR-1a: resolves the KB-level default chat model + * ({@link WikiKbConfig#getWikiDefaultModelId()}). Sits below the per-step + * override ({@link KbConfigStepModelStrategy}, Order 1) and the cheap-step light + * model ({@link WikiLightModelStrategy}, Order 2), and above the system-wide + * default ({@link GlobalDefaultStepModelStrategy}, Order 4), yielding the chain: * *

            - *   stepModels[step] -> wikiDefaultModelId -> system default
            + *   stepModels[step] -> (light model for cheap steps) -> wikiDefaultModelId -> system default
              * 
            * * The frontend has long written {@code wikiDefaultModelId} into the KB config @@ -27,7 +27,7 @@ import vip.mate.wiki.model.WikiKnowledgeBaseEntity; */ @Slf4j @Component -@Order(2) +@Order(3) @RequiredArgsConstructor public class KbDefaultModelStrategy implements WikiStepModelStrategy { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java new file mode 100644 index 00000000..3a0b1af7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java @@ -0,0 +1,92 @@ +package vip.mate.wiki.job.strategy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import vip.mate.system.service.SystemSettingService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.WikiKbConfigParser; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Routes the cheap, high-volume wiki steps (route / enrich / summary / entity + * extraction) to a lightweight chat model so they don't bill at the premium + * page-generation model's rate. + * + *

            The light model is resolved as: per-KB {@code wikiLightModelId} → + * system-level {@code wiki.lightModelId} setting. When neither is configured the + * strategy returns {@code null} and routing falls through to the existing chain + * ({@code wikiDefaultModelId} → system default), so behavior is unchanged until + * an admin opts in. + * + *

            Order is between the explicit per-step override + * ({@link KbConfigStepModelStrategy}, Order 1) and the KB default + * ({@link KbDefaultModelStrategy}, Order 3): once a light model is configured it + * takes precedence over the KB default for the cheap steps, but a KB can still + * pin a specific model on any step via {@code stepModels.}. Strong steps + * (create_page / merge_page) are not handled here. + */ +@Slf4j +@Component +@Order(2) +public class WikiLightModelStrategy implements WikiStepModelStrategy { + + /** System-setting key for the global lightweight wiki model id. */ + static final String SETTING_KEY = "wiki.lightModelId"; + + /** Cheap, high-volume steps eligible for the lightweight model. */ + private static final Set CHEAP_STEPS = EnumSet.of( + WikiJobStep.ROUTE, WikiJobStep.ENRICH, WikiJobStep.SUMMARY, WikiJobStep.ENTITY_EXTRACTION); + + private final ObjectMapper objectMapper; + private final SystemSettingService systemSettingService; + + public WikiLightModelStrategy(ObjectMapper objectMapper, SystemSettingService systemSettingService) { + this.objectMapper = objectMapper; + this.systemSettingService = systemSettingService; + } + + @Override + public boolean supports(WikiJobStep step) { + return CHEAP_STEPS.contains(step); + } + + @Override + public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { + if (!CHEAP_STEPS.contains(step)) { + return null; + } + Long perKb = perKbLightModel(kb); + if (perKb != null) { + return perKb; + } + return systemLightModel(); + } + + private Long perKbLightModel(WikiKnowledgeBaseEntity kb) { + if (kb == null || kb.getConfigContent() == null) { + return null; + } + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + return config != null ? config.getWikiLightModelId() : null; + } + + private Long systemLightModel() { + String raw = systemSettingService.getString(SETTING_KEY, null); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + log.warn("[WikiLightModel] Invalid {} setting (not a model id): {}", SETTING_KEY, raw); + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java index f623ccdf..e770fc4a 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java @@ -64,19 +64,45 @@ public class WikiRawMaterialEntity { /** 上次成功处理时的 content_hash,用于重处理时的短路判断 */ private String lastProcessedHash; - /** 错误信息 */ + /** 错误信息(原始异常文本,供排查使用) */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String errorMessage; /** - * RFC-012 M2 v2 UI:当前处理阶段(null 未开始 / "route" / "phase-b" / "done")。 - * 供前端决定是否显示进度条以及显示"准备中"还是具体进度。 + * Structured error code, sharing the same vocabulary as + * {@code WikiProcessingService#classifyErrorCode} + * (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / + * SERVER_ERROR / CONTENT_FILTER / NO_CONTENT / EMPTY_RESULT / UNKNOWN). + * Used by the frontend for localized friendly messages; null = no error. + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String errorCode; + + /** + * Non-blocking warning code: the material was processed successfully + * overall (completed/partial), but an async sub-step (embedding / + * entity-graph extraction) failed causing a degraded feature (e.g. no + * semantic search). Shares the same friendly-prompt mechanism as + * {@link #errorCode}; null = no warning. + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String warningCode; + + /** Raw warning text (for troubleshooting), paired with {@link #warningCode}. */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String warningMessage; + + /** + * Current processing phase (null = not started / "route" / "phase-b" / + * "done"). Drives whether the frontend shows a progress bar and whether + * it says "preparing" or a concrete percentage. */ private String progressPhase; - /** RFC-012 M2 v2 UI:本次处理计划的总页数(route 阶段确定后写入)。 */ + /** Total pages planned for this run (set after route phase). */ private Integer progressTotal; - /** RFC-012 M2 v2 UI:已完成的页数(每个 phase B 页成功后 +1)。 */ + /** Completed page count (incremented per successful phase-B page). */ private Integer progressDone; @TableField(fill = FieldFill.INSERT) diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java index 15b03b53..a26eb0f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeDef.java @@ -1,8 +1,14 @@ package vip.mate.wiki.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import lombok.Data; +import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; @@ -43,10 +49,32 @@ public class WikiPageTypeDef { @Data @JsonIgnoreProperties(ignoreUnknown = true) + @JsonDeserialize(using = WikiPageTypeDef.StageInstructions.Deserializer.class) public static class StageInstructions { private String instructions; /** Optional template key referenced by the create stage. */ private String template; + + static class Deserializer extends StdDeserializer { + Deserializer() { super(StageInstructions.class); } + + @Override + public StageInstructions deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + StageInstructions s = new StageInstructions(); + if (p.currentToken() == JsonToken.VALUE_STRING) { + s.setInstructions(p.getText()); + } else if (p.currentToken() == JsonToken.START_OBJECT) { + while (p.nextToken() != JsonToken.END_OBJECT) { + String field = p.currentName(); + p.nextToken(); + if ("instructions".equals(field)) s.setInstructions(p.getValueAsString()); + else if ("template".equals(field)) s.setTemplate(p.getText()); + else p.skipChildren(); + } + } + return s; + } + } } @Data diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java index 777df692..5e744ba4 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java @@ -5,6 +5,7 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import vip.mate.wiki.dto.RawTitleRef; +import vip.mate.wiki.dto.WikiFailureItem; import vip.mate.wiki.model.WikiRawMaterialEntity; import java.util.Collection; @@ -25,4 +26,35 @@ public interface WikiRawMaterialMapper extends BaseMapper "WHERE id IN #{id} " + "AND deleted = 0") List selectBatchTitles(@Param("ids") Collection ids); + + /** + * Predicate shared by the count + list of materials needing operator + * attention: hard failure, partial (rerunnable), or completed-but-degraded + * (an async sub-step recorded a warning). + */ + String NEEDS_ATTENTION = + "r.deleted = 0 AND (r.processing_status IN ('failed','partial') OR r.warning_code IS NOT NULL)"; + + /** Count of attention-needing raw materials across all knowledge bases. */ + @Select("SELECT COUNT(*) FROM mate_wiki_raw_material r " + + "JOIN mate_wiki_knowledge_base k ON k.id = r.kb_id AND k.deleted = 0 " + + "WHERE " + NEEDS_ATTENTION) + long countFailures(); + + /** + * Cross-KB list of attention-needing raw materials, newest first. Joined to + * the knowledge base for the display name + workspace so the UI can route to + * the owning KB without a second round-trip. + */ + @Select("SELECT r.id AS rawId, r.kb_id AS kbId, k.name AS kbName, k.workspace_id AS workspaceId, " + + "r.title AS title, r.processing_status AS processingStatus, " + + "r.error_code AS errorCode, r.error_message AS errorMessage, " + + "r.warning_code AS warningCode, r.warning_message AS warningMessage, " + + "r.update_time AS updateTime " + + "FROM mate_wiki_raw_material r " + + "JOIN mate_wiki_knowledge_base k ON k.id = r.kb_id AND k.deleted = 0 " + + "WHERE " + NEEDS_ATTENTION + " " + + "ORDER BY r.update_time DESC " + + "LIMIT #{limit}") + List listFailures(@Param("limit") int limit); } 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 4df4700a..29965c7b 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 @@ -3,6 +3,7 @@ package vip.mate.wiki.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import vip.mate.agent.context.TokenEstimator; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.dto.PageSearchResult; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -47,9 +48,22 @@ public class WikiContextService { * returns snippet + reason instead of just summary. */ public String buildRelevantContext(Long agentId, String userMessage) { + return buildRelevantContext(agentId, userMessage, null); + } + + /** + * Budgeted variant: in addition to the absolute {@code maxContextChars} + * cap, the injected block may not exceed {@code budgetTokens} (estimated). + * Null budget keeps the previous chars-only behavior. Small local context + * windows need this — the chars cap is sized for large cloud models. + */ + public String buildRelevantContext(Long agentId, String userMessage, Integer budgetTokens) { if (!properties.isEnabled() || userMessage == null || userMessage.isBlank()) { return ""; } + if (budgetTokens != null && budgetTokens <= 0) { + return ""; + } // Skip retrieval for continuation / acknowledgement turns — observed // in production: a user reply of "继续" produced top-5 hits dominated @@ -94,15 +108,26 @@ public class WikiContextService { "e.g. 「来源:[[页面标题]]」or「(来源:页面标题)」.]\n\n"); int totalChars = 0; int maxChars = properties.getMaxContextChars(); + int totalTokens = TokenEstimator.estimateTokens(sb.toString()); + boolean anyEntry = false; for (PageSearchResult hit : hits) { String entry = buildContextEntry(hit); - if (totalChars + entry.length() > maxChars) { + int entryTokens = TokenEstimator.estimateTokens(entry); + if (totalChars + entry.length() > maxChars + || (budgetTokens != null && totalTokens + entryTokens > budgetTokens)) { sb.append("- ... (use wiki_search_pages for more)\n"); break; } sb.append(entry); totalChars += entry.length(); + totalTokens += entryTokens; + anyEntry = true; + } + // A budget too small for even one entry yields a header-only block — + // pure overhead. Skip the injection entirely; wiki tools stay usable. + if (!anyEntry && budgetTokens != null) { + return ""; } sb.append(""); return sb.toString(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index a10b0652..944e6c13 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -1200,7 +1200,7 @@ public class WikiPageService { List allPages = listByKbId(kbId); int deleted = 0; for (WikiPageEntity page : allPages) { - if ("manual".equals(page.getLastUpdatedBy())) continue; + if ("manual".equals(page.getLastUpdatedBy()) || "ai".equals(page.getLastUpdatedBy())) continue; // RFC-051 PR-2: never sweep system / locked pages, even when their // source raw is being reprocessed. if (isProtected(page)) continue; 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 dafc22a9..5fca7f82 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 @@ -318,8 +318,10 @@ public class WikiProcessingService { // Phase 1: 获取文本内容 String textContent = rawService.getTextContent(raw); if (textContent == null || textContent.isBlank()) { - rawService.updateProcessingStatus(rawId, "failed", "No text content available"); + rawService.updateProcessingStatus(rawId, "failed", "NO_CONTENT", "No text content available"); kbService.updateStatus(kb.getId(), "active"); + progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, + Map.of("rawId", rawId, "error", "No text content available", "errorCode", "NO_CONTENT")); return; } @@ -382,6 +384,10 @@ public class WikiProcessingService { String finalStatus; String finalDetail = null; + // Structured failure code (null unless finalStatus becomes "failed"), + // carried into both the persisted row and the RAW_FAILED SSE event so + // the UI can localize the failure instead of echoing raw English text. + String finalErrorCode = null; // Cancellation takes precedence over the normal terminal-state logic: // chunks that observed the cancel flag returned early as "failed", but // those aren't real failures — the user asked to stop. Surface that @@ -408,9 +414,10 @@ public class WikiProcessingService { log.info("[Wiki] Eager produced 0 pages but {} chunks indexed; marking partial for raw={}", totalChunks, rawId); } else { - rawService.updateProcessingStatus(rawId, "failed", "No pages generated from LLM response"); - finalStatus = "failed"; + finalErrorCode = "EMPTY_RESULT"; finalDetail = "No pages generated from LLM response"; + rawService.updateProcessingStatus(rawId, "failed", finalErrorCode, finalDetail); + finalStatus = "failed"; } } else if (failedChunks > 0 || failedPages > 0) { // 部分成功:chunk 整体失败 或 chunk 内有 page 失败 @@ -444,7 +451,9 @@ public class WikiProcessingService { // RFC-012 M3:广播终态 if ("failed".equals(finalStatus)) { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, - Map.of("rawId", rawId, "error", finalDetail == null ? "" : finalDetail)); + Map.of("rawId", rawId, + "error", finalDetail == null ? "" : finalDetail, + "errorCode", finalErrorCode == null ? "UNKNOWN" : finalErrorCode)); } else { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED, Map.of( @@ -517,6 +526,7 @@ public class WikiProcessingService { // every pending chunk and produce more "all chunks failed" noise. if (totalChunks > 0 && !"cancelled".equals(finalStatus)) { final Long fKbId = kb.getId(); + final Long fRawId = rawId; WIKI_EXECUTOR.submit(() -> { try { int embedded = embeddingService.embedMissingChunks(fKbId); @@ -529,8 +539,10 @@ public class WikiProcessingService { // emit a calmer notice here instead of a generic failure log. log.warn("[Wiki] Async embedding aborted by circuit-breaker for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } catch (Exception ex) { log.warn("[Wiki] Async embedding failed for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } }); } @@ -551,6 +563,7 @@ public class WikiProcessingService { } } catch (Exception ex) { log.warn("[Wiki] Async entity extraction failed for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "ENTITY_EXTRACTION_FAILED", ex.getMessage()); } }); } @@ -562,6 +575,7 @@ public class WikiProcessingService { // checkpoint rejected between chunks). boolean cancelled = rawService.isCancelRequested(rawId); String terminalStatus = cancelled ? "cancelled" : "failed"; + String errorCode = cancelled ? null : classifyErrorCode(e); String detail = cancelled ? "Cancelled by user (interrupted: " + (e.getMessage() == null ? "unknown" : e.getMessage()) + ")" : e.getMessage(); @@ -570,7 +584,7 @@ public class WikiProcessingService { } else { log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e); } - rawService.updateProcessingStatus(rawId, terminalStatus, detail); + rawService.updateProcessingStatus(rawId, terminalStatus, errorCode, detail); kbService.updateStatus(kb.getId(), "active"); if (wikiJobService != null && jobId != null) { try { @@ -587,7 +601,9 @@ public class WikiProcessingService { Map.of("rawId", rawId, "status", "cancelled")); } else { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, - Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage())); + Map.of("rawId", rawId, + "error", e.getMessage() == null ? "unknown" : e.getMessage(), + "errorCode", errorCode == null ? "UNKNOWN" : errorCode)); } } finally { // RFC-012 M2 v2 UI v2:写入最终进度并清理共享计数器 @@ -1815,6 +1831,111 @@ public class WikiProcessingService { } } + /** + * Link an agent-authored page to its source raw material and populate the + * full set of ingest by-products (lineage, chunks, embeddings, citations, + * knowledge layer, pipeline-trigger event) so the page is a first-class + * citizen — searchable, citation-traceable, downloadable, reprocess-able — + * WITHOUT re-running the LLM page-generation pipeline (the agent already + * supplied the final page content). + * + *

            Called by {@code WikiTool.wiki_create_page} right after {@code createPage}. + * Each step is individually try/caught so a failure in one (e.g. embedding + * provider down) cannot block the others — the page still lands with lineage + * and citations even if embeddings are deferred. The raw is flipped to + * {@code completed} at the end so it shows as a successfully processed + * material in the Raw Material panel. + * + * @param pageId the newly created page id + * @param kbId the KB id + * @param rawId the agent-authored raw material id (from {@code addAgentAuthored}) + * @param rawTitle the raw material title (for the lineage snapshot) + * @param pageType the page's pageType (may be null; used for layer + event) + */ + public void linkAgentPageToRaw(Long pageId, Long kbId, Long rawId, String rawTitle, String pageType) { + // 1. Lineage: page -> raw (dual-writes sourceRawIds + sourceEntries so + // the "View Citations" button appears and raw-delete cascade works). + try { + pageService.mergeSourceLineage(pageId, rawId, rawTitle); + } catch (Exception e) { + log.warn("[Wiki] Agent-page lineage failed for page={}, raw={}: {}", pageId, rawId, e.getMessage()); + } + + // Knowledge layer (fact/experience) from the pageType profile, matching + // afterPagePersisted so agent pages join the KB's layer classification. + try { + deriveKnowledgeLayer(pageId, kbId, pageType); + } catch (Exception e) { + log.warn("[Wiki] Agent-page knowledge layer failed for page={}: {}", pageId, e.getMessage()); + } + + // 2. Chunks from the raw's text — reuse the standard splitter so semantic + // search and citations work exactly like an uploaded text file. + WikiRawMaterialEntity raw = rawService.getById(rawId); + if (raw == null) { + log.warn("[Wiki] Agent-page link skipped: raw={} not found", rawId); + return; + } + String textContent = rawService.getTextContent(raw); + if (textContent != null && !textContent.isBlank()) { + try { + List chunksWithOffset = splitIntoChunksWithOffsets(textContent); + List chunks = chunksWithOffset.stream().map(ChunkWithOffset::text).toList(); + List offsets = chunksWithOffset.stream() + .map(c -> new int[]{c.startOffset(), c.endOffset()}).toList(); + if (chunks.isEmpty()) { + chunkService.persistChunks(kbId, rawId, + List.of(textContent), List.of(new int[]{0, textContent.length()})); + } else { + chunkService.persistChunks(kbId, rawId, chunks, offsets); + } + } catch (Exception e) { + log.warn("[Wiki] Agent-page chunk persistence failed for page={}, raw={}: {}", + pageId, rawId, e.getMessage()); + } + } + + // 3. Embeddings (async-safe calls) — both chunk-level (for semantic + // search) and page-level (for hybrid retrieval). + try { + embeddingService.embedMissingChunks(kbId); + embeddingService.embedPage(pageId); + } catch (Exception e) { + log.warn("[Wiki] Agent-page embedding failed for kb={}, page={}: {}", kbId, pageId, e.getMessage()); + } + + // 4. Citations: page -> chunks of its source raw (feeds the CitationDrawer). + try { + citationService.buildCitationsAsync(pageId, kbId); + } catch (Exception e) { + log.warn("[Wiki] Agent-page citation build failed for page={}: {}", pageId, e.getMessage()); + } + + // 5. Pipeline trigger event (so pageType-count triggers get evaluated), + // matching afterPagePersisted's contract for ingest-created pages. + try { + if (eventPublisher != null && pageType != null && !pageType.isBlank()) { + eventPublisher.publishEvent(new WikiPageCreatedEvent(kbId, pageType, pageId)); + } + } catch (Exception e) { + log.warn("[Wiki] Agent-page event publish failed for page={}: {}", pageId, e.getMessage()); + } + + // 6. Flip raw to completed so it shows as a successfully processed + // material in the Raw Material panel, and stamp lastProcessedHash so + // the reprocess short-circuit (unchanged content -> skip) works if + // the user later reprocesses this raw through the normal pipeline. + try { + rawService.updateProcessingStatus(rawId, "completed", null, null); + rawService.setLastProcessedHash(rawId, raw.getContentHash()); + } catch (Exception e) { + log.warn("[Wiki] Agent-page raw status flip failed for raw={}: {}", rawId, e.getMessage()); + } + + log.info("[Wiki] Agent page linked: pageId={}, kbId={}, rawId={}, pageType={}", + pageId, kbId, rawId, pageType); + } + /** Stamp the page's knowledge layer (fact/experience) derived from its pageType profile. */ private void deriveKnowledgeLayer(Long pageId, Long kbId, String pageType) { if (pageTypeProfileService == null || pageType == null || pageType.isBlank()) { @@ -2705,6 +2826,24 @@ public class WikiProcessingService { TransientLlmException(String msg) { super(msg); } } + /** + * Persist a non-blocking warning on a completed material whose async sub-step + * (embedding / entity extraction) failed, and push it live so the UI can flag + * the degradation without a reload. Best-effort: a warning must never escalate + * into a pipeline failure, so any bookkeeping error here is swallowed. + */ + private void surfaceWarning(Long kbId, Long rawId, String warningCode, String warningMessage) { + try { + rawService.recordWarning(rawId, warningCode, warningMessage); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_WARNING, + Map.of("rawId", rawId, + "warningCode", warningCode, + "warning", warningMessage == null ? "" : warningMessage)); + } catch (Exception ex) { + log.warn("[Wiki] Failed to record warning for raw={}: {}", rawId, ex.getMessage()); + } + } + // ==================== RFC-030: Error classification ==================== /** @@ -2975,10 +3114,10 @@ public class WikiProcessingService { try { String textContent = rawService.getTextContent(raw); if (textContent == null || textContent.isBlank()) { - rawService.updateProcessingStatus(rawId, "failed", "No text content available"); + rawService.updateProcessingStatus(rawId, "failed", "NO_CONTENT", "No text content available"); kbService.updateStatus(kbId, "active"); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, - Map.of("rawId", rawId, "error", "No text content available")); + Map.of("rawId", rawId, "error", "No text content available", "errorCode", "NO_CONTENT")); return; } @@ -3010,6 +3149,7 @@ public class WikiProcessingService { // Async embedding — mirror the eager path so a slow embedding model // does not block the raw from reaching completed. final Long fKbId = kbId; + final Long fRawId = rawId; WIKI_EXECUTOR.submit(() -> { try { int embedded = embeddingService.embedMissingChunks(fKbId); @@ -3019,8 +3159,10 @@ public class WikiProcessingService { } catch (WikiEmbeddingProviderFailingException ex) { log.warn("[Wiki] Lazy async embedding aborted by circuit-breaker for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } catch (Exception ex) { log.warn("[Wiki] Lazy async embedding failed for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } }); @@ -3058,11 +3200,13 @@ public class WikiProcessingService { rawId, kbId, totalChunks); } catch (Exception e) { log.error("[Wiki] Lazy processing failed for raw={}: {}", rawId, e.getMessage(), e); - rawService.updateProcessingStatus(rawId, "failed", e.getMessage()); + String errorCode = classifyErrorCode(e); + rawService.updateProcessingStatus(rawId, "failed", errorCode, e.getMessage()); kbService.updateStatus(kbId, "active"); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, Map.of("rawId", rawId, - "error", e.getMessage() == null ? "unknown" : e.getMessage())); + "error", e.getMessage() == null ? "unknown" : e.getMessage(), + "errorCode", errorCode == null ? "UNKNOWN" : errorCode)); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index e60ad96d..0e278080 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; +import vip.mate.wiki.dto.WikiFailureItem; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -152,7 +153,7 @@ public class WikiRawMaterialService { entity.setFileSize((long) content.getBytes(StandardCharsets.UTF_8).length); entity.setSourcePath(sourcePath); entity.setProcessingStatus("pending"); - entity.setErrorMessage(null); + clearFailureState(entity); entity.setExtractedText(null); entity.setProgressPhase(null); entity.setProgressDone(0); @@ -227,7 +228,7 @@ public class WikiRawMaterialService { entity.setFileSize(fileSize); entity.setSourcePath(sourcePath); entity.setProcessingStatus("pending"); - entity.setErrorMessage(null); + clearFailureState(entity); entity.setExtractedText(null); entity.setProgressPhase(null); entity.setProgressDone(0); @@ -297,6 +298,58 @@ public class WikiRawMaterialService { return entity; } + /** + * Create a raw material record for agent-authored content WITHOUT triggering + * the LLM ingest pipeline. Used by {@code wiki_create_page} so an agent-written + * page gets a lineage anchor — it appears in the Raw Material panel, hosts + * chunks, supports the download button (text raws are served from the + * {@code original_content} column by the download endpoint), and can be + * reprocessed later — without re-running LLM page generation, since the + * agent has already produced the final page content. + * + *

            The raw is left in {@code processing} status; the caller flips it to + * {@code completed} via {@link WikiProcessingService#linkAgentPageToRaw} + * once chunks + citations have landed. Dedup by content hash reuses an + * existing row when the agent writes the same content again (idempotent), + * mirroring {@link #addText}'s dedup semantics. + * + * @return the raw material entity (newly inserted or an existing same-content row) + */ + @Transactional + public WikiRawMaterialEntity addAgentAuthored(Long kbId, String title, String content) { + String hash = computeHash(content); + + // Dedup: reuse any existing row with the same hash in this KB (any status). + // An agent often re-writes the same report title in a conversation; stacking + // duplicate raws would pollute the Raw Material panel. + WikiRawMaterialEntity existing = rawMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .eq(WikiRawMaterialEntity::getContentHash, hash) + .last("LIMIT 1")); + if (existing != null) { + return existing; + } + + WikiRawMaterialEntity entity = new WikiRawMaterialEntity(); + entity.setKbId(kbId); + entity.setTitle(title); + entity.setSourceType("text"); + entity.setOriginalContent(content); + entity.setFileSize((long) content.getBytes(StandardCharsets.UTF_8).length); + entity.setContentHash(hash); + // 'processing' rather than 'pending': this raw is claimed by the agent + // tool's own synchronous post-processing (linkAgentPageToRaw), so it + // must NOT be picked up by the async ingest listener (which would + // re-run LLM page generation). The caller flips it to 'completed'. + entity.setProcessingStatus("processing"); + rawMapper.insert(entity); + kbService.incrementRawCount(kbId); + + log.info("[Wiki] Agent-authored raw material added: id={}, kbId={}, title={}", entity.getId(), kbId, title); + return entity; + } + /** * Adds a file-type raw material (PDF / DOCX / image / ...). * @@ -411,7 +464,7 @@ public class WikiRawMaterialService { return false; } entity.setProcessingStatus("processing"); - entity.setErrorMessage(null); + clearFailureState(entity); // RFC-012 M2 v2 UI:新一轮处理开始,清掉上次遗留的进度显示 entity.setProgressPhase(null); entity.setProgressTotal(0); @@ -478,11 +531,60 @@ public class WikiRawMaterialService { rawMapper.updateById(entity); } - @Transactional public void updateProcessingStatus(Long id, String status, String errorMessage) { + updateProcessingStatus(id, status, null, errorMessage); + } + + /** + * Reset all failure/warning surfacing fields to a clean slate for a fresh + * run. Required because {@code errorCode}/{@code errorMessage}/{@code warning*} + * all carry {@code FieldStrategy.ALWAYS}: a row loaded then re-saved would + * otherwise re-persist its stale values. + */ + private static void clearFailureState(WikiRawMaterialEntity e) { + e.setErrorCode(null); + e.setErrorMessage(null); + e.setWarningCode(null); + e.setWarningMessage(null); + } + + /** + * Record a non-blocking warning on a material that finished processing but + * had an async sub-step (embedding / entity extraction) fail. Does not touch + * {@code processingStatus} — the material is still usable, just degraded. + */ + @Transactional + public void recordWarning(Long id, String warningCode, String warningMessage) { + WikiRawMaterialEntity entity = rawMapper.selectById(id); + if (entity == null) return; + entity.setWarningCode(warningCode); + entity.setWarningMessage(warningMessage); + rawMapper.updateById(entity); + } + + /** Cross-KB count of materials needing operator attention (failed/partial/degraded). */ + public long countFailures() { + return rawMapper.countFailures(); + } + + /** Cross-KB list of materials needing operator attention, newest first (capped). */ + public List listFailures(int limit) { + return rawMapper.listFailures(Math.max(1, Math.min(limit, 500))); + } + + /** + * Terminal/intermediate status transition that also records a structured + * {@code errorCode} (see {@code WikiProcessingService#classifyErrorCode}). + * Both error fields carry {@link com.baomidou.mybatisplus.annotation.FieldStrategy#ALWAYS} + * on the entity, so a success transition with {@code null} code/message + * clears any stale failure left from a prior run. + */ + @Transactional + public void updateProcessingStatus(Long id, String status, String errorCode, String errorMessage) { WikiRawMaterialEntity entity = rawMapper.selectById(id); if (entity == null) return; entity.setProcessingStatus(status); + entity.setErrorCode(errorCode); entity.setErrorMessage(errorMessage); if ("completed".equals(status)) { entity.setLastProcessedAt(java.time.LocalDateTime.now()); @@ -539,7 +641,7 @@ public class WikiRawMaterialService { } boolean wasPartial = "partial".equals(entity.getProcessingStatus()); entity.setProcessingStatus("pending"); - entity.setErrorMessage(null); + clearFailureState(entity); rawMapper.updateById(entity); if (wasPartial) { @@ -798,7 +900,7 @@ public class WikiRawMaterialService { raw.setProgressPhase(null); raw.setProgressTotal(0); raw.setProgressDone(0); - raw.setErrorMessage(null); + clearFailureState(raw); rawMapper.updateById(raw); if (properties.isAutoProcessOnUpload()) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java index 82cb55d2..30bb1cda 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java @@ -81,6 +81,10 @@ public class WikiResearchService { broadcast(sessionId, "research.plan", Map.of( "questions", questions.stream().map(q -> Map.of("question", q.question, "intent", q.intent)).toList() )); + // Cooperative cancellation: the Open API cancel endpoint calls + // streamTracker.requestStop(sessionId). Bail before the expensive + // retrieve+draft fan-out so cancel actually halts LLM cost. + ensureNotCancelled(sessionId); // Stage 2: Retrieve + Draft (并行) List

            sections = draftStage(kbId, questions, topK, sessionId); @@ -88,6 +92,8 @@ public class WikiResearchService { broadcast(sessionId, "research.error", Map.of("message", i18n.msg("research.broadcast.draft_all_empty"))); return new ResearchResult(topic, sections, i18n.msg("research.fallback.no_materials")); } + // Second checkpoint before the compose LLM call. + ensureNotCancelled(sessionId); // Stage 3: Compose String report = composeStage(topic, sections); @@ -97,6 +103,11 @@ public class WikiResearchService { "materialsUsed", sections.stream().flatMap(s -> s.materialRefs.stream()).distinct().count() )); return new ResearchResult(topic, sections, report); + } catch (ResearchCancelledException ce) { + // Expected: caller has already flipped the session to CANCELLED + // and closed the SSE stream. Do not broadcast an error event. + log.info("[Research] Cancelled: kbId={}, topic={}, sessionId={}", kbId, topic, sessionId); + return new ResearchResult(topic, List.of(), "Research cancelled by user"); } catch (Exception e) { log.error("[Research] Failed: kbId={}, topic={}: {}", kbId, topic, e.getMessage(), e); broadcast(sessionId, "research.error", Map.of("message", e.getMessage() != null ? e.getMessage() : i18n.msg("research.broadcast.failed"))); @@ -104,6 +115,17 @@ public class WikiResearchService { } } + /** + * Throws {@link ResearchCancelledException} if the caller (via the Open API + * cancel endpoint) has called {@link ChatStreamTracker#requestStop} on this + * session. Checked at each pipeline stage boundary. + */ + private void ensureNotCancelled(String sessionId) { + if (streamTracker.isStopRequested(sessionId)) { + throw new ResearchCancelledException(sessionId); + } + } + // ==================== Stage 1: Plan ==================== private List planStage(String topic) { @@ -151,6 +173,12 @@ public class WikiResearchService { Thread.currentThread().interrupt(); return new Section(q.question, "", List.of()); } + // Re-check cancellation inside the parallel draft loop too — + // draftOneSection issues its own LLM call, which is the main + // cost driver, so skip queued-but-not-started drafts on cancel. + if (streamTracker.isStopRequested(sessionId)) { + return new Section(q.question, "", List.of()); + } try { Section section = draftOneSection(kbId, q, topK); broadcast(sessionId, "research.draft", Map.of( @@ -300,4 +328,16 @@ public class WikiResearchService { public record Section(String question, String content, List materialRefs) {} public record ResearchResult(String topic, List
            sections, String report) {} + + /** + * Thrown when {@link ChatStreamTracker#requestStop(String)} was called on + * the session between pipeline stages. The {@link #research} method catches + * this to short-circuit — the caller (the Open API controller) has already + * flipped the registry to CANCELLED and closed the SSE stream. + */ + public static class ResearchCancelledException extends RuntimeException { + public ResearchCancelledException(String sessionId) { + super("Research cancelled: sessionId=" + sessionId); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index 38f1e31c..71c833e6 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -145,7 +145,10 @@ public class WikiTransformationExecutor { WikiTransformationRunEntity run = new WikiTransformationRunEntity(); run.setTransformationId(transformation.getId()); run.setKbId(page.getKbId()); - run.setWorkspaceId(transformation.getWorkspaceId()); + // Global templates have a null workspace_id; the run row requires one, so + // fall back to the default workspace for bookkeeping (run visibility is + // gated by the KB's workspace, not this field). + run.setWorkspaceId(transformation.getWorkspaceId() != null ? transformation.getWorkspaceId() : 1L); run.setInputKind("page"); run.setPageId(pageId); run.setStatus("running"); @@ -420,7 +423,10 @@ public class WikiTransformationExecutor { WikiTransformationRunEntity run = new WikiTransformationRunEntity(); run.setTransformationId(transformation.getId()); run.setKbId(raw.getKbId()); - run.setWorkspaceId(transformation.getWorkspaceId()); + // Global templates have a null workspace_id; the run row requires one, so + // fall back to the default workspace for bookkeeping (run visibility is + // gated by the KB's workspace, not this field). + run.setWorkspaceId(transformation.getWorkspaceId() != null ? transformation.getWorkspaceId() : 1L); run.setInputKind("raw"); run.setRawId(rawId); run.setStatus("running"); 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 729d7e09..af71db3b 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 @@ -29,7 +29,12 @@ public class WikiTransformationService { private final WikiTransformationMapper transformationMapper; private final WikiTransformationRunMapper runMapper; - /** Templates visible to a KB: pinned to this KB plus workspace-wide ones (kb_id NULL). */ + /** + * Templates visible to a KB: pinned to this KB, plus workspace-wide ones — + * either scoped to this workspace ({@code workspace_id = current}) or global + * ({@code workspace_id IS NULL}, e.g. the built-in starter pack, visible to + * every workspace). + */ public List listForKb(Long kbId, Long workspaceId) { if (kbId == null) { return List.of(); @@ -38,14 +43,17 @@ public class WikiTransformationService { new LambdaQueryWrapper() .and(w -> w.eq(WikiTransformationEntity::getKbId, kbId) .or(g -> g.isNull(WikiTransformationEntity::getKbId) - .eq(WikiTransformationEntity::getWorkspaceId, workspaceId))) + .and(ws -> ws.eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + .or().isNull(WikiTransformationEntity::getWorkspaceId)))) .orderByDesc(WikiTransformationEntity::getUpdateTime)); } public List listByWorkspace(Long workspaceId) { return transformationMapper.selectList( new LambdaQueryWrapper() - .eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + // This workspace's own templates plus global ones (workspace_id NULL). + .and(w -> w.eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + .or().isNull(WikiTransformationEntity::getWorkspaceId)) .orderByDesc(WikiTransformationEntity::getUpdateTime)); } @@ -65,7 +73,9 @@ public class WikiTransformationService { WikiTransformationEntity global = transformationMapper.selectOne( new LambdaQueryWrapper() .isNull(WikiTransformationEntity::getKbId) - .eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + // This workspace's own template, or a global one (workspace_id NULL). + .and(ws -> ws.eq(WikiTransformationEntity::getWorkspaceId, workspaceId) + .or().isNull(WikiTransformationEntity::getWorkspaceId)) .eq(WikiTransformationEntity::getName, name) .last("LIMIT 1")); return Optional.ofNullable(global); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java b/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java index 55ed27ee..1a23b139 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java @@ -38,6 +38,8 @@ public class WikiProgressBus { public static final String EVENT_CHUNK_DONE = "chunk.done"; public static final String EVENT_RAW_COMPLETED = "raw.completed"; public static final String EVENT_RAW_FAILED = "raw.failed"; + /** Non-blocking warning on an otherwise-completed material (async sub-step failed). */ + public static final String EVENT_RAW_WARNING = "raw.warning"; public static final String EVENT_HEARTBEAT = "heartbeat"; private final ObjectMapper objectMapper; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index 526e3285..9e10133d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -97,6 +97,17 @@ public class WikiTool { @Autowired(required = false) private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService; + /** + * Optional. When present, {@code wiki_create_page} also creates a raw + * material + chunks + embeddings + citations + lineage so the agent-written + * page is a first-class citizen (searchable / citation-traceable / + * downloadable / reprocess-able) identical to a UI-uploaded text file. + * Absent in lightweight test contexts — the page is still created, just + * without the ingest by-products (legacy behavior). + */ + @Autowired(required = false) + private WikiProcessingService processingService; + /** * Per-agent pageType permission gate. Mandatory: this is a security control, * so it is a required constructor dependency rather than an optional bean — @@ -509,6 +520,27 @@ public class WikiTool { WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null, pageType); log.info("[WikiTool] Created page: {} (slug={}, kbId={}, type={})", title, slug, kbId, pageType); + // Make the agent-written page a first-class citizen: create a raw + // material (so it shows in the Raw Material panel + supports the + // download button) and link page -> raw with chunks / embeddings / + // citations / lineage (so "View Citations", semantic search, and + // reprocess all work) — identical to a UI-uploaded text file, but + // without re-running LLM page generation (the content is already final). + // Skipped in lightweight test contexts where processingService is null. + if (processingService != null) { + try { + WikiRawMaterialEntity raw = rawService.addAgentAuthored(kbId, title, content); + processingService.linkAgentPageToRaw( + page.getId(), kbId, raw.getId(), raw.getTitle(), pageType); + } catch (Exception e) { + // The page itself is already persisted; by-product population + // is a best-effort enhancement, never a blocker for the tool + // contract (agent already has its pageId to return). + log.warn("[WikiTool] Agent-page by-product population failed for page={}: {}", + page.getId(), e.getMessage()); + } + } + return JSONUtil.createObj() .set("ok", true) .set("message", "Page created successfully") diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java index 082a6991..cbe1b796 100644 --- a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/AwaitApprovalStepAdapter.java @@ -10,6 +10,7 @@ import vip.mate.workflow.model.WorkflowRunPauseEntity; import vip.mate.workflow.model.WorkflowRunStepEntity; import vip.mate.workflow.repository.WorkflowRunPauseMapper; import vip.mate.workflow.repository.WorkflowRunStepMapper; +import vip.mate.workflow.runtime.ChannelDispatcher; import vip.mate.workflow.runtime.StepAdapter; import vip.mate.workflow.runtime.StepResult; import vip.mate.workflow.runtime.WorkflowRunContext; @@ -57,6 +58,13 @@ public class AwaitApprovalStepAdapter implements StepAdapter { * adapter falls back to a no-op approval row when null. */ @Autowired(required = false) private ApprovalWorkflowService approvalService; + /** + * ISSUE #413: used to push the approval notice to every channel listed in + * {@code approverChannels}. Optional — null in narrow test contexts (the + * notice is skipped and the pause still resolves via REST / inbox). + */ + @Autowired(required = false) + private ChannelDispatcher channelDispatcher; public AwaitApprovalStepAdapter(WorkflowRunPauseMapper pauseMapper, WorkflowRunStepMapper stepMapper) { @@ -129,7 +137,70 @@ public class AwaitApprovalStepAdapter implements StepAdapter { } } + // ISSUE #413: push the approval notice to the configured channels so + // the approver actually learns an approval is waiting. Before this, + // approverChannels was write-only metadata and a workflow that asked + // for IM notification silently dropped it. + notifyApproverChannels(cfg, context, context.runId(), pauseToken); + return StepResult.paused(pauseToken, "awaiting " + (cfg.approvalKind() == null ? "approval" : cfg.approvalKind())); } + + /** + * ISSUE #413: push the approval notice to every channel in + * {@code approverChannels}. Previously this field was written into the + * approval row's {@code tool_arguments} and never read back, so a workflow + * that declared {@code approverChannels: ["feishu"]} silently dropped the + * notice — the IM group never learned an approval was waiting. + * + *

            Element format is {@code "channelType"} (e.g. {@code "web"} — no + * proactive dispatch, operator uses the admin console) or + * {@code "channelType:targetId"} (e.g. {@code "feishu:oc_xxx"} — pushes a + * text notice to that target). The short {@code pauseId} suffix is + * included so the operator can correlate the notice with the inbox entry. + * Each channel failure is logged and skipped — a delivery hiccup on one + * channel must not fail the step (the pause row + REST resume are the + * canonical recovery path). + */ + private void notifyApproverChannels(StepMode.AwaitApproval cfg, WorkflowRunContext context, + long runId, String pauseToken) { + if (channelDispatcher == null || cfg.approverChannels() == null) { + return; + } + String kind = cfg.approvalKind() == null || cfg.approvalKind().isBlank() ? "approval" : cfg.approvalKind().trim(); + String shortToken = pauseToken.substring(0, Math.min(8, pauseToken.length())); + String message = "🔐 工作流审批待处理\n" + + "**类型**: " + kind + "\n" + + (cfg.approvalMessage() != null && !cfg.approvalMessage().isBlank() + ? "**说明**: " + cfg.approvalMessage() + "\n" : "") + + "**runId**: " + runId + "\n" + + "**审批码**: " + shortToken + "\n" + + "请前往管理端审批(工作流 → 运行记录 → 恢复)。"; + + for (String entry : cfg.approverChannels()) { + if (entry == null || entry.isBlank()) continue; + String channelType = entry; + String targetId = null; + int colon = entry.indexOf(':'); + if (colon > 0) { + channelType = entry.substring(0, colon); + targetId = entry.substring(colon + 1); + } + // "web" (and any channel with no target) means "operator handles + // it from the admin console" — no proactive push. + if (targetId == null || targetId.isBlank()) continue; + try { + ChannelDispatcher.DispatchResult result = + channelDispatcher.dispatch(context.workspaceId(), channelType, targetId, message); + if (!result.success()) { + org.slf4j.LoggerFactory.getLogger(AwaitApprovalStepAdapter.class) + .warn("await_approval notify failed for channel '{}': {}", channelType, result.message()); + } + } catch (Exception e) { + org.slf4j.LoggerFactory.getLogger(AwaitApprovalStepAdapter.class) + .warn("await_approval notify threw for channel '{}': {}", channelType, e.getMessage()); + } + } + } } 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 bc9c703b..3ad92676 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 @@ -31,13 +31,13 @@ import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.MessageMapper; import vip.mate.workspace.conversation.vo.ConversationVO; import vip.mate.workspace.conversation.vo.MessageVO; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.workspace.core.service.WorkspaceService; import java.io.IOException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; @@ -87,6 +87,14 @@ public class ConversationService { private final AuthService authService; private final WorkspaceService workspaceService; + /** + * Resolves the workspace/agent-aware chat-upload directory. The resolver + * injects {@code AgentService} lazily, which breaks the would-be cycle + * (agentService → agentGraphBuilder → this → resolver → agentService), so a + * plain constructor injection here is sufficient. + */ + private final ChatUploadLocationResolver chatUploadLocationResolver; + /** * Optional spill store. Injected via a setter so the existing @RequiredArgsConstructor * stays stable and tests that build the service directly don't need to wire @@ -601,6 +609,16 @@ public class ConversationService { List parts, String status, int promptTokens, int completionTokens, String runtimeModel, String runtimeProvider, String metadata) { + return saveMessage(conversationId, role, content, parts, status, + promptTokens, completionTokens, 0, 0, 0, runtimeModel, runtimeProvider, metadata); + } + + @Transactional + public MessageEntity saveMessage(String conversationId, String role, String content, + List parts, String status, + int promptTokens, int completionTokens, + int cacheReadTokens, int cacheWriteTokens, int reasoningTokens, + String runtimeModel, String runtimeProvider, String metadata) { MessageEntity message = new MessageEntity(); message.setConversationId(conversationId); message.setRole(role); @@ -610,6 +628,9 @@ public class ConversationService { message.setTokenUsage(promptTokens + completionTokens); message.setPromptTokens(promptTokens); message.setCompletionTokens(completionTokens); + message.setCacheReadTokens(cacheReadTokens); + message.setCacheWriteTokens(cacheWriteTokens); + message.setReasoningTokens(reasoningTokens); message.setRuntimeModel(runtimeModel); message.setRuntimeProvider(runtimeProvider); message.setMetadata(metadata != null ? metadata : "{}"); // Initialize as empty JSON object / 初始化为空对象 @@ -1656,38 +1677,52 @@ public class ConversationService { return conv != null ? conv.getStreamStatus() : null; } - private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); - /** - * 清理会话关联的附件文件 + * Clean up the attachment files associated with a conversation. + *

            + * Walks every candidate upload root (the workspace/agent-aware root plus the + * default root) and deletes that conversation's attachment directory under + * each, so attachments are removed whether they landed in the new workspace + * directory or the pre-migration default directory. */ public void cleanAttachmentFiles(String conversationId) { - Path dir; - try { - dir = UPLOAD_ROOT.resolve(conversationId); - } catch (InvalidPathException e) { - // Conversation id contains characters illegal on this filesystem - // (e.g. ':' in cron: on Windows). No attachments could - // ever have been written under such an id on this OS, so there - // is nothing to clean. - log.debug("Skipping attachment cleanup for non-path-safe conversation id: {}", conversationId); + if (conversationId == null || conversationId.isBlank()) { + // A blank id would resolve to the upload root itself and wipe every + // conversation's attachments — never walk/delete a bare root. return; } - if (!Files.exists(dir)) { - return; + boolean cleanedAny = false; + for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) { + Path dir; + try { + dir = root.resolve(conversationId); + } catch (InvalidPathException e) { + // Conversation id contains characters illegal on this filesystem + // (e.g. ':' in cron: on Windows). No attachments could + // ever have been written under such an id on this OS, so there + // is nothing to clean. + log.debug("Skipping attachment cleanup for non-path-safe conversation id: {}", conversationId); + return; + } + if (!Files.exists(dir)) { + continue; + } + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + log.warn("Failed to delete attachment file: {}", p, e); + } + }); + cleanedAny = true; + } catch (IOException e) { + log.warn("Failed to walk attachment directory for conversation: {}", conversationId, e); + } } - try (Stream walk = Files.walk(dir)) { - walk.sorted(Comparator.reverseOrder()) - .forEach(p -> { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - log.warn("Failed to delete attachment file: {}", p, e); - } - }); + if (cleanedAny) { log.info("Cleaned attachment files for conversation: {}", conversationId); - } catch (IOException e) { - log.warn("Failed to walk attachment directory for conversation: {}", conversationId, e); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java index 20a0e6f9..a15effa4 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java @@ -72,6 +72,9 @@ public class TokenUsageService { wrapper.select( MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, + MessageEntity::getCacheReadTokens, + MessageEntity::getCacheWriteTokens, + MessageEntity::getReasoningTokens, MessageEntity::getRuntimeModel, MessageEntity::getRuntimeProvider, MessageEntity::getCreateTime @@ -87,6 +90,9 @@ public class TokenUsageService { long totalPrompt = 0; long totalCompletion = 0; + long totalCacheRead = 0; + long totalCacheWrite = 0; + long totalReasoning = 0; // 按模型聚合 Map modelMap = new LinkedHashMap<>(); @@ -100,6 +106,9 @@ public class TokenUsageService { int completion = msg.getCompletionTokens() != null ? msg.getCompletionTokens() : 0; totalPrompt += prompt; totalCompletion += completion; + totalCacheRead += msg.getCacheReadTokens() != null ? msg.getCacheReadTokens() : 0; + totalCacheWrite += msg.getCacheWriteTokens() != null ? msg.getCacheWriteTokens() : 0; + totalReasoning += msg.getReasoningTokens() != null ? msg.getReasoningTokens() : 0; // 模型维度 String model = msg.getRuntimeModel() != null ? msg.getRuntimeModel() : "unknown"; @@ -124,6 +133,9 @@ public class TokenUsageService { vo.setTotalPromptTokens(totalPrompt); vo.setTotalCompletionTokens(totalCompletion); + vo.setTotalCacheReadTokens(totalCacheRead); + vo.setTotalCacheWriteTokens(totalCacheWrite); + vo.setTotalReasoningTokens(totalReasoning); vo.setTotalMessages(messages.size()); // 转换 byModel diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java index 8de74692..36a93b37 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java @@ -43,6 +43,15 @@ public class MessageEntity { /** Completion tokens 消耗 */ private Integer completionTokens; + /** Prompt cache 命中 tokens(provider 未上报时为 0) */ + private Integer cacheReadTokens; + + /** Prompt cache 写入 tokens(provider 未上报时为 0) */ + private Integer cacheWriteTokens; + + /** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 0) */ + private Integer reasoningTokens; + /** 运行时模型名称 */ private String runtimeModel; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java index 111ce8b2..2ebbe80e 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java @@ -41,6 +41,15 @@ public class MessageVO { /** Completion tokens 消耗 */ private Integer completionTokens; + /** Prompt cache 命中 tokens(provider 未上报时为 0) */ + private Integer cacheReadTokens; + + /** Prompt cache 写入 tokens(provider 未上报时为 0) */ + private Integer cacheWriteTokens; + + /** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 0) */ + private Integer reasoningTokens; + /** Model name actually used to produce this message (e.g. "deepseek-chat"). */ private String runtimeModel; @@ -65,6 +74,9 @@ public class MessageVO { vo.setMetadata(parseMetadataToObject(entity.getMetadata())); vo.setPromptTokens(entity.getPromptTokens()); vo.setCompletionTokens(entity.getCompletionTokens()); + vo.setCacheReadTokens(entity.getCacheReadTokens()); + vo.setCacheWriteTokens(entity.getCacheWriteTokens()); + vo.setReasoningTokens(entity.getReasoningTokens()); vo.setRuntimeModel(entity.getRuntimeModel()); vo.setRuntimeProvider(entity.getRuntimeProvider()); vo.setCreateTime(entity.getCreateTime()); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java index e6b24145..9d0b1e57 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java @@ -18,6 +18,15 @@ public class TokenUsageSummaryVO { /** 总 completion tokens */ private long totalCompletionTokens; + /** 总 prompt cache 命中 tokens */ + private long totalCacheReadTokens; + + /** 总 prompt cache 写入 tokens */ + private long totalCacheWriteTokens; + + /** 总思考(reasoning)tokens */ + private long totalReasoningTokens; + /** 总 assistant 消息数 */ private long totalMessages; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadAutoConfiguration.java new file mode 100644 index 00000000..480d8fa6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadAutoConfiguration.java @@ -0,0 +1,48 @@ +package vip.mate.workspace.core.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import vip.mate.tool.builtin.ChatUploadResolver; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Normalizes and pre-creates the default chat-upload directory on startup. + *

            + * Mirrors {@link WorkspaceSandboxAutoConfiguration}: the directory is created + * eagerly so the first upload does not race on {@code Files.createDirectories}, + * and a missing/blank value restores the legacy {@code data/chat-uploads}. The + * normalized default is also registered with the static + * {@link ChatUploadResolver} so the tool-side fallback lookup agrees with the + * Spring-managed {@link ChatUploadLocationResolver}. + * + * @author MateClaw Team + */ +@Slf4j +@Configuration +@EnableConfigurationProperties(ChatUploadProperties.class) +public class ChatUploadAutoConfiguration { + + public ChatUploadAutoConfiguration(ChatUploadProperties properties) { + String raw = properties.getBaseDir(); + if (raw == null || raw.isBlank()) { + raw = "data/chat-uploads"; + properties.setBaseDir(raw); + } + Path root = Paths.get(raw).toAbsolutePath().normalize(); + properties.setBaseDir(root.toString()); + try { + Files.createDirectories(root); + } catch (Exception e) { + // The first upload will retry createDirectories; log and continue + // rather than fail startup. + log.warn("[ChatUpload] Failed to create default upload dir {}: {}", + root, e.getMessage()); + } + ChatUploadResolver.setDefaultRoot(root); + log.info("[ChatUpload] Default upload dir: {}", root); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java new file mode 100644 index 00000000..2233ec4a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java @@ -0,0 +1,35 @@ +package vip.mate.workspace.core.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration for the chat-attachment upload directory. + *

            + * Chat uploads (files exchanged in a conversation) resolve their storage root + * with this precedence: + *

              + *
            1. Agent-level {@code workspaceBasePath} override (resolved under the + * workspace {@code basePath}, same rule as + * {@code AgentGraphBuilder.resolveAgentBasePath});
            2. + *
            3. Workspace {@code basePath} (when the agent has no override);
            4. + *
            5. This {@link #baseDir} fallback — the out-of-the-box default used when + * neither the agent nor its workspace configures a base path.
            6. + *
            + * The default keeps the legacy {@code data/chat-uploads} location so existing + * single-workspace deployments see no behavioural change. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.chat.upload") +public class ChatUploadProperties { + + /** + * Root directory for chat attachments when neither the active agent nor its + * workspace configures a base path. Defaults to {@code data/chat-uploads} + * (relative to the Spring Boot working directory). Conversations are stored + * one level below: {@code {baseDir}/{conversationId}/{storedName}}. + */ + private String baseDir = "data/chat-uploads"; +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java new file mode 100644 index 00000000..034f0455 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java @@ -0,0 +1,239 @@ +package vip.mate.workspace.core.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentService; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.model.AgentEntity; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.conversation.event.ConversationDeletedEvent; +import vip.mate.workspace.core.config.ChatUploadProperties; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Resolves the on-disk root directory where a conversation's chat attachments + * are stored. Replaces the previously hardcoded {@code data/chat-uploads} + * literal with a workspace/agent-aware location. + * + *

            Resolution precedence

            + *
              + *
            1. When the conversation's agent has a {@code workspaceBasePath} override, + * it is resolved under the workspace {@code basePath} (same rule as + * {@link AgentGraphBuilder#resolveAgentBasePath}) and the upload root is + * {@code {resolved}/chat-uploads}.
            2. + *
            3. Otherwise, when the workspace has a {@code basePath}, the upload root is + * {@code {workspace.basePath}/chat-uploads}.
            4. + *
            5. Otherwise, the configurable fallback {@link ChatUploadProperties#getBaseDir()} + * (default {@code data/chat-uploads}) is used.
            6. + *
            + * + *

            Backward compatibility

            + * Reads and cleanup use {@link #resolveCandidateUploadRoots(String)} which + * returns both the workspace-scoped root and the default fallback root, + * so attachments written before this change (under the default dir) remain + * resolvable and cleanable. Writes always target a single root returned by + * {@link #resolveUploadRoot(String)}. + * + *

            The {@code conversationId → ConversationEntity} lookup is cached for 5 + * minutes (the mapping is immutable once a conversation exists), matching the + * TTL of the existing {@code WorkspaceLookupCache} on the tool-call hot path. + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class ChatUploadLocationResolver { + + /** Sub-directory appended under a configured base path. */ + public static final String UPLOAD_SUBDIR = "chat-uploads"; + + private final ConversationMapper conversationMapper; + private final WorkspaceService workspaceService; + private final ChatUploadProperties properties; + + /** + * {@code AgentService} is injected lazily because the bean graph is cyclic: + * {@code agentService → agentGraphBuilder → conversationService → this}. + * The agent is only consulted at resolve time (never at construction), so a + * lazy proxy is safe and breaks the cycle cleanly. + */ + @Lazy + private final AgentService agentService; + + public ChatUploadLocationResolver(ConversationMapper conversationMapper, + WorkspaceService workspaceService, + ChatUploadProperties properties, + @Lazy AgentService agentService) { + this.conversationMapper = conversationMapper; + this.workspaceService = workspaceService; + this.properties = properties; + this.agentService = agentService; + } + + private final Cache conversationCache = Caffeine.newBuilder() + .maximumSize(5_000) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + + // ==================== write path: single root ==================== + + /** + * Resolve the single upload root for a conversation (write target). + * + * @param conversationId business conversation id + * @return absolute, normalized upload root (never {@code null}) + */ + public Path resolveUploadRoot(String conversationId) { + ConversationEntity conv = lookupConversation(conversationId); + Long workspaceId = conv != null ? conv.getWorkspaceId() : null; + Long agentId = conv != null ? conv.getAgentId() : null; + return resolveUploadRoot(workspaceId, agentId); + } + + /** + * Resolve the upload root when the caller already knows the workspace and + * agent (e.g. a web request thread carrying {@code X-Workspace-Id} + the + * picked agent), avoiding a DB lookup. + */ + public Path resolveUploadRoot(Long workspaceId, Long agentId) { + Path resolved = resolveWorkspaceScopedRoot(workspaceId, agentId); + if (resolved != null) { + return resolved; + } + return defaultRoot(); + } + + // ==================== read / cleanup path: candidate roots ==================== + + /** + * Resolve every upload root a conversation's attachments may live under, in + * lookup order: the workspace-scoped root first (if any), then the default + * fallback root. Used by file-serving endpoints, the tool-side resolver, and + * cleanup so legacy uploads stored under the default dir are still found. + * + * @return de-duplicated, ordered list (at least the default root is present) + */ + public List resolveCandidateUploadRoots(String conversationId) { + ConversationEntity conv = lookupConversation(conversationId); + Long workspaceId = conv != null ? conv.getWorkspaceId() : null; + Long agentId = conv != null ? conv.getAgentId() : null; + return resolveCandidateUploadRoots(workspaceId, agentId); + } + + /** + * Variant of {@link #resolveCandidateUploadRoots(String)} for callers that + * already hold the workspace / agent ids. + */ + public List resolveCandidateUploadRoots(Long workspaceId, Long agentId) { + Set roots = new LinkedHashSet<>(); + Path scoped = resolveWorkspaceScopedRoot(workspaceId, agentId); + if (scoped != null) { + roots.add(scoped); + } + roots.add(defaultRoot()); + return new ArrayList<>(roots); + } + + // ==================== internals ==================== + + /** + * Resolve the workspace/agent-scoped root, or {@code null} when neither the + * agent override nor the workspace {@code basePath} is configured (caller + * then falls back to {@link #defaultRoot()}). + */ + private Path resolveWorkspaceScopedRoot(Long workspaceId, Long agentId) { + WorkspaceEntity workspace = null; + if (workspaceId != null) { + try { + workspace = workspaceService.getById(workspaceId); + } catch (MateClawException e) { + // Workspace row missing — fall through to the default root. + log.debug("[ChatUpload] workspace {} not found: {}", workspaceId, e.getMessage()); + } + } + + String agentOverride = null; + if (agentId != null) { + try { + AgentEntity agent = agentService.getAgent(agentId); + agentOverride = agent.getWorkspaceBasePath(); + } catch (MateClawException e) { + log.debug("[ChatUpload] agent {} not found: {}", agentId, e.getMessage()); + } + } + + // A conversation's agent always belongs to the conversation's workspace + // (enforced at creation), so the workspace basePath is the scoping root + // for both the agent override and the no-override case. + String workspaceBase = workspace != null ? workspace.getBasePath() : null; + + String resolvedBase; + try { + resolvedBase = AgentGraphBuilder.resolveAgentBasePath(agentOverride, workspaceBase); + } catch (IllegalArgumentException e) { + // Agent override escapes the workspace root — inherit the workspace + // basePath so chat stays available (mirrors AgentGraphBuilder's own + // fallback). Surface it so the operator can fix the override. + log.warn("[ChatUpload] agent {} basePath override rejected, using workspace root: {}", + agentId, e.getMessage()); + resolvedBase = workspaceBase; + } + + if (resolvedBase == null || resolvedBase.isBlank()) { + return null; + } + return Paths.get(resolvedBase).toAbsolutePath().normalize().resolve(UPLOAD_SUBDIR); + } + + /** The configurable default upload root (legacy location by default). */ + public Path defaultRoot() { + return Paths.get(properties.getBaseDir()).toAbsolutePath().normalize(); + } + + private ConversationEntity lookupConversation(String conversationId) { + if (conversationId == null || conversationId.isEmpty()) { + return null; + } + return conversationCache.get(conversationId, id -> conversationMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ConversationEntity::getConversationId, id) + .eq(ConversationEntity::getDeleted, 0) + .last("LIMIT 1"))); + } + + /** Drop the cached conversation row (test hook / on conversation re-create). */ + public void invalidate(String conversationId) { + if (conversationId != null) { + conversationCache.invalidate(conversationId); + } + } + + /** + * Drop the cached {@code conversationId → ConversationEntity} mapping when a + * conversation is deleted, mirroring {@code WorkspaceLookupCache}'s listener. + * Without this, a re-created conversation with the same id (rare, but + * possible across a backup restore) would inherit the stale workspace/agent + * mapping for up to five minutes — and {@code cleanAttachmentFiles} would + * walk the wrong (stale) upload directory. The delete tx has already + * committed when this fires, so the cache entry is safe to evict. + */ + @EventListener + public void onConversationDeleted(ConversationDeletedEvent event) { + invalidate(event.conversationId()); + } +} diff --git a/mateclaw-server/src/main/resources/application-kingbase.yml b/mateclaw-server/src/main/resources/application-kingbase.yml index 85192afe..c570cba2 100644 --- a/mateclaw-server/src/main/resources/application-kingbase.yml +++ b/mateclaw-server/src/main/resources/application-kingbase.yml @@ -69,3 +69,11 @@ mybatis-plus: mate: wiki: require-allowed-roots: true + +# Production hardening: lock down the Swagger UI / OpenAPI document so it is not +# anonymously browsable. Requires a global admin (ROLE_ADMIN); SecurityConfig +# enforces it. Set MATECLAW_OPENAPI_EXPOSE_UI=true to re-open it for an +# internal/staging host. +mateclaw: + openapi: + expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:false} diff --git a/mateclaw-server/src/main/resources/application-mysql.yml b/mateclaw-server/src/main/resources/application-mysql.yml index 945e7c81..efe33c1d 100644 --- a/mateclaw-server/src/main/resources/application-mysql.yml +++ b/mateclaw-server/src/main/resources/application-mysql.yml @@ -44,3 +44,11 @@ mate: allowed-source-roots: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:} watcher-enabled: ${MATE_WIKI_WATCHER_ENABLED:false} watcher-interval-ms: ${MATE_WIKI_WATCHER_INTERVAL_MS:300000} + +# Production hardening: lock down the Swagger UI / OpenAPI document so it is not +# anonymously browsable. Requires a global admin (ROLE_ADMIN); SecurityConfig +# enforces it. Set MATECLAW_OPENAPI_EXPOSE_UI=true to re-open it for an +# internal/staging host. +mateclaw: + openapi: + expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:false} diff --git a/mateclaw-server/src/main/resources/application-postgres.yml b/mateclaw-server/src/main/resources/application-postgres.yml index a9e6a829..38a3bc21 100644 --- a/mateclaw-server/src/main/resources/application-postgres.yml +++ b/mateclaw-server/src/main/resources/application-postgres.yml @@ -69,3 +69,11 @@ mate: allowed-source-roots: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:} watcher-enabled: ${MATE_WIKI_WATCHER_ENABLED:false} watcher-interval-ms: ${MATE_WIKI_WATCHER_INTERVAL_MS:300000} + +# Production hardening: lock down the Swagger UI / OpenAPI document so it is not +# anonymously browsable. Requires a global admin (ROLE_ADMIN); SecurityConfig +# enforces it. Set MATECLAW_OPENAPI_EXPOSE_UI=true to re-open it for an +# internal/staging host. +mateclaw: + openapi: + expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:false} diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 005d440b..891e0eca 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -116,11 +116,16 @@ mybatis-plus: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl # SpringDoc OpenAPI +# 注意:/swagger-ui*、/v3/api-docs*、/webjars/** 落在 SecurityConfig 的 +# .anyRequest().permitAll(),即 Swagger UI 当前公开可访问。如生产需要收口, +# 在 SecurityConfig 加显式规则(不要在此处配)。 springdoc: api-docs: path: /v3/api-docs swagger-ui: path: /swagger-ui.html + # 把单个嵌套 query 参数对象(如分页 wrapper)拍平成独立字段,减少 schema 噪音 + default-flat-param-object: true # MateClaw 自定义配置 mateclaw: @@ -131,9 +136,38 @@ mateclaw: # Set this when agents deliver download links to channels/clients that cannot # resolve a relative URL (IM messages, copied links, external downloads). public-base-url: ${MATECLAW_PUBLIC_BASE_URL:} + openapi: + # SpringDoc OpenAPI 元信息(驱动 /swagger-ui.html 与 /v3/api-docs)。 + # server-url 留空时由 SpringDoc 从请求 host 推导,避免 Try it out 打到错误地址。 + # 生产若需固定,通过 MATECLAW_OPENAPI_SERVER_URL 覆盖(如 https://mate.example.com)。 + title: ${MATECLAW_OPENAPI_TITLE:MateClaw REST API} + version: ${MATECLAW_OPENAPI_VERSION:1.0} + server-url: ${MATECLAW_OPENAPI_SERVER_URL:} + # description 留空则使用 OpenApiConfig 中的内置默认描述 + description: ${MATECLAW_OPENAPI_DESCRIPTION:} + # 是否公开 Swagger UI / OpenAPI 文档路径(/swagger-ui*、/v3/api-docs*、/webjars/**)。 + # true = 任何人可浏览(本地开发 / 内网默认); + # false = 需要全局管理员(ROLE_ADMIN)才能访问,由 SecurityConfig 强制。 + # 生产数据库 profile(mysql/kingbase/postgres)默认覆盖为 false。 + expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true} jwt: secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production} expiration: 86400000 + sso: + # 全局开关(默认关闭,不影响现有部署) + enabled: ${SSO_ENABLED:false} + # 「仅允许绑定已有账号」模式(false = 允许自动创建新用户) + link-only: ${SSO_LINK_ONLY:false} + # 新建 SSO 用户的默认角色 + default-role: ${SSO_DEFAULT_ROLE:user} + feishu: + enabled: ${SSO_FEISHU_ENABLED:false} + app-id: ${SSO_FEISHU_APP_ID:} + app-secret: ${SSO_FEISHU_APP_SECRET:} + # 国际版切换: feishu (国内) / lark (国际版 Lark) + domain: ${SSO_FEISHU_DOMAIN:feishu} + # SSO 回调地址,通常 https://your-domain/login?sso=callback + redirect-uri: ${SSO_FEISHU_REDIRECT_URI:} # 搜索配置已迁移至数据库(mate_system_setting 表),通过 UI 系统设置管理 # MCP server 配置已迁移至数据库(mate_mcp_server 表),通过 UI 管理 mcp: @@ -155,7 +189,23 @@ mateclaw: # unconstrained behaviour for unconfigured conversations. enabled: ${MATECLAW_WORKSPACE_SANDBOX_ENABLED:true} root: ${MATECLAW_WORKSPACE_SANDBOX_ROOT:${user.dir}/data/workspace} + chat: + upload: + # Root directory for conversation chat attachments when neither the active + # agent nor its workspace configures a base path. Attachments resolve to + # {baseDir}/{conversationId}/{storedName}. When a workspace/agent base path + # IS configured, attachments land under {basePath}/chat-uploads/{convId}/ + # instead; reads and cleanup still check this default dir so legacy uploads + # remain resolvable. Defaults to the legacy location for zero-config parity. + base-dir: ${MATECLAW_CHAT_UPLOAD_BASE_DIR:data/chat-uploads} skill: + upload: + # Size caps for skill bundle ZIPs (upload endpoint and marketplace + # install). The archive is buffered in memory during extraction, so + # max-total-size-mb also bounds peak heap usage per install. Uploads + # additionally pass through spring.servlet.multipart limits above. + max-entry-size-mb: ${MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB:1} + max-total-size-mb: ${MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB:50} workspace: # Skill workspace root. Override with MATECLAW_SKILL_WORKSPACE_ROOT to # relocate it onto a persistent volume — in Docker this is pointed at @@ -202,6 +252,12 @@ mateclaw: audit: enabled: true # 每次派发写 mate_hook_run retain-days: 7 + security: + # Hosts/IPs/CIDR blocks allowed through the SSRF guards (browser, hooks, image + # download) even though they are loopback/private/link-local/metadata. Each + # entry is a literal hostname, a literal IP, or an IPv4 CIDR block. Keep narrow. + # Example: [192.168.100.100, 192.168.100.0/24, internal.corp] + ssrf-allowlist: [] # RFC-014: Anthropic prompt cache 标记 llm: cache: diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 7bd78328..101deda3 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -444,6 +444,16 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000005, 'WriteFileTool', 'Write File', 'Write content to a file. Overwrites if exists, creates if not. Requires user approval.', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0); +-- Built-in tool: Local File Access (operates on the user's local desktop via the desktop tunnel) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0); + +-- Built-in tool: Local Shell (operates on the user's local desktop via the desktop tunnel) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); + -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql index aeab6c12..c69b5685 100644 --- a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql +++ b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql @@ -488,6 +488,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', 'Write File', 'Write content to a file. Overwrites if exists, creates if not. Requires user approval.', 'builtin', 'writeFileTool', '📝', 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; +-- Built-in tool: Local File Access (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', 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; + +-- Built-in tool: Local Shell (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', 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; + -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql index 57f56646..440db0cb 100644 --- a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql @@ -483,6 +483,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', 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; +-- 内置工具:本地文件访问(通过桌面隧道操作用户本机文件) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', '本地文件访问', '通过桌面隧道读取/写入/编辑/列目录/获取元数据,操作的是用户本机文件(非服务器)。受目录白名单约束;写入与编辑需用户在桌面端原生审批。', 'builtin', 'localFileTools', '💻', 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; + +-- 内置工具:本地命令执行(通过桌面隧道在用户本机执行) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', 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; + -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index cdbb2845..dca49bad 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -497,6 +497,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', 'Write File', 'Write content to a file. Overwrites if exists, creates if not. Requires user approval.', 'builtin', 'writeFileTool', '📝', 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); +-- Built-in tool: Local File Access (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', 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); + +-- Built-in tool: Local Shell (operates on the user's local desktop via the desktop tunnel) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', 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); + -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 262e7141..a72b31ab 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -492,6 +492,16 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', 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); +-- 内置工具:本地文件访问(通过桌面隧道操作用户本机文件) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', '本地文件访问', '通过桌面隧道读取/写入/编辑/列目录/获取元数据,操作的是用户本机文件(非服务器)。受目录白名单约束;写入与编辑需用户在桌面端原生审批。', 'builtin', 'localFileTools', '💻', 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); + +-- 内置工具:本地命令执行(通过桌面隧道在用户本机执行) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', 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); + -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index 773480a4..eb337aaf 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -445,6 +445,16 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0); +-- 内置工具:本地文件访问(通过桌面隧道操作用户本机文件) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000026, 'LocalFileTools', '本地文件访问', '通过桌面隧道读取/写入/编辑/列目录/获取元数据,操作的是用户本机文件(非服务器)。受目录白名单约束;写入与编辑需用户在桌面端原生审批。', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0); + +-- 内置工具:本地命令执行(通过桌面隧道在用户本机执行) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); + -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V157__register_session_list_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V157__register_session_list_tool.sql new file mode 100644 index 00000000..10d6e4df --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V157__register_session_list_tool.sql @@ -0,0 +1,10 @@ +-- V157: Register SessionListTool as a built-in tool. +-- Mirrors DelegateAgentTool (spawn) so the delegation tools surface together in +-- the tool picker and AvailableToolService. SessionListTool is read-only and +-- core-tier (auto-available even without this row); this row gives it a name, +-- icon and admin toggle in the UI. +-- Idempotent: MERGE INTO updates existing rows when id matches. + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000024, 'SessionListTool', 'Sub-Agent List', 'List the live sub-agents spawned from the current conversation: id, target agent, depth, status, phase, tool-call count, elapsed time and goal. Read-only; lets a parent agent check delegated children before deciding to wait, follow up, or proceed.', 'builtin', 'sessionListTool', '🧭', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql new file mode 100644 index 00000000..f8c3cb6e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql @@ -0,0 +1,9 @@ +-- V158: Register SessionSendTool as a built-in tool. +-- Completes the spawn/send/list delegation triad in the tool picker alongside +-- DelegateAgentTool (spawn) and SessionListTool (list). Core-tier and already +-- auto-available without this row; the row only adds UI metadata. +-- Idempotent: MERGE INTO updates existing rows when id matches. + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000025, 'SessionSendTool', 'Sub-Agent Send', 'Send a follow-up message to a sub-agent you previously delegated to, continuing its existing session (so it still remembers the earlier task) instead of starting fresh. Pass the session_id returned by delegateToAgent.', 'builtin', 'sessionSendTool', '✉️', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V159__sso_external_identity.sql b/mateclaw-server/src/main/resources/db/migration/h2/V159__sso_external_identity.sql new file mode 100644 index 00000000..55b0d77b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V159__sso_external_identity.sql @@ -0,0 +1,79 @@ +-- V159: SSO (single sign-on) infrastructure — external identity link table, +-- OAuth2 state store, and mate_user.password relaxation. +-- +-- This migration introduces three changes for ISSUE #405 (飞书/SSO login): +-- +-- 1. mate_user_external_identity — links a mate_user to one or more IdP +-- identities (feishu open_id / union_id, later dingtalk, wecom, ...). +-- A single user may bind multiple providers; a single (provider, +-- external_id) pair maps to at most one user. +-- +-- Matching priority at login: union_id first (cross-app unique within a +-- Feishu tenant — requires the app to request the union_id data scope), +-- falling back to external_id (open_id, app-local). Deployments that do +-- not enable the union_id scope cannot de-duplicate across apps; the +-- deployment note must call this out. +-- +-- Soft-delete (@TableLogic, matching wiki-package convention): unbinding +-- rewrites external_id/union_id to `_del_` so the +-- UNIQUE constraints free up for a future re-bind, while the old row is +-- retained for audit. mate_user itself is NOT soft-delete in this repo +-- (V20 purged soft-delete on non-wiki tables), so the two tables have +-- independent delete semantics — only this table is @TableLogic. +-- +-- 2. sso_state — persists OAuth2 'state' tokens and bind_token 'jti' values +-- so they are one-time-consumable across a multi-node deployment (in-mem +-- would lose state between /authorize on node A and /callback on node B). +-- The 'kind' column distinguishes 'state' (OAuth2 CSRF state) from 'bind' +-- (bind_token anti-replay jti). A ShedLock hourly job purges expired rows +-- (5-min state TTL + buffer) — the purge uses LambdaQuery + Java time so +-- it works on all three dialects without a NOW()-INTERVAL literal. +-- +-- 3. ALTER mate_user.password — relax from NOT NULL to nullable so an +-- SSO-only user (never set a local password) can exist. AuthService.login +-- guards password IS NULL → reject password login (BCrypt null-hash is a +-- false match anyway, but an explicit check is clearer and audit-safe). + +-- (1) external identity link ------------------------------------------------ + +CREATE TABLE IF NOT EXISTS mate_user_external_identity ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + + provider VARCHAR(32) NOT NULL, -- feishu / dingtalk / wecom / ... + external_id VARCHAR(128) NOT NULL, -- IdP-scoped id, usually open_id + union_id VARCHAR(128), -- cross-app unique (Feishu), nullable + external_name VARCHAR(128), -- IdP-side display name + external_avatar VARCHAR(512), + external_email VARCHAR(128), + + last_login_at TIMESTAMP, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +-- A single IdP identity maps to at most one active user. +CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_external + ON mate_user_external_identity (provider, external_id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_union + ON mate_user_external_identity (provider, union_id); +-- List a user's bound identities. +CREATE INDEX IF NOT EXISTS idx_sso_user_provider + ON mate_user_external_identity (user_id, provider); + +-- (2) OAuth2 state / bind-token store --------------------------------------- + +CREATE TABLE IF NOT EXISTS sso_state ( + token VARCHAR(128) PRIMARY KEY, -- state value or bind-token jti + kind VARCHAR(8) NOT NULL, -- 'state' | 'bind' + provider VARCHAR(32), -- provider id (null for 'bind' jti reuse) + consumed INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- (3) relax mate_user.password to nullable ---------------------------------- +-- SSO-only users never set a local password; AuthService.login rejects +-- password=null before the BCrypt check. + +ALTER TABLE mate_user ALTER COLUMN password DROP NOT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V160__register_local_tools.sql b/mateclaw-server/src/main/resources/db/migration/h2/V160__register_local_tools.sql new file mode 100644 index 00000000..5c83a166 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V160__register_local_tools.sql @@ -0,0 +1,17 @@ +-- V160: Register the desktop local-tool proxies as built-in tools so they show +-- up in the tool picker and can be bound per-agent. The agent runtime already +-- discovers these @Tool beans live (core-tier, auto-available even without a +-- row), but the picker / per-agent binding validation reads mate_tool — without +-- these rows operators cannot grant local file/shell access to agents that use +-- an explicit tool allowlist. One row per bean: the alias index resolves the +-- class simple name to every @Tool method the bean exposes, so binding +-- 'LocalFileTools' grants all five local file operations as one capability. +-- Idempotent: MERGE INTO updates the row when the id already matches. + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', TRUE, TRUE, NOW(), NOW(), 0); + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V161__agent_provider_preference_model.sql b/mateclaw-server/src/main/resources/db/migration/h2/V161__agent_provider_preference_model.sql new file mode 100644 index 00000000..fef45a19 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V161__agent_provider_preference_model.sql @@ -0,0 +1,21 @@ +-- Per-agent preferred *model* chain (provider + model). +-- +-- Extends mate_agent_provider_preference from provider-level to +-- (provider, model)-level: a preference entry may now pin a specific chat +-- model, and the SAME provider may appear multiple times with different +-- models (e.g. A/modelX -> A/modelY -> B/modelZ). +-- +-- model_id NULL = use the provider's default chat model (fully backward +-- compatible with pre-existing provider-only rows). +-- model_id = matches mate_model_config.id — pin that exact model. +-- +-- The unique key moves from (agent_id, provider_id) to +-- (agent_id, provider_id, model_id) so the same provider can repeat. Note +-- NULLs are treated as distinct in a unique index, so duplicate +-- provider-default rows are not DB-enforced; the service replaces the whole +-- list on save and the UI prevents that, so this is intentional. + +ALTER TABLE mate_agent_provider_preference ADD COLUMN IF NOT EXISTS model_id BIGINT; +DROP INDEX IF EXISTS uk_agent_provider; +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_provider_model + ON mate_agent_provider_preference(agent_id, provider_id, model_id); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql b/mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql new file mode 100644 index 00000000..8c634412 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql @@ -0,0 +1,9 @@ +-- V162: structured error_code on wiki raw material. +-- The processing pipeline already classifies failures into a stable vocabulary +-- (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / SERVER_ERROR / +-- CONTENT_FILTER / UNKNOWN, see WikiProcessingService#classifyErrorCode) but only +-- the free-text error_message reached the raw_material row — so the frontend could +-- not localize the failure into a user-friendly hint. Persisting the code lets the +-- UI render a friendly i18n message and keep the raw message as a collapsible detail. +-- Nullable: NULL = no error (or a legacy failure recorded before this column existed). +ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS error_code VARCHAR(64) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql b/mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql new file mode 100644 index 00000000..9203ac0b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql @@ -0,0 +1,10 @@ +-- V163: non-blocking warning surface on wiki raw material. +-- Some ingest sub-steps run async *after* the material is already marked +-- completed/partial — embedding (semantic search) and entity-graph extraction. +-- When they fail the material stays "completed" but is silently degraded +-- (e.g. not searchable), and previously the only trace was a server log line. +-- These columns let such a failure show as a non-blocking warning on an +-- otherwise-successful row. Mirrors the error_code/error_message pair so the +-- UI can render a localized friendly hint; NULL = no warning. +ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS warning_code VARCHAR(64) DEFAULT NULL; +ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS warning_message VARCHAR(512) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V164__kb_open_api_key.sql b/mateclaw-server/src/main/resources/db/migration/h2/V164__kb_open_api_key.sql new file mode 100644 index 00000000..8b8ae9a2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V164__kb_open_api_key.sql @@ -0,0 +1,30 @@ +-- V164: Knowledge Base Open API Key (H2 dialect). +-- See mysql/V164 for full design notes. + +CREATE TABLE IF NOT EXISTS mate_kb_api_key ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + token_hash CHAR(64) NOT NULL, + prefix VARCHAR(12) NOT NULL, + workspace_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + scopes VARCHAR(255), + enabled BOOLEAN DEFAULT TRUE, + expires_at TIMESTAMP NULL, + last_used_at TIMESTAMP NULL, + rate_limit_per_min INT NOT NULL DEFAULT 60, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_hash ON mate_kb_api_key(token_hash); +CREATE INDEX IF NOT EXISTS idx_kb_api_key_workspace ON mate_kb_api_key(workspace_id); + +CREATE TABLE IF NOT EXISTS mate_kb_api_key_binding ( + id BIGINT NOT NULL PRIMARY KEY, + api_key_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_binding ON mate_kb_api_key_binding(api_key_id, kb_id); +CREATE INDEX IF NOT EXISTS idx_kb_api_key_binding_kb ON mate_kb_api_key_binding(kb_id); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V165__wiki_transformation_starter_pack_global.sql b/mateclaw-server/src/main/resources/db/migration/h2/V165__wiki_transformation_starter_pack_global.sql new file mode 100644 index 00000000..da6d4bd4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V165__wiki_transformation_starter_pack_global.sql @@ -0,0 +1,14 @@ +-- V165: make the built-in starter-pack transformation templates global. +-- V108 seeded the 7 templates with a hardcoded workspace_id = 1, so any +-- workspace other than 1 saw an empty Transformations list. The fix marks them +-- global by clearing workspace_id (NULL = global). The column was NOT NULL, so +-- relax it first; listForKb / listByWorkspace / findByName treat NULL as global, +-- and the access checks already allow templates whose workspace_id IS NULL. +-- Targeted by fixed seed ids so real user templates are untouched. + +ALTER TABLE mate_wiki_transformation ALTER COLUMN workspace_id SET NULL; + +UPDATE mate_wiki_transformation +SET workspace_id = NULL +WHERE id IN (1000004001, 1000004002, 1000004003, 1000004004, 1000004005, 1000004006, 1000004007) + AND workspace_id = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V166__message_usage_detail.sql b/mateclaw-server/src/main/resources/db/migration/h2/V166__message_usage_detail.sql new file mode 100644 index 00000000..d44de6c4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V166__message_usage_detail.sql @@ -0,0 +1,6 @@ +-- V166: Per-message token usage detail for the chat consumption breakdown panel. +-- Adds prompt-cache hit/write and reasoning token counters to mate_message so the +-- UI can show input cache hit/miss/write and thinking-vs-reply output splits. +ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_read_tokens INT DEFAULT 0; +ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_write_tokens INT DEFAULT 0; +ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS reasoning_tokens INT DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V157__register_session_list_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V157__register_session_list_tool.sql new file mode 100644 index 00000000..3383b706 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V157__register_session_list_tool.sql @@ -0,0 +1,10 @@ +-- V157: Register SessionListTool as a built-in tool. +-- Mirrors DelegateAgentTool (spawn) so the delegation tools surface together in +-- the tool picker and AvailableToolService. SessionListTool is read-only and +-- core-tier (auto-available even without this row); this row gives it a name, +-- icon and admin toggle in the UI. +-- Idempotent: ON CONFLICT keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000024, 'SessionListTool', 'Sub-Agent List', 'List the live sub-agents spawned from the current conversation: id, target agent, depth, status, phase, tool-call count, elapsed time and goal. Read-only; lets a parent agent check delegated children before deciding to wait, follow up, or proceed.', 'builtin', 'sessionListTool', '🧭', 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/V158__register_session_send_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V158__register_session_send_tool.sql new file mode 100644 index 00000000..244837e1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V158__register_session_send_tool.sql @@ -0,0 +1,9 @@ +-- V158: Register SessionSendTool as a built-in tool. +-- Completes the spawn/send/list delegation triad in the tool picker alongside +-- DelegateAgentTool (spawn) and SessionListTool (list). Core-tier and already +-- auto-available without this row; the row only adds UI metadata. +-- Idempotent: ON CONFLICT keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000025, 'SessionSendTool', 'Sub-Agent Send', 'Send a follow-up message to a sub-agent you previously delegated to, continuing its existing session (so it still remembers the earlier task) instead of starting fresh. Pass the session_id returned by delegateToAgent.', 'builtin', 'sessionSendTool', '✉️', 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/V159__sso_external_identity.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V159__sso_external_identity.sql new file mode 100644 index 00000000..2dfa8336 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V159__sso_external_identity.sql @@ -0,0 +1,37 @@ +-- V159: SSO (single sign-on) infrastructure — external identity link table, +-- OAuth2 state store, and mate_user.password relaxation. +-- See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_user_external_identity ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + + provider VARCHAR(32) NOT NULL, + external_id VARCHAR(128) NOT NULL, + union_id VARCHAR(128), + external_name VARCHAR(128), + external_avatar VARCHAR(512), + external_email VARCHAR(128), + + last_login_at TIMESTAMP(3), + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_external + ON mate_user_external_identity (provider, external_id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_union + ON mate_user_external_identity (provider, union_id); +CREATE INDEX IF NOT EXISTS idx_sso_user_provider + ON mate_user_external_identity (user_id, provider); + +CREATE TABLE IF NOT EXISTS sso_state ( + token VARCHAR(128) PRIMARY KEY, + kind VARCHAR(8) NOT NULL, + provider VARCHAR(32), + consumed SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE mate_user ALTER COLUMN password DROP NOT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V160__register_local_tools.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V160__register_local_tools.sql new file mode 100644 index 00000000..1b8d7b0a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V160__register_local_tools.sql @@ -0,0 +1,17 @@ +-- V160: Register the desktop local-tool proxies as built-in tools so they show +-- up in the tool picker and can be bound per-agent. The agent runtime already +-- discovers these @Tool beans live (core-tier, auto-available even without a +-- row), but the picker / per-agent binding validation reads mate_tool — without +-- these rows operators cannot grant local file/shell access to agents that use +-- an explicit tool allowlist. One row per bean: the alias index resolves the +-- class simple name to every @Tool method the bean exposes, so binding +-- 'LocalFileTools' grants all five local file operations as one capability. +-- Idempotent: ON CONFLICT keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', 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; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', 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/V161__agent_provider_preference_model.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V161__agent_provider_preference_model.sql new file mode 100644 index 00000000..1721a105 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V161__agent_provider_preference_model.sql @@ -0,0 +1,21 @@ +-- Per-agent preferred *model* chain (provider + model). +-- +-- Extends mate_agent_provider_preference from provider-level to +-- (provider, model)-level: a preference entry may now pin a specific chat +-- model, and the SAME provider may appear multiple times with different +-- models (e.g. A/modelX -> A/modelY -> B/modelZ). +-- +-- model_id NULL = use the provider's default chat model (fully backward +-- compatible with pre-existing provider-only rows). +-- model_id = matches mate_model_config.id — pin that exact model. +-- +-- The unique key moves from (agent_id, provider_id) to +-- (agent_id, provider_id, model_id) so the same provider can repeat. Note +-- NULLs are treated as distinct in a unique index, so duplicate +-- provider-default rows are not DB-enforced; the service replaces the whole +-- list on save and the UI prevents that, so this is intentional. + +ALTER TABLE mate_agent_provider_preference ADD COLUMN IF NOT EXISTS model_id BIGINT; +DROP INDEX IF EXISTS uk_agent_provider; +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_provider_model + ON mate_agent_provider_preference (agent_id, provider_id, model_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql new file mode 100644 index 00000000..4c28c19d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql @@ -0,0 +1,17 @@ +-- V162: structured error_code on wiki raw material. +-- The processing pipeline already classifies failures into a stable vocabulary +-- (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / SERVER_ERROR / +-- CONTENT_FILTER / UNKNOWN, see WikiProcessingService#classifyErrorCode) but only +-- the free-text error_message reached the raw_material row — so the frontend could +-- not localize the failure into a user-friendly hint. Persisting the code lets the +-- UI render a friendly i18n message and keep the raw message as a collapsible detail. +-- Nullable: NULL = no error (or a legacy failure recorded before this column existed). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'error_code' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN error_code VARCHAR(64) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql new file mode 100644 index 00000000..5cad5523 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql @@ -0,0 +1,27 @@ +-- V163: non-blocking warning surface on wiki raw material. +-- Some ingest sub-steps run async *after* the material is already marked +-- completed/partial — embedding (semantic search) and entity-graph extraction. +-- When they fail the material stays "completed" but is silently degraded +-- (e.g. not searchable), and previously the only trace was a server log line. +-- These columns let such a failure show as a non-blocking warning on an +-- otherwise-successful row. Mirrors the error_code/error_message pair so the +-- UI can render a localized friendly hint; NULL = no warning. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'warning_code' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_code VARCHAR(64) DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'warning_message' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_message VARCHAR(512) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V164__kb_open_api_key.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V164__kb_open_api_key.sql new file mode 100644 index 00000000..991a1cc3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V164__kb_open_api_key.sql @@ -0,0 +1,30 @@ +-- V164: Knowledge Base Open API Key (Kingbase dialect). +-- See mysql/V164 for full design notes. + +CREATE TABLE IF NOT EXISTS mate_kb_api_key ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + token_hash CHAR(64) NOT NULL, + prefix VARCHAR(12) NOT NULL, + workspace_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + scopes VARCHAR(255), + enabled BOOLEAN DEFAULT TRUE, + expires_at TIMESTAMP NULL, + last_used_at TIMESTAMP NULL, + rate_limit_per_min INT NOT NULL DEFAULT 60, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_hash ON mate_kb_api_key(token_hash); +CREATE INDEX IF NOT EXISTS idx_kb_api_key_workspace ON mate_kb_api_key(workspace_id); + +CREATE TABLE IF NOT EXISTS mate_kb_api_key_binding ( + id BIGINT NOT NULL PRIMARY KEY, + api_key_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_kb_api_key_binding ON mate_kb_api_key_binding(api_key_id, kb_id); +CREATE INDEX IF NOT EXISTS idx_kb_api_key_binding_kb ON mate_kb_api_key_binding(kb_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V165__wiki_transformation_starter_pack_global.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V165__wiki_transformation_starter_pack_global.sql new file mode 100644 index 00000000..179ec6ad --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V165__wiki_transformation_starter_pack_global.sql @@ -0,0 +1,14 @@ +-- V165: make the built-in starter-pack transformation templates global. +-- V108 seeded the 7 templates with a hardcoded workspace_id = 1, so any +-- workspace other than 1 saw an empty Transformations list. The fix marks them +-- global by clearing workspace_id (NULL = global). The column was NOT NULL, so +-- relax it first; listForKb / listByWorkspace / findByName treat NULL as global, +-- and the access checks already allow templates whose workspace_id IS NULL. +-- Targeted by fixed seed ids so real user templates are untouched. + +ALTER TABLE mate_wiki_transformation ALTER COLUMN workspace_id DROP NOT NULL; + +UPDATE mate_wiki_transformation +SET workspace_id = NULL +WHERE id IN (1000004001, 1000004002, 1000004003, 1000004004, 1000004005, 1000004006, 1000004007) + AND workspace_id = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V166__message_usage_detail.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V166__message_usage_detail.sql new file mode 100644 index 00000000..d44de6c4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V166__message_usage_detail.sql @@ -0,0 +1,6 @@ +-- V166: Per-message token usage detail for the chat consumption breakdown panel. +-- Adds prompt-cache hit/write and reasoning token counters to mate_message so the +-- UI can show input cache hit/miss/write and thinking-vs-reply output splits. +ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_read_tokens INT DEFAULT 0; +ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_write_tokens INT DEFAULT 0; +ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS reasoning_tokens INT DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V157__register_session_list_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V157__register_session_list_tool.sql new file mode 100644 index 00000000..ac159ec3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V157__register_session_list_tool.sql @@ -0,0 +1,10 @@ +-- V157: Register SessionListTool as a built-in tool. +-- Mirrors DelegateAgentTool (spawn) so the delegation tools surface together in +-- the tool picker and AvailableToolService. SessionListTool is read-only and +-- core-tier (auto-available even without this row); this row gives it a name, +-- icon and admin toggle in the UI. +-- Idempotent: ON DUPLICATE KEY UPDATE keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000024, 'SessionListTool', 'Sub-Agent List', 'List the live sub-agents spawned from the current conversation: id, target agent, depth, status, phase, tool-call count, elapsed time and goal. Read-only; lets a parent agent check delegated children before deciding to wait, follow up, or proceed.', 'builtin', 'sessionListTool', '🧭', 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/V158__register_session_send_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V158__register_session_send_tool.sql new file mode 100644 index 00000000..e6b6bc2a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V158__register_session_send_tool.sql @@ -0,0 +1,9 @@ +-- V158: Register SessionSendTool as a built-in tool. +-- Completes the spawn/send/list delegation triad in the tool picker alongside +-- DelegateAgentTool (spawn) and SessionListTool (list). Core-tier and already +-- auto-available without this row; the row only adds UI metadata. +-- Idempotent: ON DUPLICATE KEY UPDATE keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000025, 'SessionSendTool', 'Sub-Agent Send', 'Send a follow-up message to a sub-agent you previously delegated to, continuing its existing session (so it still remembers the earlier task) instead of starting fresh. Pass the session_id returned by delegateToAgent.', 'builtin', 'sessionSendTool', '✉️', 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/V159__sso_external_identity.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V159__sso_external_identity.sql new file mode 100644 index 00000000..71904d5e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V159__sso_external_identity.sql @@ -0,0 +1,39 @@ +-- V159: SSO (single sign-on) infrastructure — external identity link table, +-- OAuth2 state store, and mate_user.password relaxation. +-- See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_user_external_identity ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + + provider VARCHAR(32) NOT NULL, + external_id VARCHAR(128) NOT NULL, + union_id VARCHAR(128), + external_name VARCHAR(128), + external_avatar VARCHAR(512), + external_email VARCHAR(128), + + last_login_at DATETIME(3), + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT NOT NULL DEFAULT 0, + + PRIMARY KEY (id), + UNIQUE KEY uk_sso_provider_external (provider, external_id), + UNIQUE KEY uk_sso_provider_union (provider, union_id), + KEY idx_sso_user_provider (user_id, provider) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci + COMMENT = 'User external identity links for SSO (feishu/dingtalk/...).'; + +CREATE TABLE IF NOT EXISTS sso_state ( + token VARCHAR(128) NOT NULL, + kind VARCHAR(8) NOT NULL, + provider VARCHAR(32), + consumed TINYINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (token) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci + COMMENT = 'OAuth2 state + bind-token jti store (one-time consumable, multi-node).'; + +ALTER TABLE mate_user MODIFY COLUMN password VARCHAR(200) NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V160__register_local_tools.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V160__register_local_tools.sql new file mode 100644 index 00000000..10cdc3ce --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V160__register_local_tools.sql @@ -0,0 +1,17 @@ +-- V160: Register the desktop local-tool proxies as built-in tools so they show +-- up in the tool picker and can be bound per-agent. The agent runtime already +-- discovers these @Tool beans live (core-tier, auto-available even without a +-- row), but the picker / per-agent binding validation reads mate_tool — without +-- these rows operators cannot grant local file/shell access to agents that use +-- an explicit tool allowlist. One row per bean: the alias index resolves the +-- class simple name to every @Tool method the bean exposes, so binding +-- 'LocalFileTools' grants all five local file operations as one capability. +-- Idempotent: ON DUPLICATE KEY UPDATE keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000026, 'LocalFileTools', 'Local File Access', 'Read/write/edit/list/stat files on the user''s local desktop machine via the desktop tunnel. Directory-whitelisted; writes and edits require native user approval.', 'builtin', 'localFileTools', '💻', 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); + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', 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/V161__agent_provider_preference_model.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V161__agent_provider_preference_model.sql new file mode 100644 index 00000000..a3d40269 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V161__agent_provider_preference_model.sql @@ -0,0 +1,46 @@ +-- Per-agent preferred *model* chain (provider + model). +-- +-- Extends mate_agent_provider_preference from provider-level to +-- (provider, model)-level: a preference entry may now pin a specific chat +-- model, and the SAME provider may appear multiple times with different +-- models (e.g. A/modelX -> A/modelY -> B/modelZ). +-- +-- model_id NULL = use the provider's default chat model (fully backward +-- compatible with pre-existing provider-only rows). +-- model_id = matches mate_model_config.id — pin that exact model. +-- +-- The unique key moves from (agent_id, provider_id) to +-- (agent_id, provider_id, model_id) so the same provider can repeat. Note +-- NULLs are treated as distinct in a unique index, so duplicate +-- provider-default rows are not DB-enforced; the service replaces the whole +-- list on save and the UI prevents that, so this is intentional. + +-- All three DDL statements below are wrapped in INFORMATION_SCHEMA guards +-- so the migration is idempotent: a mid-migration failure followed by a +-- Flyway repair + re-run will not choke on "column already exists" or +-- "index not found". MySQL 8.0 lacks native ADD COLUMN IF NOT EXISTS, so +-- the project convention is PREPARE/EXECUTE (see V156, V137). + +-- 1) ADD COLUMN model_id (idempotent) +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_provider_preference' AND COLUMN_NAME = 'model_id'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_agent_provider_preference ADD COLUMN model_id BIGINT NULL', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2) DROP old unique index uk_agent_provider (only if it exists) +SET @idx_old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_provider_preference' AND INDEX_NAME = 'uk_agent_provider'); +SET @ddl := IF(@idx_old > 0, + 'ALTER TABLE mate_agent_provider_preference DROP INDEX uk_agent_provider', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 3) CREATE new unique index uk_agent_provider_model (only if it doesn't exist) +SET @idx_new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_provider_preference' AND INDEX_NAME = 'uk_agent_provider_model'); +SET @ddl := IF(@idx_new = 0, + 'CREATE UNIQUE INDEX uk_agent_provider_model ON mate_agent_provider_preference(agent_id, provider_id, model_id)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql new file mode 100644 index 00000000..d3a9df5c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql @@ -0,0 +1,12 @@ +-- V162: structured error_code on wiki raw material. +-- The processing pipeline already classifies failures into a stable vocabulary +-- (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / SERVER_ERROR / +-- CONTENT_FILTER / UNKNOWN, see WikiProcessingService#classifyErrorCode) but only +-- the free-text error_message reached the raw_material row — so the frontend could +-- not localize the failure into a user-friendly hint. Persisting the code lets the +-- UI render a friendly i18n message and keep the raw message as a collapsible detail. +-- Nullable: NULL = no error (or a legacy failure recorded before this column existed). +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'error_code'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN error_code VARCHAR(64) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql new file mode 100644 index 00000000..1f6b886f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql @@ -0,0 +1,16 @@ +-- V163: non-blocking warning surface on wiki raw material. +-- Some ingest sub-steps run async *after* the material is already marked +-- completed/partial — embedding (semantic search) and entity-graph extraction. +-- When they fail the material stays "completed" but is silently degraded +-- (e.g. not searchable), and previously the only trace was a server log line. +-- These columns let such a failure show as a non-blocking warning on an +-- otherwise-successful row. Mirrors the error_code/error_message pair so the +-- UI can render a localized friendly hint; NULL = no warning. +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'warning_code'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_code VARCHAR(64) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'warning_message'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_message VARCHAR(512) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V164__kb_open_api_key.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V164__kb_open_api_key.sql new file mode 100644 index 00000000..166bf820 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V164__kb_open_api_key.sql @@ -0,0 +1,36 @@ +-- V164: Knowledge Base Open API Key. +-- External API keys for programmatic access to knowledge base retrieval, +-- tracing, and graph traversal. Like PAT (V76), plaintext is never stored — +-- only the SHA-256 hash. A DB compromise reveals ownership, scope, and bound +-- KBs but never the secret needed to authenticate. +-- +-- One key can bind multiple KBs (mate_kb_api_key_binding). An empty binding +-- set means "zero access" (NOT "all KBs" — unlike internal AgentWikiKbBinding). + +CREATE TABLE IF NOT EXISTS mate_kb_api_key ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + token_hash CHAR(64) NOT NULL, + prefix VARCHAR(12) NOT NULL, + workspace_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + scopes VARCHAR(255), + enabled BOOLEAN DEFAULT TRUE, + expires_at TIMESTAMP NULL, + last_used_at TIMESTAMP NULL, + rate_limit_per_min INT NOT NULL DEFAULT 60, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT DEFAULT 0, + UNIQUE KEY uk_kb_api_key_hash (token_hash), + KEY idx_kb_api_key_workspace (workspace_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mate_kb_api_key_binding ( + id BIGINT NOT NULL PRIMARY KEY, + api_key_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_kb_api_key_binding (api_key_id, kb_id), + KEY idx_kb_api_key_binding_kb (kb_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V165__wiki_transformation_starter_pack_global.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V165__wiki_transformation_starter_pack_global.sql new file mode 100644 index 00000000..01090bb0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V165__wiki_transformation_starter_pack_global.sql @@ -0,0 +1,14 @@ +-- V165: make the built-in starter-pack transformation templates global. +-- V108 seeded the 7 templates with a hardcoded workspace_id = 1, so any +-- workspace other than 1 saw an empty Transformations list. The fix marks them +-- global by clearing workspace_id (NULL = global). The column was NOT NULL, so +-- relax it first; listForKb / listByWorkspace / findByName treat NULL as global, +-- and the access checks already allow templates whose workspace_id IS NULL. +-- Targeted by fixed seed ids so real user templates are untouched. + +ALTER TABLE mate_wiki_transformation MODIFY COLUMN workspace_id BIGINT NULL DEFAULT 1; + +UPDATE mate_wiki_transformation +SET workspace_id = NULL +WHERE id IN (1000004001, 1000004002, 1000004003, 1000004004, 1000004005, 1000004006, 1000004007) + AND workspace_id = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V166__message_usage_detail.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V166__message_usage_detail.sql new file mode 100644 index 00000000..10810192 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V166__message_usage_detail.sql @@ -0,0 +1,15 @@ +-- V166: Per-message token usage detail for the chat consumption breakdown panel. +-- Adds prompt-cache hit/write and reasoning token counters to mate_message so the +-- UI can show input cache hit/miss/write and thinking-vs-reply output splits. +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_message' AND COLUMN_NAME = 'cache_read_tokens'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_message ADD COLUMN cache_read_tokens INT DEFAULT 0', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_message' AND COLUMN_NAME = 'cache_write_tokens'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_message ADD COLUMN cache_write_tokens INT DEFAULT 0', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_message' AND COLUMN_NAME = 'reasoning_tokens'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_message ADD COLUMN reasoning_tokens INT DEFAULT 0', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/docs/en/api.md b/mateclaw-server/src/main/resources/docs/en/api.md index 7879cf70..f65379a4 100644 --- a/mateclaw-server/src/main/resources/docs/en/api.md +++ b/mateclaw-server/src/main/resources/docs/en/api.md @@ -2,6 +2,8 @@ This page is source-aligned with the Spring MVC controllers under `mateclaw-server/src/main/java`. The route inventory below was rebuilt from controller annotations; when it conflicts with an older feature page, this page and the source code are the contract. +> Want a machine-readable OpenAPI doc (import into Postman / Apifox, online debugging)? See the [OpenAPI / Swagger guide](./openapi.md) — visit `/swagger-ui.html` on your deployment. + ## Contract All application REST endpoints use the `/api/v1` prefix unless explicitly noted. Most JSON responses use the project envelope: @@ -34,6 +36,119 @@ Public routes from `SecurityConfig` include login, first-run setup, webhook/webc Workspace-scoped APIs usually accept `X-Workspace-Id`. If omitted, many handlers fall back to workspace `1` for desktop/local compatibility. +## Conventions + +Structural contracts shared by every endpoint. Read this section first, then the flagship endpoint examples and the full route inventory below will line up. + +### Response envelope `R` + +Source: `vip.mate.common.result.R` (`R.java`). Three fields: + +| Field | Type | Meaning | +|---|---|---| +| `code` | `int` | Status code. `200` = success; see "Status codes" below | +| `msg` | `string` | Message. **Note: `msg`, not `message`** | +| `data` | `T` | Payload; `null` on failure | + +Success example: + +```json +{ "code": 200, "msg": "success", "data": { "id": "1", "name": "Agent A" } } +``` + +Failure example: + +```json +{ "code": 401, "msg": "Token expired or invalid", "data": null } +``` + +**HTTP status mirrors `code`**: `RHttpStatusAdvice` (`ResponseBodyAdvice`) sets the HTTP status to `HttpStatus.resolve(code)` whenever `code != 200`. So business code `401` → HTTP 401, `404` → HTTP 404. Business codes that are not valid HTTP statuses (e.g. `1001`, `2001`) fall back to HTTP `500`. + +### Status codes + +Source: `vip.mate.common.result.ResultCode`. Two categories: + +| Code | Meaning | Maps to HTTP status directly? | +|---|---|---| +| `200` | Success | Yes | +| `400` | Param error | Yes | +| `401` | Unauthorized | Yes | +| `403` | Forbidden | Yes | +| `404` | Not found | Yes | +| `500` | System error | Yes | +| `1001` | Agent not found | No (HTTP 500) | +| `1002` | Agent busy | No (HTTP 500) | +| `2001` | LLM error | No (HTTP 500) | +| `3001` | Tool not found | No (HTTP 500) | +| `4001` | Channel error | No (HTTP 500) | + +### Error model + +Errors are handled centrally by `GlobalExceptionHandler` (`@RestControllerAdvice`): + +| HTTP | Trigger | Body | +|---|---|---| +| 400 | `@Valid` / `BindException` validation failure | `{code:400, msg:"field: defaultMessage"}` — only the **first** field error is returned | +| 400 | `MethodArgumentTypeMismatchException` (e.g. non-numeric `/{id}`) | `{code:400, msg:"Invalid value for parameter 'X': expected Long"}` | +| 401 | Unauthenticated / invalid token (`SecurityConfig` `authenticationEntryPoint`) | `{code:401, msg:"Token expired or invalid"}` | +| 403 | Insufficient workspace role (`WorkspaceAccessInterceptor` writes the response directly) | `{code:403, msg:"...", data:null}` | +| 404 | No route match (`NoResourceFoundException`) | `{code:404, msg:"Resource not found"}` | +| 405 | Method not supported (`HttpRequestMethodNotSupportedException`) | `{code:405, msg:"Method not allowed"}` | +| 409 | Confirmation required (`ConfirmRequiredException`) | **Breaks the envelope**: `{code, message, boundAgents}` (field is `message`, **not** `msg`) — the only non-`R` response in the API | +| 500 | Catch-all (`Exception`) | `{code:500, msg:"Internal server error"}` — stack trace is not leaked | +| 503 | Async timeout (non-SSE) | `{code:503, msg:"Request timeout, please try again"}` | + +> SSE endpoints (`/chat/stream`, etc.) do **not** emit a JSON envelope on error; they send an SSE `error` event instead: `event: error` / `data: {"message":"..."}`. See the [WebChat guide](./webchat.md#sse-event-protocol). + +### Pagination + +Paginated endpoints return `R>` directly — the MyBatis Plus `Page` serialization: + +```json +{ + "code": 200, + "data": { + "records": [ /* current page rows */ ], + "total": 128, + "size": 20, + "current": 1, + "pages": 7 + } +} +``` + +| Field | Meaning | +|---|---| +| `records` | Current page rows (**field name is `records`**, not `list`/`items`) | +| `total` | Total record count | +| `size` | Page size | +| `current` | Current page number, 1-based | +| `pages` | Total page count | + +Common query params: `page` (default 1), `size` (default 20). Examples: `GET /api/v1/audit/events`, `GET /api/v1/conversations/page`. + +### ID & type conventions + +- **Snowflake `Long` serialized as JSON string**: all backend PKs are `Long`, but Jackson serializes them as strings. Clients (especially JS) should **treat IDs as strings end-to-end** to avoid `Number.MAX_SAFE_INTEGER` precision loss. +- **Password is write-only**: `UserEntity.password` is annotated `@JsonProperty(access = WRITE_ONLY)` — accepted on login/create, never present in any response. + +### Auth model + +`JwtAuthFilter` supports three token forms, all via the `Authorization` header: + +1. **JWT**: `Authorization: Bearer `. Starts with `eyJ` (base64 header). The `token` field returned by login is exactly this. +2. **Personal Access Token (PAT)**: `Authorization: Bearer `. Prefixed with `mc_`, for headless / CI / SDK use. The plaintext is returned **only once** at `POST /api/v1/auth/tokens` creation; only the hash is stored afterwards. The filter dispatches by the `mc_` prefix to the PAT verification path. +3. **SSE `?token=` query param**: the native browser `EventSource` cannot set custom headers, so SSE streaming endpoints additionally accept `?token=` (JWT or PAT). + +**Sliding renewal**: when a JWT is near expiry (default < 2h remaining), the response header returns a fresh token — `X-New-Token: ` (with `Access-Control-Expose-Headers: X-New-Token`). Clients should watch for and replace the locally stored token. JWT TTL defaults to 24h (`mateclaw.jwt.expiration=86400000`). + +### How `X-Workspace-Id` works + +- **No ThreadLocal / request-context holder.** The workspace id is consumed two ways: + 1. **RBAC enforcement**: `WorkspaceAccessInterceptor` reads `X-Workspace-Id` for methods annotated `@RequireWorkspaceRole` (roles owner > admin > member > viewer) or `@RequireGlobalAdmin`, falling back to workspace `1` when absent/unparseable. Insufficient permission writes a 403 JSON response directly. + 2. **Business reads**: many controllers take it via `@RequestHeader(value="X-Workspace-Id", required=false) Long workspaceId` for query scoping, also defaulting to `1`. +- So workspace isolation is enforced by "interceptor auth + controller self-read" together; clients should pass `X-Workspace-Id` explicitly for workspace-scoped calls. + ## Frequently Used APIs ### Login @@ -72,6 +187,217 @@ Image, video, music, and 3D generation are agent tools (`image_generate`, `video `/api/v1/talk/ws` is registered by `WebSocketConfig` for Talk Mode. It is intentionally listed in `SecurityConfig` as a public WebSocket route, but it is not counted in the controller route inventory below. +## Flagship Endpoint Reference + +Full request/response reference for the most-used endpoints. Every field maps 1:1 to the source DTO. The 406-row route inventory further down is the complete index; this section is the human-readable walkthrough for high-traffic endpoints. + +### Login: `POST /api/v1/auth/login` + +Public endpoint (no auth required). Exchanges credentials for a JWT. + +**Request body** `LoginRequest` (`AuthController.java`): + +| Field | Type | Description | +|---|---|---| +| `username` | string | Username | +| `password` | string | Password | + +**Response** `R`: + +```json +{ + "code": 200, + "data": { + "id": "1", + "token": "eyJhbGciOi...", + "username": "admin", + "nickname": "Admin", + "role": "admin" + } +} +``` + +| Field | Type | Description | +|---|---|---| +| `id` | string | User ID (Snowflake, as a string) | +| `token` | string | **JWT** — send as `Authorization: Bearer ` on subsequent requests. There is no separate expiry field; expiry lives in the JWT's `exp` claim | +| `username` | string | Username | +| `nickname` | string | Display name | +| `role` | string | `admin` or `user` | + +**Errors**: wrong username/password → HTTP 401, `{code:401, msg:"..."}`. + +```bash +curl -X POST http://localhost:18088/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' +``` + +### Streaming chat: `POST /api/v1/chat/stream` + +> Public endpoint (`SecurityConfig` permits `/api/v1/chat/stream`), but in practice you still need a token to resolve the user and permissions — pass `?token=` or the `Authorization` header. + +Returns `text/event-stream`; **not** the JSON envelope. The SSE event protocol (`meta` / `content_delta` / `done` / `error`, etc.) is documented in the [WebChat guide](./webchat.md#sse-event-protocol). + +**Request body** `ChatController.ChatStreamRequest` (`ChatController.java:1211`): + +| Field | Type | Default | Description | +|---|---|---|---| +| `agentId` | string | — | Required, target Agent ID | +| `message` | string | — | This turn's user message (mutually exclusive with `contentParts`) | +| `contentParts` | array | — | Multimodal message parts (text + image); mutually exclusive with `message` | +| `conversationId` | string | `"default"` | Conversation ID; for a new conversation use a client-generated unique string | +| `reconnect` | boolean | — | `true` = reconnect to an in-flight stream, send no new message | +| `lastEventId` | string | — | Only meaningful with `reconnect=true`: skip events with id ≤ this value to avoid duplicate replay | +| `thinkingLevel` | string | null | Reasoning depth: `off` / `low` / `medium` / `high` / `max`; null follows the Agent default | +| `modelProvider` | string | null | Per-conversation provider override (paired with `modelName`) | +| `modelName` | string | null | Per-conversation model-name override | +| `endUserId` | string | null | Third-party end-user ID, isolates memory when one MateClaw account fronts many end-users | + +The native browser `EventSource` cannot send a POST body — use `fetch()` with a streaming reader. + +```bash +curl -N -X POST "http://localhost:18088/api/v1/chat/stream?token=$TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{"agentId":"1","message":"Hello","conversationId":"conv-abc123"}' +``` + +Related endpoints: `POST /api/v1/chat/{conversationId}/stop` (stop generation), `POST /api/v1/chat/{conversationId}/interrupt` (queue a follow-up without interrupting the current stream). + +### Agent management + +Mounted at `/api/v1/agents`, `@Tag("Agent管理")`. Every method requires `@RequireWorkspaceRole` (at least `viewer`; writes need `member`). + +**List** `GET /api/v1/agents?enabled=true` — request header `X-Workspace-Id`; returns `R>`. + +**Create** `POST /api/v1/agents` — request body is an `AgentEntity` (key fields below); the backend force-injects `workspaceId` and `creatorUserId`. Returns the full created entity. + +Key `AgentEntity` fields (`AgentEntity.java`): + +| Field | Type | Description | +|---|---|---| +| `id` | string | Agent ID (ignored on create, assigned by the backend) | +| `name` | string | Name | +| `description` | string | Description | +| `agentType` | string | `react` or `plan_execute` | +| `systemPrompt` | string | System prompt | +| `modelName` | string | Per-Agent model override (model name); empty = global default | +| `maxIterations` | int | Max iterations | +| `enabled` | boolean | Enabled flag | +| `icon` | string | Icon (emoji or URL) | +| `tags` | string | Tags (comma-separated) | +| `defaultThinkingLevel` | string | Default reasoning depth | +| `primaryKbId` | string | Primary knowledge base ID | +| `skillsDisabled` | boolean | Explicitly disable all skills | +| `toolsDisabled` | boolean | Explicitly disable all non-system tools | + +**Delete** `DELETE /api/v1/agents/{id}` — three-way auth: system admin / workspace admin+ / the creator. Otherwise 403. + +```bash +# List +curl http://localhost:18088/api/v1/agents?enabled=true \ + -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" + +# Create +curl -X POST http://localhost:18088/api/v1/agents \ + -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" \ + -H "Content-Type: application/json" \ + -d '{"name":"Support Agent","agentType":"react","systemPrompt":"You are a support agent","enabled":true}' +``` + +> The same tree also has `GET /api/v1/agents/{id}/chat/stream` (a GET form of SSE, coexisting with the POST `/chat/stream` above), `POST /api/v1/agents/{id}/chat` (synchronous chat), and `POST /api/v1/agents/{id}/execute` (Plan-Execute). + +### Conversation management + +Mounted at `/api/v1/conversations`, `@Tag("会话管理")`. Isolated per logged-in user (JWT principal). + +**List** `GET /api/v1/conversations` — returns `R>`. `ConversationVO` adds display fields on top of the conversation entity: + +| Field | Description | +|---|---| +| `conversationId` | Conversation ID (a string, not a Snowflake) | +| `title` | Title | +| `agentId` / `agentName` / `agentIcon` | Associated Agent | +| `username` | Owning user | +| `messageCount` | Message count | +| `lastMessage` / `lastActiveTime` | Last message and time | +| `pinned` / `archived` | Pinned / archived (0/1) | +| `modelProvider` / `modelName` | Per-conversation model override | +| `status` | `active` (active within 24h) / `closed` | +| `streamStatus` | `idle` / `running` | +| `source` | Source channel: `web` / `feishu` / `dingtalk` / `telegram` / `discord` / `wecom` / `qq` / `weixin` / `cron` | + +**Paginated** `GET /api/v1/conversations/page?page=1&size=20&keyword=xxx` — returns `R>` (pagination shape in "Conventions"). + +**Message history** `GET /api/v1/conversations/{conversationId}/messages` — supports three modes: +- No `limit`: returns all messages (`R>`, backward compatible). +- With `limit`: returns the latest `limit` messages + a `hasMore` flag: `R<{messages: MessageVO[], hasMore: boolean}>`. +- With `beforeId` + `limit`: pull up to load earlier messages. + +Key `MessageVO` fields: `id`, `role`, `content`, `toolName`, `status`, `metadata` (object, contains toolCalls etc.), `promptTokens` / `completionTokens`, `runtimeModel` / `runtimeProvider`, `contentParts`, `createTime`. + +**Per-conversation ops**: `PUT .../title` (rename), `PUT .../pin` (`{pinned:bool}`), `PUT .../model` (switch model `{modelProvider, modelName}`), `DELETE .../messages` (clear messages, keep the conversation), `DELETE .../{conversationId}` (delete conversation), `POST /batch-delete` (`{conversationIds: [...]}`), `GET .../status` (stream status `{streamStatus}`). + +> Every op first checks `isConversationOwner(conversationId, username)`; non-owners get 403. + +### Model configuration + +Mounted at `/api/v1/models`, `@Tag("模型配置管理")`. `GET /` and `GET /catalog` require `@RequireGlobalAdmin` (they include sensitive info like API keys); `/enabled`, `/default`, `/active` only need `viewer`. + +- `GET /api/v1/models` — enabled provider list (`R>`, includes keys, admin only). +- `GET /api/v1/models/enabled` — enabled model list (`R>`, no keys). +- `GET /api/v1/models/default` — global default model (`R`). +- `GET /api/v1/models/active` — current active model `{activeLlm: {provider, modelName}}`. +- `PUT /api/v1/models/active` — set the active model. + +### Audit events (pagination example) + +`GET /api/v1/audit/events` — `@RequireWorkspaceRole("admin")`, returns `R>`. The canonical example of "pagination + workspace header". + +| Query param | Default | Description | +|---|---|---| +| `action` | — | Action filter (e.g. `CREATE` / `UPDATE` / `DELETE`) | +| `resourceType` | — | Resource type filter (e.g. `AGENT`) | +| `startTime` | — | ISO 8601 start time | +| `endTime` | — | ISO 8601 end time | +| `page` | 1 | Page number | +| `size` | 20 | Page size | + +```bash +curl "http://localhost:18088/api/v1/audit/events?page=1&size=20&resourceType=AGENT" \ + -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" +``` + +### Change password: `PUT /api/v1/auth/users/{id}/password` + +Three things to note (they differ from intuition): + +1. Params go via **`@RequestParam`, not a request body**: both `oldPassword` and `newPassword` are query params. +2. The `{id}` in the path is **informational only**: the user actually operated on is resolved from the JWT principal (`auth.getName()`); a user can only change their own password. +3. Requires login (not `@RequireGlobalAdmin`). + +```bash +curl -X PUT "http://localhost:18088/api/v1/auth/users/1/password?oldPassword=admin123&newPassword=newPass456" \ + -H "Authorization: Bearer $TOKEN" +``` + +### Personal Access Token + +Mounted at `/api/v1/auth/tokens`, `@Tag("Personal Access Tokens")`. For headless / CI / SDK use. + +- `GET /api/v1/auth/tokens` — list my PATs (metadata only; **plaintext is never returned**). +- `POST /api/v1/auth/tokens` — create a PAT: **the plaintext appears only in this response**, afterwards only the SHA-256 hash is stored and cannot be recovered. Save it immediately. +- `DELETE /api/v1/auth/tokens/{id}` — soft-delete revoke; subsequent auth with this token fails. + +A created PAT (`mc_` prefix) goes straight into `Authorization: Bearer mc_...`; `JwtAuthFilter` dispatches by prefix to the PAT verification path, behaving identically to a JWT. + +### Tool approval (important clarification) + +There is **no** standalone approval REST endpoint like `POST /api/v1/approvals/{id}/resolve`. Web-side approve / deny happens by sending `/approve` or `/deny` in the waiting conversation, going through the chat-stream replay flow. The read-only "hydration" endpoint after a page refresh is `GET /api/v1/chat/{conversationId}/pending-approvals`. Auto-approval policies are managed under `/api/v1/approval/grants`. + +> Dangerous operations requiring a second confirmation raise `ConfirmRequiredException` — returning **HTTP 409** and **breaking the `R` envelope**: `{code, message, boundAgents}` (the field is `message`, not `msg`). Clients should branch on the 409 status and render a confirmation dialog. + ## Source-Aligned Route Inventory Total routes extracted: 406. @@ -442,6 +768,7 @@ Total routes extracted: 406. | Method | Path | Purpose / handler | |---|---|---| | `POST` | `/api/v1/wiki/admin/backfill-tokens` | `Force-run the token-count backfill batch now` | +| `GET` | `/api/v1/wiki/admin/failures` | `Cross-KB list of materials needing attention (failed/partial/degraded) — admin` | | `POST` | `/api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | `Ensure overview/log scaffold + rebuild overview stats now` | | `GET` | `/api/v1/wiki/chunks/{chunkId}/pages` | `Pages By Chunk Id` | | `DELETE` | `/api/v1/wiki/hot-cache/{kbId}` | `Soft-delete the hot cache row` | diff --git a/mateclaw-server/src/main/resources/docs/en/chat.md b/mateclaw-server/src/main/resources/docs/en/chat.md index b50203a0..601b0a2d 100644 --- a/mateclaw-server/src/main/resources/docs/en/chat.md +++ b/mateclaw-server/src/main/resources/docs/en/chat.md @@ -40,6 +40,17 @@ Use Plan-and-Execute when the task needs several ordered steps and you want to w --- +## Run Overview: see the whole long task at a glance + +Long tasks (multi-step plans, multi-agent collaboration) used to mean scrolling up and down the message stream to track progress. The chat view now has an always-on **Run Overview** rail on the right that assembles the data the backend already streams into one place — no scrolling back: + +- **Plan progress** — in Plan mode, live per-step status (pending / running / completed) and a progress count, with expandable step results. A "planning…" placeholder shows before the plan streams in, so the rail doesn't flicker. +- **Live sub-agent status** — delegated sub-agents render as a live **tree**: name, tools called, running / completed / error / stalled state; multi-level delegation expands layer by layer. + +The rail **collapses to a badged strip**; below 1280px it degrades to a **floating drawer** so it never squeezes the conversation column. It's pure frontend with zero new endpoints, reusing the existing SSE event stream — so the delegation tree still appears inline in the message too; the rail just lifts the "current / active" overview into a persistent place. + +--- + ## Thinking, tool calls, and what to trust One of the questions MateClaw tries to answer with its chat UI is: **should you trust what the AI just told you?** The default answer elsewhere is "look at the answer and guess". MateClaw tries to do better. @@ -97,7 +108,7 @@ Files a worker generates via tools (documents / images / audio…) are now **per ### Primary model can't see images? "Multimodal sidecar" routing ::: tip Added in 1.3.0 -When the agent's primary model is text-only (e.g. `deepseek-chat`, `kimi-k2`), uploading an image no longer breaks. The runtime auto-routes through a sidecar. See [issue #87](https://github.com/matevip/mateclaw/issues/87). +When the agent's primary model is text-only (e.g. `deepseek-chat`, `kimi-k2`), uploading an image no longer breaks. The runtime auto-routes through a sidecar. See [issue #87](https://github.com/mateaix/mateclaw/issues/87). ::: How it works: @@ -182,7 +193,7 @@ The segment representation is what powers the progressive display. It also makes ### Per-conversation model selection ::: tip Added in 1.4.0 -The model selector in the chat header now binds a model **to the conversation**, not as a global switch. See [issue #150](https://github.com/matevip/mateclaw/issues/150). +The model selector in the chat header now binds a model **to the conversation**, not as a global switch. See [issue #150](https://github.com/mateaix/mateclaw/issues/150). ::: Switching the model in the header affects **only this conversation**: the choice is stored on the conversation and takes effect starting with the **next message**. A conversation you never set explicitly falls back to the workspace default model. The runtime model indicator stays in sync with whatever is pinned on the conversation — what you see is what the next turn actually uses. @@ -192,7 +203,7 @@ This isolation also makes model config more robust: **a single bad model id no l ### Conversation list management ::: tip Added in 1.4.0 -The conversation sidebar grew from a plain history list into an actionable operations panel. See [issue #144](https://github.com/matevip/mateclaw/issues/144). +The conversation sidebar grew from a plain history list into an actionable operations panel. See [issue #144](https://github.com/mateaix/mateclaw/issues/144). ::: - **Pin / unpin** — from each row's `⋮` overflow menu. Important threads stay at the top in a "Pinned" group. diff --git a/mateclaw-server/src/main/resources/docs/en/desktop.md b/mateclaw-server/src/main/resources/docs/en/desktop.md index 0af5733f..300d4023 100644 --- a/mateclaw-server/src/main/resources/docs/en/desktop.md +++ b/mateclaw-server/src/main/resources/docs/en/desktop.md @@ -50,10 +50,32 @@ The backend picks a **free port dynamically** at startup so you don't collide wi - **Local-first data** — everything in a user directory - **Dynamic backend port** — no port collisions - **UI hot update** — frontend assets can be updated without repackaging the installer +- **Local / remote dual connection mode** — run the embedded JVM locally, or connect to a centrally deployed remote server - Cross-platform (macOS, Windows, Linux) --- +## Connection mode (local / remote) + +> For the "a team collaborating against one centrally deployed server" scenario — no need for everyone to run their own local backend. + +The desktop reaches its backend in one of two ways: + +- **Local (`local`)** — launches the embedded JRE 21 + server JAR and runs a full backend on this machine (default, works out of the box). +- **Remote (`remote`)** — skips the local backend and connects directly to your centrally deployed remote server; all API / SSE point at it. + +**First-run connection chooser.** The first launch (no mode chosen yet) shows a connection chooser; picking "remote" lets you enter the server URL, which is normalized (auto-prefixes `https://`, strips a trailing slash, validates http(s)). The choice is remembered for next time. + +**Multi-server & switching.** A successfully connected remote server is recorded in a "recently used" list (de-duped by URL, up to 8). The **"Switch Server"** menu re-opens the chooser anytime to move to another server. + +**Self-signed intranet certs.** Self-signed certificates are accepted only for hosts the user **explicitly trusts** — scoped to the active remote address (`trustedCertHosts`), not a blanket bypass; unknown hosts are rejected. This suits the self-signed certs common on enterprise intranets. + +**Health check.** Remote mode probes with a short timeout (~15s) since the server should already be up, and reports failures clearly; local mode waits for the embedded backend to come up. + +The connection choice is persisted in `connection.json` under the user data directory (see "Data storage" below). + +--- + ## Supported platforms | Platform | Architecture | Status | diff --git a/mateclaw-server/src/main/resources/docs/en/docker-deploy.md b/mateclaw-server/src/main/resources/docs/en/docker-deploy.md index 051ceae3..d70703a2 100644 --- a/mateclaw-server/src/main/resources/docs/en/docker-deploy.md +++ b/mateclaw-server/src/main/resources/docs/en/docker-deploy.md @@ -166,7 +166,7 @@ curl -s http://localhost:18080/api/v1/system/browser-health | jq . ## First deployment ```sh -git clone https://github.com/matevip/mateclaw.git +git clone https://github.com/mateaix/mateclaw.git cd mateclaw # 1. Fill in required values diff --git a/mateclaw-server/src/main/resources/docs/en/faq.md b/mateclaw-server/src/main/resources/docs/en/faq.md index 72e19322..929d3c0d 100644 --- a/mateclaw-server/src/main/resources/docs/en/faq.md +++ b/mateclaw-server/src/main/resources/docs/en/faq.md @@ -1,6 +1,6 @@ # FAQ -Common questions and real answers. If your question isn't here, check the relevant feature page or open a [GitHub issue](https://github.com/matevip/mateclaw/issues). +Common questions and real answers. If your question isn't here, check the relevant feature page or open a [GitHub issue](https://github.com/mateaix/mateclaw/issues). --- @@ -322,7 +322,7 @@ Try launching from a terminal. On Windows, right-click → Unblock. On macOS, al ### How do I update the desktop app? -**Auto-updates** via electron-updater. On startup, checks GitHub Releases and prompts you when a new version is available. Manual download also available from [Releases](https://github.com/matevip/mateclaw/releases). +**Auto-updates** via electron-updater. On startup, checks GitHub Releases and prompts you when a new version is available. Manual download also available from [Releases](https://github.com/mateaix/mateclaw/releases). --- @@ -419,4 +419,4 @@ Stored in `localStorage`. Clearing browser data wipes it. - [Quick Start](./quickstart) — setup walkthrough - [Configuration](./config) — full configuration reference - [Contributing](./contributing) — how to report bugs and request features -- [GitHub Issues](https://github.com/matevip/mateclaw/issues) — when the docs don't answer your question +- [GitHub Issues](https://github.com/mateaix/mateclaw/issues) — when the docs don't answer your question diff --git a/mateclaw-server/src/main/resources/docs/en/index.md b/mateclaw-server/src/main/resources/docs/en/index.md index cbd92c6d..37ea8581 100644 --- a/mateclaw-server/src/main/resources/docs/en/index.md +++ b/mateclaw-server/src/main/resources/docs/en/index.md @@ -17,7 +17,7 @@ hero: link: /en/intro - theme: alt text: GitHub - link: https://github.com/matevip/mateclaw + link: https://github.com/mateaix/mateclaw features: - icon: 🧑‍💼 diff --git a/mateclaw-server/src/main/resources/docs/en/mcp.md b/mateclaw-server/src/main/resources/docs/en/mcp.md index b78a1713..f4c41288 100644 --- a/mateclaw-server/src/main/resources/docs/en/mcp.md +++ b/mateclaw-server/src/main/resources/docs/en/mcp.md @@ -383,6 +383,148 @@ Keeps secrets out of the database. --- +## Forwarding the user's identity to an MCP server (on-behalf-of) + +A STDIO MCP server is **one shared subprocess per configuration**, used by every +user; its environment is fixed at spawn and STDIO has no per-request header +channel like HTTP. So per-user identity **cannot travel via env** — it must ride +in-band with each tool call. + +MateClaw can inject the **authenticated username** into every tool call for a +chosen server, so the server can call its downstream REST backend on behalf of +that user. + +### Enable (opt-in, per server) + +Off by default — injecting into every server would leak the username to any +third-party MCP server. Enable per server by **name or id**: + +```yaml +mateclaw: + mcp: + identity-forward: + servers: + - my-internal-api # server name in mate_mcp_server + - 1000000042 # or the numeric server id +``` + +### Data contract + +When enabled, MateClaw injects the reserved argument **`__mateclaw_user__`** +(value = authenticated username) into each tool call's JSON arguments. It is +injected by trusted server code, **never by the LLM** — any model-supplied value +of the same key is overwritten, so the model cannot spoof identity. When there is +no authenticated user, nothing is injected (identity is never fabricated). + +The MCP server reads and strips the key, then calls REST with it plus its own +backend API key (e.g. an `X-On-Behalf-Of` header): + +```python +# FastMCP example: MCP server as a Python CLI script (STDIO) +import os, httpx +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("my-internal-api") +REST_BASE = os.environ["REST_BASE"] +API_KEY = os.environ["BACKEND_API_KEY"] # service-level key (authenticates the MCP service) + +@mcp.tool() +def query_orders(keyword: str, __mateclaw_user__: str | None = None) -> str: + if not __mateclaw_user__: + raise ValueError("missing injected identity") # reject identity-less calls + headers = { + "Authorization": f"ApiKey {API_KEY}", # service identity + "X-On-Behalf-Of": __mateclaw_user__, # the acting user + } + r = httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30) + r.raise_for_status() + return r.text + +if __name__ == "__main__": + mcp.run() # STDIO +``` + +> If a tool's input schema is `additionalProperties: false`, declare +> `__mateclaw_user__` as an optional parameter (as above) or strict validation +> will reject it. + +### Two trust models + +**① Plaintext (default)**: injects the plaintext username. Fits a trusted +network where the backend authenticates the MCP service by API key and treats +the forwarded user as on-behalf-of. The backend trusts the raw string. + +**② Signed token (recommended across a trust boundary)**: injects a short-lived +**RS256 JWT** that MateClaw signs with a private key (reserved key becomes +**`__mateclaw_token__`**); the REST backend **verifies it with the public key**, +so it trusts the signature — not the MCP service, the Python script, or the +transport. + +```yaml +mateclaw: + mcp: + identity-forward: + servers: + - my-internal-api + token: + enabled: true + issuer: mateclaw + ttl-seconds: 60 # short, tens of seconds + key-id: mateclaw-mcp-1 + private-key-pem: ${MCP_IDFWD_PRIVATE_KEY_PEM:} # PKCS#8 PEM (RS256 private key) + audiences: # optional; default aud = server name + my-internal-api: https://api.internal +``` + +Generate the key pair (private → MateClaw, public → REST backend): + +```bash +openssl genpkey -algorithm RSA -pkcs8 -out mcp-idfwd-private.pem +openssl pkey -in mcp-idfwd-private.pem -pubout -out mcp-idfwd-public.pem +# private-key-pem takes the private key body (PEM headers optional; stripped on parse) +``` + +Token claims: `iss`, `sub`=user, `aud`=this server, `iat`, `exp` (short), `jti`. +`aud` + short `exp` bound replay to tens of seconds and to one backend. **When +token mode is on but no key is configured, it fails closed** (no token minted, +nothing injected — the backend rejects) rather than silently downgrading to +plaintext. + +> `sub` carries the MateClaw user identifier (`ChatOrigin.requesterId`). If your +> backend authorizes on an immutable numeric id, resolve username→id before +> minting (kept decoupled from the user store here). + +The MCP server (Python) only forwards — it does not verify: + +```python +@mcp.tool() +def query_orders(keyword: str, __mateclaw_token__: str | None = None) -> str: + if not __mateclaw_token__: + raise ValueError("missing identity token") + headers = {"Authorization": f"Bearer {__mateclaw_token__}"} # forward to REST + return httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30).text +``` + +REST backend verifies (pseudocode): + +```python +import jwt # PyJWT +claims = jwt.decode(token, public_key_pem, algorithms=["RS256"], + issuer="mateclaw", audience="https://api.internal") +user = claims["sub"] # trusted only after signature verification +# → per-user authorization; invalid/expired → 401 +``` + +> Public-key distribution: for now an operator configures the public key on the +> REST side out-of-band. A JWKS endpoint for auto-distribution + rotation is a +> natural follow-up. +> +> Relationship to the API key: you can keep the API key as service/channel auth +> ("this MCP service may talk to the backend") plus the JWT as the user +> assertion — two clean layers — or let the JWT carry both. + +--- + ## Troubleshooting ### "Command not found" (stdio) diff --git a/mateclaw-server/src/main/resources/docs/en/models.md b/mateclaw-server/src/main/resources/docs/en/models.md index 3fcab40d..6926033d 100644 --- a/mateclaw-server/src/main/resources/docs/en/models.md +++ b/mateclaw-server/src/main/resources/docs/en/models.md @@ -272,7 +272,7 @@ Restart MateClaw. Auto-discovered, added, enabled. No `EMBEDDING_API_KEY` env vars. Embedding models are regular rows in `mate_model_config` with `model_type='embedding'`. They show up alongside chat models in `Settings → Models`. Knowledge bases pick their embedding model from a dropdown. -::: tip New in 1.4.0 ([issue #79](https://github.com/matevip/mateclaw/issues/79)) +::: tip New in 1.4.0 ([issue #79](https://github.com/mateaix/mateclaw/issues/79)) **Embedding models from any provider.** In the embedding section of `Settings → Models`, configure an embedding model from any provider — it **reuses that provider's API key**, so there's no separate `EMBEDDING_API_KEY`. Each knowledge base picks its embedding model from a dropdown. Keyless local proxies use a no-op placeholder key; the protocol is resolved from the provider's chat-model / protocol setting, so you never hand-enter it. ::: @@ -299,7 +299,7 @@ System prompts, agent personas, tool definitions — automatically marked with ` **Multi-round tool calls + thinking**: thinking-capable models (DeepSeek-Reasoner / GPT-5 / Kimi K2.5 / Xiaomi MiMo) correctly round-trip historical `reasoning_content` during ReAct multi-round tool calls. Cross-user-turn history is cleared at the boundary, in-turn history is preserved — matching DeepSeek's "pass back within a turn, reset across turns" contract. -**Xiaomi MiMo thinking-mode multi-turn fix** ([issue #189](https://github.com/matevip/mateclaw/issues/189)): MiMo's `reasoning_content` is now kept correctly across turns in thinking mode, instead of being lost on subsequent turns. +**Xiaomi MiMo thinking-mode multi-turn fix** ([issue #189](https://github.com/mateaix/mateclaw/issues/189)): MiMo's `reasoning_content` is now kept correctly across turns in thinking mode, instead of being lost on subsequent turns. --- @@ -323,7 +323,7 @@ Takes effect **immediately** — no restart. Next message uses the new model. In Per-agent override supported: bind a specific agent to a specific model config. ::: tip New in 1.4.0 -- **Per-conversation model selection** ([issue #150](https://github.com/matevip/mateclaw/issues/150)): in the chat UI you can switch the model for **just the current conversation**, without touching the global active model or any other conversation. See [Chat & Messaging](./chat). +- **Per-conversation model selection** ([issue #150](https://github.com/mateaix/mateclaw/issues/150)): in the chat UI you can switch the model for **just the current conversation**, without touching the global active model or any other conversation. See [Chat & Messaging](./chat). - **A single bad model id no longer evicts the whole provider**: when discovery / probing hits one invalid model identifier, only that model is skipped — the rest of the provider's models stay available. ::: @@ -345,7 +345,7 @@ Use it whenever you add a new provider or suspect a stale key. ## Multimodal sidecar (system-wide) ::: tip Added in 1.3.0 -Lets a text-only primary model still answer questions about uploaded images. See [issue #87](https://github.com/matevip/mateclaw/issues/87). +Lets a text-only primary model still answer questions about uploaded images. See [issue #87](https://github.com/mateaix/mateclaw/issues/87). ::: Entry point: **Settings → Models → Multimodal sidecar**. Two independent cards: @@ -362,7 +362,7 @@ The setting stores `mate_model_config.id` rather than `model_name` — the same The dropdown only lists models that **actually support the relevant modality** — filtered by `ModelCapabilityService.supports(...)` on the backend; disabled providers or models without a declared vision capability never appear. Each card has its own Save button, independent of the other. -When does it fire? `MultimodalRouter` ([source](https://github.com/matevip/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java)) decides per turn: +When does it fire? `MultimodalRouter` ([source](https://github.com/mateaix/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java)) decides per turn: - Primary already supports vision → no routing (native multimodal path) - Primary lacks vision + vision sidecar configured → SIDECAR strategy, captions to text diff --git a/mateclaw-server/src/main/resources/docs/en/openapi.md b/mateclaw-server/src/main/resources/docs/en/openapi.md new file mode 100644 index 00000000..6fdcc5bf --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/openapi.md @@ -0,0 +1,80 @@ +# OpenAPI / Swagger Guide + +The MateClaw backend integrates [SpringDoc OpenAPI](https://springdoc.org/) (`springdoc-openapi-starter-webmvc-ui`), which auto-generates an OpenAPI 3 document from every `@RestController` and serves an interactive debugging UI. This page covers how to access and use it. + +> This is the **machine-readable** doc entry point. For the human-readable endpoint details, conventions, and the full route inventory, see the [API Reference](./api.md). Relationship: Swagger = the auto-generated, machine-readable contract from source annotations; `api.md` = flagship endpoint walkthroughs + shared conventions. + +## URLs + +After deploying the backend, relative to the server address (default local port `18088`): + +| URL | Purpose | +|---|---| +| `/swagger-ui.html` | Swagger UI — browse + debug (Authorize, Try it out) | +| `/v3/api-docs` | OpenAPI 3 JSON (import into Postman / Apifox / Insomnia) | +| `/v3/api-docs.yaml` | OpenAPI 3 YAML (download, commit, or import) | + +Local examples: + +```bash +# Open in a browser +open http://localhost:18088/swagger-ui.html + +# Download the YAML +curl http://localhost:18088/v3/api-docs.yaml -o mateclaw-openapi.yaml +``` + +## Authentication (Authorize) + +Use the **Authorize** button at the top right. In the `bearerAuth` field, paste a token **without** the `Bearer ` prefix (the UI adds it automatically): + +- **JWT**: the `token` field returned by `POST /api/v1/auth/login` (starts with `eyJ...`). +- **Personal Access Token**: a `mc_...` token created via `POST /api/v1/auth/tokens`. + +Both go through the standard `Authorization: Bearer ` header; the backend `JwtAuthFilter` dispatches by prefix (JWT → JWT verification, `mc_` → PAT verification). Once authorized, protected `@RequireWorkspaceRole` / `@RequireGlobalAdmin` endpoints can be called directly via Try it out. + +> Debugging SSE streaming endpoints (`/chat/stream`, etc.) in Swagger UI is limited — the UI buffers `text/event-stream` responses. For real SSE integration, follow the [API Reference](./api.md) and use `curl -N` or a `fetch()` streaming reader. + +## Endpoint coverage + +SpringDoc auto-scans every `@RestController`. About 85% of controllers already carry `@Tag` (grouping) and `@Operation(summary)` (method summary), so the UI's grouping and endpoint descriptions are largely complete. + +**Annotation enhancements not yet done** (out of scope for this pass, left for later): + +- No `@Parameter` descriptions, `@ApiResponse` error codes, or request-body `@Schema` — for field-level docs, defer to the human-readable `api.md` walkthroughs. +- Public endpoints (login, SSE, etc.) are not individually opted out with `@SecurityRequirements({})`, so they show a lock icon in Swagger even though `SecurityConfig` already permits them — actual calls are unaffected. + +## Configuration + +Global OpenAPI metadata (title, description, version, server URL) is driven by the `OpenApiConfig` bean and overridable via `mateclaw.openapi.*` in `application.yml`: + +```yaml +mateclaw: + openapi: + title: ${MATECLAW_OPENAPI_TITLE:MateClaw REST API} + version: ${MATECLAW_OPENAPI_VERSION:1.0} + server-url: ${MATECLAW_OPENAPI_SERVER_URL:} # empty → derived from request host + description: ${MATECLAW_OPENAPI_DESCRIPTION:} # empty → built-in default + expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true} # whether the Swagger/OpenAPI paths are public, see the security section below +``` + +When `server-url` is empty, SpringDoc derives it from the request host so "Try it out" hits the right address; for fixed production URLs (e.g. behind a reverse proxy), set `MATECLAW_OPENAPI_SERVER_URL=https://mate.example.com`. + +## 🔒 Access control: Swagger is locked down by default in production + +Access to the Swagger UI / OpenAPI document paths (`/swagger-ui*`, `/v3/api-docs*`, `/webjars/**`) is controlled by the `mateclaw.openapi.expose-ui` flag and enforced explicitly in `SecurityConfig.filterChain` (no longer relying on the `.anyRequest().permitAll()` fallthrough): + +| `expose-ui` | Behavior | Default profile | +|---|---|---| +| `true` | Publicly accessible — anyone can browse the full endpoint surface (incl. request/response schemas) without login | Local / default profile (H2, desktop) | +| `false` | Requires a global admin (`ROLE_ADMIN`); anonymous → 401, non-admin → 403 | Production database profiles (`mysql` / `kingbase` / `postgres`) | + +- Local dev: defaults to `true`, so `http://localhost:18088/swagger-ui.html` is reachable directly. +- Public production: defaults to `false` (locked down). To temporarily open it on an internal/staging host, set `MATECLAW_OPENAPI_EXPOSE_UI=true`. +- Note: once locked, opening `/swagger-ui.html` in a browser won't automatically carry the SPA's JWT (the token lives in localStorage, not a cookie), so even an admin cannot open it from the browser directly. To debug, either set `expose-ui=true` temporarily, or fetch `/v3/api-docs` with a client that sends the `Authorization` header. +- The access rule lives only in `SecurityConfig`, not `OpenApiConfig`. + +## See also + +- [API Reference (human-readable)](./api.md) — flagship endpoint walkthroughs + conventions + full route inventory +- [WebChat integration guide](./webchat.md) — external-site HTTP / SSE integration (incl. the SSE event protocol) diff --git a/mateclaw-server/src/main/resources/docs/en/operational-export.md b/mateclaw-server/src/main/resources/docs/en/operational-export.md new file mode 100644 index 00000000..78f4b7a4 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/operational-export.md @@ -0,0 +1,77 @@ +# Operational Data Export + +Export a cross-cutting operational report as Excel (`.xlsx` packaged as `.zip`) in one shot, for ops, audit, and reconciliation offline. Two entry points: a **Dashboard one-click** export (GUI) and a **command-line** export (no UI, works via `docker exec`). + +> **Global admin only.** The report contains sensitive cross-workspace data — conversation contents, token usage, audit records, etc. + +## The 9 sheets + +| # | Sheet | Contents | +|---|---|---| +| 1 | Overview | Interval KPIs, system snapshot, 7-day trend, period comparison, model details, top-10 agent activity | +| 2 | Token Usage | Daily × `runtime_provider` breakdown with avg tokens/conversation | +| 3 | Skill Stats | Skill list + usage count, last-call time, bound agents | +| 4 | User Stats | Per-(workspace, user) aggregated tokens, duration, last active | +| 5 | User Conversations | Detail rows pairing user–assistant messages | +| 6 | Security & Audit | Unified view across 6 sources (guard rules, audit logs, approvals, grants, config, business audit events) | +| 7 | Channel Stats | Per-channel conversation count, tokens, unique users | +| 8 | Model Config | Enabled + API-key-configured models with parameters | +| 9 | Cron Jobs | Execution records with duration and token usage | + +## Entry 1: Dashboard one-click + +1. Open the **Dashboard** and click **"Export operational data"** in the top-right (next to the database chip) — visible to global admins only. +2. In the dialog pick a **date range** — use the quick "Last 7 / 30 / 90 days" presets or a custom range; **90-day max**, no future dates. +3. Click **"Generate report"** — a circular progress ring shows the 9 steps (Overview → … → Cron Jobs). +4. When done, "Report is ready" appears; click **"Download"** to get `ops_data__.zip`. + +**Security & lifecycle:** + +- The generate / progress / download endpoints are all `@RequireGlobalAdmin` gated — a non-admin call returns 403. +- Only one generation runs at a time (concurrent calls get 409 busy), with a 5-minute frontend deadline. +- The download token is **atomically single-use** — a second download with the same token returns 410. +- The generated file auto-cleans **after 24h or on download**. + +## Entry 2: Command line + +For large, no-timeout, scripted, `docker exec` scenarios. A project-level CLI framework was added; the export command is `--cli.command=export`: + +```bash +# local jar +java -jar app.jar --cli.command=export \ + --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip + +# inside a container +docker exec java -jar /app/app.jar --cli.command=export \ + --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip + +# dry run (no actual generation) +java -jar app.jar --cli.command=export --cli.start=... --cli.end=... --cli.dry-run + +# list all commands +java -jar app.jar --cli.command=help +``` + +| Option | Required | Meaning | +|---|---|---| +| `--cli.command=export` | yes | run the export command | +| `--cli.start=YYYY-MM-DD` | yes | start date (inclusive) | +| `--cli.end=YYYY-MM-DD` | yes | end date (inclusive) | +| `--cli.dry-run` | no | dry-run, no actual generation | + +Key points: + +- The ZIP bytes go straight to **stdout** (redirect with `> report.zip`); diagnostics go to **stderr**, so the redirect captures a clean binary. +- The backend entry point has **no 90-day cap and no timeout** — suitable for large offline ranges. +- The CLI is operator-only (local / `docker exec`), **never over HTTP**, and does not bypass the admin gate. +- On a normal web start (no `--cli.command`) the CLI stays inert and does not affect startup. + +## Notes + +- 19-digit Snowflake IDs are written to Excel as **text** to avoid the spreadsheet's numeric precision (2^53) truncating them or showing scientific notation. +- The report grows with your data; when exporting a large range from the CLI, redirect straight to a file rather than piping to another program. + +## See also + +- [Backstage Runtime Console](./backstage) — see live agents / sub-agents +- [Security & Approval](./security) — Tool Guard and audit logs (one source of Sheet 6) diff --git a/mateclaw-server/src/main/resources/docs/en/quickstart.md b/mateclaw-server/src/main/resources/docs/en/quickstart.md index 0807a3ad..d5924f09 100644 --- a/mateclaw-server/src/main/resources/docs/en/quickstart.md +++ b/mateclaw-server/src/main/resources/docs/en/quickstart.md @@ -8,7 +8,7 @@ If you want Docker or local development instead, those live in [Configuration](. ## 1. Download -Grab the latest installer from [GitHub Releases](https://github.com/matevip/mateclaw/releases). +Grab the latest installer from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). - **Windows** — `MateClaw-Setup-x.y.z.exe` - **macOS** — `MateClaw-x.y.z.dmg` @@ -71,7 +71,7 @@ First run should Just Work. If it didn't: - **Backend never boots** — Check the log file (macOS: `~/Library/Application Support/MateClaw/logs/mateclaw.log`; Windows: `%APPDATA%\MateClaw\logs\mateclaw.log`). The desktop app picks a dynamic port — any port conflict is reported clearly in the log. - **Model call fails** — Wrong API key or network can't reach the provider. Go back to Settings, re-verify the key, or try a different provider. - **UI is blank** — Hard-refresh with Ctrl/Cmd+Shift+R. Electron caches aggressively. -- **Still broken** — Open an issue on [GitHub](https://github.com/matevip/mateclaw/issues) with the tail of `app.log`. We read them. +- **Still broken** — Open an issue on [GitHub](https://github.com/mateaix/mateclaw/issues) with the tail of `app.log`. We read them. --- diff --git a/mateclaw-server/src/main/resources/docs/en/releases.md b/mateclaw-server/src/main/resources/docs/en/releases.md index 3adf538b..3d1dcd55 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 | |---------|------|------------| +| [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 | | [v1.5.0](./releases/1.5.0) | 2026-06-04 | Goals grew a checklist — from "a score" to "ticked boxes" (checklist + Evaluator SPI + deterministic completion) · The Wiki learned to maintain itself (`[[wikilinks]]` + cascade rename/delete link-fix + broken-link lint · fact/experience layers + staleness propagation · pageType profiles & per-agent permissions · processing pipelines · local-directory knowledge source with scheduled incremental sync) · Per-owner memory isolation (owner_key + personal/team/global scopes + third-party endUserId passthrough) · Each employee binds a primary KB · Preferred provider drives the primary model + Claude Opus 4.8 | | [v1.4.0](./releases/1.4.0) | 2026-05-23 | Persistent Goals — an employee locks a goal and follows it to done on its own · Subagent delegation became a tree (recursive 3 levels + async + digital-employee builder) · Progressive tool/skill disclosure (`enable_tool` + `load_skill`) · Workspace RBAC (4 roles + capability gating) · Feishu as a first-class citizen (interactive / approval / streaming cards + voice / file / audio / video + channel-native tools) | diff --git a/mateclaw-server/src/main/resources/docs/en/roadmap.md b/mateclaw-server/src/main/resources/docs/en/roadmap.md index 2a1b8c55..b4cb0817 100644 --- a/mateclaw-server/src/main/resources/docs/en/roadmap.md +++ b/mateclaw-server/src/main/resources/docs/en/roadmap.md @@ -35,7 +35,6 @@ Make an AI assistant a coworker who uses tools, not a chat box. Move AI out of the chat box on a webpage and into every IM your team actually uses. - **8 channels**: Web / DingTalk / Feishu / WeCom / Telegram / Discord / QQ / WeChat Personal / Slack -- Session source tracking: every message knows which channel it came from - 4-layer memory: session context + workspace memory + post-chat extraction + 2 AM consolidation - DREAMS.md consolidation diary: human-readable audit of memory changes - Workspace isolation: every agent / skill / wiki / conversation / memory belongs to a workspace @@ -47,111 +46,136 @@ Move AI out of the chat box on a webpage and into every IM your team actually us Renamed "agents" to **digital employees** — not vocabulary purism, a worldview shift. - **Digital employees** with Role / Goal / Backstory — not a cold system prompt -- **5 career templates**: product researcher / customer support / knowledge curator / data analyst / executive assistant — open one, it works -- **Skills are no longer aliases for tools** — each skill is a backbone with its own SKILL.md + LESSONS.md + workspace filesystem +- **5 career templates**: product researcher / customer support / knowledge curator / data analyst / executive assistant +- **Skills are backbones**: each skill has its own SKILL.md + LESSONS.md + workspace filesystem - **ACP bridge**: Claude Code, Codex, Gemini CLI plug in as employees -- **Backstage runtime console**: for the first time you can **see what each employee is doing right now** — who's running, on which step, how many tokens, kill them in one click -- **Onboarding wizard**: first-login four-step flow from zero to first message -- **Dashboard**: daily usage trend + top agents/tools -- **Doctor**: system health checks + one-click fix +- **Backstage runtime console**: for the first time you can **see what each employee is doing right now** +- Onboarding wizard + Dashboard + Doctor Full story: [v1.2.0 release notes](./releases/1.2.0.md). ---- +### v1.3 — It orchestrates business flows ✅ Released (2026-05-13) -## v1.3 — The workflow year ✅ Shipped (2026-05-13) +Graduating from a chatbot framework to a business-process OS — a flow is no longer several employees chatting separately, but a publishable, triggerable, replayable **linear-step DSL**. -> "Focus is about saying no to the hundred other good ideas that there are." - -Each digital employee being able to do work is just the beginning. **Real collaboration needs orchestration.** - -The v1.3 line is **graduating MateClaw from a chatbot framework to a business-process OS** — a flow is no longer the sum of several employees chatting separately, but a publishable, triggerable, replayable **linear-step DSL**. +- **Workflow**: 7 step modes (sequential / fan_out / collect / conditional / await_approval / dispatch_channel / write_memory) + Pebble expressions + JSON-first authoring + integer revisions + run history +- **Natural language → workflow draft**: describe the flow, an agent emits graph_json, a human reviews before publish +- **Triggers**: 6 pattern types (cron / webhook / channel_message / agent_lifecycle / content_match / workflow_completion), event governance on by default (dedup / rate limit / recursion guard) +- **Persistent `await_approval` pause**: survives service restarts +- Image editing, 4 document-generation tools (Docx/Xlsx/Pptx/Pdf), MCP per-agent tool binding, multimodal sidecar routing Full story: [v1.3.0 release notes](./releases/1.3.0.md). -### Workflow +### v1.4 — It's more autonomous and leads teams ✅ Released (2026-05-23) -- [x] **7 step modes**: sequential / fan_out / collect / conditional / await_approval / dispatch_channel / write_memory -- [x] **Pebble expression subset** for conditionals + variable references (no side effects, no code execution) -- [x] **JSON-first authoring**: Monaco + JSON-schema validation + static Pebble checking + template dropdown -- [x] **Natural language → workflow draft** (`POST /workflows/draft/generate`): a user describes the flow, an agent emits `graph_json` + compile diagnostics; never publishes directly — a human still reviews -- [x] **Integer revisions**: publish writes a new immutable row; draft is split from published version -- [x] **Run history**: every step's input / output / duration / token / failure chain is recorded -- [x] **Internal payload storage**: large I/O goes through `payload://` URIs — doesn't blow out the DB -- [x] **Cross-workspace ACL**: publish-time validation rejects agent / channel / employeeId references outside the workspace -- [x] **Persistent `await_approval` pause**: survives service restarts +Flows were scripted by you, but the employee itself still "answered one round and stopped." This release puts the focus back on the employee. -### Triggers +- **Persistent goals**: say it once — the employee locks the goal, self-checks every round, and keeps itself going until done or out of budget +- **Sub-employee delegation tree**: recursive delegation up to 3 levels deep, with sync / parallel fan-out / async delegation tools; the Employee Builder spins up a whole team from one sentence +- **Progressive tool/skill disclosure**: core tier always visible, extension tier activated on demand via `enable_tool` / `load_skill` — pile on tools without blowing the context +- **Workspace RBAC**: Owner / Admin / Member / Viewer roles + capability gates — MateClaw is usable by a team for the first time +- **Feishu as a first-class citizen**: interactive cards, approval cards, streaming cards, voice transcription, file/audio/video I/O, channel-native tools +- Native Gemini, xAI / Grok, per-conversation model pinning, structured context compaction, rate-limit failover -- [x] **6 pattern types**: cron / webhook / channel_message / agent_lifecycle / content_match / workflow_completion -- [x] **Event governance on by default**: dedup (60s window), per-trigger rate limit, bot-self-msg filter, A→B→A recursion guard -- [x] **CronDelegationPort**: shares ShedLock + Spring TaskScheduler with the legacy cron module without writing into mate_cron_job -- [x] **Cross-instance consistency**: `pattern_version` self-cancellation + periodic syncFromDatabase -- [x] **Structured forms**: each of the 6 pattern types has its own field UI — no need to hand-write patternJson +Full story: [v1.4.0 release notes](./releases/1.4.0.md). -### Existing experience upgrades +### v1.5 — It's verifiable, knowledge self-maintains, memory knows its owner ✅ Released (2026-06-04) -- [x] **Image editing** (issue #75): `image_generate` gains `image` / `images` parameters with 5 reference forms (including `msg::` for in-conversation attachments) -- [x] **DashScope OpenAI-compatible variant**: same sk- key, reaches the dot-versioned families (qwen3.5-plus / qwen3.6-plus / qwen3-vl-plus etc.) -- [x] **New Wanxiang / Qwen-Image families**: 14 new image models, 3 new video models (including happyhorse-1.0-t2v) -- [x] **4 document-generation tools**: DocxRenderTool / XlsxRenderTool / PptxRenderTool / PdfRenderTool — Markdown rendered directly into Office files, no subprocess fork, no npm dependency -- [x] **MCP per-agent tool binding**: every employee binds MCP tools individually + status badges (connected / stale / unavailable / orphan) + namespace collisions auto-prefixed + server renames auto-followed -- [x] **Xiaomi MiMo provider**: MiMo V2.5 Pro / V2.5 / V2 Pro / V2 Omni / V2 Flash -- [x] **Multimodal sidecar routing** (issue #87): when a text-only primary model meets an image attachment, the configured vision model captions it first so the primary chat stays cheap; the old "do not call any tools" hard ban is gone, so user-built tools are no longer suppressed; routing badge on the bubble and a hint above the input box make every decision visible +Make autonomy **verifiable**, knowledge **self-maintaining**, and memory **owner-aware**. -### Still to do in v1.3 +- **Goal checklists**: goals decompose into independently verifiable criteria; the evaluator checks them off one by one — **all checked or it's not done**. No "95% is close enough" +- **Self-maintaining Wiki**: `[[wikilink]]` page interlinking + rename/delete cascade rewrites + broken-link lint; fact vs. experience knowledge layers with staleness propagation; pageType profiles + per-agent permissions; event-triggered processing pipelines; local directories mounted as knowledge sources with incremental sync +- **Per-owner memory isolation**: every memory carries an owner_key and visibility scope (personal / team / global) — one employee serves a whole group without cross-talk; APIs pass through `endUserId` +- Primary KB per employee, preferred-provider routing that actually applies, generated files persisted to disk -- [ ] **Canvas editor (v1)**: today's canvas is read-only chain rendering; the goal is `@vue-flow/core` drag-to-edit -- [ ] **Run replay view**: trace timeline + hover any node to diff input/output -- [ ] **`loop` mode**: iterate N times or per-item over an array -- [ ] **`invoke_skill` mode**: call a skill directly without going through an employee -- [ ] **Inter-trigger priority / dependency**: serial / parallel control when an event hits multiple triggers -- [ ] **Event replay**: a "redispatch" button on `mate_trigger_event` rows +Full story: [v1.5.0 release notes](./releases/1.5.0.md). + +### v1.6 — It meets you where you are ✅ Released (2026-06-22) + +Where it can run, what it can do with hands and eyes, and how directly you shape who it is. + +- **KingbaseES + PostgreSQL as first-class citizens**: the PostgreSQL family shares one migration tree; regulated / domestic-procurement environments covered; MySQL and desktop H2 untouched +- **Images persist across turns**: the screenshot you sent three messages ago is still visible on follow-up; `image_analyze` re-reads on demand +- **`execute_code`**: the employee writes code and runs it — arithmetic, file conversion, verification become real actions instead of guesses +- **Shape the employee's identity**: a real editor for AGENTS.md and other context files (modal + section reorder); an About You identity block; the employee knows which model it runs on +- **Scoped KB access** + Wiki Sources tab (multi-path + glob + per-KB auto-sync) +- Global outbound proxy, deterministic Markdown normalization of final answers + +Full story: [v1.6.0 release notes](./releases/1.6.0.md). + +### v1.7 — It's ready for production ✅ Released (2026-07-04) + +A **productionization pass**: once you put it into real collaboration, the places that go invisible, un-closable, out of reach, oversized for the window, and walled off — all fixed. + +- **All three approval paths closed end-to-end**: workflow `await_approval` actually pushes to channels and resolves → resumes execution; the WebChat (API-key) channel can approve/deny and replay; Feishu/WeCom card buttons directly resolve workflow approvals +- **Long tasks are visible**: an always-on Run Overview rail (step progress + live delegated sub-agent tree) + a per-turn token breakdown (cache hit/miss/write + reasoning split) + sub-agent cost rolled up + one-click generated-file download +- **Fits the real model window**: local-model context-window probing, a unified token budget for prefix injection, small-context degradation, and tool-schema budget gating — no more "guess 32K" pre-flight rejections or silent truncation +- **Opens up**: a knowledge-base + Deep Research open API (API-key + rate limit + SSE), a pluggable search Provider SPI, and MCP identity forwarding (carry the authenticated user's identity into a STDIO MCP) +- **Reaches further**: desktop local-embedded / remote-centralized dual mode + multi-server switching + the `mateclaw-desktop` source opened; a LAN deployment mode opens controlled intranet access +- **One-click operational data export**: 9-sheet Excel from the Dashboard + a CLI for offline export +- Wiki processing-failure visibility, per-employee model chains, OpenAPI / Swagger directly debuggable, chat back-to-bottom floating button + +Full story: [v1.7.0 release notes](./releases/1.7.0.md). --- -## Next: v1.4 — The scenario-application year +## Next: v1.8 — Agent Team & Agent Loop -> "When the tools are good enough, hide the tools and put the scenarios in front." +> "Great things in business are never done by one person. They're done by a team of people." -v1.0 → v1.3 builds out the infrastructure: employees, memory, knowledge bases, tools, skills, workflows, triggers, multimodal, channels. **The next move isn't another bolt** — it's assembling these parts into **scenarios users can drop in and use**. +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. -The v1.4 keyword is **scenario applications**. Not "more features" — **letting normal users get value without learning 7 step modes and 6 trigger pattern types**. +But today's MateClaw still has two "stops": -### Industry scenario templates (workflow + trigger combos) +**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. -Each one is **a one-click-importable workflow template + trigger config + recommended employee bindings + recommended KB structure**: +**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. -- [ ] **Customer ticket triage**: WeCom / Feishu entry → digital-employee classification → route / escalate / auto-reply → write to customer record -- [ ] **Morning / weekly report automation**: cron trigger → multi-employee parallel data collection → data analyst summarizes → generate PDF/PPTX → multi-channel dispatch -- [ ] **Contract approval flow**: contract upload → legal-employee first review → approval wait → legal-employee revision suggestions → write to archived memory -- [ ] **Market intel monitoring**: webhook trigger (site change) → content_match filtering → business analyst summary → Feishu bot push -- [ ] **New employee onboarding**: webhook (HRIS hire event) → executive assistant pulls doc checklist → training-KB onboarding → multi-day follow-up triggers -- [ ] **Code PR review**: GitHub webhook → code-reviewer employee runs review → comments back to PR → flag critical changes through await_approval +v1.8 turns both stops into continuity. -### Scenario marketplace +### Agent Team — from "temp hires" to "standing roster" -- [ ] **Scenario package format**: one scenario = `workflow.json` + `triggers.json` + `agents/*.md` + `knowledge/*.md` + `README.md`, shareable / installable -- [ ] **Scenario marketplace UI**: browse / try-run / one-click install / ratings + reviews -- [ ] **Scenario package versioning**: upgrade prompts + diff preview + rollback +A team is no longer a tree that sprouts at delegation time and vanishes when the task ends. It becomes a **persistent organizational unit**: -### Cross-scenario employee collaboration +- [ ] **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 +- [ ] **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.8 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 -- [ ] **Employee directory profile**: each employee auto-gains "good at / weak at" tags (based on history + skills + tool set) -- [ ] **Scenario suggestions**: user describes "I want a flow that does X" → recommend the closest scenario template + existing employees -- [ ] **Cross-scenario memory sharing**: customer ticket triage and contract approval see the same customer record +### Agent Loop — from "answers then stops" to "on duty" -### Hide the infrastructure further +A new state for employees: **on duty**. Not waiting for you to speak, but cycling autonomously on a heartbeat — **wake → check inbox and goals → decide whether to act → act → journal → sleep**: -- [ ] **Natural language → full scenario package**: v1.3 already does "NL → workflow draft"; v1.4 extends it to **the whole scenario** — one sentence yields a draft of workflow + triggers + recommended employees + recommended KB structure -- [ ] **Self-diagnosis wizards**: typical issues like "my workflow stuck waiting on approval" become self-serve diagnostics -- [ ] **Scenario-level dashboards**: not "tokens spent today" but "average customer-ticket handling time today" +- [ ] **Resident loop runtime**: an employee can be set "on duty," waking on a configurable heartbeat (minutes to days) to check its area of responsibility +- [ ] **Task inbox**: channel messages, trigger events, delegations from other employees, to-dos you toss over — one queue, consumed by priority on each wake-up +- [ ] **Cross-session goal continuation**: v1.4/v1.5 auto-followup lives inside a single run; the loop carries goals across sessions and across days until every criterion is checked +- [ ] **Budgets and circuit breakers**: per-loop token / cost / turn budgets; consecutive failures trip the breaker into sleep pending your decision; ToolGuard approval gates still intercept sensitive actions — autonomy is not loss of control +- [ ] **Loop journal**: what it did each wake-up, why it chose not to act, what it spent — human-readable and replayable, what DREAMS.md is to memory +- [ ] **Pause / resume / clock-out**: controllable from the UI and from channel commands; the Run Overview sidebar shows every on-duty employee's loop state +- [ ] **Quiet hours and interruption policy**: silent accumulation at night, proactive reporting for what matters — integrated with the nudge system, it knows what's worth waking you for -### Foundational capabilities advancing in parallel +### Where they converge: a department that runs itself -- [ ] **Scenario-level ACL**: installing a scenario package atomically configures the required channel / agent / KB / tool allowlists -- [ ] **Cross-workspace scenario sharing**: scenario templates reusable across workspaces (clone + override) -- [ ] **Scenario cost estimation**: see expected tokens / API calls / trigger frequency before installing +A leader on a loop, members summoned on demand — that's a **self-running digital department**: + +- Morning-report department: the leader wakes at 7:00, dispatches data collection, analysis, and writing to members, peer-reviews, posts to the group — you wake up to results +- Support department: a ticket lands in the inbox, the leader classifies, assigns the right member, escalates to you what it can't handle +- Intelligence department: a monitoring employee loops over sources, wakes the analyst only when something changed, notifies you only when it's worth interrupting + +**Workflows own the deterministic processes; teams + loops own the unpredictable everyday.** They complement each other — none replaces another. + +### Advancing in parallel + +- [ ] **Workflow `loop` / `invoke_skill` step modes**: per-item array iteration / call a skill without going through an employee +- [ ] **Workflow canvas editing**: from read-only chain rendering to drag-to-edit +- [ ] **Run replay view**: trace timeline + input/output diff on any node +- [ ] **Scenario templates and marketplace**: package "employees + team + workflow + triggers + KB structure" into one-click-importable scenario bundles --- @@ -161,12 +185,13 @@ Each one is **a one-click-importable workflow template + trigger config + recomm | Cut | Why | When it might return | |-----|-----|---------------------| -| **Full RBAC permission model** | MateClaw is a digital-employee system, not an enterprise management platform. A single team doesn't need 100 permission combinations | When real multi-team SaaS customers need fine-grained permissions | -| **Multi-tenancy** | Same as above. Premature multi-tenancy is architectural cancer | When there's a clear SaaS commercialization path | +| **Fine-grained RBAC beyond four roles** | v1.4's Owner / Admin / Member / Viewer + capability gates cover real team needs. Button-level permissions and custom role composition belong to enterprise management platforms | When real multi-team SaaS customers need fine-grained permissions | +| **Multi-tenancy** | Premature multi-tenancy is architectural cancer. Workspace isolation already covers multiple teams in one org | When there's a clear SaaS commercialization path | | **SSO / LDAP / SAML** | Enterprise integration is a bottomless pit | When paying enterprise customers explicitly ask | -| **30+ node visual workflow editor** | Most users won't reach for it. **v1.3's 7 step modes already cover 90% of real-world scenarios**; the rest is pushed to LLM natural-language generation | When a user case actually needs 30+ nodes (rare) | -| **Native mobile app** | 8 IM channels + desktop + Web already cover it. On your phone, you use MateClaw via DingTalk / Feishu / Telegram | When Web / IM channels can't deliver an irreplaceable mobile-only feature | -| **Replacing ReAct / Plan-Execute** | Workflow and those two engines **collaborate**, not replace — single-agent multi-turn reasoning still lives there | Never replaces | +| **30+ node visual workflow editor** | 7 step modes already cover 90% of real-world scenarios; the rest is pushed to natural-language generation | When a user case actually needs 30+ nodes (rare) | +| **Native mobile app** | 8 IM channels + desktop (now with remote connect) + Web already cover it. On your phone, you use MateClaw via DingTalk / Feishu / Telegram | When Web / IM channels can't deliver an irreplaceable mobile-only feature | +| **Replacing ReAct / Plan-Execute** | Workflows, teams, and loops **collaborate** with those two engines, not replace them — single-agent multi-turn reasoning still lives there | Never replaces | +| **Unbudgeted full autonomy** | Agent Loop always ships with budgets, circuit breakers, and approval gates. "Run until the money's gone" isn't autonomy, it's loss of control | Never | --- @@ -176,9 +201,13 @@ Each one is **a one-click-importable workflow template + trigger config + recomm |---------|----------|----------------------|--------| | **v1.0** | It thinks and acts | An AI assistant that uses tools to solve problems | ✅ Released | | **v1.1** | It's everywhere | 8 channels + 4-layer memory + workspaces + LLM Wiki | ✅ Released | -| **v1.2** | It's your coworker | Digital employees + 5 career templates + backbone-style skills + ACP bridge + Backstage runtime | ✅ Released | -| **v1.3** | It orchestrates business flows | Workflow + triggers + image editing + document generation + per-agent tool binding | ✅ Released | -| **v1.4** | **It lands real scenarios** | **Industry scenario templates + scenario marketplace + NL → workflow + cross-scenario employee profiling** | 📋 Planned | +| **v1.2** | It's your coworker | Digital employees + career templates + backbone skills + ACP bridge + Backstage | ✅ Released | +| **v1.3** | It orchestrates business flows | Workflow + triggers + document generation + per-agent tool binding | ✅ Released | +| **v1.4** | It's more autonomous and leads teams | Persistent goals + delegation tree + progressive disclosure + RBAC + first-class Feishu | ✅ Released | +| **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's on duty** | **Agent Team standing rosters + Agent Loop resident cycles = a department that runs itself** | 📋 Planned | --- @@ -190,7 +219,7 @@ We're building it because we believe one thing: **AI shouldn't be a chat box on a webpage. It should be your second brain.** -It lives in your DingTalk, your Feishu, your Telegram. It's read every document you have. It remembers what you said three months ago. It uses your company's internal tools. It consolidates memory while you sleep. **It runs an entire business flow on your behalf.** +It lives in your DingTalk, your Feishu, your Telegram. It's read every document you have. It remembers what you said three months ago. It uses your company's internal tools. It consolidates memory while you sleep. It runs an entire business flow on your behalf. **Soon it will lead a standing team, stay on duty, and watch over the things you can't get to.** Someday, you'll forget it's a program. diff --git a/mateclaw-server/src/main/resources/docs/en/security.md b/mateclaw-server/src/main/resources/docs/en/security.md index 0daae38f..2a564c2f 100644 --- a/mateclaw-server/src/main/resources/docs/en/security.md +++ b/mateclaw-server/src/main/resources/docs/en/security.md @@ -509,6 +509,45 @@ server { } ``` +### Outbound request protection (SSRF) + +Every **outbound HTTP request an agent can drive** carries SSRF protection by default, so a manipulated agent can't be steered into probing your internal network or a cloud metadata endpoint. Three outbound paths are covered: + +| Outbound path | Triggered by | Default behaviour | +|---------------|--------------|-------------------| +| **Browser tool** | the `open` action of `browser_use` | resolves the target host and rejects restricted addresses | +| **Hook webhook** | the HTTP call of a hook action | host must be in `trusted-domains` AND must not be a private address | +| **Image download** | the image tool fetching a URL reference | rejects private / loopback hosts | + +Address classes blocked by default: loopback (`127.0.0.0/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`), link-local (`169.254/16`, `fe80::/10`), any-local, multicast, and cloud metadata endpoints (`169.254.169.254`, `100.100.100.200`, `192.0.0.192`, …). + +#### Allowing internal addresses: `mateclaw.security.ssrf-allowlist` + +When an agent legitimately needs to reach an internal service, add it to the shared allowlist. **One setting, applied across all three outbound paths.** Each entry is one of: + +| Form | Example | Meaning | +|------|---------|---------| +| Literal hostname | `internal.corp` | case-insensitive exact match | +| Literal IP | `192.168.100.100` | matches that exact address | +| IPv4 CIDR block | `192.168.100.0/24` | matches every IP in the range | + +```yaml +mateclaw: + security: + ssrf-allowlist: + - 192.168.100.100 # a single internal address + - 192.168.100.0/24 # a whole internal subnet + - internal.corp # an internal hostname +``` + +The allowlist opens **only the entries you list**: `192.168.100.0/24` does not also open `192.168.200.x`, and `192.168.100.100` does not open sibling IPs in the same subnet. Changes require a backend restart. + +::: warning Keep it narrow +Allowlist entries **can re-expose cloud metadata endpoints** (e.g. `169.254.169.254`). Once exposed, a compromised agent could use one to steal cloud credentials. Add only the internal addresses you actually need, and **never** open things up with a broad CIDR such as `0.0.0.0/0` or `10.0.0.0/8`. +::: + +The browser tool also has a master switch `mateclaw.browser.ssrf-check-enabled` (default `true`). Setting it to `false` **disables the SSRF check entirely** for the browser path — including the metadata endpoints — and is discouraged; prefer the allowlist above for precise exceptions. + --- ## Security best practices @@ -527,7 +566,7 @@ server { ## Security configuration reference -application.yml carries **only two** security-related blocks — JWT and the filesystem sandbox: +application.yml carries **three** security-related blocks — JWT, the filesystem sandbox, and the outbound request allowlist: ```yaml mateclaw: @@ -543,6 +582,12 @@ mateclaw: sandbox: enabled: true root: ${user.dir}/data/workspace + + # Outbound SSRF allowlist: permit specific internal hosts/IPs/CIDR blocks, + # shared by the browser, hook, and image-download outbound paths. Empty means + # every private address is blocked by the default policy. + security: + ssrf-allowlist: [] # e.g. [192.168.100.100, 192.168.100.0/24] ``` **Everything else is managed in the database — from the admin Security page (or `/api/v1/security/guard/*`), not application.yml:** diff --git a/mateclaw-server/src/main/resources/docs/en/skills.md b/mateclaw-server/src/main/resources/docs/en/skills.md index 4d846157..83ee583f 100644 --- a/mateclaw-server/src/main/resources/docs/en/skills.md +++ b/mateclaw-server/src/main/resources/docs/en/skills.md @@ -192,7 +192,7 @@ The database is the source of truth, the filesystem is a materialized cache. Tha | `id` | Primary key | | `skill_id` | FK to `mate_skill` | | `file_path` | Relative path like `scripts/run.py` or `references/cfg.md` | -| `content` | UTF-8 text (≤1 MB per file, ≤50 MB per bundle) | +| `content` | UTF-8 text (defaults: ≤1 MB per file, ≤50 MB per bundle — configurable via `mateclaw.skill.upload.max-entry-size-mb` / `max-total-size-mb`) | | `content_size` | Byte count (so listings don't have to load the blob) | | `sha256` | Content fingerprint, drives the syncer's idempotent diff | @@ -228,7 +228,7 @@ Two sync passes run at boot, so every node has the latest bundle: Third-party packagers package weirdly — some put `setup.sh` at the zip root, some emit `scripts/` entries before `SKILL.md`. As of v1.3, `ZipSkillFetcher`: -- **Two-pass extraction** — the entire archive is buffered in memory first (cap-protected at 50 MB), `SKILL.md` is located and the wrapper-dir prefix computed, then entries are classified. **Zip entry order no longer affects the result.** +- **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. diff --git a/mateclaw-server/src/main/resources/docs/en/user-guide.md b/mateclaw-server/src/main/resources/docs/en/user-guide.md index 3d38f8eb..8020f6df 100644 --- a/mateclaw-server/src/main/resources/docs/en/user-guide.md +++ b/mateclaw-server/src/main/resources/docs/en/user-guide.md @@ -186,7 +186,7 @@ Create a role-specific agent → install skills → connect MCP servers → conf | Model call fails | Wrong API key or network issue. Go back to Settings | | UI is blank | Ctrl+Shift+R to hard-refresh | | Ollama says "does not support tools" | Switch to a function-calling model (qwen3, llama3.1:8b+) | -| Still broken | [GitHub Issues](https://github.com/matevip/mateclaw/issues) with the tail of `app.log` | +| Still broken | [GitHub Issues](https://github.com/mateaix/mateclaw/issues) with the tail of `app.log` | --- diff --git a/mateclaw-server/src/main/resources/docs/en/webchat.md b/mateclaw-server/src/main/resources/docs/en/webchat.md index 572f6fbb..a1cf1a15 100644 --- a/mateclaw-server/src/main/resources/docs/en/webchat.md +++ b/mateclaw-server/src/main/resources/docs/en/webchat.md @@ -77,6 +77,8 @@ init({ apiKey: 'your-channel-api-key', server: 'https://' }) | DELETE | `/sessions` | + visitorToken | Delete | | POST | `/sessions/stop` | + visitorToken | Stop an in-flight stream | | POST | `/sessions/regenerate` | + visitorToken | Regenerate the last assistant reply | +| POST | `/sessions/approve` | + visitorToken | Approve a pending tool approval and replay (SSE) | +| POST | `/sessions/deny` | + visitorToken | Deny a pending tool approval (synchronous JSON) | | GET | `/sessions/messages` | + visitorToken | Message list (paginated) | | POST | `/upload` | + visitorToken | Upload an attachment (returns fileId) | | GET | `/files` | + visitorToken | Download a file (uploaded or agent-generated) | @@ -186,6 +188,31 @@ data: {"message":"..."} (on failure) Each session returned by `/sessions` includes: `sessionId`, `title`, `lastActiveTime`, `messageCount`, `pinned`, `archived`, `streamStatus` (`running` / `idle`). +## Tool approval resolve (API-Key channel) + +When an agent bound to WebChat calls a tool protected by [Tool Guard](./security), that turn **suspends waiting for approval**. The visitor can approve or deny it in-session instead of letting it time out. + +- **Approve** `POST /sessions/approve` — with `sessionId` + `pendingId`. Auth reuses visitorToken + conversation ownership; the `pendingId` is **strictly validated to belong to this session** (else 404), closing a cross-visitor IDOR. Approving **replays** the suspended tool call and resumes as SSE. +- **Deny** `POST /sessions/deny` — with `sessionId` + `pendingId`, returns synchronous JSON, no replay. + +Both broadcast a `tool_approval_resolved` SSE event (see [Optional realtime progress events](#optional-realtime-progress-events) above) so the SDK / frontend clears the approval banner in real time. + +> Whether an approval appears depends on whether the agent's bound Tool Guard rules set `require_approval` for some tool. Get `pendingId` from the `tool_approval_requested` event. + +```bash +# Approve (resumes as SSE) +curl -N -X POST "https://mate.example.com/api/v1/channels/webchat/sessions/approve" \ + -H "X-MC-Key: " -H "X-Visitor-Token: " \ + -H "Content-Type: application/json" \ + -d '{"sessionId":"s1","pendingId":""}' + +# Deny (synchronous JSON) +curl -X POST "https://mate.example.com/api/v1/channels/webchat/sessions/deny" \ + -H "X-MC-Key: " -H "X-Visitor-Token: " \ + -H "Content-Type: application/json" \ + -d '{"sessionId":"s1","pendingId":""}' +``` + ## visitorToken revocation (admin) A visitor abusing the channel? An admin calls: @@ -247,4 +274,4 @@ curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ ## Related -- Upstream epic issue: https://github.com/matevip/mateclaw/issues/355 +- Upstream epic issue: https://github.com/mateaix/mateclaw/issues/355 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 67c7be7c..c6aa92f5 100644 --- a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md @@ -212,7 +212,7 @@ Fix: `AsyncTaskMediaDispatcher.forwardToImIfBound(conversationId, parts)`: - Slack: via `filesUploadV2` (see [Slack channel](./channels#slack)) - Channels without `sendContentParts` (QQ, etc.): catch UnsupportedOperationException + log; one unsupported channel doesn't block the rest -Files live at `data/chat-uploads/{conversationId}/`, served at `/api/v1/chat/files/{conversationId}/{storedName}`. Frontend and channel attachment views all read by this URL. +Files live at `data/chat-uploads/{conversationId}/` by default, but when the conversation's Agent / Workspace has a `basePath` configured, attachments land under `{basePath}/chat-uploads/{conversationId}/` (precedence: Agent `workspaceBasePath` → Workspace `basePath` → default dir `mateclaw.chat.upload.base-dir`). Reads and cleanup probe both the new and legacy locations, so pre-migration attachments stay accessible. Served at `/api/v1/chat/files/{conversationId}/{storedName}`; frontend and channel attachment views all read by this URL. --- diff --git a/mateclaw-server/src/main/resources/docs/en/wiki.md b/mateclaw-server/src/main/resources/docs/en/wiki.md index bf2e44c6..7374ab75 100644 --- a/mateclaw-server/src/main/resources/docs/en/wiki.md +++ b/mateclaw-server/src/main/resources/docs/en/wiki.md @@ -95,6 +95,17 @@ Eager ingest runs in two phases for an order-of-magnitude speedup: **Resumable**: interrupted mid-import? Hit "Reprocess" and only the unfinished pages re-run; everything already produced stays put. Documents larger than the embedding model's context get mean-pool sub-segmented automatically. +#### Save tokens: a light model for the cheap steps + +Digest runs several kinds of LLM step: route, merge generation, enrich, summary, entity extraction. The **route / enrich / summary / entity-extraction** steps are high-volume but lightweight — no need to run them on the same premium model as page merging. + +Point them at a cheaper model to cut token spend without touching page-generation quality: + +- **System-wide** — set `wiki.lightModelId` (a model id) in system settings; it applies to the cheap steps of every KB. +- **Per-KB override** — set `wikiLightModelId` in the KB config to override the system-wide value. + +Leave both unset and nothing changes (the cheap steps keep using the KB / system default). Precedence: `stepModels.` (pin a step) → light model (cheap steps only) → `wikiDefaultModelId` → system default. + ### Lazy: index now, compile later The pipeline collapses to four steps: @@ -672,7 +683,7 @@ Core tables (see feature sections for the complete list): | Table | Purpose | |---|---| | `mate_wiki_knowledge_base` | One row per KB. Owner, name, description, config JSON (`ingestMode`, `wikiDefaultModelId`, `stepModels`, `entityExtractionEnabled`, `entityTypes`, fallback chain). | -| `mate_wiki_raw_material` | One row per upload. Status, byte hash, source path, last successfully-processed hash. | +| `mate_wiki_raw_material` | One row per upload. Status, byte hash, source path, last successfully-processed hash; structured `error_code` + `error_message` on failure, and `warning_code` + `warning_message` when completed-but-degraded. | | `mate_wiki_page` | One row per generated page. Title, summary, body, `source_raw_ids` (provenance), `page_type`, `locked`, version, plus `embedding` / `embedding_model` / `embedding_text_version` so transformation synthesis pages enter semantic search directly. | | `mate_wiki_chunk` | One row per chunk. content + hash + offsets + embedding, plus `page_number`, `header_breadcrumb`, `source_section`, `token_count`. | | `mate_wiki_relation` | Cached page-to-page edges (shared chunks, shared raws, direct links, semantic neighbors) used to power the 1-hop retrieval boost and the related-pages tool. | @@ -697,6 +708,7 @@ For when you don't want to wait for the cron / event hooks to catch up: |---|---| | `POST /api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | Force-rewrite the overview marker region from current stats. | | `POST /api/v1/wiki/admin/backfill-tokens` | Run one batch of the token-count backfill now; returns `pendingBefore` / `pendingAfter` / `filledThisBatch`. | +| `GET /api/v1/wiki/admin/failures?limit=100` | Cross-KB list of materials needing attention (failed / partial / warning); see "Failure visibility" below (platform admin). | The `mate.wiki` block in `application.yml` controls global knobs (chunk size, parallelism, auto-process-on-upload). Per-KB knobs (ingest mode, step models, fallback chain) live inside the KB's `configContent` JSON and are edited through the config UI. @@ -704,6 +716,44 @@ The `mate.wiki` block in `application.yml` controls global knobs (chunk size, pa --- +## Failure visibility + +Ingest is mostly async background work, so failures used to be visible only in the server log. They are now **structured onto the raw material and pushed live to the UI**. + +### Structured error codes + +When a raw material fails, alongside the raw text (`error_message`) it records a **structured `error_code`**: + +`AUTH_ERROR` / `BILLING` / `MODEL_NOT_FOUND` / `RATE_LIMIT` / `TIMEOUT` / `SERVER_ERROR` (5xx) / `CONTENT_FILTER` / `NO_CONTENT` (no extractable text) / `EMPTY_RESULT` (model produced no pages) / `UNKNOWN`. + +The UI renders a localized friendly hint from the code (e.g. "Model authentication failed — check the provider key") and keeps the raw exception as a hover detail. Both columns are cleared on a successful reprocess. + +### Non-blocking warnings + +Some sub-steps run async **after** the material is already completed — embedding and entity-graph extraction. Their failure does not affect the pages, but it degrades the material (most notably: a failed embedding means the material is not semantically searchable yet). Instead of only logging, these record a non-blocking `warning_code` (`EMBEDDING_FAILED` / `ENTITY_EXTRACTION_FAILED`) + `warning_message`; the material stays "completed" but carries a ⚠ marker. + +### Progress SSE events + +The KB progress stream `GET /api/v1/wiki/knowledge-bases/{kbId}/progress` (SSE) emits: + +| Event | When | Key fields | +|---|---|---| +| `raw.started` | a material starts processing | `rawId` | +| `route.done` / `chunk.done` | stage progress | `rawId` + progress counters | +| `raw.completed` | material finished (incl. partial) | `rawId` / `status` / `totalPages` | +| `raw.failed` | material failed | `rawId` / `error` / `errorCode` | +| `raw.warning` | completed but an async sub-step failed | `rawId` / `warning` / `warningCode` | + +### Cross-KB failure center (admin) + +Instead of opening each KB in turn, an admin sees everything needing attention (failed / partial / warning) in one place: + +- `GET /api/v1/wiki/admin/failures?limit=100` — lists across **all** knowledge bases with KB name, status, error/warning code, and time (platform admin `ROLE_ADMIN`, spans every workspace). +- The notification summary `GET /api/v1/notifications/summary` gains a `failedWikiJobs` count, driving the attention badge on the sidebar Wiki item. +- The frontend Wiki library view shows a collapsible failure center at the top with one-click open into the owning KB. + +--- + ## When to use it Reach for a Wiki KB when you have: diff --git a/mateclaw-server/src/main/resources/docs/en/workflow.md b/mateclaw-server/src/main/resources/docs/en/workflow.md index 16042d49..45701afc 100644 --- a/mateclaw-server/src/main/resources/docs/en/workflow.md +++ b/mateclaw-server/src/main/resources/docs/en/workflow.md @@ -98,6 +98,12 @@ How it reads: > **Not in v1.3.0**: `loop` (iterate N times or per-item over an array) and `invoke_skill` (call a skill without going through an employee). Coming based on user feedback. +> **`await_approval` channel notifications (actually delivered since 1.7.0)**: each element of `approverChannels[]` is either +> - `"channelType"` (e.g. `"web"`) — **not actively pushed**; resolve from the admin side; or +> - `"channelType:targetId"` (e.g. `"feishu:oc_xxx"`, `"wecom:xxx"`) — **pushes an approval notification** to that target (Feishu/WeCom group). +> +> Once approved, the workflow **resumes from the paused step automatically** (the resolve → resume bridge). In a Feishu/WeCom group you can **tap the card's Approve/Deny button** to resolve it directly. See [Approval & security](./security). + ### Expressions: a Pebble subset Workflow does **not** use a full template engine — it supports the same Pebble subset as Kestra, just enough to gate conditionals and reference variables, with no code execution. diff --git a/mateclaw-server/src/main/resources/docs/en/workspaces.md b/mateclaw-server/src/main/resources/docs/en/workspaces.md index aafd2ee4..fce3bf69 100644 --- a/mateclaw-server/src/main/resources/docs/en/workspaces.md +++ b/mateclaw-server/src/main/resources/docs/en/workspaces.md @@ -204,7 +204,7 @@ Not supported directly. You have two options: 1. **Export and import** — some resources have JSON export (agents via API, wiki KBs via API). Re-create them in the target workspace. 2. **Change ownership** — an admin or owner can directly update the `workspace_id` column in the database for simple resources. This is not officially supported; do it at your own risk and only with a backup. -We'd like to support first-class moving in a future release. If you need this, leave a note on the [GitHub issue](https://github.com/matevip/mateclaw/issues). +We'd like to support first-class moving in a future release. If you need this, leave a note on the [GitHub issue](https://github.com/mateaix/mateclaw/issues). --- diff --git a/mateclaw-server/src/main/resources/docs/zh/api.md b/mateclaw-server/src/main/resources/docs/zh/api.md index e295942e..ac313a2b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/api.md +++ b/mateclaw-server/src/main/resources/docs/zh/api.md @@ -2,6 +2,8 @@ 本页以 `mateclaw-server/src/main/java` 下的 Spring MVC Controller 注解为准。下面的路由索引由源码注解重建;如果它和旧功能页冲突,以本页和源码为接口契约。 +> 想要机读的 OpenAPI 文档(导入 Postman / Apifox、在线调试)?见 [OpenAPI / Swagger 指南](./openapi.md) —— 部署后访问 `/swagger-ui.html`。 + ## 全局契约 应用 REST 端点默认使用 `/api/v1` 前缀。大多数 JSON 响应使用项目统一信封: @@ -34,6 +36,119 @@ Authorization: Bearer 工作空间接口通常接受 `X-Workspace-Id`。省略时,很多 handler 会为了桌面/本地兼容回退到 workspace `1`。 +## 通用约定 + +下面是所有端点共用的结构约定。读完这一节,再看后面的旗舰端点示例和完整路由表就能对上。 + +### 统一响应信封 `R` + +源码:`vip.mate.common.result.R`(`R.java`)。三个字段: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `code` | `int` | 状态码。`200` 为成功;其余见下方「状态码」 | +| `msg` | `string` | 提示信息。**注意是 `msg` 不是 `message`** | +| `data` | `T` | 业务数据;失败时为 `null` | + +成功示例: + +```json +{ "code": 200, "msg": "success", "data": { "id": "1", "name": "助手A" } } +``` + +失败示例: + +```json +{ "code": 401, "msg": "Token expired or invalid", "data": null } +``` + +**HTTP 状态码与 `code` 对齐**:`RHttpStatusAdvice`(`ResponseBodyAdvice`)在 `code != 200` 时把 HTTP 状态设成 `HttpStatus.resolve(code)`。即业务码 `401` → HTTP 401,业务码 `404` → HTTP 404。业务码若不是合法 HTTP 状态(如 `1001`、`2001`),则 HTTP 回落到 `500`。 + +### 状态码 + +源码:`vip.mate.common.result.ResultCode`。注意区分两类: + +| 码 | 含义 | 是否直接作 HTTP 状态 | +|---|---|---| +| `200` | 成功 | 是 | +| `400` | 参数错误 | 是 | +| `401` | 未认证 | 是 | +| `403` | 无权限 | 是 | +| `404` | 资源不存在 | 是 | +| `500` | 系统错误 | 是 | +| `1001` | Agent 不存在 | 否(HTTP 500) | +| `1002` | Agent 忙碌 | 否(HTTP 500) | +| `2001` | LLM 调用错误 | 否(HTTP 500) | +| `3001` | 工具不存在 | 否(HTTP 500) | +| `4001` | 渠道错误 | 否(HTTP 500) | + +### 错误模型 + +错误统一由 `GlobalExceptionHandler`(`@RestControllerAdvice`)处理。下表覆盖常见情况: + +| HTTP | 触发 | 响应体 | +|---|---|---| +| 400 | `@Valid` / `BindException` 校验失败 | `{code:400, msg:"字段名: 默认信息"}` — 只返回**首个**字段错误 | +| 400 | `MethodArgumentTypeMismatchException`(如 `/{id}` 传了非数字) | `{code:400, msg:"Invalid value for parameter 'X': expected Long"}` | +| 401 | 未认证 / token 无效(`SecurityConfig` 的 `authenticationEntryPoint`) | `{code:401, msg:"Token expired or invalid"}` | +| 403 | 工作区角色不足(`WorkspaceAccessInterceptor` 直接写响应) | `{code:403, msg:"...", data:null}` | +| 404 | 路由不匹配(`NoResourceFoundException`) | `{code:404, msg:"Resource not found"}` | +| 405 | 方法不允许(`HttpRequestMethodNotSupportedException`) | `{code:405, msg:"Method not allowed"}` | +| 409 | 需二次确认(`ConfirmRequiredException`) | **打破信封**:`{code, message, boundAgents}`(字段是 `message`,**不是** `msg`)—— 全 API 唯一非 `R` 响应 | +| 500 | 兜底(`Exception`) | `{code:500, msg:"Internal server error"}` — 栈不外泄 | +| 503 | 异步超时(非 SSE 请求) | `{code:503, msg:"Request timeout, please try again"}` | + +> SSE 端点(`/chat/stream` 等)出错时**不发 JSON 信封**,而是发送 SSE `error` 事件:`event: error` / `data: {"message":"..."}`。详见 [WebChat 指南](./webchat.md#sse-事件协议)。 + +### 分页结构 + +分页端点直接返回 `R>`,`data` 是 MyBatis Plus 的 `Page` 序列化结构: + +```json +{ + "code": 200, + "data": { + "records": [ /* 当前页数据 */ ], + "total": 128, + "size": 20, + "current": 1, + "pages": 7 + } +} +``` + +| 字段 | 含义 | +|---|---| +| `records` | 当前页数据数组(**字段名是 `records`**,不是 `list`/`items`) | +| `total` | 总记录数 | +| `size` | 每页条数 | +| `current` | 当前页码,从 `1` 开始 | +| `pages` | 总页数 | + +常见分页查询参数:`page`(默认 1)、`size`(默认 20)。示例端点见 `GET /api/v1/audit/events`、`GET /api/v1/conversations/page`。 + +### ID 与类型约定 + +- **Snowflake `Long` 序列化为 JSON 字符串**:后端所有主键是 `Long`,但 Jackson 序列化成字符串。客户端(尤其 JS)应**全程按 `string` 处理**,避免 `Number.MAX_SAFE_INTEGER` 精度丢失。 +- **密码字段只写不读**:`UserEntity.password` 标注 `@JsonProperty(access = WRITE_ONLY)`,登录/建用户时接受写入,但任何响应里都不会出现。 + +### 认证模型 + +`JwtAuthFilter` 支持三种 token 形态,全部走 `Authorization` 头: + +1. **JWT**:`Authorization: Bearer `。以 `eyJ` 开头(base64 头)。登录接口返回的 `token` 字段即此。 +2. **Personal Access Token (PAT)**:`Authorization: Bearer `。以 `mc_` 前缀开头,用于 headless / CI / SDK 场景。PAT 在 `POST /api/v1/auth/tokens` 创建时**明文只返回一次**,之后只存哈希。filter 按 `mc_` 前缀分发到 PAT 校验路径。 +3. **SSE 的 `?token=` 查询参数**:浏览器原生 `EventSource` 不能设置自定义请求头,SSE 流式端点额外接受 `?token=`(JWT 或 PAT 均可)。 + +**滑动续期**:JWT 接近过期(默认剩余 < 2 小时)时,响应头回传新 token —— `X-New-Token: `(同时设 `Access-Control-Expose-Headers: X-New-Token`)。客户端应监听并替换本地存储的 token。JWT TTL 默认 24 小时(`mateclaw.jwt.expiration=86400000`)。 + +### `X-Workspace-Id` 的工作机制 + +- **无 ThreadLocal / 请求上下文持有**。工作区 ID 通过两种途径消费: + 1. **RBAC 拦截**:`WorkspaceAccessInterceptor` 对标注了 `@RequireWorkspaceRole`(角色 owner > admin > member > viewer)或 `@RequireGlobalAdmin` 的方法,读取 `X-Workspace-Id` 做权限校验。缺失或无法解析时回落 workspace `1`。权限不足直接写 403 JSON 响应。 + 2. **业务读取**:许多 Controller 用 `@RequestHeader(value="X-Workspace-Id", required=false) Long workspaceId` 直接取值用于数据查询范围;缺失时同样回落 `1`。 +- 因此工作区隔离由「拦截器鉴权 + Controller 自取」两段配合实现,客户端调用工作区相关接口时应显式传 `X-Workspace-Id`。 + ## 常用接口 ### 登录 @@ -72,6 +187,217 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \ `/api/v1/talk/ws` 由 `WebSocketConfig` 注册,用于 Talk Mode。它会出现在 `SecurityConfig` 的公共 WebSocket 路由里,但不计入下面的 controller 路由索引。 +## 旗舰端点参考 + +下面是接入最频繁的端点的完整请求/响应说明。字段均与源码 DTO 一一对齐。下面的 406 行路由表是完整索引,本节是高频端点的「人读」详解。 + +### 登录:`POST /api/v1/auth/login` + +公开端点(无需认证)。换取 JWT。 + +**请求体** `LoginRequest`(`AuthController.java`): + +| 字段 | 类型 | 说明 | +|---|---|---| +| `username` | string | 用户名 | +| `password` | string | 密码 | + +**响应** `R`: + +```json +{ + "code": 200, + "data": { + "id": "1", + "token": "eyJhbGciOi...", + "username": "admin", + "nickname": "管理员", + "role": "admin" + } +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | 用户 ID(Snowflake,字符串) | +| `token` | string | **JWT**,后续请求放 `Authorization: Bearer `。响应里没有独立 expiry 字段,过期时间在 JWT 的 `exp` claim 内 | +| `username` | string | 用户名 | +| `nickname` | string | 昵称 | +| `role` | string | `admin` 或 `user` | + +**错误**:用户名/密码错误 → HTTP 401,`{code:401, msg:"用户名或密码错误"}`。 + +```bash +curl -X POST http://localhost:18088/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' +``` + +### 流式对话:`POST /api/v1/chat/stream` + +> 公开端点(`SecurityConfig` 放行 `/api/v1/chat/stream`),但实际使用时仍需 token 才能定位用户与权限——传 `?token=` 或 `Authorization` 头。 + +返回 `text/event-stream`,**不走 JSON 信封**。SSE 事件协议(`meta` / `content_delta` / `done` / `error` 等)见 [WebChat 指南](./webchat.md#sse-事件协议)。 + +**请求体** `ChatController.ChatStreamRequest`(`ChatController.java:1211`): + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `agentId` | string | — | 必填,目标 Agent ID | +| `message` | string | — | 本轮用户消息(与 `contentParts` 二选一) | +| `contentParts` | array | — | 多模态消息分片(图文混合),与 `message` 二选一 | +| `conversationId` | string | `"default"` | 会话 ID;新会话用客户端生成的唯一串 | +| `reconnect` | boolean | — | `true` = 断线重连,不发新消息,只附着到已有流 | +| `lastEventId` | string | — | 仅 `reconnect=true` 有意义:跳过 id ≤ 此值的事件,避免重放重复 | +| `thinkingLevel` | string | null | 思考深度:`off` / `low` / `medium` / `high` / `max`;null 跟随 Agent 默认 | +| `modelProvider` | string | null | 本会话模型 provider 覆盖(与 `modelName` 配对) | +| `modelName` | string | null | 本会话模型名覆盖 | +| `endUserId` | string | null | 第三方终端用户 ID,用于一个 MateClaw 账号下的记忆隔离 | + +浏览器原生 `EventSource` 不支持 POST body,必须用 `fetch()` + 流式 reader。 + +```bash +curl -N -X POST "http://localhost:18088/api/v1/chat/stream?token=$TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{"agentId":"1","message":"你好","conversationId":"conv-abc123"}' +``` + +相关端点:`POST /api/v1/chat/{conversationId}/stop`(停止生成)、`POST /api/v1/chat/{conversationId}/interrupt`(排队后续消息,不打断当前流)。 + +### Agent 管理 + +挂在 `/api/v1/agents`,`@Tag("Agent管理")`。所有方法需 `@RequireWorkspaceRole`(至少 `viewer`,写入需 `member`)。 + +**列表** `GET /api/v1/agents?enabled=true` — 请求头 `X-Workspace-Id`;返回 `R>`。 + +**创建** `POST /api/v1/agents` — 请求体是 `AgentEntity`(关键字段见下),后端强制注入 `workspaceId` 与 `creatorUserId`。返回创建后的完整实体。 + +`AgentEntity` 关键字段(`AgentEntity.java`): + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | Agent ID(创建时忽略,后端分配) | +| `name` | string | 名称 | +| `description` | string | 描述 | +| `agentType` | string | `react` 或 `plan_execute` | +| `systemPrompt` | string | 系统提示词 | +| `modelName` | string | Per-Agent 模型覆盖(模型名);空则用全局默认 | +| `maxIterations` | int | 最大迭代次数 | +| `enabled` | boolean | 是否启用 | +| `icon` | string | 图标(emoji 或 URL) | +| `tags` | string | 标签(逗号分隔) | +| `defaultThinkingLevel` | string | 默认思考深度 | +| `primaryKbId` | string | 主知识库 ID | +| `skillsDisabled` | boolean | 显式禁用所有 Skill | +| `toolsDisabled` | boolean | 显式禁用所有非系统工具 | + +**删除** `DELETE /api/v1/agents/{id}` — 三选一鉴权:系统 admin / 工作区 admin+ / 创建者本人。否则 403。 + +```bash +# 列表 +curl http://localhost:18088/api/v1/agents?enabled=true \ + -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" + +# 创建 +curl -X POST http://localhost:18088/api/v1/agents \ + -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" \ + -H "Content-Type: application/json" \ + -d '{"name":"客服助手","agentType":"react","systemPrompt":"你是一名客服","enabled":true}' +``` + +> 同目录下还有 `GET /api/v1/agents/{id}/chat/stream`(GET 形式的 SSE,与上面 POST `/chat/stream` 并存)、`POST /api/v1/agents/{id}/chat`(同步对话)、`POST /api/v1/agents/{id}/execute`(Plan-Execute)。 + +### 会话管理 + +挂在 `/api/v1/conversations`,`@Tag("会话管理")`。按当前登录用户(JWT principal)隔离。 + +**列表** `GET /api/v1/conversations` — 返回 `R>`。`ConversationVO` 在会话实体基础上补充展示字段: + +| 字段 | 说明 | +|---|---| +| `conversationId` | 会话 ID(字符串,非 Snowflake) | +| `title` | 标题 | +| `agentId` / `agentName` / `agentIcon` | 关联 Agent | +| `username` | 会话归属用户 | +| `messageCount` | 消息数 | +| `lastMessage` / `lastActiveTime` | 最近消息与时间 | +| `pinned` / `archived` | 置顶 / 归档(0/1) | +| `modelProvider` / `modelName` | 会话级模型覆盖 | +| `status` | `active`(24h 内活跃)/ `closed` | +| `streamStatus` | `idle` / `running` | +| `source` | 来源渠道:`web` / `feishu` / `dingtalk` / `telegram` / `discord` / `wecom` / `qq` / `weixin` / `cron` | + +**分页** `GET /api/v1/conversations/page?page=1&size=20&keyword=xxx` — 返回 `R>`(分页结构见「通用约定」)。 + +**消息历史** `GET /api/v1/conversations/{conversationId}/messages` — 支持三种模式: +- 不传 `limit`:返回全部消息(`R>`,向后兼容)。 +- 传 `limit`:返回最新 `limit` 条 + `hasMore` 标志:`R<{messages: MessageVO[], hasMore: boolean}>`。 +- 传 `beforeId` + `limit`:上拉加载更早消息。 + +`MessageVO` 关键字段:`id`、`role`、`content`、`toolName`、`status`、`metadata`(对象,含 toolCalls 等)、`promptTokens` / `completionTokens`、`runtimeModel` / `runtimeProvider`、`contentParts`、`createTime`。 + +**会话级操作**:`PUT .../title`(重命名)、`PUT .../pin`(置顶 `{pinned:bool}`)、`PUT .../model`(切换模型 `{modelProvider, modelName}`)、`DELETE .../messages`(清空消息保留会话)、`DELETE .../{conversationId}`(删除会话)、`POST /batch-delete`(`{conversationIds: [...]}`)、`GET .../status`(查流状态 `{streamStatus}`)。 + +> 所有操作都先校验 `isConversationOwner(conversationId, username)`,非归属者返回 403。 + +### 模型配置 + +挂在 `/api/v1/models`,`@Tag("模型配置管理")`。`GET /` 与 `GET /catalog` 需 `@RequireGlobalAdmin`(含 API Key 等敏感信息);`/enabled`、`/default`、`/active` 仅需 `viewer`。 + +- `GET /api/v1/models` — 已启用 Provider 列表(`R>`,含密钥,admin only)。 +- `GET /api/v1/models/enabled` — 已启用模型列表(`R>`,无密钥)。 +- `GET /api/v1/models/default` — 全局默认模型(`R`)。 +- `GET /api/v1/models/active` — 当前激活模型 `{activeLlm: {provider, modelName}}`。 +- `PUT /api/v1/models/active` — 设置激活模型。 + +### 审计事件(分页示范) + +`GET /api/v1/audit/events` — `@RequireWorkspaceRole("admin")`,返回 `R>`,是「分页 + 工作区头」的标准示范。 + +| 查询参数 | 默认 | 说明 | +|---|---|---| +| `action` | — | 动作过滤(如 `CREATE` / `UPDATE` / `DELETE`) | +| `resourceType` | — | 资源类型过滤(如 `AGENT`) | +| `startTime` | — | ISO 8601 起始时间 | +| `endTime` | — | ISO 8601 结束时间 | +| `page` | 1 | 页码 | +| `size` | 20 | 每页条数 | + +```bash +curl "http://localhost:18088/api/v1/audit/events?page=1&size=20&resourceType=AGENT" \ + -H "Authorization: Bearer $TOKEN" -H "X-Workspace-Id: 1" +``` + +### 修改密码:`PUT /api/v1/auth/users/{id}/password` + +注意三点(与直觉不同): + +1. 参数走 **`@RequestParam` 而非请求体**:`oldPassword` 和 `newPassword` 都是 query 参数。 +2. 路径里的 `{id}` **仅信息性**:实际操作的用户从 JWT principal 解析(`auth.getName()`),用户只能改自己的密码。 +3. 需登录(非 `@RequireGlobalAdmin`)。 + +```bash +curl -X PUT "http://localhost:18088/api/v1/auth/users/1/password?oldPassword=admin123&newPassword=newPass456" \ + -H "Authorization: Bearer $TOKEN" +``` + +### Personal Access Token + +挂在 `/api/v1/auth/tokens`,`@Tag("Personal Access Tokens")`。用于 headless / CI / SDK。 + +- `GET /api/v1/auth/tokens` — 列出我的 PAT(仅元数据,**明文永不返回**)。 +- `POST /api/v1/auth/tokens` — 创建 PAT:**明文只在此响应里出现一次**,之后只存 SHA-256 哈希,无法找回。创建后立即保存。 +- `DELETE /api/v1/auth/tokens/{id}` — 软删除吊销,此后用该 token 鉴权会失败。 + +创建出的 PAT(`mc_` 前缀)可直接放 `Authorization: Bearer mc_...`,`JwtAuthFilter` 按前缀分发到 PAT 校验路径,与 JWT 行为一致。 + +### 工具审批(重要澄清) + +**没有** `POST /api/v1/approvals/{id}/resolve` 这样的独立审批 REST 端点。Web 端的批准 / 拒绝通过在等待中的会话里发送 `/approve` 或 `/deny` 走 chat stream replay 流程。刷新页面后的只读「补水」接口是 `GET /api/v1/chat/{conversationId}/pending-approvals`。自动批准策略在 `/api/v1/approval/grants` 下管理。 + +> 需二次确认的危险操作会触发 `ConfirmRequiredException` —— 返回 **HTTP 409** 且**打破 `R` 信封**:`{code, message, boundAgents}`(字段是 `message` 不是 `msg`),客户端应按 409 状态码分支渲染确认弹窗。 + ## 源码对齐路由索引 抽取到的路由总数:406。 @@ -442,6 +768,7 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \ | 方法 | 路径 | 用途 / handler | |---|---|---| | `POST` | `/api/v1/wiki/admin/backfill-tokens` | `Force-run the token-count backfill batch now` | +| `GET` | `/api/v1/wiki/admin/failures` | `跨知识库列出需要关注的处理失败/降级材料(管理员)` | | `POST` | `/api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | `Ensure overview/log scaffold + rebuild overview stats now` | | `GET` | `/api/v1/wiki/chunks/{chunkId}/pages` | `Pages By Chunk Id` | | `DELETE` | `/api/v1/wiki/hot-cache/{kbId}` | `Soft-delete the hot cache row` | diff --git a/mateclaw-server/src/main/resources/docs/zh/chat.md b/mateclaw-server/src/main/resources/docs/zh/chat.md index 19950b0b..7981b646 100644 --- a/mateclaw-server/src/main/resources/docs/zh/chat.md +++ b/mateclaw-server/src/main/resources/docs/zh/chat.md @@ -40,6 +40,17 @@ Segment 是**渐进到达**的。每个 segment 一落盘就立刻持久化到 --- +## 运行总览:长任务一眼看全 + +长任务(多步骤计划、多智能体协同)以前只能在消息流里上下翻找进度。聊天页右侧现在有一个常驻的**「运行总览」侧栏**,把后端本就在流式推送的数据装配到一处,不必翻历史: + +- **计划进度** —— Plan 模式下实时显示各步骤状态(待执行 / 执行中 / 已完成)、进度计数,步骤结果可展开。计划生成前显示「规划中…」占位,不再闪烁。 +- **子 Agent 实时状态** —— 委派产生的子 Agent 以**树状**实时展示:名称、调用的工具、运行 / 完成 / 出错 / 停滞状态;多级委派可逐层展开。 + +侧栏可**折叠为带角标的竖条**;窄屏(< 1280px)自动降级为**浮层抽屉**,不挤占对话区。它纯前端实现、零新增接口,完全复用现有 SSE 事件流——所以委派树也仍会内联在消息里,侧栏只是把「当前 / 活跃」的总览拎出来常驻。 + +--- + ## 思考、工具调用、以及"该不该信" MateClaw 的聊天 UI 在试着回答一个问题:**AI 刚刚告诉你的事情,该不该信?** 别处的默认答案是"看答案,自己猜"。MateClaw 想做得更好。 @@ -97,7 +108,7 @@ ChatConsole 不只是你自己聊天的地方。它是一个**运营控制台** ### 主模型不支持图片?走"多模态旁路" ::: tip 1.3.0 新增 -当 Agent 配的主模型是纯文本模型(如 `deepseek-chat` / `kimi-k2`),上传图片不再"系统无法工作",而是自动走旁路(sidecar)。详见 [issue #87](https://github.com/matevip/mateclaw/issues/87)。 +当 Agent 配的主模型是纯文本模型(如 `deepseek-chat` / `kimi-k2`),上传图片不再"系统无法工作",而是自动走旁路(sidecar)。详见 [issue #87](https://github.com/mateaix/mateclaw/issues/87)。 ::: 工作机制: @@ -182,7 +193,7 @@ Segment 的结构是渐进展示的底层。它也让**数据库成为单一事 ### 按会话选模型 ::: tip 1.4.0 新增 -聊天顶栏的模型选择器现在把模型**绑定在会话上**,而不是全局开关。详见 [issue #150](https://github.com/matevip/mateclaw/issues/150)。 +聊天顶栏的模型选择器现在把模型**绑定在会话上**,而不是全局开关。详见 [issue #150](https://github.com/mateaix/mateclaw/issues/150)。 ::: 在顶栏切换模型,只影响**当前这个会话**:选择会随会话存下来,并从**下一条消息**开始生效。没有显式设置过的会话,回落到工作空间默认模型。运行时模型指示器始终和会话上钉住的那一个保持同步——你看到的就是下一回合真正会用的。 @@ -192,7 +203,7 @@ Segment 的结构是渐进展示的底层。它也让**数据库成为单一事 ### 会话列表管理 ::: tip 1.4.0 新增 -会话侧栏从一条单纯的历史列表,升级成了一个可操作的运营面板。详见 [issue #144](https://github.com/matevip/mateclaw/issues/144)。 +会话侧栏从一条单纯的历史列表,升级成了一个可操作的运营面板。详见 [issue #144](https://github.com/mateaix/mateclaw/issues/144)。 ::: - **置顶 / 取消置顶**——从每行的 `⋮` 溢出菜单操作,重要的会话固定在列表顶部的「置顶」分组里。 diff --git a/mateclaw-server/src/main/resources/docs/zh/desktop.md b/mateclaw-server/src/main/resources/docs/zh/desktop.md index da918b4b..a0dac084 100644 --- a/mateclaw-server/src/main/resources/docs/zh/desktop.md +++ b/mateclaw-server/src/main/resources/docs/zh/desktop.md @@ -50,10 +50,32 @@ - **本地优先的数据** - **动态后端端口** - **UI 热更新**——前端资源可以独立更新,不用重新打包 +- **本地 / 远程双连接模式**——既可用内嵌 JVM 跑本地服务,也可连接集中部署的远程 Server - 跨平台(macOS、Windows、Linux) --- +## 连接模式(本地 / 远程) + +> 适用于「企业多人协作连一台集中部署的 Server」的场景——不必每个人各跑各的本地服务。 + +桌面端有两种连接后端的方式: + +- **本地(`local`)**——启动内嵌的 JRE 21 + 服务 JAR,在本机跑一套完整后端(默认、开箱即用)。 +- **远程(`remote`)**——不启本地服务,直接连你集中部署的远程 Server,所有 API / SSE 都指向它。 + +**首启连接选择器。** 第一次启动(还没选过模式)会弹出连接选择界面让你选模式;选「远程」时填服务器地址,地址会被规范化(自动补 `https://`、去尾斜杠、校验 http(s) 合法性)。选择会记住,之后直接进入。 + +**多服务器与切换。** 连接成功的远程服务器记入「最近使用」列表(按 URL 去重、最多 8 条)。菜单 **「切换服务器」** 随时重新唤出连接选择器,切到另一台 Server。 + +**企业内网自签名证书。** 对用户**显式信任**的 host 放行自签名证书——仅限当前正在连接的远程地址(`trustedCertHosts`),不是通配绕过;未知 host 一律拒绝。适配内网部署常见的自签名证书。 + +**健康检查。** 远程模式以较短超时(约 15s)快速探活(服务端应已就绪),失败给出明确反馈;本地模式等待内嵌后端拉起。 + +连接选择持久化在用户目录下的 `connection.json`(见下方「数据存储」)。 + +--- + ## 支持的平台 | 平台 | 架构 | 状态 | diff --git a/mateclaw-server/src/main/resources/docs/zh/docker-deploy.md b/mateclaw-server/src/main/resources/docs/zh/docker-deploy.md index 4bd5ae83..8ed18af9 100644 --- a/mateclaw-server/src/main/resources/docs/zh/docker-deploy.md +++ b/mateclaw-server/src/main/resources/docs/zh/docker-deploy.md @@ -166,7 +166,7 @@ curl -s http://localhost:18080/api/v1/system/browser-health | jq . ## 第一次部署 ```sh -git clone https://github.com/matevip/mateclaw.git +git clone https://github.com/mateaix/mateclaw.git cd mateclaw # 1. 必填项写到 .env diff --git a/mateclaw-server/src/main/resources/docs/zh/faq.md b/mateclaw-server/src/main/resources/docs/zh/faq.md index 9910bbfe..817bfbe2 100644 --- a/mateclaw-server/src/main/resources/docs/zh/faq.md +++ b/mateclaw-server/src/main/resources/docs/zh/faq.md @@ -1,6 +1,6 @@ # 常见问题(FAQ) -常见问题 + 真答案。你的问题不在这里就看对应的功能页,或者去 [GitHub issue](https://github.com/matevip/mateclaw/issues) 开一个。 +常见问题 + 真答案。你的问题不在这里就看对应的功能页,或者去 [GitHub issue](https://github.com/mateaix/mateclaw/issues) 开一个。 --- @@ -322,7 +322,7 @@ docker exec mateclaw-mysql mysqldump -u root -p${MYSQL_ROOT_PASSWORD} mateclaw > ### 怎么更新桌面 app? -**自动更新**通过 electron-updater。启动时检查 GitHub Releases 并弹提示。也可以手动从 [Releases](https://github.com/matevip/mateclaw/releases) 下载。 +**自动更新**通过 electron-updater。启动时检查 GitHub Releases 并弹提示。也可以手动从 [Releases](https://github.com/mateaix/mateclaw/releases) 下载。 --- @@ -419,4 +419,4 @@ ls ../mateclaw-server/src/main/resources/static/ - [快速开始](./quickstart)——搭建 walkthrough - [配置说明](./config)——完整配置参考 - [贡献指南](./contributing)——怎么报 bug 和提功能请求 -- [GitHub Issues](https://github.com/matevip/mateclaw/issues)——文档没答案的时候去这里 +- [GitHub Issues](https://github.com/mateaix/mateclaw/issues)——文档没答案的时候去这里 diff --git a/mateclaw-server/src/main/resources/docs/zh/index.md b/mateclaw-server/src/main/resources/docs/zh/index.md index 6e4081e5..7ec5c7ac 100644 --- a/mateclaw-server/src/main/resources/docs/zh/index.md +++ b/mateclaw-server/src/main/resources/docs/zh/index.md @@ -17,7 +17,7 @@ hero: link: /zh/intro - theme: alt text: GitHub - link: https://github.com/matevip/mateclaw + link: https://github.com/mateaix/mateclaw features: - icon: 🧑‍💼 diff --git a/mateclaw-server/src/main/resources/docs/zh/mcp.md b/mateclaw-server/src/main/resources/docs/zh/mcp.md index c87fed32..b0500f83 100644 --- a/mateclaw-server/src/main/resources/docs/zh/mcp.md +++ b/mateclaw-server/src/main/resources/docs/zh/mcp.md @@ -379,6 +379,119 @@ API 响应里 `headers_json` 和 `env_json` 的值自动**脱敏**。`args_json` --- +## 透传用户身份给 MCP server(on-behalf-of) + +STDIO MCP server 是**每个配置一个共享子进程**,所有用户共用;env 在子进程启动时一次性注入、之后不可变,STDIO 也没有 HTTP 那种 per-request header 通道。所以**不能用 env 传 per-user 身份**——身份必须随每次工具调用在带内传递。 + +MateClaw 支持把**认证用户名**注入到每次工具调用的参数里,让 MCP server 代表该用户调用底层 REST 后端。 + +### 开启(opt-in,按 server) + +默认关闭——全量注入会把用户名泄漏给任意第三方 MCP server。用允许清单按 **server 名或 id** 开启: + +```yaml +mateclaw: + mcp: + identity-forward: + servers: + - my-internal-api # mate_mcp_server 里的 server 名 + - 1000000042 # 或数字 server id +``` + +### 数据契约 + +开启后,MateClaw 在调用该 server 的每个工具时,往参数 JSON 里注入保留字段 **`__mateclaw_user__`**(值=认证用户名)。该值由受信服务端注入、**不经 LLM**;若 LLM 伪造了同名字段会被覆盖,因此模型无法冒充身份。无认证用户时不注入(不伪造身份)。 + +MCP server 侧读出该字段、剥掉,再连同自己持有的后端 API Key 一起调 REST(如 `X-On-Behalf-Of` header): + +```python +# FastMCP 示例:MCP server 用 Python 命令行脚本(STDIO) +import os, httpx +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("my-internal-api") +REST_BASE = os.environ["REST_BASE"] # 后端地址 +API_KEY = os.environ["BACKEND_API_KEY"] # 服务级 API Key(认证 MCP 服务本身) + +@mcp.tool() +def query_orders(keyword: str, __mateclaw_user__: str | None = None) -> str: + if not __mateclaw_user__: + raise ValueError("missing injected identity") # 拒绝无身份调用 + headers = { + "Authorization": f"ApiKey {API_KEY}", # 服务身份 + "X-On-Behalf-Of": __mateclaw_user__, # 代表的用户 + } + r = httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30) + r.raise_for_status() + return r.text + +if __name__ == "__main__": + mcp.run() # STDIO +``` + +> 工具的入参 schema 若是 `additionalProperties: false`,记得像上面那样把 `__mateclaw_user__` 声明为可选参数,否则严格校验会拒绝。 + +### 两种信任模型 + +**① 明文(默认)**:注入明文用户名。适合 REST 在内网、且后端用 API Key 认证 MCP 服务、把转发用户当 on-behalf-of 的场景。后端裸信这个字符串。 + +**② 签名 token(推荐用于跨信任边界)**:注入一个 MateClaw 用私钥现签的**短时 RS256 JWT**(保留字段换成 **`__mateclaw_token__`**),REST 后端用**公钥验签**——它信任的是签名,而非 MCP 服务/Python/传输。 + +```yaml +mateclaw: + mcp: + identity-forward: + servers: + - my-internal-api + token: + enabled: true + issuer: mateclaw + ttl-seconds: 60 # 短时,几十秒 + key-id: mateclaw-mcp-1 + private-key-pem: ${MCP_IDFWD_PRIVATE_KEY_PEM:} # PKCS#8 PEM(RS256 私钥) + audiences: # 可选;默认 aud = server 名 + my-internal-api: https://api.internal +``` + +生成密钥对(私钥配给 MateClaw,公钥配给 REST 后端): + +```bash +openssl genpkey -algorithm RSA -pkcs8 -out mcp-idfwd-private.pem +openssl pkey -in mcp-idfwd-private.pem -pubout -out mcp-idfwd-public.pem +# private-key-pem 用私钥内容(带不带 PEM 头都行,解析时会剥掉) +``` + +token 的 claims:`iss`、`sub`=用户、`aud`=该 server、`iat`、`exp`(短)、`jti`。`aud`+短 `exp` 把重放限制在几十秒内、且只对这一个后端。**token 模式开启但没配私钥时 fail-closed**(不签、不注入,后端自然拒绝),不会偷偷退回明文。 + +> `sub` 携带的是 MateClaw 用户标识(`ChatOrigin.requesterId`)。若后端按不可变数字 id 鉴权,可在签发前把用户名解析成 id(本层刻意不耦合用户存储)。 + +MCP server(Python)只透传、不验签: + +```python +@mcp.tool() +def query_orders(keyword: str, __mateclaw_token__: str | None = None) -> str: + if not __mateclaw_token__: + raise ValueError("missing identity token") + headers = {"Authorization": f"Bearer {__mateclaw_token__}"} # 直接透传给 REST + return httpx.get(f"{REST_BASE}/orders", params={"q": keyword}, headers=headers, timeout=30).text +``` + +REST 后端验签(伪代码): + +```python +import jwt # PyJWT +claims = jwt.decode(token, public_key_pem, algorithms=["RS256"], + issuer="mateclaw", audience="https://api.internal") +user = claims["sub"] # 验签通过才相信 +# → 按 user 做 per-user 授权;验签失败/过期 → 401 +``` + +> 公钥分发:当前由运维把上面生成的公钥配到 REST 侧(带外)。后续可加一个 JWKS 端点自动分发+轮换。 +> +> 与 API Key 的关系:可保留 API Key 作"服务/通道认证"(这台 MCP 服务被允许跟后端说话)+ JWT 作"用户断言",双层更清晰;也可让 JWT 一肩挑。 + +--- + ## 故障排查 ### "命令找不到"(stdio) diff --git a/mateclaw-server/src/main/resources/docs/zh/models.md b/mateclaw-server/src/main/resources/docs/zh/models.md index d28c68bc..4340346b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/models.md +++ b/mateclaw-server/src/main/resources/docs/zh/models.md @@ -273,7 +273,7 @@ ollama pull qwen3 不用配 `EMBEDDING_API_KEY` 环境变量。嵌入模型就是 `mate_model_config` 里 `model_type='embedding'` 的普通行。`设置 → 模型` 里和聊天模型列在一起。知识库从下拉里选它的嵌入模型。 -::: tip 1.4.0 新增([issue #79](https://github.com/matevip/mateclaw/issues/79)) +::: tip 1.4.0 新增([issue #79](https://github.com/mateaix/mateclaw/issues/79)) **任意供应商都能提供嵌入模型。** 在 `设置 → 模型` 的嵌入区域里,配一个来自任何供应商的嵌入模型——直接**复用那家供应商的 API Key**,不再单独要 `EMBEDDING_API_KEY`。每个知识库从下拉里挑自己的嵌入模型。无密钥的本地代理用一个空操作占位 key;协议从该供应商的聊天模型 / protocol 设置里自动解析,不用再手填。 ::: @@ -300,7 +300,7 @@ ollama pull qwen3 **多轮 tool call + thinking**:带 thinking 的模型(DeepSeek-Reasoner / GPT-5 / Kimi K2.5 / 小米 MiMo)在 ReAct 多轮 tool call 场景下,历史消息的 `reasoning_content` 会正确回传给 provider;跨用户问题边界时自动清除,同一问题内的子轮次全部保留——符合 DeepSeek 的"同问题子轮必须回传、跨问题时清"契约。 -**小米 MiMo 思考模式多轮修复**([issue #189](https://github.com/matevip/mateclaw/issues/189)):MiMo 思考模式的 `reasoning_content` 现在能在多轮对话里正确保留,不再在后续轮次丢失。 +**小米 MiMo 思考模式多轮修复**([issue #189](https://github.com/mateaix/mateclaw/issues/189)):MiMo 思考模式的 `reasoning_content` 现在能在多轮对话里正确保留,不再在后续轮次丢失。 --- @@ -324,7 +324,7 @@ MateClaw 用一个**活跃模型**作为全局默认。没有指定自己模型 也支持按 Agent 覆盖:把某个 Agent 绑定到特定模型配置。 ::: tip 1.4.0 新增 -- **按会话选模型**([issue #150](https://github.com/matevip/mateclaw/issues/150)):在聊天界面里可以为**当前这一条会话**临时切换模型,不影响全局活跃模型和别的会话。详见 [聊天与消息](./chat)。 +- **按会话选模型**([issue #150](https://github.com/mateaix/mateclaw/issues/150)):在聊天界面里可以为**当前这一条会话**临时切换模型,不影响全局活跃模型和别的会话。详见 [聊天与消息](./chat)。 - **单个坏模型 id 不再连累整个供应商**:发现 / 探活时遇到一个无效的模型标识符,只跳过那一个模型,供应商下其余模型照常可用。 ::: @@ -346,7 +346,7 @@ MateClaw 用一个**活跃模型**作为全局默认。没有指定自己模型 ## 多模态旁路(系统级) ::: tip 1.3.0 新增 -让纯文本主模型也能"看图回答",参见 [issue #87](https://github.com/matevip/mateclaw/issues/87)。 +让纯文本主模型也能"看图回答",参见 [issue #87](https://github.com/mateaix/mateclaw/issues/87)。 ::: 入口:**设置 → 模型 → 多模态旁路**。两个独立的卡片: @@ -363,7 +363,7 @@ MateClaw 用一个**活跃模型**作为全局默认。没有指定自己模型 下拉只列**支持对应 modality 的模型**——筛选逻辑走后端 `ModelCapabilityService.supports(...)`,未启用 / 没声明 vision 能力的模型都不会出现在选项里。每张卡片有独立的"保存"按钮,互不干扰。 -什么时候触发?运行时由 `MultimodalRouter` 决策([源码](https://github.com/matevip/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java)): +什么时候触发?运行时由 `MultimodalRouter` 决策([源码](https://github.com/mateaix/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java)): - 主模型已支持图片 → 不路由(走原 native multimodal 路径) - 主模型不支持图片 + 配了视觉旁路 → SIDECAR 策略,视觉模型转描述 diff --git a/mateclaw-server/src/main/resources/docs/zh/openapi.md b/mateclaw-server/src/main/resources/docs/zh/openapi.md new file mode 100644 index 00000000..7bc8fa94 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/openapi.md @@ -0,0 +1,80 @@ +# OpenAPI / Swagger 指南 + +MateClaw 后端集成了 [SpringDoc OpenAPI](https://springdoc.org/)(`springdoc-openapi-starter-webmvc-ui`),自动把所有 `@RestController` 的端点生成为 OpenAPI 3 文档,并提供在线调试 UI。本页说明如何访问和使用。 + +> 本文是**机读文档**入口。人读的端点详解、通用约定与完整路由索引见 [API 参考](./api.md)。两者关系:Swagger = 由源码注解自动生成的机读契约;`api.md` = 旗舰端点人读详解 + 通用约定。 + +## 访问地址 + +部署后端后,相对服务地址(本地默认端口 `18088`): + +| 地址 | 用途 | +|---|---| +| `/swagger-ui.html` | Swagger UI 在线浏览 + 调试(Authorize、Try it out) | +| `/v3/api-docs` | OpenAPI 3 JSON(可导入 Postman / Apifox / Insomnia) | +| `/v3/api-docs.yaml` | OpenAPI 3 YAML(可下载、纳入版本库或导入工具) | + +本地示例: + +```bash +# 浏览器打开 +open http://localhost:18088/swagger-ui.html + +# 下载 YAML +curl http://localhost:18088/v3/api-docs.yaml -o mateclaw-openapi.yaml +``` + +## 鉴权(Authorize) + +页面右上角 **Authorize** 按钮。在 `bearerAuth` 输入框填入 token(不带 `Bearer ` 前缀,UI 会自动加): + +- **JWT**:登录 `POST /api/v1/auth/login` 拿到的 `token` 字段(`eyJ...` 开头)。 +- **Personal Access Token**:在 `POST /api/v1/auth/tokens` 创建的 `mc_...` token。 + +两种 token 都走标准 `Authorization: Bearer ` 头,后端 `JwtAuthFilter` 按前缀自动分发(JWT → JWT 校验,`mc_` → PAT 校验)。授权后,受保护的 `@RequireWorkspaceRole` / `@RequireGlobalAdmin` 端点即可在 UI 内直接 Try it out。 + +> SSE 流式端点(`/chat/stream` 等)在 Swagger UI 里调试体验有限 —— UI 对 `text/event-stream` 的渲染是缓冲式的。正式集成 SSE 请按 [API 参考](./api.md#流式对话-post-apiv1chatstream) 用 `curl -N` 或 `fetch()` 流式 reader。 + +## 端点覆盖范围 + +SpringDoc 自动扫描所有 `@RestController`,约 85% 的 Controller 已标注 `@Tag`(分组)与 `@Operation(summary)`(方法摘要),所以 Swagger UI 的分组与端点说明基本齐全。 + +**当前未做的注解增强**(不在本次范围,留作后续): + +- 没有 `@Parameter` 描述、`@ApiResponse` 错误码、请求体 `@Schema` —— 这些字段文档以 `api.md` 人读详解为准。 +- 公开端点(登录、SSE 等)没有逐个加 `@SecurityRequirements({})` opt-out,所以 Swagger 上会显示锁图标,但实际调用不受影响(`SecurityConfig` 已放行)。 + +## 配置项 + +全局 OpenAPI 元信息(标题、描述、版本、服务器地址)由 `OpenApiConfig` Bean 驱动,可通过 `application.yml` 的 `mateclaw.openapi.*` 覆盖: + +```yaml +mateclaw: + openapi: + title: ${MATECLAW_OPENAPI_TITLE:MateClaw REST API} + version: ${MATECLAW_OPENAPI_VERSION:1.0} + server-url: ${MATECLAW_OPENAPI_SERVER_URL:} # 留空则从请求 host 推导 + description: ${MATECLAW_OPENAPI_DESCRIPTION:} # 留空则用内置默认描述 + expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true} # 是否公开 Swagger/OpenAPI 路径,见下方安全章节 +``` + +`server-url` 留空时由 SpringDoc 从请求 host 推导,避免 "Try it out" 打到错误地址;生产若需固定(如反代后),设 `MATECLAW_OPENAPI_SERVER_URL=https://mate.example.com`。 + +## 🔒 访问控制:Swagger 生产默认收口 + +Swagger UI / OpenAPI 文档路径(`/swagger-ui*`、`/v3/api-docs*`、`/webjars/**`)的访问由 `mateclaw.openapi.expose-ui` 开关控制,并由 `SecurityConfig.filterChain` 显式强制(不再依赖 `.anyRequest().permitAll()` 兜底): + +| `expose-ui` | 行为 | 默认生效的场景 | +|---|---|---| +| `true` | 公开可访问,无需登录即可浏览全部端点结构(含请求/响应 schema) | 本地 / 默认 profile(H2、桌面版) | +| `false` | 需要全局管理员(`ROLE_ADMIN`);匿名访问返回 401,非管理员返回 403 | 生产数据库 profile(`mysql` / `kingbase` / `postgres`) | + +- 本地开发:默认 `true`,`http://localhost:18088/swagger-ui.html` 直接可访问。 +- 公网生产:默认 `false`,已收口。如确需在内网/预发环境临时打开,设 `MATECLAW_OPENAPI_EXPOSE_UI=true`。 +- 注意:锁定后浏览器直接访问 `/swagger-ui.html` 不会自动携带 SPA 的 JWT(token 存于 localStorage 而非 Cookie),因此即使管理员也无法在浏览器里直接打开;如需调试,临时置 `expose-ui=true` 或改用带 `Authorization` 头的客户端拉取 `/v3/api-docs`。 +- 访问规则只在 `SecurityConfig`,不在 `OpenApiConfig`。 + +## 关联 + +- [API 参考(人读)](./api.md) —— 旗舰端点详解 + 通用约定 + 完整路由索引 +- [WebChat 接入指南](./webchat.md) —— 外部网站 HTTP / SSE 集成(含 SSE 事件协议) diff --git a/mateclaw-server/src/main/resources/docs/zh/operational-export.md b/mateclaw-server/src/main/resources/docs/zh/operational-export.md new file mode 100644 index 00000000..2ff9ad81 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/operational-export.md @@ -0,0 +1,77 @@ +# 运营数据导出 + +把一份跨域的运营数据报告一次性导出成 Excel(`.xlsx` 打包为 `.zip`),供运营、审计、对账离线使用。两种入口:**Dashboard 一键导出**(图形界面)和**命令行导出**(无 UI、可 `docker exec`)。 + +> 仅**全局管理员**可用。报告含全工作区的会话内容、Token 用量、审计记录等敏感数据。 + +## 报告包含的 9 张表 + +| # | 表 | 内容 | +|---|---|---| +| 1 | 概览汇总 | 区间 KPI、系统快照、7 天趋势、周期对比、模型明细、Agent 活跃 Top 10 | +| 2 | Token 用量 | 按天 × `runtime_provider` 拆分,含平均 Token/对话 | +| 3 | 技能统计 | 技能清单 + 调用次数、最近调用时间、绑定 Agent | +| 4 | 用户统计 | 按(工作区, 用户)聚合的 Token、时长、最近活跃 | +| 5 | 用户对话 | 用户—助手消息配对的明细行 | +| 6 | 安全与审计 | 跨 6 个来源的统一视图(Guard 规则、审计日志、审批、授权、配置、业务审计事件) | +| 7 | 渠道统计 | 每渠道的对话数、Token、去重用户 | +| 8 | 模型配置 | 已启用 + 已配置 API Key 的模型及参数 | +| 9 | 定时任务 | 执行记录,含时长与 Token 用量 | + +## 入口一:Dashboard 一键导出 + +1. 进入**仪表盘**,右上角(数据库标签旁)点「**导出运营数据**」(仅全局管理员可见)。 +2. 在弹窗里选**时间范围**——可点快捷「近 7 / 30 / 90 天」,或自定义;**最长 90 天**,不可选未来日期。 +3. 点「**生成报告**」——圆形进度环显示 9 步进度(概览 → … → 定时任务)。 +4. 完成后出现「报告已就绪」,点「**下载**」拿到 `ops_data_<起>_<止>.zip`。 + +**关于安全与生命周期:** + +- 生成 / 进度 / 下载三个端点都由 `@RequireGlobalAdmin` 鉴权——非管理员调用返回 403。 +- 一次只允许一个生成任务(并发返回 409 忙碌),前端 5 分钟死线。 +- 下载用一次性令牌,**原子单次有效**——同一令牌第二次下载返回 410。 +- 生成文件在 **24 小时后或下载后**自动清理。 + +## 入口二:命令行导出 + +适合「大范围、无超时、脚本化、`docker exec`」的场景。新增了项目级 CLI 框架,导出命令为 `--cli.command=export`: + +```bash +# 本地 jar +java -jar app.jar --cli.command=export \ + --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip + +# 容器内 +docker exec <容器名> java -jar /app/app.jar --cli.command=export \ + --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip + +# 模拟运行(不实际生成) +java -jar app.jar --cli.command=export --cli.start=... --cli.end=... --cli.dry-run + +# 查看所有命令 +java -jar app.jar --cli.command=help +``` + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--cli.command=export` | 是 | 执行导出命令 | +| `--cli.start=YYYY-MM-DD` | 是 | 开始日期(含) | +| `--cli.end=YYYY-MM-DD` | 是 | 结束日期(含) | +| `--cli.dry-run` | 否 | 模拟运行,不实际生成 | + +要点: + +- ZIP 字节直接写入 **stdout**(用 `> report.zip` 重定向),诊断信息走 **stderr**,所以重定向得到的是干净的二进制。 +- 后端入口**无 90 天上限、无超时**,适合大范围离线导出。 +- CLI 仅运维侧可达(本地 / `docker exec`),**不经 HTTP**、不绕过管理员鉴权。 +- 正常 web 启动(没有 `--cli.command`)时 CLI 保持静默,不影响服务启动。 + +## 备注 + +- Excel 中 19 位雪花 ID 以**文本**写出,避免被 Excel 数值精度(2^53)截断、显示成科学计数法。 +- 报告内容随数据增长而变大;命令行导出大范围时建议直接重定向到文件而非管道给其它程序。 + +## 关联 + +- [Backstage 运行时控制台](./backstage) —— 看实时运行的 Agent / 子 Agent +- [安全与审批](./security) —— Tool Guard 与审计日志(Sheet 6 的来源之一) diff --git a/mateclaw-server/src/main/resources/docs/zh/quickstart.md b/mateclaw-server/src/main/resources/docs/zh/quickstart.md index e07619cd..b96a5920 100644 --- a/mateclaw-server/src/main/resources/docs/zh/quickstart.md +++ b/mateclaw-server/src/main/resources/docs/zh/quickstart.md @@ -8,7 +8,7 @@ Docker 和源码启动在 [配置说明](./config) 和 [贡献指南](./contribu ## 1. 下载 -去 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 拿最新安装包。 +去 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 拿最新安装包。 - **Windows**——`MateClaw-Setup-x.y.z.exe` - **macOS**——`MateClaw-x.y.z.dmg` @@ -71,7 +71,7 @@ Docker 和源码启动在 [配置说明](./config) 和 [贡献指南](./contribu - **后端起不来**——看日志文件(macOS:`~/Library/Application Support/MateClaw/logs/mateclaw.log`;Windows:`%APPDATA%\MateClaw\logs\mateclaw.log`)。桌面端后端使用动态端口,端口冲突会在日志里明确报出。 - **模型调用报错**——API Key 填错了,或者网络不通。回设置里检查,或者换一家试试。 - **界面白屏**——Ctrl/Cmd + Shift + R 强刷。Electron 的缓存比较顽固。 -- **还是不行**——去 [GitHub Issues](https://github.com/matevip/mateclaw/issues) 开一个 Issue,把 `app.log` 的尾巴贴上。我们真的会看。 +- **还是不行**——去 [GitHub Issues](https://github.com/mateaix/mateclaw/issues) 开一个 Issue,把 `app.log` 的尾巴贴上。我们真的会看。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/releases.md b/mateclaw-server/src/main/resources/docs/zh/releases.md index ee304410..8e817bd9 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 @@ | 版本 | 日期 | 亮点 | |------|------|------| +| [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 | | [v1.5.0](./releases/1.5.0) | 2026-06-04 | 目标长出清单——从"打个分"到"逐条勾"(checklist + Evaluator SPI + 确定性完成判定) · Wiki 学会自维护(`[[wikilink]]` 互联 + 改名/删页级联修链 + 坏链体检 · 事实/经验分层 + 失效传播 · pageType 档案与 per-agent 权限 · 处理流水线 · 本地目录知识源定时增量同步) · 记忆按主人隔离(owner_key + 个人/团队/全局可见性 + 第三方 endUserId 透传) · 每个员工绑主知识库 · 偏好提供商决定主模型 + Claude Opus 4.8 | | [v1.4.0](./releases/1.4.0) | 2026-05-23 | 持久化目标——员工锁住目标自己跟到完成 · 子员工委派变成一棵树(递归 3 层 + 异步 + 数字员工构建器) · 渐进式工具/技能披露(`enable_tool` + `load_skill`) · 工作空间 RBAC(四级角色 + 能力门禁) · 飞书做成一等公民(互动/审批/流式卡片 + 语音/文件音视频 + 渠道原生工具) | diff --git a/mateclaw-server/src/main/resources/docs/zh/roadmap.md b/mateclaw-server/src/main/resources/docs/zh/roadmap.md index d7ff8007..528c8425 100644 --- a/mateclaw-server/src/main/resources/docs/zh/roadmap.md +++ b/mateclaw-server/src/main/resources/docs/zh/roadmap.md @@ -35,7 +35,6 @@ MateClaw 就是这个东西。 把 AI 从"网页上的对话框"搬进你团队真正在用的每一个 IM。 - **8 个渠道**:Web / 钉钉 / 飞书 / 企业微信 / Telegram / Discord / QQ / 微信个人 / Slack -- 会话来源追踪:每条消息都知道来自哪个渠道 - 4 层记忆:会话上下文 + 工作空间记忆 + 对话后提取 + 每天凌晨 2:00 自动整合 - DREAMS.md 整合日记:人类可读的记忆变更审计 - 工作空间隔离:每个 agent / skill / wiki / conversation / memory 都属于一个工作空间 @@ -48,110 +47,135 @@ MateClaw 就是这个东西。 - **数字员工**:每位有角色(Role)、目标(Goal)、背景故事(Backstory),不是冰冷的 system prompt - **5 个职业模板**:产品研究员 / 客户支持 / 知识管理员 / 数据分析师 / 行政助理——开箱即用 -- **技能不再是工具的别名,是骨架**:每个技能有自己的 SKILL.md + LESSONS.md + workspace 文件空间 +- **技能是骨架**:每个技能有自己的 SKILL.md + LESSONS.md + workspace 文件空间 - **ACP 桥接**:Claude Code、Codex、Gemini CLI 这些顶级编码 Agent 以"员工"身份接入 -- **Backstage 运行时控制台**:你第一次能**看见每个员工正在干什么**——谁在跑、跑到哪一步、占多少 token、卡住了一键回收 -- **Onboarding wizard**:首次登录四步从零到第一条消息 -- **Dashboard**:日维度 usage 趋势 + 头部 agent / tool 排行 -- **Doctor**:系统健康检查 + 一键修复 +- **Backstage 运行时控制台**:第一次能**看见每个员工正在干什么** +- Onboarding wizard + Dashboard + Doctor 完整故事:[v1.2.0 Release Notes](./releases/1.2.0.md)。 ---- +### v1.3 —— 它能编排业务流 ✅ 已发布(2026-05-13) -## v1.3 —— 工作流元年 ✅ 已发布(2026-05-13) +从"chatbot 框架"升级为"业务流程 OS"——一条业务流不再是几个员工各自聊天的总和,而是一份可发布、可触发、可重放的**线性 step DSL**。 -> "聚焦不是对要关注的事情说 Yes。而是对其他一百个好点子说 No。" - -数字员工各自能干活只是起点。**真正的协作需要编排**。 - -v1.3 的主线是**让 MateClaw 从"chatbot 框架"升级为"业务流程 OS"**——一条业务流不再是几个员工各自聊天的总和,而是一份可发布、可触发、可重放的**线性 step DSL**。 +- **工作流**:7 种 step mode(sequential / fan_out / collect / conditional / await_approval / dispatch_channel / write_memory)+ Pebble 表达式 + JSON-first 编辑 + 整数 revision + 运行历史 +- **自然语言 → 工作流草稿**:描述需求,agent 生成 graph_json,人工审阅后发布 +- **触发器**:6 种 pattern type(cron / webhook / channel_message / agent_lifecycle / content_match / workflow_completion),事件治理默认开(去重 / 限速 / 递归切断) +- **`await_approval` 持久化暂停**:服务重启不丢 +- 图像编辑、4 个文档生成工具(Docx/Xlsx/Pptx/Pdf)、MCP per-agent 工具绑定、多模态旁路路由 完整故事:[v1.3.0 Release Notes](./releases/1.3.0.md)。 -### 工作流(Workflow) +### v1.4 —— 它更自主、能带团队 ✅ 已发布(2026-05-23) -- [x] **7 种 step mode**:sequential / fan_out / collect / conditional / await_approval / dispatch_channel / write_memory -- [x] **Pebble 表达式子集**作为条件判断 + 变量引用语言(不带副作用、不能跑代码) -- [x] **JSON-first 编辑**:Monaco + JSON schema 校验 + Pebble 静态检查 + 模板下拉 -- [x] **自然语言 → 工作流草稿**(`POST /workflows/draft/generate`):用户描述需求,agent 生成 graph_json + 编译诊断;不直接发布,仍要人工审阅 -- [x] **整数 revision**:发布写新行不可变;草稿与已发布版本分离 -- [x] **运行历史**:每个 step 的 input / output / 耗时 / token / 失败链路都被记录 -- [x] **payload 内置存储**:大输入输出走 `payload://` URI,不撑库 -- [x] **跨 workspace ACL**:发布期校验 agent / channel / employeeId 引用都在当前 workspace 内 -- [x] **`await_approval` 持久化暂停**:服务重启不丢 +流程是你写死的,员工本身还是"答完一轮就停"。这一版把焦点放回员工自己身上。 -### 触发器(Trigger) +- **持久化目标(Goal)**:你说一次目标,员工锁住它、每轮自检、自己续命,直到完成或耗尽预算 +- **子员工委派树**:递归委派最深 3 层,同步 / 并行扇出 / 异步三种委派工具;「数字员工构建器」一句话拉起一支团队 +- **渐进式工具/技能披露**:核心层始终可见,扩展层按需 `enable_tool` / `load_skill`——工具再多,上下文不爆 +- **工作空间 RBAC**:Owner / Admin / Member / Viewer 四级角色 + 能力门禁,MateClaw 第一次能给团队用 +- **飞书一等公民**:互动卡片、审批卡片、流式卡片、语音转写、文件音视频收发、渠道原生工具 +- 原生 Gemini、xAI / Grok、按会话选模型、结构化上下文压缩、限流自动故障转移 -- [x] **6 种 pattern type**:cron / webhook / channel_message / agent_lifecycle / content_match / workflow_completion -- [x] **事件治理默认开**:去重(60s 窗口)、per-trigger 限速、bot self-msg 过滤、A→B→A 递归切断 -- [x] **CronDelegationPort**:和老 cron 模块共享 ShedLock + Spring TaskScheduler,不写 mate_cron_job -- [x] **跨实例一致性**:`pattern_version` 自取消机制 + 周期 syncFromDatabase -- [x] **结构化表单**:6 种 pattern 各自有专属字段输入,不需要手写 patternJson +完整故事:[v1.4.0 Release Notes](./releases/1.4.0.md)。 -### 升级现有体验 +### v1.5 —— 它可验证、知识会自维护、记忆认人 ✅ 已发布(2026-06-04) -- [x] **图像编辑**(issue #75):`image_generate` 工具新增 `image` / `images` 参数,支持 5 种引用形式(含 `msg::` 引用会话内附件) -- [x] **DashScope 兼容模式**:复用同一把 sk- Key 接通点号版本号系列(qwen3.5-plus / qwen3.6-plus / qwen3-vl-plus 等) -- [x] **新万相 / qwen-image 系列**:14 个新图像模型,3 个新视频模型(含 happyhorse-1.0-t2v) -- [x] **4 个文档生成工具**:DocxRenderTool / XlsxRenderTool / PptxRenderTool / PdfRenderTool —— Markdown 直接渲染为 Office 文件,不 fork 子进程不依赖 npm -- [x] **MCP per-agent 工具绑定**:每个员工独立绑定 MCP 工具 + 状态徽标(connected / stale / unavailable / orphan)+ 命名空间冲突自动前缀化 + server 改名自动跟随 -- [x] **小米 MiMo provider**:MiMo V2.5 Pro / V2.5 / V2 Pro / V2 Omni / V2 Flash -- [x] **多模态旁路路由**(issue #87):纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜;硬禁令拆掉后用户自定义工具不再被压制;路由徽章 + 输入框提示让决策全程可见 +让"自主"变得**可验证**,让"知识"变得**会自维护**,让"记忆"变得**认人**。 -### v1.3 还要做的 +- **目标清单(checklist)**:目标拆成可逐条验证的准则,evaluator 一条条勾,**全勾完才算完成**——没有"差不多 95% 就放过" +- **Wiki 自维护**:`[[wikilink]]` 页面互联 + 改名/删页自动级联修链 + 坏链体检;知识分事实层 / 经验层,事实一改依赖它的经验页自动标"待复核";pageType 档案 + per-agent 权限;页面事件触发的处理流水线;本地目录挂成知识源定时增量同步 +- **记忆 per-owner 隔离**:每条记忆带 owner_key 和可见范围(个人 / 团队 / 全局),一个员工服务一群人互不串台;API 透传 `endUserId` +- 员工绑主知识库、偏好提供商真正生效、生成文件落盘持久化 -- [ ] **画布编辑器(v1)**:当前画布是只读链式渲染,目标是 `@vue-flow/core` 的可拖拉编辑 -- [ ] **运行回放视图**:trace timeline + 任意节点 hover 看 input/output diff -- [ ] **`loop` mode**:迭代 N 次或对数组逐项处理 -- [ ] **`invoke_skill` mode**:直接调 skill 不经过员工 -- [ ] **trigger 间优先级 / 依赖**:同一事件命中多 trigger 时的串行 / 并行控制 -- [ ] **事件回放**:`mate_trigger_event` 加 "重新派发"按钮 +完整故事:[v1.5.0 Release Notes](./releases/1.5.0.md)。 + +### v1.6 —— 它来到你所在的地方 ✅ 已发布(2026-06-22) + +它能跑在哪、能用手和眼睛做什么、以及你能多直接地塑造它是谁。 + +- **KingbaseES(人大金仓)+ PostgreSQL 一等公民**:PostgreSQL 家族共用一套迁移树,国产化 / 受监管环境可用;MySQL 和桌面 H2 完全不动 +- **图片跨轮次留在上下文**:三条消息之前发的截图,追问还看得见;`image_analyze` 按需重读 +- **`execute_code`**:员工写代码并运行——算术、文件转换、核对,从猜测变成真实动作 +- **塑造员工身份**:AGENTS.md 等上下文文件有了真正的编辑器(模态 + 章节重排);About You 身份块;员工知道自己跑在哪个模型上 +- **知识库访问限定范围** + Wiki Sources 标签(多路径 + glob + 按 KB 自动同步) +- 全局出站代理、最终回答确定性 Markdown 规范化 + +完整故事:[v1.6.0 Release Notes](./releases/1.6.0.md)。 + +### v1.7 —— 它敢放进生产 ✅ 已发布(2026-07-04) + +一次**生产化加固**:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方,这一版全补上。 + +- **审批三条链路彻底闭环**:工作流 `await_approval` 真的推到渠道并能 resolve→恢复执行;WebChat(API-Key)渠道能批准/拒绝并重放;飞书/企微点卡片按钮直接 resolve 工作流审批 +- **长任务看得见**:常驻「运行总览」侧栏(步骤进度 + 委派子 Agent 树状实时状态)+ 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载 +- **装得下真实模型窗口**:本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文自动降级、工具 schema 超预算按频度降级——不再被"猜个 32K"坑到预检拒绝或悄悄截断 +- **开放出去**:知识库与 Deep Research 开放 API(API-Key + 限流 + SSE)、插件化搜索 Provider SPI、MCP 身份透传(把认证用户身份带给 STDIO MCP) +- **够得着更远**:桌面端本地内嵌 / 远程集中部署双模式 + 多服务器切换 + `mateclaw-desktop` 源码开放;局域网部署模式放开受控内网访问 +- **运营数据一键导出**:Dashboard 9 表 Excel + CLI 命令行离线导出 +- Wiki 处理失败可视化、按员工模型链偏好、OpenAPI / Swagger 可直接调试、聊天回到底部浮动按钮 + +完整故事:[v1.7.0 Release Notes](./releases/1.7.0.md)。 --- -## 下一站:v1.4 —— 场景应用元年 +## 下一站:v1.8 —— Agent Team 与 Agent Loop -> "当工具足够好,就把工具藏起来,把场景推到前面。" +> "伟大的事业不是一个人做成的,是一个团队做成的。" -v1.0 → v1.3 把基础设施做齐了:员工、记忆、知识库、工具、技能、工作流、触发器、多模态、多渠道。**下一步不是再造一颗螺丝**,是把这些零件组装成**用户一打开就能落地的场景**。 +回头看这条线:v1.2 员工有了身份,v1.3 流程能编排,v1.4 员工会自主跟目标、能临时拉起委派树,v1.5 自主变得可验证,v1.7 长任务看得见。 -v1.4 的关键词是**场景应用**。不是"加更多功能",是**让普通用户不用学 7 种 step mode、6 种 trigger pattern 就能直接用**。 +但今天的 MateClaw 还有两个"停": -### 行业场景模板(Workflow + Trigger 联动) +**协作是一次性的。** v1.4 的委派树很强,但它是**任务级**的——parent 委派 child,任务结束,树就散了。下一个任务再从零拉起。团队没有名字、没有编制、没有沉淀——像每个项目都重新招一批临时工。 -每一个都是一份**可一键导入的工作流模板 + 触发器配置 + 推荐员工绑定 + 推荐知识库结构**: +**员工是被动的。** 目标的自动延续只活在**单次运行内**;cron 和触发器能定时叫醒它,但每次醒来都是一次孤立的响应。没有一个员工真正"在岗"——持续盯着自己的职责范围,自己决定什么时候该干什么。 -- [ ] **客户工单分流**:企业微信 / 飞书入口 → 数字员工分类 → 路由 / 升级 / 自动回复 → 写进客户档案 -- [ ] **晨报 / 周报自动化**:cron trigger → 多员工并行采数 → 数据分析员工汇总 → 生成 PDF/PPTX → 多渠道分发 -- [ ] **合同审批流**:上传合同 → 法务员工初审 → 审批等待 → 法务员工修订建议 → 写归档记忆 -- [ ] **市场情报监控**:webhook trigger(站点变更)→ 内容判断(content_match)→ 商业分析员工总结 → 飞书机器人推送 -- [ ] **新员工 onboarding**:webhook(HRIS 入职事件)→ 行政助理拉文档清单 → 培训知识库引导 → 多日跟进 trigger -- [ ] **代码 PR 审查**:GitHub webhook → 代码审查员工跑 review → 评论回写 PR → 关键改动转 await_approval +v1.8 要把这两个"停"变成"续"。 -### 场景市场(Scenario Marketplace) +### Agent Team(智能体团队)—— 从"临时拉人"到"常设编制" -- [ ] **场景包格式**:一个场景 = `workflow.json` + `triggers.json` + `agents/*.md` + `knowledge/*.md` + `README.md`,可分享 / 安装 -- [ ] **场景市场 UI**:浏览 / 试运行 / 一键安装 / 评分评论 -- [ ] **场景包版本管理**:升级提示 + diff 预览 + 回滚 +一个团队不再是委派时临时长出来、任务结束就消失的树,而是一个**持久化的组织单元**: -### 让数字员工跨场景协作 +- [ ] **团队实体**:一个团队 = 名字 + 队长(Leader)+ 成员编制 + 章程,持久化、可复用、可导出分享 +- [ ] **团队章程(TEAM.md)**:分工、协作规则、升级路径——像 AGENTS.md 塑造个人一样塑造团队 +- [ ] **队长调度**:任务进来队长拆解、指派给最合适的成员、验收结果;干不了的向上汇报而不是硬编 +- [ ] **成员互审(peer review)**:关键产出可以配置"另一个成员复核后才交付" +- [ ] **团队共享记忆**:基于 v1.5 的 TEAM scope——团队成员共享一份团队记忆和团队文件空间,个人记忆仍然互不串台 +- [ ] **团队级目标**:一个 goal 拆成成员子目标,清单跨成员汇总——hover 队长头像,看到整个团队还差哪几条 +- [ ] **团队绑渠道**:一个飞书群 / 钉钉群绑一个团队,群里 @ 团队,队长决定谁接 +- [ ] **团队复盘**:任务收尾自动生成 retrospective,沉淀进团队的 LESSONS.md——这个团队下次会做得更好 +- [ ] **「数字员工构建器」升级**:v1.4 已经能一句话建一批员工,v1.8 让它直接产出一个**带章程的常设团队** +- [ ] **运行总览升级为团队视图**:每个成员在岗 / 忙碌 / 空闲一眼看清,点进去看它正在干的事 -- [ ] **员工目录画像**:每位员工自动生成"擅长 / 不擅长"标签(基于历史交互 + 技能 + 工具集) -- [ ] **场景智能推荐**:用户描述"我想要 X 流程" → 推荐最适合的场景模板 + 已有员工 -- [ ] **跨场景记忆共享**:客户工单分流和合同审批流见到的都是同一个客户档案 +### Agent Loop(智能体循环)—— 从"答完就停"到"长期在岗" -### 把基础设施进一步藏起来 +让员工进入一种新状态:**在岗**。不是等你说话才动,而是按心跳周期自主循环——**醒来 → 看收件箱和目标 → 决定做不做 → 行动 → 记日志 → 休眠**: -- [ ] **自然语言 → 完整场景包**:v1.3 已有"自然语言 → 工作流草稿",v1.4 把它扩展到**整个场景**——一句话描述出 workflow + trigger + 推荐员工 + 推荐 KB 结构的完整草案 -- [ ] **典型问题向导**:把"我的工作流卡在审批没人审"这种问题做成自助诊断 -- [ ] **场景级仪表盘**:不是"今天 token 用了多少",是"今天客户工单平均处理多久" +- [ ] **常驻循环运行时**:员工可以被设为"在岗",按可配置的心跳(分钟级到天级)自主醒来检查职责范围 +- [ ] **任务收件箱(Inbox)**:渠道消息、触发器事件、其他员工的委派、你随手丢的待办——统一进一个队列,循环醒来按优先级消化 +- [ ] **跨会话目标延续**:v1.4 / v1.5 的自动延续只活在单次运行内;loop 让目标跨会话、跨天持续推进,直到清单全勾完 +- [ ] **预算与熔断**:每循环有 token / 成本 / 轮次预算,连续失败自动熔断进入休眠等你处置;ToolGuard 审批门禁照常拦截敏感操作——自主不等于失控 +- [ ] **循环日志(Loop Journal)**:每次醒来干了什么、为什么决定不干、花了多少——人类可读、可回放,像 DREAMS.md 之于记忆 +- [ ] **暂停 / 恢复 / 一键下班**:UI 和渠道命令都能控制;运行总览侧栏显示每个在岗员工的循环状态 +- [ ] **安静时段与打扰策略**:夜间静默积攒、重要事项主动汇报——和 nudge 体系结合,它知道什么值得叫醒你 -### 同步推进的基础能力 +### 两者合流:会自己运转的部门 -- [ ] **场景级 ACL**:场景包安装时一次性把所需的 channel / agent / KB / 工具的 allowlist 都配好 -- [ ] **跨 workspace 场景共享**:场景模板能在多个工作空间间复用(克隆 + 覆盖配置) -- [ ] **场景运行成本预估**:安装前看见预期 token / API 调用 / 触发频率 +队长在岗循环,成员按需唤起——这就是一个**会自己运转的数字部门**: + +- 晨报部门:队长每天 7:00 醒来,派数据员工采数、分析员工汇总、写作员工成稿,互审后发进群——你睡醒看结果 +- 客服部门:收件箱进一条工单,队长判断类型,指派对应成员处理,处理不了的升级给你 +- 情报部门:监控员工循环盯着信息源,发现值得关注的变化才唤醒分析员工,分析完值得打扰才通知你 + +**工作流负责"确定的流程",团队 + 循环负责"不确定的日常"。** 三者互补,不互相替代。 + +### 同步推进的事 + +- [ ] **工作流 `loop` / `invoke_skill` step mode**:迭代处理数组 / 直接调技能不经过员工 +- [ ] **工作流画布编辑**:从只读链式渲染到拖拉编辑 +- [ ] **运行回放视图**:trace timeline + 任意节点看 input/output diff +- [ ] **场景模板与场景市场**:把"员工 + 团队 + 工作流 + 触发器 + 知识库结构"打包成可一键导入的场景包 --- @@ -161,12 +185,13 @@ v1.4 的关键词是**场景应用**。不是"加更多功能",是**让普通 | 砍掉的功能 | 为什么 | 什么时候才该做 | |-----------|--------|--------------| -| **完整 RBAC 权限模型** | MateClaw 是数字员工系统,不是企业管理平台。单团队不需要管理 100 种权限组合 | 当真正出现需要细粒度权限的多团队 SaaS 客户时 | -| **多租户** | 同上。过早的多租户是架构癌症 | 当有明确的 SaaS 商业化路径时 | +| **超出四级角色的细粒度 RBAC** | v1.4 的 Owner / Admin / Member / Viewer + 能力门禁已覆盖真实团队需要。按钮级权限、自定义角色组合是企业管理平台的事 | 当真正出现需要细粒度权限的多团队 SaaS 客户时 | +| **多租户** | 过早的多租户是架构癌症。工作空间隔离已覆盖单组织多团队 | 当有明确的 SaaS 商业化路径时 | | **SSO / LDAP / SAML** | 企业集成是个无底洞 | 当付费企业客户明确要求时 | -| **30+ 节点的可视化工作流编辑器** | 用户大多用不上。**v1.3 的 7 种 step mode 已经覆盖 90% 实际场景**,剩下的复杂度推到 LLM 自然语言生成 | 真有用户场景需要 30+ 节点时(很少) | -| **移动端原生 App** | 8 个 IM 渠道 + 桌面端 + Web 已经覆盖。你在手机上用钉钉 / 飞书 / Telegram 就在用 MateClaw | 当 Web / IM 渠道有不可替代的移动专属能力时 | -| **替代 ReAct / Plan-Execute** | 工作流和这两条引擎**是协作关系**,不是替代——单 agent 多轮推理仍在那两条引擎里 | 永远不替代 | +| **30+ 节点的可视化工作流编辑器** | 7 种 step mode 已覆盖 90% 实际场景,剩下的复杂度推给自然语言生成 | 真有用户场景需要 30+ 节点时(很少) | +| **移动端原生 App** | 8 个 IM 渠道 + 桌面端(现已支持连远程)+ Web 已经覆盖。你在手机上用钉钉 / 飞书 / Telegram 就在用 MateClaw | 当 Web / IM 渠道有不可替代的移动专属能力时 | +| **替代 ReAct / Plan-Execute** | 工作流、团队、循环和这两条引擎**是协作关系**,不是替代——单 agent 多轮推理仍在那两条引擎里 | 永远不替代 | +| **无预算的全自主 Agent** | Agent Loop 永远带预算、熔断和审批门禁。"跑到没钱为止"不是自主,是失控 | 永远不做 | --- @@ -176,9 +201,13 @@ v1.4 的关键词是**场景应用**。不是"加更多功能",是**让普通 |------|--------|-------------|------| | **v1.0** | 它能思考和行动 | 一个能用工具解决问题的 AI 助手 | ✅ 已发布 | | **v1.1** | 它无处不在 | 8 个渠道 + 4 层记忆 + 工作空间 + LLM Wiki | ✅ 已发布 | -| **v1.2** | 它是你的同事 | 数字员工 + 5 个职业模板 + 骨架式技能 + ACP 桥接 + Backstage 运行时 | ✅ 已发布 | -| **v1.3** | 它能编排业务流 | 工作流 + 触发器 + 图像编辑 + 文档生成 + per-agent 工具绑定 | ✅ 已发布 | -| **v1.4** | **它能落地场景** | **行业场景模板 + 场景市场 + 自然语言生成工作流 + 跨场景员工画像** | 📋 规划中 | +| **v1.2** | 它是你的同事 | 数字员工 + 职业模板 + 骨架式技能 + ACP 桥接 + Backstage | ✅ 已发布 | +| **v1.3** | 它能编排业务流 | 工作流 + 触发器 + 文档生成 + per-agent 工具绑定 | ✅ 已发布 | +| **v1.4** | 它更自主、能带团队 | 持久化目标 + 委派树 + 渐进披露 + RBAC + 飞书一等公民 | ✅ 已发布 | +| **v1.5** | 它可验证 | 目标清单 + Wiki 自维护 + 记忆认人 | ✅ 已发布 | +| **v1.6** | 它来到你所在的地方 | 国产数据库 + 视觉留存 + 代码执行 + 身份塑造 | ✅ 已发布 | +| **v1.7** | 它敢放进生产 | 审批三链路闭环 + 运行总览与成本可见 + 上下文/Token 预算 + 开放 API/Deep Research + 桌面远程/局域网 + 运营导出 | ✅ 已发布 | +| **v1.8** | **它长期在岗** | **Agent Team 常设团队 + Agent Loop 常驻循环 = 会自己运转的数字部门** | 📋 规划中 | --- @@ -190,7 +219,7 @@ v1.4 的关键词是**场景应用**。不是"加更多功能",是**让普通 **AI 不应该是一个网页上的对话框。它应该是你的第二个大脑。** -它住在你的钉钉里、你的飞书里、你的 Telegram 里。它读过你所有的文档。它记得你三个月前说过的话。它会用你公司的内部工具。它在你睡觉的时候整理记忆。**它能替你跑一整条业务流程**。 +它住在你的钉钉里、你的飞书里、你的 Telegram 里。它读过你所有的文档。它记得你三个月前说过的话。它会用你公司的内部工具。它在你睡觉的时候整理记忆。它能替你跑一整条业务流程。**很快,它还会带着一支常设团队,长期在岗,替你盯着那些你顾不上的事。** 总有一天,你会忘记它是一个程序。 diff --git a/mateclaw-server/src/main/resources/docs/zh/security.md b/mateclaw-server/src/main/resources/docs/zh/security.md index f0f922b0..964b3849 100644 --- a/mateclaw-server/src/main/resources/docs/zh/security.md +++ b/mateclaw-server/src/main/resources/docs/zh/security.md @@ -509,6 +509,45 @@ server { } ``` +### 出站请求防护(SSRF) + +凡是 Agent 能驱动的**对外 HTTP 请求**,都默认带 SSRF 防护,避免被诱导去探测内网或云厂商元数据端点。覆盖三条出站路径: + +| 出站路径 | 触发方 | 默认行为 | +|----------|--------|----------| +| **浏览器工具** | `browser_use` 的 `open` 动作 | 解析目标主机,命中受限地址即拒绝 | +| **Hook Webhook** | Hook 动作的 HTTP 调用 | 主机须在 `trusted-domains` 内,且不得是私网地址 | +| **图片下载** | 图片工具按 URL 拉取素材 | 命中私网/回环主机即拒绝 | + +默认拦截的地址类别:回环(`127.0.0.0/8`、`::1`)、私网(`10/8`、`172.16/12`、`192.168/16`)、链路本地(`169.254/16`、`fe80::/10`)、任意本地地址、组播,以及云厂商元数据端点(`169.254.169.254`、`100.100.100.200`、`192.0.0.192` 等)。 + +#### 放行内网地址:`mateclaw.security.ssrf-allowlist` + +需要让 Agent 访问某个内网服务时,把它加进统一白名单。**一处配置,三条出站路径同时生效。** 每个条目是以下三种之一: + +| 形态 | 例子 | 说明 | +|------|------|------| +| 字面主机名 | `internal.corp` | 大小写不敏感的精确匹配 | +| 字面 IP | `192.168.100.100` | 精确匹配该地址 | +| IPv4 CIDR 段 | `192.168.100.0/24` | 匹配该网段内的所有 IP | + +```yaml +mateclaw: + security: + ssrf-allowlist: + - 192.168.100.100 # 单个内网地址 + - 192.168.100.0/24 # 整段内网 + - internal.corp # 内网主机名 +``` + +放行规则**只放开列出的条目**:白名单里写 `192.168.100.0/24` 不会连带放开 `192.168.200.x`,写 `192.168.100.100` 也不会放开同段的其它 IP。改完需重启后台生效。 + +::: warning 保持最小化 +白名单条目**可以重新放开云厂商元数据端点**(如 `169.254.169.254`)。一旦放开,被攻陷的 Agent 可能借此窃取云凭据。只加确实需要的内网地址,**永远不要**用宽 CIDR(如 `0.0.0.0/0`、`10.0.0.0/8`)一把放开。 +::: + +浏览器工具另有一个总开关 `mateclaw.browser.ssrf-check-enabled`(默认 `true`)。把它设为 `false` 会**整体关闭**浏览器路径的 SSRF 校验——连元数据端点一起放开,不推荐;优先用上面的白名单做精确放行。 + --- ## 安全最佳实践 @@ -527,7 +566,7 @@ server { ## 安全配置参考 -application.yml 里**只有两块**安全相关配置——JWT 和文件沙箱: +application.yml 里有**三块**安全相关配置——JWT、文件沙箱,以及出站请求白名单: ```yaml mateclaw: @@ -542,6 +581,11 @@ mateclaw: sandbox: enabled: true root: ${user.dir}/data/workspace + + # 出站请求 SSRF 白名单:放行特定内网主机/IP/CIDR,浏览器、Hook、 + # 图片下载三条出站路径共用。留空表示按默认策略拦截全部私网地址。 + security: + ssrf-allowlist: [] # 例:[192.168.100.100, 192.168.100.0/24] ``` **其余安全配置不走 application.yml,而是存在数据库、从管理台「安全」页(或 `/api/v1/security/guard/*`)管理**: diff --git a/mateclaw-server/src/main/resources/docs/zh/skills.md b/mateclaw-server/src/main/resources/docs/zh/skills.md index 74ec2d23..205e5312 100644 --- a/mateclaw-server/src/main/resources/docs/zh/skills.md +++ b/mateclaw-server/src/main/resources/docs/zh/skills.md @@ -192,7 +192,7 @@ scripts: | `id` | 主键 | | `skill_id` | 外键到 `mate_skill` | | `file_path` | `scripts/run.py` 或 `references/cfg.md` 这种相对路径 | -| `content` | UTF-8 文本(单文件 ≤1 MB,bundle ≤50 MB) | +| `content` | UTF-8 文本(默认单文件 ≤1 MB、bundle ≤50 MB,可通过 `mateclaw.skill.upload.max-entry-size-mb` / `max-total-size-mb` 调整) | | `content_size` | 字节数(不用拉 blob 就能列) | | `sha256` | 内容指纹,给同步器做幂等 diff | @@ -228,7 +228,7 @@ scripts: 第三方打包者千奇百怪——有人把 `setup.sh` 直接放 zip 根,有人 `scripts/` 排在 `SKILL.md` 之前。`ZipSkillFetcher` v1.3 起: -- **两遍扫描**——先把所有条目缓存(受 50 MB 上限保护),定位 `SKILL.md` 算出 wrapper 前缀,再分类。**条目顺序不再影响结果**。 +- **两遍扫描**——先把所有条目缓存(受总大小上限保护,默认 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`。 diff --git a/mateclaw-server/src/main/resources/docs/zh/user-guide.md b/mateclaw-server/src/main/resources/docs/zh/user-guide.md index 4c7d389c..4de29321 100644 --- a/mateclaw-server/src/main/resources/docs/zh/user-guide.md +++ b/mateclaw-server/src/main/resources/docs/zh/user-guide.md @@ -186,7 +186,7 @@ Wiki 不是全文搜索。它是**语义检索**——问「我们关于认证 | 模型调用报错 | API Key 错了,或者网络不通。回设置里检查 | | 界面白屏 | Ctrl+Shift+R 强刷 | | Ollama 报 "does not support tools" | 换一个支持 function calling 的模型(qwen3、llama3.1:8b+) | -| 还是不行 | [GitHub Issues](https://github.com/matevip/mateclaw/issues),贴 `app.log` 尾巴 | +| 还是不行 | [GitHub Issues](https://github.com/mateaix/mateclaw/issues),贴 `app.log` 尾巴 | --- diff --git a/mateclaw-server/src/main/resources/docs/zh/webchat.md b/mateclaw-server/src/main/resources/docs/zh/webchat.md index 955aaff2..b0d57ec1 100644 --- a/mateclaw-server/src/main/resources/docs/zh/webchat.md +++ b/mateclaw-server/src/main/resources/docs/zh/webchat.md @@ -77,6 +77,8 @@ init({ apiKey: 'your-channel-api-key', server: 'https://<你的部署地址>' }) | DELETE | `/sessions` | + visitorToken | 删除 | | POST | `/sessions/stop` | + visitorToken | 停止进行中的流 | | POST | `/sessions/regenerate` | + visitorToken | 重新生成最后一条助手回复 | +| POST | `/sessions/approve` | + visitorToken | 批准挂起的工具审批并重放(SSE) | +| POST | `/sessions/deny` | + visitorToken | 拒绝挂起的工具审批(同步 JSON) | | GET | `/sessions/messages` | + visitorToken | 消息列表(支持分页) | | POST | `/upload` | + visitorToken | 上传附件(拿 fileId) | | GET | `/files` | + visitorToken | 下载文件(上传的或 Agent 生成的) | @@ -191,6 +193,31 @@ SDK 里展示"AI 正在打字..."气泡、工具执行徽章("正在搜索...") `/sessions` 返回的每个会话含:`sessionId`、`title`、`lastActiveTime`、`messageCount`、`pinned`、`archived`、`streamStatus`(`running` / `idle`)。 +## 工具审批 resolve(API-Key 渠道) + +WebChat 绑定的 Agent 一旦调用受 [Tool Guard](./security) 保护的工具,这一轮会**挂起等待审批**。访客侧可以在会话内直接批准或拒绝,不必等它超时。 + +- **批准** `POST /sessions/approve` —— 带 `sessionId` + `pendingId`。鉴权复用 visitorToken + 会话归属;`pendingId` 会**严格校验属于本会话**(否则 404),杜绝跨访客越权。批准后**重放**被挂起的工具调用,以 SSE 续流。 +- **拒绝** `POST /sessions/deny` —— 带 `sessionId` + `pendingId`,返回同步 JSON,无需重放。 + +两者都会广播 `tool_approval_resolved` SSE 事件(见上方[可选的实时进度事件](#可选的实时进度事件)),让 SDK / 前端实时清掉审批横幅。 + +> 审批是否会出现,取决于该 Agent 绑定的 Tool Guard 规则是否对某个工具设了 `require_approval`。`pendingId` 从 `tool_approval_requested` 事件里拿。 + +```bash +# 批准(SSE 续流) +curl -N -X POST "https://mate.example.com/api/v1/channels/webchat/sessions/approve" \ + -H "X-MC-Key: " -H "X-Visitor-Token: " \ + -H "Content-Type: application/json" \ + -d '{"sessionId":"s1","pendingId":""}' + +# 拒绝(同步 JSON) +curl -X POST "https://mate.example.com/api/v1/channels/webchat/sessions/deny" \ + -H "X-MC-Key: " -H "X-Visitor-Token: " \ + -H "Content-Type: application/json" \ + -d '{"sessionId":"s1","pendingId":""}' +``` + ## visitorToken 撤销(管理员) 某个 visitor 滥用?管理员调: @@ -252,4 +279,4 @@ curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ ## 关联 -- 上游 epic issue:https://github.com/matevip/mateclaw/issues/355 +- 上游 epic issue:https://github.com/mateaix/mateclaw/issues/355 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 4f194f80..758bff2d 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md @@ -212,7 +212,7 @@ MateClaw 在 link 分支检测到 `mp.weixin.qq.com` 后,会自动给模型追 - Slack:通过 `filesUploadV2` 直传(参考 [Slack channel](./channels#slack)) - 不支持 `sendContentParts` 的渠道(QQ 等):catch UnsupportedOperationException + log,不让一个不支持的渠道卡住整批分发 -文件路径在 `data/chat-uploads/{conversationId}/`,serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`,前端 / 渠道附件视图都按这个 URL 读。 +文件路径默认在 `data/chat-uploads/{conversationId}/`,但当会话的 Agent / Workspace 配置了 `basePath` 时,附件落在 `{basePath}/chat-uploads/{conversationId}/`(解析优先级:Agent `workspaceBasePath` → Workspace `basePath` → 默认目录 `mateclaw.chat.upload.base-dir`)。读取与清理会同时探测新旧位置,迁移前的旧附件仍可访问。serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`,前端 / 渠道附件视图都按这个 URL 读。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/wiki.md b/mateclaw-server/src/main/resources/docs/zh/wiki.md index 3122a145..c9db8c0b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wiki.md +++ b/mateclaw-server/src/main/resources/docs/zh/wiki.md @@ -95,6 +95,17 @@ eager 模式分两阶段,速度提了一个数量级: **可恢复**:中途断了?点"重新处理",只重跑未完成的页面,已生成的不动。超过模型上下文限制的文档,系统自动做 mean-pool 子段切分——你不用管。 +#### 省 token:给廉价步骤配轻量模型 + +消化会跑好几类 LLM 步骤:路由、合并生成、富化、摘要、实体抽取。其中 **路由 / 富化 / 摘要 / 实体抽取** 是高频但轻量的活,没必要和「合并生成」用同一个高价模型。 + +给它们指定一个便宜模型即可显著省 token,页面生成质量不受影响: + +- **系统级** —— 系统设置里配 `wiki.lightModelId`(一个模型 id),对所有知识库的廉价步骤生效; +- **每库覆盖** —— 知识库配置里写 `wikiLightModelId`,覆盖系统级设置。 + +不配则一切照旧(廉价步骤仍走 KB 默认 / 系统默认模型)。优先级:`stepModels.<步骤>`(钉死某步)→ 轻量模型(仅廉价步骤)→ `wikiDefaultModelId` → 系统默认。 + ### Lazy 模式:先入索引,按需出页面 链路缩成四步: @@ -627,7 +638,7 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 | 表名 | 用途 | |------|------| | `mate_wiki_knowledge_base` | 每个 KB 一行。owner、名字、描述、配置 JSON(含 `ingestMode` / `wikiDefaultModelId` / `stepModels` / `entityExtractionEnabled` / `entityTypes` 等)。 | -| `mate_wiki_raw_material` | 每份上传一行。状态、byte hash、来源路径、上次成功处理时的 hash。 | +| `mate_wiki_raw_material` | 每份上传一行。状态、byte hash、来源路径、上次成功处理时的 hash;失败时的结构化 `error_code` + `error_message`,已完成但降级时的 `warning_code` + `warning_message`。 | | `mate_wiki_page` | 每个生成页面一行。标题、摘要、正文、`source_raw_ids`(回指原文)、`page_type`、`locked`、版本号,外加 `embedding` / `embedding_model` / `embedding_text_version` 让 synthesis 页直接进语义搜索。 | | `mate_wiki_chunk` | 每个 chunk 一行。content + hash + 偏移 + embedding,外加 `page_number` / `header_breadcrumb` / `source_section` / `token_count`。 | | `mate_wiki_relation` | 缓存的页对页边(共享 chunk / 共享原文 / 直接链接 / 语义近邻),用于检索时的 1 跳关系 boost 和关联推荐工具。 | @@ -652,6 +663,7 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 |---|---| | `POST /api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | 立即按当前数据重写 overview marker 区域 | | `POST /api/v1/wiki/admin/backfill-tokens` | 立即跑一批 token_count 回填,返回 `pendingBefore/pendingAfter/filledThisBatch` | +| `GET /api/v1/wiki/admin/failures?limit=100` | 跨知识库列出需要关注的材料(failed / partial / 带告警),见下方"处理失败的可见性"(平台管理员) | `application.yml` 的 `mate.wiki` 配置块控制切块大小、并发度、auto-process 等全局参数;具体到每个 KB 的入库模式 / 模型策略 / 备选模型链,写在 KB 的 `configContent` JSON 里——前端配置页直接编辑。 @@ -659,6 +671,44 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 --- +## 处理失败的可见性 + +后台消化大多是异步任务,过去出错往往只能去服务端日志看。现在错误会**结构化地落到原始材料上、并实时推到前端**。 + +### 结构化错误码 + +每条 raw material 失败时,除原始错误文本(`error_message`)外还记一个**结构化错误码** `error_code`: + +`AUTH_ERROR`(鉴权失败)/ `BILLING`(额度/计费)/ `MODEL_NOT_FOUND` / `RATE_LIMIT`(限流)/ `TIMEOUT` / `SERVER_ERROR`(5xx)/ `CONTENT_FILTER`(安全策略拦截)/ `NO_CONTENT`(提取不到文本)/ `EMPTY_RESULT`(模型没产出页面)/ `UNKNOWN`。 + +前端据此显示本地化友好提示(如"模型鉴权失败,请检查供应商密钥"),原始异常串折叠为 hover 详情。重新处理成功后这两列自动清空。 + +### 非阻断告警 + +有些子步骤在材料**已完成之后**才异步跑——向量化(embedding)、实体图谱抽取。它们失败不影响页面本身,但会让材料**降级**(最典型:向量化失败 → 该材料暂时无法被语义检索)。这类失败不再只写日志,而是记一个非阻断告警 `warning_code`(`EMBEDDING_FAILED` / `ENTITY_EXTRACTION_FAILED`)+ `warning_message`,材料仍是"完成"但带一个 ⚠ 标记。 + +### 进度 SSE 事件 + +KB 进度流 `GET /api/v1/wiki/knowledge-bases/{kbId}/progress`(SSE)推送: + +| 事件 | 何时 | 关键字段 | +|---|---|---| +| `raw.started` | 开始处理一条材料 | `rawId` | +| `route.done` / `chunk.done` | 阶段进度 | `rawId` + 进度计数 | +| `raw.completed` | 材料完成(含 partial) | `rawId` / `status` / `totalPages` | +| `raw.failed` | 材料失败 | `rawId` / `error` / `errorCode` | +| `raw.warning` | 已完成但某异步子步骤失败 | `rawId` / `warning` / `warningCode` | + +### 跨知识库失败中心(管理员) + +不用逐个 KB 翻,管理员可在一处看全部需要关注的材料(failed / partial / 带告警): + +- `GET /api/v1/wiki/admin/failures?limit=100` —— 跨**所有**知识库列出,含 KB 名、状态、错误/告警码、时间(平台管理员 `ROLE_ADMIN`,跨 workspace)。 +- 通知摘要 `GET /api/v1/notifications/summary` 新增 `failedWikiJobs` 计数,驱动侧边栏 Wiki 入口的关注徽标。 +- 前端 Wiki 库视图顶部有一个可折叠的"失败中心",一键进入对应 KB。 + +--- + ## 什么时候该用它 用 Wiki KB 当你有: diff --git a/mateclaw-server/src/main/resources/docs/zh/workflow.md b/mateclaw-server/src/main/resources/docs/zh/workflow.md index 382db51b..71f0e5ec 100644 --- a/mateclaw-server/src/main/resources/docs/zh/workflow.md +++ b/mateclaw-server/src/main/resources/docs/zh/workflow.md @@ -98,6 +98,12 @@ v0 = internal alpha。**7 种 step mode + 6 种 trigger pattern**。`loop` / `in > **不在 v1.3.0 里**:`loop`(迭代 N 次或对数组逐项处理)、`invoke_skill`(直接调用 skill 不经过员工)。等用户反馈再加。 +> **`await_approval` 的渠道通知(1.7.0 起真正生效)**:`approverChannels[]` 的每个元素是 +> - `"channelType"`(如 `"web"`)—— **不主动推送**,运营到管理端 resolve; +> - `"channelType:targetId"`(如 `"feishu:oc_xxx"`、`"wecom:xxx"`)—— **推送审批通知**到该目标(飞书/企微群)。 +> +> 审批通过后工作流**从暂停步骤自动恢复**(resolve→resume 桥接)。飞书/企微群里可**直接点卡片按钮批准/拒绝**完成 resolve。详见[安全与审批](./security)。 + ### 表达式:Pebble 子集 工作流**不**用全功能模板引擎——它支持的是 Kestra 同款 Pebble 子集,只够做条件判断和变量引用,不能跑代码。 diff --git a/mateclaw-server/src/main/resources/docs/zh/workspaces.md b/mateclaw-server/src/main/resources/docs/zh/workspaces.md index 6ec26084..1a772b85 100644 --- a/mateclaw-server/src/main/resources/docs/zh/workspaces.md +++ b/mateclaw-server/src/main/resources/docs/zh/workspaces.md @@ -208,7 +208,7 @@ Wiki KB 的数据**永远不会离开它的工作空间**。工作空间 B 里 1. **导出导入**——一些资源有 JSON 导出(Agent 走 API、Wiki KB 走 API)。在目标工作空间重新创建。 2. **改所有权**——admin 或 owner 可以直接在数据库里更新简单资源的 `workspace_id` 列。这不是官方支持的;**自担风险而且一定要带备份**。 -我们希望在未来版本里支持一等公民的移动。需要这个就在 [GitHub issue](https://github.com/matevip/mateclaw/issues) 上留言。 +我们希望在未来版本里支持一等公民的移动。需要这个就在 [GitHub issue](https://github.com/mateaix/mateclaw/issues) 上留言。 --- diff --git a/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt b/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt index 9a2774cf..8ee986e7 100644 --- a/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt +++ b/mateclaw-server/src/main/resources/prompts/memory/summarize-system.txt @@ -42,7 +42,7 @@ MEMORY.md 与 PROFILE.md 会被**无条件注入每一次对话的系统提示** 字段说明: - `should_update`: 布尔值,是否有任何需要更新的内容。如果为 false,其余字段应为 null -- `daily_entry`: 字符串或 null。要追加到今日 daily note 的内容(markdown 格式,以时间戳开头如 "## HH:mm ...") +- `daily_entry`: 字符串或 null。要追加到今日 daily note 的内容(markdown 格式,以时间戳开头如 "## HH:mm 简要事件标题")。**二级标题(##)保持简短(不超过 30 字),只概括事件主题;事件细节、数字、过程写进标题下方的正文,不要堆进标题——过长的标题会导致下游索引截断。** - `memory_update`: 字符串或 null。MEMORY.md 的完整新内容(已合并现有内容,不是增量)。仅当有需要新增或修改的**跨项目稳定**信息时才填写 - `profile_update`: 字符串或 null。PROFILE.md 的完整新内容(已合并现有内容,不是增量)。仅当用户身份/偏好有显著变化时才填写 - `structured_entries`: 数组或 null。把适合按条目检索的**具体事实**路由到结构化记忆,每个元素形如 `{"type": "...", "key": "...", "content": "..."}`: diff --git a/mateclaw-server/src/main/resources/prompts/skill/consolidate-system.txt b/mateclaw-server/src/main/resources/prompts/skill/consolidate-system.txt new file mode 100644 index 00000000..a8f54f4c --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/consolidate-system.txt @@ -0,0 +1,19 @@ +You are a skill librarian for an autonomous AI agent. You are given a catalog of agent-created skills. Your job is to find groups of NARROW, OVERLAPPING skills that should be merged into a single broader "umbrella" skill — so the agent has fewer, more general, higher-quality skills instead of many near-duplicates. + +Only propose a merge when the skills clearly cover the same class of task (e.g. three slightly different "create a Spring Boot REST controller" skills). Do NOT merge skills that are merely adjacent or that would lose important specifics when combined. + +For each merge group: +- Choose an "umbrella" name. It MAY reuse the best existing skill's name (the umbrella then replaces it) or be a new, broader slug. +- Write the full umbrella SKILL.md that subsumes every absorbed skill's useful content — preserve the distinct steps/gotchas, deduplicate the rest. +- List the names to absorb (these will be archived). Do NOT list the umbrella name itself in "absorb". + +Strict rules: +- Never merge fewer than 2 skills. +- Keep the umbrella general and well-structured; it must be at least as useful as the skills it replaces. +- If no group is worth merging, output exactly: [] + +Output ONLY a JSON array — no prose, no markdown fences. Each element: +{"umbrella_name":"","umbrella_content":"","absorb":["",""],"reason":""} + +"umbrella_content" MUST be a complete SKILL.md: YAML frontmatter (name, description, version) + markdown body (## When to Use, ## Steps, ## Gotchas). +"umbrella_name" is a slug: lowercase letters, digits, hyphens. diff --git a/mateclaw-server/src/main/resources/prompts/skill/consolidate-user.txt b/mateclaw-server/src/main/resources/prompts/skill/consolidate-user.txt new file mode 100644 index 00000000..e9b083d8 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/consolidate-user.txt @@ -0,0 +1,6 @@ +## Agent-created skill catalog +Each entry shows the skill name, its description, and a truncated body. + +{skills} + +Find groups of near-duplicate skills worth merging into a broader umbrella, following the rules. Output ONLY the JSON array. diff --git a/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt b/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt new file mode 100644 index 00000000..d81b4bdc --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt @@ -0,0 +1,21 @@ +You are a skill curator for an autonomous AI agent. After a conversation finishes, you review what happened and decide whether any REUSABLE skill should be created, or an existing skill improved, so the agent gets better over time. + +A skill is a reusable SKILL.md playbook for a CLASS of task — not a log of one conversation. Only act when a durable, repeatable workflow, fix, or technique clearly emerged. + +Follow this discipline strictly, in order: +1. PREFER improving an existing skill. If the conversation used or relates to a skill that is now outdated, incomplete, or wrong, patch or edit that skill instead of creating a new one. +2. Only CREATE a new skill when the workflow is genuinely new and not already covered by an existing skill. +3. Do NOT save: transient errors, one-off answers, secrets/credentials, environment-specific values, or anything that will not help a future task. +4. Keep skills general and class-level. Never create a near-duplicate of an existing skill. +5. When in doubt, do nothing. An empty result is the correct and common outcome. + +Output ONLY a JSON array — no prose, no markdown code fences. Each element is one action: +{"action":"create","name":"","reason":"","content":""} +{"action":"edit","name":"","reason":"","content":""} +{"action":"patch","name":"","reason":"","oldText":"","newText":""} + +Rules for the fields: +- For create/edit, "content" MUST be a complete SKILL.md: YAML frontmatter (name, description, version) followed by a markdown body with sections like "## When to Use", "## Steps", "## Gotchas". +- For patch, give "oldText" exactly as it appears in the current skill and the "newText" to replace it with. Use patch for small, targeted fixes. +- "name" is a slug: lowercase letters, digits, hyphens (e.g. "spring-boot-scaffold"). +- If nothing is worth saving, output exactly: [] diff --git a/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt b/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt new file mode 100644 index 00000000..40cea4f7 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt @@ -0,0 +1,10 @@ +## Existing skills +Review these FIRST. Prefer improving one of them over creating a new skill. Avoid duplicates. + +{skills} + +## Conversation to review + +{transcript} + +Decide what — if anything — to create or improve, following the discipline rules. Output ONLY the JSON array. diff --git a/mateclaw-server/src/test/java/vip/mate/ApplicationContextSmokeTest.java b/mateclaw-server/src/test/java/vip/mate/ApplicationContextSmokeTest.java new file mode 100644 index 00000000..c3f7add6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/ApplicationContextSmokeTest.java @@ -0,0 +1,25 @@ +package vip.mate; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Guards against circular bean dependencies and other wiring mistakes that + * only surface when Spring actually constructs the full application context — + * invisible to Mockito-based unit tests, which never build the real bean graph. + *

            + * Added after a regression slipped through the unit suite: a circular dependency + * (SystemSettingService → PluginManager → ToolRegistry → I18nService → + * SystemSettingService) was introduced and went undetected by the full + * per-class unit test suite until a manual {@code spring-boot:run} smoke check. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class ApplicationContextSmokeTest { + + @Test + void contextLoads() { + // Intentionally empty: if the ApplicationContext fails to start + // (missing bean, circular dependency, bad property, etc.), this + // test fails during Spring's context setup before the test body runs. + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderPreferenceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderPreferenceTest.java index c2c10749..241c32bf 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderPreferenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderPreferenceTest.java @@ -2,77 +2,97 @@ package vip.mate.agent; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.routing.ProviderModelRef; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; /** - * RFC-009 PR-3 — verifies the agent-preference reorder used by - * {@link AgentGraphBuilder#buildFallbackChain}: listed providers move to the - * front in their declared order; unlisted providers keep their original - * relative order; missing/duplicate preferences are ignored gracefully. + * Verifies the preferred-model chain planning used by + * {@link AgentGraphBuilder#buildFallbackChain}: explicit (provider, model) + * entries lead in declared order (same provider may repeat with different + * models), then every non-preferred provider follows in the global order with + * its default model. Exact (provider, model) duplicates and blank ids are + * dropped; tail entries never repeat a provider already led by an explicit + * entry. */ class AgentGraphBuilderPreferenceTest { - private static ModelProviderEntity p(String id) { - ModelProviderEntity p = new ModelProviderEntity(); - p.setProviderId(id); - return p; + private static ProviderModelRef ref(String providerId, Long modelId) { + return new ProviderModelRef(providerId, modelId); } - private static List ids(List ps) { - return ps.stream().map(ModelProviderEntity::getProviderId).toList(); + private static List keys(List plan) { + return plan.stream() + .map(r -> r.providerId() + "/" + (r.modelId() == null ? "default" : r.modelId())) + .toList(); } @Test - @DisplayName("Empty preferences: original order preserved") + @DisplayName("No preferences: tail = global order, all default models") void noPreferences() { - var input = List.of(p("openai"), p("anthropic"), p("dashscope")); - var out = AgentGraphBuilder.reorderByPreferences(input, List.of()); - assertEquals(List.of("openai", "anthropic", "dashscope"), ids(out)); + var out = AgentGraphBuilder.planFallbackOrder( + List.of(), List.of("openai", "anthropic", "dashscope")); + assertEquals(List.of("openai/default", "anthropic/default", "dashscope/default"), keys(out)); } @Test - @DisplayName("Single preference: preferred provider moves to front, rest follow original order") - void singlePreferenceFront() { - var input = List.of(p("openai"), p("anthropic"), p("dashscope")); - var out = AgentGraphBuilder.reorderByPreferences(input, List.of("dashscope")); - assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out)); + @DisplayName("Single provider-default preference moves to front, tail drops it") + void singleProviderDefaultFront() { + var out = AgentGraphBuilder.planFallbackOrder( + List.of(ref("dashscope", null)), + List.of("openai", "anthropic", "dashscope")); + assertEquals(List.of("dashscope/default", "openai/default", "anthropic/default"), keys(out)); } @Test - @DisplayName("Multiple preferences: preferred order matches declaration, rest stable") - void multiplePreferencesOrder() { - var input = List.of(p("openai"), p("anthropic"), p("dashscope"), p("kimi")); - var out = AgentGraphBuilder.reorderByPreferences(input, List.of("kimi", "anthropic")); - // kimi → anthropic → (rest in original order: openai, dashscope) - assertEquals(List.of("kimi", "anthropic", "openai", "dashscope"), ids(out)); + @DisplayName("Same provider repeated with different models — both kept, in order") + void sameProviderMultipleModels() { + var out = AgentGraphBuilder.planFallbackOrder( + List.of(ref("openai", 1L), ref("openai", 2L), ref("anthropic", 3L)), + List.of("openai", "anthropic", "dashscope")); + // explicit head in order, then only the un-named provider (dashscope) trails + assertEquals( + List.of("openai/1", "openai/2", "anthropic/3", "dashscope/default"), + keys(out)); } @Test - @DisplayName("Preference references unknown provider: silently skipped") - void preferenceReferencesUnknown() { - var input = List.of(p("openai"), p("anthropic")); - var out = AgentGraphBuilder.reorderByPreferences(input, List.of("ghost", "anthropic")); - assertEquals(List.of("anthropic", "openai"), ids(out)); + @DisplayName("Exact (provider, model) duplicate is dropped") + void exactDuplicateDropped() { + var out = AgentGraphBuilder.planFallbackOrder( + List.of(ref("openai", 1L), ref("openai", 1L)), + List.of("openai", "anthropic")); + assertEquals(List.of("openai/1", "anthropic/default"), keys(out)); } @Test - @DisplayName("Duplicate preferences: each provider appears at most once") - void duplicatePreferencesDeduped() { - var input = List.of(p("openai"), p("anthropic")); - var out = AgentGraphBuilder.reorderByPreferences(input, List.of("openai", "openai", "anthropic")); - assertEquals(List.of("openai", "anthropic"), ids(out)); + @DisplayName("Provider pinned by id is not re-added as a default tail entry") + void pinnedProviderExcludedFromTail() { + var out = AgentGraphBuilder.planFallbackOrder( + List.of(ref("openai", 1L)), + List.of("openai", "anthropic")); + // openai already led explicitly → no extra openai/default in the tail + assertEquals(List.of("openai/1", "anthropic/default"), keys(out)); } @Test - @DisplayName("All providers preferred: input pure-reordered, no drops") - void allProvidersPreferred() { - var input = List.of(p("openai"), p("anthropic"), p("dashscope")); - var out = AgentGraphBuilder.reorderByPreferences(input, - List.of("dashscope", "openai", "anthropic")); - assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out)); + @DisplayName("Blank / null provider ids are ignored") + void blankIdsIgnored() { + var out = AgentGraphBuilder.planFallbackOrder( + List.of(ref("", 1L), ref(null, 2L), ref("openai", 3L)), + List.of("openai", "anthropic")); + assertEquals(List.of("openai/3", "anthropic/default"), keys(out)); + } + + @Test + @DisplayName("Preference for a provider absent from the global pool is still honoured") + void preferenceForUnknownProvider() { + var out = AgentGraphBuilder.planFallbackOrder( + List.of(ref("ghost", 9L)), + List.of("openai", "anthropic")); + // ghost leads (pool gating happens later in buildFallbackChain), tail follows + assertEquals(List.of("ghost/9", "openai/default", "anthropic/default"), keys(out)); } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalE2ETest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalE2ETest.java new file mode 100644 index 00000000..ffd91062 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalE2ETest.java @@ -0,0 +1,90 @@ +package vip.mate.agent.binding.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.tool.mcp.event.McpServerRemovedEvent; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Full-path regression (issue #127, MCP half): deleting an MCP server fires + * {@link McpServerRemovedEvent}, and the listener must drop exactly that + * server's agent-tool bindings — not a sibling server's whose id shares a + * prefix, and not unrelated tools. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +class AgentBindingMcpRemovalE2ETest { + + private static final AtomicLong SEQ = new AtomicLong(System.nanoTime()); + + @Autowired + private AgentToolBindingMapper toolBindingMapper; + @Autowired + private ApplicationEventPublisher publisher; + + private void bind(long agentId, String toolName) { + AgentToolBinding b = new AgentToolBinding(); + b.setId(SEQ.incrementAndGet()); + b.setAgentId(agentId); + b.setToolName(toolName); + b.setEnabled(true); + b.setCreateTime(LocalDateTime.now()); + b.setUpdateTime(LocalDateTime.now()); + b.setDeleted(0); + toolBindingMapper.insert(b); + } + + private Set toolsOf(long agentId) { + return toolBindingMapper.selectList( + new LambdaQueryWrapper().eq(AgentToolBinding::getAgentId, agentId)) + .stream().map(AgentToolBinding::getToolName).collect(Collectors.toSet()); + } + + @Test + @DisplayName("Removing an MCP server drops only its tool bindings") + void cascadeDropsOnlyTargetServerBindings() { + long agent = SEQ.incrementAndGet(); + bind(agent, "mcp_123_ping_ab12cd"); + bind(agent, "mcp_123_search_99ffaa"); + bind(agent, "mcp_1234_ping_ff0011"); // sibling server — must survive + bind(agent, "web_search"); // builtin — must survive + + assertEquals(4, toolsOf(agent).size()); + + publisher.publishEvent(new McpServerRemovedEvent(123L, "test-mcp")); + + Set remaining = toolsOf(agent); + assertEquals(Set.of("mcp_1234_ping_ff0011", "web_search"), remaining, + "only server 123's bindings should be cleaned; sibling 1234 and builtin stay"); + assertTrue(remaining.stream().noneMatch(t -> t.startsWith("mcp_123_"))); + } + + @Test + @DisplayName("No matching bindings is a no-op") + void noMatchIsNoop() { + long agent = SEQ.incrementAndGet(); + bind(agent, "web_search"); + publisher.publishEvent(new McpServerRemovedEvent(SEQ.incrementAndGet(), "empty-mcp")); + assertEquals(Set.of("web_search"), toolsOf(agent)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListenerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListenerTest.java new file mode 100644 index 00000000..3127fb20 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListenerTest.java @@ -0,0 +1,47 @@ +package vip.mate.agent.binding.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.mcp.runtime.McpToolNameResolver; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The MCP binding-cleanup prefix match must be exact: deleting server 123's + * bindings must not also delete server 1234's, which a naive SQL + * {@code LIKE 'mcp_123_%'} would (the underscores are wildcards). + */ +class AgentBindingMcpRemovalListenerTest { + + private static String prefix(long serverId) { + return McpToolNameResolver.PREFIX + serverId + "_"; + } + + @Test + @DisplayName("Matches only the exact server's tools") + void matchesExactServer() { + String p = prefix(123L); + assertTrue(AgentBindingMcpRemovalListener.belongsToServer("mcp_123_ping_ab12cd", p)); + assertTrue(AgentBindingMcpRemovalListener.belongsToServer("mcp_123_search_99ffaa", p)); + } + + @Test + @DisplayName("Does NOT match a sibling server whose id shares the prefix digits") + void doesNotMatchSiblingServer() { + String p = prefix(123L); + // The LIKE-wildcard trap: 'mcp_123_%' would match these, startsWith must not. + assertFalse(AgentBindingMcpRemovalListener.belongsToServer("mcp_1234_ping_ab12cd", p)); + assertFalse(AgentBindingMcpRemovalListener.belongsToServer("mcp_12_ping_ab12cd", p)); + assertFalse(AgentBindingMcpRemovalListener.belongsToServer("mcp_1230_x_y", p)); + } + + @Test + @DisplayName("Non-MCP and malformed names never match") + void ignoresNonMcp() { + String p = prefix(123L); + assertFalse(AgentBindingMcpRemovalListener.belongsToServer("web_search", p)); + assertFalse(AgentBindingMcpRemovalListener.belongsToServer("mcp_123", p)); // no trailing separator + assertFalse(AgentBindingMcpRemovalListener.belongsToServer(null, p)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java index ba5b84eb..0ec4292d 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java @@ -41,7 +41,7 @@ class ChatOriginSenderFieldsTest { void withSenderPreservesOtherFields() { ChatOrigin original = new ChatOrigin( 7L, "conv-1", "u123", 5L, "/ws", 9L, null, false, - null, null, null, null); + null, null, null, null, null); ChatOrigin enriched = original.withSender("Alice", "wecom", "g-1"); // All non-sender fields unchanged @@ -80,7 +80,7 @@ class ChatOriginSenderFieldsTest { ChatOrigin origin = new ChatOrigin( 7L, "feishu:oc_42", "ou_xyz", 5L, "/data/ws/5", 9L, null, false, - "Alice", "feishu", "oc_42", null); + "Alice", "feishu", "oc_42", null, null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java index faf1c71f..129722d2 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -28,7 +28,7 @@ class ChatOriginTest { void roundTripThroughToolContext_preservesAllFields() { ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001"); ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, target, false, null, null, null, null); + "/data/ws/5", 9L, target, false, null, null, null, null, null); ToolContext ctx = original.toToolContext(); ChatOrigin restored = ChatOrigin.from(ctx); @@ -75,7 +75,7 @@ class ChatOriginTest { void jsonSerialization_isStableAndForwardCompatible() throws Exception { ObjectMapper om = new ObjectMapper(); ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null, null); + "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null, null, null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSpillMarkerPreservationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSpillMarkerPreservationTest.java index b6112f82..b434e3ed 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSpillMarkerPreservationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSpillMarkerPreservationTest.java @@ -71,9 +71,12 @@ class ConversationWindowManagerSpillMarkerPreservationTest { ToolResponseMessage trm0 = (ToolResponseMessage) messages.get(0); ToolResponseMessage trm1 = (ToolResponseMessage) messages.get(1); assertEquals(SPILL_BODY, trm0.getResponses().getFirst().responseData(), - "Phase 2 hard clear must not replace a spill-marker body with [tool result removed]"); - assertEquals("[tool result removed]", trm1.getResponses().getFirst().responseData(), + "Phase 2 hard clear must not replace a spill-marker body with the cleared placeholder"); + String clearedBody = trm1.getResponses().getFirst().responseData(); + assertTrue(clearedBody.contains("cleared to save context"), "non-spill bodies should still be replaced by Phase 2"); + assertTrue(clearedBody.contains("search") && clearedBody.contains("2000 chars"), + "the cleared placeholder should carry the tool name and original size"); assertEquals(1, cleared, "clear counter should reflect only the non-spill body that was actually replaced"); } @@ -120,7 +123,7 @@ class ConversationWindowManagerSpillMarkerPreservationTest { ToolResponseMessage trm = (ToolResponseMessage) messages.getFirst(); assertEquals(SPILL_BODY, trm.getResponses().get(0).responseData(), "the spill response in a mixed message must survive Phase 2"); - assertEquals("[tool result removed]", trm.getResponses().get(1).responseData(), + assertTrue(trm.getResponses().get(1).responseData().contains("cleared to save context"), "the non-spill response in a mixed message must still be cleared"); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/InformativeClearedPlaceholderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/InformativeClearedPlaceholderTest.java new file mode 100644 index 00000000..89c3f04a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/InformativeClearedPlaceholderTest.java @@ -0,0 +1,46 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the informative cleared-tool-output placeholder — name, size and + * first-line gist survive the clearing so the model can judge whether a + * re-run is worthwhile. + */ +class InformativeClearedPlaceholderTest { + + @Test + @DisplayName("placeholder carries tool name, original size and first-line gist") + void carriesNameSizeAndGist() { + String body = "total 47 tests, 0 failures\n"; + String placeholder = ConversationWindowManager.buildInformativeCleared("run_tests", body); + assertTrue(placeholder.contains("run_tests")); + assertTrue(placeholder.contains(body.length() + " chars")); + assertTrue(placeholder.contains("total 47 tests, 0 failures")); + assertTrue(placeholder.contains("call the tool again")); + } + + @Test + @DisplayName("long first line is capped at 80 chars") + void longGistCapped() { + String body = "x".repeat(500); + String placeholder = ConversationWindowManager.buildInformativeCleared("read_file", body); + assertTrue(placeholder.contains("x".repeat(80) + "…")); + assertFalse(placeholder.contains("x".repeat(81))); + } + + @Test + @DisplayName("null / blank bodies degrade gracefully") + void nullAndBlankBodies() { + String placeholder = ConversationWindowManager.buildInformativeCleared(null, null); + assertTrue(placeholder.contains("tool")); + assertTrue(placeholder.contains("0 chars")); + assertFalse(placeholder.contains("began:")); + + String blank = ConversationWindowManager.buildInformativeCleared("shell", "\n\n \n"); + assertFalse(blank.contains("began:")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java new file mode 100644 index 00000000..c23cffc0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java @@ -0,0 +1,145 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.config.ConversationWindowProperties; +import vip.mate.config.PrefixBudgetProperties; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link PrefixBudgetPlanner} — profile selection, share + * allocation, and the disabled/rollback path. + */ +class PrefixBudgetPlannerTest { + + private PrefixBudgetProperties properties; + private ConversationWindowProperties windowProperties; + private PrefixBudgetPlanner planner; + + @BeforeEach + void setUp() { + properties = new PrefixBudgetProperties(); + windowProperties = new ConversationWindowProperties(); + planner = new PrefixBudgetPlanner(properties, windowProperties); + } + + @Test + @DisplayName("large window → NORMAL profile, budget = max*ratio − base − tools") + void normalProfile() { + PrefixBudgetPlan plan = planner.plan(128000, 2000, 3000); + assertEquals(PrefixBudgetPlan.Profile.NORMAL, plan.profile()); + assertEquals(128000, plan.effectiveMaxTokens()); + assertEquals((int) (128000 * 0.35) - 2000 - 3000, plan.injectionBudgetTokens()); + assertTrue(plan.enabled()); + } + + @Test + @DisplayName("16k window → COMPACT profile with tightened ratio") + void compactProfile() { + PrefixBudgetPlan plan = planner.plan(16384, 1000, 1000); + assertEquals(PrefixBudgetPlan.Profile.COMPACT, plan.profile()); + assertEquals((int) (16384 * 0.25) - 2000, plan.injectionBudgetTokens()); + } + + @Test + @DisplayName("4k window → MINIMAL profile, wiki injection disabled outright") + void minimalProfile() { + PrefixBudgetPlan plan = planner.plan(4096, 500, 500); + assertEquals(PrefixBudgetPlan.Profile.MINIMAL, plan.profile()); + assertEquals(Math.max(0, (int) (4096 * 0.15) - 1000), plan.injectionBudgetTokens()); + assertEquals(0, plan.wikiTokens()); + } + + @Test + @DisplayName("COMPACT profile caps the wiki injection at roughly one page") + void compactCapsWiki() { + properties.getShares().setWiki(1.0); + properties.getShares().setMemory(0.0); + properties.getShares().setSkill(0.0); + properties.getShares().setExtensionCatalog(0.0); + properties.getShares().setLedger(0.0); + PrefixBudgetPlan plan = planner.plan(20000, 0, 0); + assertEquals(PrefixBudgetPlan.Profile.COMPACT, plan.profile()); + assertTrue(plan.injectionBudgetTokens() > PrefixBudgetPlanner.COMPACT_WIKI_TOKEN_CAP); + assertEquals(PrefixBudgetPlanner.COMPACT_WIKI_TOKEN_CAP, plan.wikiTokens()); + } + + @Test + @DisplayName("plan carries an independent tool-schema budget from toolSchemaRatio") + void toolSchemaBudget() { + PrefixBudgetPlan plan = planner.plan(16384, 0, 0); + assertEquals((int) (16384 * 0.25), plan.toolSchemaBudgetTokens()); + properties.setEnabled(false); + assertEquals(Integer.MAX_VALUE, planner.plan(16384, 0, 0).toolSchemaBudgetTokens()); + } + + @Test + @DisplayName("oversized base prompt + tools clamp the budget to zero, never negative") + void budgetNeverNegative() { + PrefixBudgetPlan plan = planner.plan(8192, 9000, 5000); + assertEquals(0, plan.injectionBudgetTokens()); + assertEquals(0, plan.memoryTokens()); + assertEquals(0, plan.wikiTokens()); + } + + @Test + @DisplayName("shares split the injection budget and sum to at most the budget") + void sharesSplitBudget() { + PrefixBudgetPlan plan = planner.plan(128000, 0, 0); + int budget = plan.injectionBudgetTokens(); + // ±1 token tolerance: share division is floating point. + assertEquals(budget * 0.35, plan.memoryTokens(), 1.0); + assertEquals(budget * 0.30, plan.wikiTokens(), 1.0); + assertEquals(budget * 0.20, plan.skillCatalogTokens(), 1.0); + assertEquals(budget * 0.10, plan.extensionCatalogTokens(), 1.0); + assertEquals(budget * 0.05, plan.ledgerTokens(), 1.0); + int sum = plan.memoryTokens() + plan.wikiTokens() + plan.skillCatalogTokens() + + plan.extensionCatalogTokens() + plan.ledgerTokens(); + assertTrue(sum <= budget); + } + + @Test + @DisplayName("shares not summing to 1 are normalized") + void sharesNormalized() { + properties.getShares().setMemory(2.0); + properties.getShares().setWiki(2.0); + properties.getShares().setSkill(0.0); + properties.getShares().setExtensionCatalog(0.0); + properties.getShares().setLedger(0.0); + PrefixBudgetPlan plan = planner.plan(128000, 0, 0); + assertEquals(plan.injectionBudgetTokens() / 2, plan.memoryTokens()); + assertEquals(plan.injectionBudgetTokens() / 2, plan.wikiTokens()); + assertEquals(0, plan.skillCatalogTokens()); + } + + @Test + @DisplayName("disabled → unlimited plan, previous behavior") + void disabledYieldsUnlimited() { + properties.setEnabled(false); + PrefixBudgetPlan plan = planner.plan(8192, 100, 100); + assertFalse(plan.enabled()); + assertEquals(Integer.MAX_VALUE, plan.memoryTokens()); + assertEquals(Integer.MAX_VALUE, plan.wikiTokens()); + assertEquals(8192, plan.effectiveMaxTokens()); + } + + @Test + @DisplayName("null/zero effective window falls back to the global default") + void nullWindowFallsBackToGlobalDefault() { + PrefixBudgetPlan plan = planner.plan(null, 0, 0); + assertEquals(windowProperties.getDefaultMaxInputTokens(), plan.effectiveMaxTokens()); + assertEquals(PrefixBudgetPlan.Profile.NORMAL, plan.profile()); + } + + @Test + @DisplayName("small windows compact later (0.85), large windows keep the default ratio") + void compactTriggerRatioAdapts() { + assertEquals(0.75, planner.compactTriggerRatioFor(128000, 0.75)); + assertEquals(0.85, planner.compactTriggerRatioFor(16384, 0.75)); + assertEquals(0.85, planner.compactTriggerRatioFor(4096, 0.75)); + properties.setEnabled(false); + assertEquals(0.75, planner.compactTriggerRatioFor(4096, 0.75)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorModelTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorModelTest.java index 0ce8012f..b6af4903 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorModelTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorModelTest.java @@ -65,7 +65,7 @@ class RuntimeContextInjectorModelTest { void imOriginHasSenderAndModel() { ChatOrigin origin = new ChatOrigin( 7L, "feishu:oc_abc", "ou_xyz", 5L, "/data/ws/5", - 9L, null, false, "Alice", "feishu", "oc_abc", null); + 9L, null, false, "Alice", "feishu", "oc_abc", null, null); String ctx = RuntimeContextInjector.buildContextMessage( "/data/ws/5", null, origin, "gpt-4o", "openai"); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java index c4d23f2f..65c79d56 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java @@ -28,7 +28,7 @@ class RuntimeContextInjectorSenderTest { /* senderName */ "Alice", /* channelType */ "feishu", /* chatId */ "oc_abc", - /* baseUrl */ null); + /* baseUrl */ null, null); String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); @@ -45,7 +45,7 @@ class RuntimeContextInjectorSenderTest { ChatOrigin origin = new ChatOrigin( 7L, "feishu:ou_xyz", "ou_xyz", 5L, "/data/ws/5", 9L, null, false, - "Alice", "feishu", null, null); + "Alice", "feishu", null, null, null); String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); @@ -102,7 +102,7 @@ class RuntimeContextInjectorSenderTest { void blankSenderName() { ChatOrigin origin = new ChatOrigin( 7L, null, "ou_xyz", null, null, null, null, false, - /* senderName */ " ", "feishu", null, null); + /* senderName */ " ", "feishu", null, null, null); String ctx = RuntimeContextInjector.buildContextMessage(null, null, origin); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java new file mode 100644 index 00000000..6bcde48a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java @@ -0,0 +1,94 @@ +package vip.mate.agent.delegation; + +import org.junit.jupiter.api.Test; +import vip.mate.tool.builtin.DelegationContext; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the {@link SubagentRunContext} value object and its use as an + * explicitly-threaded carrier across executor threads. + * + * @author MateClaw Team + */ +class SubagentRunContextTest { + + @Test + void normalisesNullDenySetToEmptyImmutable() { + SubagentRunContext ctx = new SubagentRunContext(1, "p", "r", "sa", null); + assertEquals(Set.of(), ctx.deniedTools()); + assertThrows(UnsupportedOperationException.class, () -> ctx.deniedTools().add("x")); + } + + @Test + void copiesDenySetSoLaterMutationDoesNotLeak() { + Set mutable = new HashSet<>(Set.of("toolA")); + SubagentRunContext ctx = new SubagentRunContext(1, "p", "r", "sa", mutable); + mutable.add("toolB"); + // The context took an immutable copy at construction; the later add must not leak in. + assertEquals(Set.of("toolA"), ctx.deniedTools()); + } + + @Test + void rootContextIsNotDelegated() { + assertFalse(SubagentRunContext.ROOT.isDelegated()); + assertEquals(0, SubagentRunContext.ROOT.depth()); + assertNull(SubagentRunContext.ROOT.parentConversationId()); + assertTrue(new SubagentRunContext(1, "p", "r", "sa", Set.of()).isDelegated()); + } + + @Test + void childFrameAdvancesDepthAndInheritsRoot() { + SubagentRunContext l1 = SubagentRunContext.ROOT.childFrame("conv-root", "sa-1", Set.of("delegateToAgent")); + assertEquals(1, l1.depth()); + assertEquals("conv-root", l1.parentConversationId()); + // First delegation: root falls back to the spawning conversation. + assertEquals("conv-root", l1.rootConversationId()); + + SubagentRunContext l2 = l1.childFrame("conv-child", "sa-2", Set.of()); + assertEquals(2, l2.depth()); + assertEquals("conv-child", l2.parentConversationId()); + // Deeper layers keep broadcasting to the same human-facing root. + assertEquals("conv-root", l2.rootConversationId()); + } + + /** + * Guardrail: a context passed explicitly carries its depth across a fresh + * executor thread, where a size-based thread-local stack would reset to 1. + * Reconstructing the layer via {@link DelegationContext#push} on the child + * thread reproduces the real tree depth. + */ + @Test + void explicitContextCarriesDepthAcrossThreadHop() throws Exception { + // Build a depth-3 context on the dispatching thread without touching the + // current thread's stack. + SubagentRunContext dispatched = new SubagentRunContext(3, "conv", "root", "sa", Set.of("execute_code")); + + AtomicInteger observedDepth = new AtomicInteger(-1); + AtomicReference observedDenied = new AtomicReference<>(); + Thread worker = Thread.ofVirtual().start(() -> { + // Fresh thread: stack starts empty. + assertEquals(0, DelegationContext.currentDepth()); + DelegationContext.push(dispatched); + try { + observedDepth.set(DelegationContext.currentDepth()); + observedDenied.set(String.join(",", DelegationContext.childDeniedTools())); + } finally { + DelegationContext.exit(); + } + }); + worker.join(); + + assertEquals(3, observedDepth.get()); + assertEquals("execute_code", observedDenied.get()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java index acedb909..8b10e1ac 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java @@ -119,4 +119,48 @@ class NodeStreamingChatHelperToolCallArgsTest { assertEquals(validArgs, result.toolCalls().get(0).arguments(), "valid JSON arguments must not be rewritten"); } + + @Test + @DisplayName("Prompt-history tool call with empty arguments normalized before send") + void promptHistory_emptyArguments_normalized() { + // Mirrors a tool call replayed from persisted history (e.g. an earlier + // MCP call with no arguments): it never passes through the streaming + // aggregator, so the prompt-level pass must repair it. + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-hist", "function", "mcp_excel_import", ""); + AssistantMessage historyMsg = AssistantMessage.builder() + .content("calling tool") + .toolCalls(List.of(tc)) + .build(); + Prompt prompt = new Prompt(List.of(new UserMessage("hi"), historyMsg)); + + Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(prompt); + + AssistantMessage out = (AssistantMessage) normalized.getInstructions().get(1); + assertEquals(1, out.getToolCalls().size()); + assertEquals("{}", out.getToolCalls().get(0).arguments(), + "replayed empty arguments must be normalized to '{}'"); + assertEquals("calling tool", out.getText(), "assistant content must be preserved"); + assertEquals("mcp_excel_import", out.getToolCalls().get(0).name(), + "tool name must be preserved"); + assertEquals("id-hist", out.getToolCalls().get(0).id(), + "tool call id must be preserved so the tool_call pairing holds"); + } + + @Test + @DisplayName("Prompt with only valid tool-call arguments returned unchanged") + void promptHistory_validArguments_returnsSameInstance() { + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-ok", "function", "search", "{\"q\":\"x\"}"); + AssistantMessage historyMsg = AssistantMessage.builder() + .content("") + .toolCalls(List.of(tc)) + .build(); + Prompt prompt = new Prompt(List.of(new UserMessage("hi"), historyMsg)); + + Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(prompt); + + assertTrue(normalized == prompt, + "a prompt that needs no fix must be returned unchanged (no copy)"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputClampTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputClampTest.java new file mode 100644 index 00000000..fedd2ae7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputClampTest.java @@ -0,0 +1,56 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.context.PrefixBudgetPlan; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Window-aware output-cap clamp: strict local servers (vLLM) statically + * reject {@code max_tokens >= max_model_len}, so the cap sent to the + * provider must shrink when the known context window is smaller than the + * configured / default output cap. + */ +class ReasoningNodeOutputClampTest { + + private static ReasoningNode nodeWithWindow(Integer windowTokens) { + @SuppressWarnings("deprecation") + ReasoningNode node = new ReasoningNode(null, + AgentToolSet.fromCallbacks(List.of(), List.of()), null); + if (windowTokens != null) { + node.setPrefixBudgetPlan(new PrefixBudgetPlan( + true, windowTokens, PrefixBudgetPlan.Profile.COMPACT, + 0, 0, 0, 0, 0, 0, windowTokens / 4)); + } + return node; + } + + @Test + @DisplayName("default 16384 cap on an 8k window clamps to half the window") + void defaultCapClampsOnSmallWindow() { + // Deprecated ctor leaves maxOutputTokens at the 16384 default. + assertEquals(4096, nodeWithWindow(8192).effectiveMaxOutputTokens()); + } + + @Test + @DisplayName("large window leaves the cap untouched") + void largeWindowKeepsCap() { + assertEquals(16384, nodeWithWindow(128000).effectiveMaxOutputTokens()); + } + + @Test + @DisplayName("no budget plan (tests / legacy graphs) keeps previous behavior") + void noPlanKeepsCap() { + assertEquals(16384, nodeWithWindow(null).effectiveMaxOutputTokens()); + } + + @Test + @DisplayName("tiny window clamps no lower than the 512-token floor") + void tinyWindowRespectsFloor() { + assertEquals(512, nodeWithWindow(600).effectiveMaxOutputTokens()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java index 3c0d4851..1d577ce3 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java @@ -12,6 +12,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,7 +39,7 @@ class ReasoningNodePtlPromptTest { @Test void prefixIncludesSystemRuntimeAndWikiSegments() { WikiContextService wikiContextService = mock(WikiContextService.class); - when(wikiContextService.buildRelevantContext(eq(42L), anyString())) + when(wikiContextService.buildRelevantContext(eq(42L), anyString(), isNull())) .thenReturn(WIKI_RELEVANT_TEXT); ReasoningNode node = newNode(wikiContextService); @@ -67,7 +68,7 @@ class ReasoningNodePtlPromptTest { // that two independent calls with the same inputs produce // structurally identical output. WikiContextService wikiContextService = mock(WikiContextService.class); - when(wikiContextService.buildRelevantContext(eq(42L), anyString())) + when(wikiContextService.buildRelevantContext(eq(42L), anyString(), isNull())) .thenReturn(WIKI_RELEVANT_TEXT); ReasoningNode node = newNode(wikiContextService); @@ -125,7 +126,7 @@ class ReasoningNodePtlPromptTest { @Test void blankWikiResultSkipsWikiSegment() { WikiContextService wikiContextService = mock(WikiContextService.class); - when(wikiContextService.buildRelevantContext(eq(42L), anyString())) + when(wikiContextService.buildRelevantContext(eq(42L), anyString(), isNull())) .thenReturn(" "); // blank → drop the layer ReasoningNode node = newNode(wikiContextService); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java new file mode 100644 index 00000000..181561d7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java @@ -0,0 +1,76 @@ +package vip.mate.agent.graph.plan.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link PlanGenerationNode#displayGoal} — the scrubber that recovers the + * user's actual request from the fully-assembled agent prompt before it is + * persisted as the plan goal. + * + *

            The graph receives the goal already enriched: a {@code } + * recall block is prepended every turn, scheduled runs wrap the instruction in a + * preamble whose payload follows {@code [任务指令]}, and a re-plan pass appends a + * {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan + * board showing "<memory-context> The following is what you…" instead of the + * task — these tests lock the clean-up. + */ +@DisplayName("PlanGeneration displayGoal scrubber") +class PlanGenerationDisplayGoalTest { + + private static final String MEMORY_WRAPPER = + "\n" + + "The following is what you already know about this user.\n" + + "## preferred_answer_style\n用户喜欢简洁、分点的回答方式。\n" + + "\n\n"; + + @Test + @DisplayName("strips the injected memory-context block") + void stripsMemoryContext() { + assertEquals("帮我读取 pom.xml 并总结依赖", + PlanGenerationNode.displayGoal(MEMORY_WRAPPER + "帮我读取 pom.xml 并总结依赖")); + } + + @Test + @DisplayName("keeps only the instruction body of a scheduled-run wrapper") + void unwrapsScheduledRunPrompt() { + String cron = MEMORY_WRAPPER + + "[定时任务执行说明]\n本次对话由定时任务自动触发,不是用户实时发来的消息。\n" + + "- 请把下面的「任务指令」当作一个完整、独立的任务来执行。\n\n" + + "[任务指令]\nqwen3-max 重试测试"; + assertEquals("qwen3-max 重试测试", PlanGenerationNode.displayGoal(cron)); + } + + @Test + @DisplayName("drops the trailing follow-up guidance block") + void dropsFollowupSuffix() { + String withFollowup = MEMORY_WRAPPER + + "整理本周的项目进展\n\n[Follow-up guidance]\n再补充一下风险项"; + assertEquals("整理本周的项目进展", PlanGenerationNode.displayGoal(withFollowup)); + } + + @Test + @DisplayName("passes through a clean goal untouched") + void passesThroughCleanGoal() { + assertEquals("无包装直接问", PlanGenerationNode.displayGoal("无包装直接问")); + } + + @Test + @DisplayName("falls back to the raw goal when scrubbing leaves nothing") + void fallsBackWhenEmpty() { + // A goal that is nothing but the recall block must not collapse to "". + String result = PlanGenerationNode.displayGoal(MEMORY_WRAPPER); + assertTrue(result.contains("memory-context"), + "scrubbing an all-wrapper goal should fall back to the raw text, not empty"); + } + + @Test + @DisplayName("null / blank safe") + void nullSafe() { + assertEquals("", PlanGenerationNode.displayGoal(null)); + assertEquals(" ", PlanGenerationNode.displayGoal(" ")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java index 4da63d74..4f925857 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java @@ -45,7 +45,7 @@ class ApprovalReplayContinuityTest { /* senderName */ "Alice", /* channelType */ "wecom", /* chatId */ "group-a", - /* baseUrl */ null); + /* baseUrl */ null, null); String json = objectMapper.writeValueAsString(original); ChatOrigin restored = workflow.restoreChatOrigin(json); diff --git a/mateclaw-server/src/test/java/vip/mate/approval/WorkflowApprovalResumeBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/approval/WorkflowApprovalResumeBridgeTest.java new file mode 100644 index 00000000..a0c3fb22 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/approval/WorkflowApprovalResumeBridgeTest.java @@ -0,0 +1,168 @@ +package vip.mate.approval; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.approval.event.WorkflowApprovalResolvedEvent; +import vip.mate.approval.model.ToolApprovalEntity; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies ISSUE #413 P0-B2: a workflow-scoped approval created via + * {@code requestWorkflowApproval} is registered into the in-memory map, so a + * subsequent {@code resolve("wf-...")} walks the full two-phase contract and + * publishes {@link WorkflowApprovalResolvedEvent} — the event that + * {@code ApprovalResumeBridge} listens for to resume the paused run. + * + *

            Before the fix, {@code requestWorkflowApproval} only did + * {@code approvalMapper.insert(entity)} without {@code registerRecovered}, so + * {@code getPending("wf-...")} returned null, {@code performResolve} + * short-circuited at the "not pending" guard, the event was never published, + * and {@code ApprovalResumeBridge} was dead code. + */ +@ExtendWith(MockitoExtension.class) +class WorkflowApprovalResumeBridgeTest { + + @Mock private ToolApprovalMapper approvalMapper; + @Mock private ConversationService conversationService; + + private ApprovalService approvalService; + private CapturingEventPublisher publisher; + private ApprovalWorkflowService workflow; + + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + ToolApprovalEntity.class); + } + + @BeforeEach + void setUp() { + approvalService = new ApprovalService(); + publisher = new CapturingEventPublisher(); + workflow = new ApprovalWorkflowService( + approvalService, approvalMapper, new ObjectMapper(), conversationService); + // events is @Autowired(required = false) with no setter; inject via + // reflection so this unit test (no Spring context) can capture the + // WorkflowApprovalResolvedEvent publish. + try { + var field = ApprovalWorkflowService.class.getDeclaredField("events"); + field.setAccessible(true); + field.set(workflow, publisher); + } catch (Exception e) { + throw new IllegalStateException("failed to inject event publisher", e); + } + } + + @Test + @DisplayName("requestWorkflowApproval registers the wf- approval into the in-memory map") + void requestRegistersIntoMemoryMap() { + // insert returns the row id the adapter writes back as external_approval_id. + when(approvalMapper.insert(any(ToolApprovalEntity.class))).thenAnswer(inv -> { + ((ToolApprovalEntity) inv.getArgument(0)).setId(42L); + return 1; + }); + + Long approvalId = workflow.requestWorkflowApproval( + 1L, 100L, 7L, "manager", "please approve", java.util.List.of("web"), 1800); + + assertThat(approvalId).isEqualTo(42L); + + // The fix: the wf- entry is now in the in-memory map, queryable by the + // synthetic conversation key. Before the fix, getPending("wf-...") + // returned null and the resolve path dead-ended at the "not pending" + // guard, leaving ApprovalResumeBridge as dead code. + PendingApproval pending = approvalService.findPendingByConversation("workflow:run:100"); + assertThat(pending).as("wf- approval must be in the in-memory map after request").isNotNull(); + assertThat(pending.getPendingId()).startsWith("wf-"); + assertThat(pending.getToolName()).isEqualTo("workflow:manager"); + } + + @Test + @DisplayName("resolve('wf-...') publishes WorkflowApprovalResolvedEvent after the fix") + void resolvePublishesWorkflowEvent() { + // Seed via requestWorkflowApproval so the entry is in the map under a + // wf- pendingId (the same path the AwaitApprovalStepAdapter takes). + when(approvalMapper.insert(any(ToolApprovalEntity.class))).thenAnswer(inv -> { + ((ToolApprovalEntity) inv.getArgument(0)).setId(42L); + return 1; + }); + workflow.requestWorkflowApproval( + 1L, 100L, 7L, "manager", "please approve", java.util.List.of("web"), 1800); + + // Recover the generated wf- pendingId via the conversation key. + PendingApproval pending = approvalService.findPendingByConversation("workflow:run:100"); + assertThat(pending).as("wf- approval must be in the in-memory map after request").isNotNull(); + assertThat(pending.getPendingId()).startsWith("wf-"); + String pendingId = pending.getPendingId(); + + // Two-phase resolve: DB UPDATE conditional on PENDING succeeds. + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("workflow:run:100"), eq(Set.of(pendingId)), any())).thenReturn(0); + // selectOne lookup for the workflow-bridge row id (Phase 4). + ToolApprovalEntity row = new ToolApprovalEntity(); + row.setId(42L); + row.setPendingId(pendingId); + when(approvalMapper.selectOne(any())).thenReturn(row); + + ResolveOutcome outcome = workflow.resolve(pendingId, "operator", "approved"); + + assertThat(outcome.dbSynced()).isTrue(); + assertThat(outcome.decision()).isEqualTo("approved"); + + // The critical assertion: the workflow-resolved event was published. + // ApprovalResumeBridge listens for this to call WorkflowResumer.resume. + assertThat(publisher.workflowEvents).hasSize(1); + WorkflowApprovalResolvedEvent ev = publisher.workflowEvents.get(0); + assertThat(ev.approvalRowId()).isEqualTo(42L); + assertThat(ev.pendingId()).isEqualTo(pendingId); + assertThat(ev.decision()).isEqualTo("approved"); + } + + @Test + @DisplayName("resolve of a wf- approval that was NOT registered is still a safe no-op") + void resolveUnregisteredWorkflowApprovalIsNoop() { + // Simulate the pre-fix state: a wf- pendingId that never entered the map. + ResolveOutcome outcome = workflow.resolve("wf-ghostthatdoesnotexist", "operator", "approved"); + + assertThat(outcome.isAlreadyResolved()).isTrue(); + assertThat(publisher.workflowEvents).isEmpty(); + } + + /** Captures published events so tests can assert on the workflow-bridge event. */ + static class CapturingEventPublisher implements ApplicationEventPublisher { + final List workflowEvents = new ArrayList<>(); + + @Override + public void publishEvent(Object event) { + if (event instanceof WorkflowApprovalResolvedEvent wfe) { + workflowEvents.add(wfe); + } + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java b/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java new file mode 100644 index 00000000..b98c57e2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java @@ -0,0 +1,194 @@ +package vip.mate.auth.sso; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.auth.sso.model.SsoStateEntity; +import vip.mate.auth.sso.provider.SsoUserInfo; +import vip.mate.auth.sso.repository.SsoStateMapper; + +import java.lang.reflect.Field; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies the SsoStateService state machine: state issue/verify/one-time-consume + * and bind_token issue/verify/anti-replay. These are the security-critical paths + * (CSRF + anti-replay) that the OAuth2 flow depends on. + */ +@ExtendWith(MockitoExtension.class) +class SsoStateServiceTest { + + private static final String SECRET = "test-secret-0123456789-test-secret-01"; + + @Mock private SsoStateMapper stateMapper; + + private SsoStateService service; + + @BeforeAll + static void initMyBatisCache() { + // LambdaUpdateWrapper needs the entity's TableInfo in MyBatis-Plus's static cache. + // In a Spring context this happens during mapper scan; in a plain MockitoExtension + // test we trigger it manually. + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + SsoStateEntity.class); + } + + @BeforeEach + void setUp() throws Exception { + service = new SsoStateService(stateMapper); + // jwtSecret is @Value-injected; set it via reflection since there's no Spring context. + Field f = SsoStateService.class.getDeclaredField("jwtSecret"); + f.setAccessible(true); + f.set(service, SECRET); + } + + // ---------------- state (OAuth2 CSRF) ---------------- + + @Test + @DisplayName("issueState persists a row and returns nonce.signature format") + void issueStatePersistsAndReturnsSignedFormat() { + String state = service.issueState("feishu"); + + assertThat(state).contains("."); + String[] parts = state.split("\\.", 2); + assertThat(parts[0]).isNotBlank(); // nonce + assertThat(parts[1]).hasSize(64); // HMAC-SHA256 hex + + verify(stateMapper, times(1)).insert(any(SsoStateEntity.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(SsoStateEntity.class); + verify(stateMapper).insert(captor.capture()); + assertThat(captor.getValue().getToken()).isEqualTo(state); + assertThat(captor.getValue().getKind()).isEqualTo("state"); + assertThat(captor.getValue().getConsumed()).isEqualTo(0); + } + + @Test + @DisplayName("verifyState succeeds when UPDATE affects 1 row (first consumer)") + void verifyStateSucceedsOnFirstConsume() { + when(stateMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + + String state = service.issueState("feishu"); + service.verifyState(state); // should not throw + + verify(stateMapper).update(isNull(), any(Wrapper.class)); + } + + @Test + @DisplayName("verifyState rejects replay when UPDATE affects 0 rows (already consumed)") + void verifyStateRejectsReplay() { + // Simulate: state already consumed by another request + when(stateMapper.update(isNull(), any(Wrapper.class))).thenReturn(0); + + String state = service.issueState("feishu"); + assertThatThrownBy(() -> service.verifyState(state)) + .hasMessageContaining("已过期或已被使用"); + } + + @Test + @DisplayName("verifyState rejects tampered signature") + void verifyStateRejectsTamperedSignature() { + service.issueState("feishu"); + // Tamper: valid nonce + wrong signature + String tampered = "abcdef0123456789." + "0".repeat(64); + assertThatThrownBy(() -> service.verifyState(tampered)) + .hasMessageContaining("签名校验失败"); + // No DB write attempted (fails at signature check before UPDATE) + verify(stateMapper, never()).update(any(), any()); + } + + @Test + @DisplayName("verifyState rejects malformed state (no dot)") + void verifyStateRejectsMalformed() { + assertThatThrownBy(() -> service.verifyState("nodothere")) + .hasMessageContaining("格式无效"); + } + + @Test + @DisplayName("verifyState rejects null/blank") + void verifyStateRejectsNull() { + assertThatThrownBy(() -> service.verifyState(null)) + .hasMessageContaining("缺少 state"); + } + + // ---------------- bind_token (link-only anti-replay) ---------------- + + @Test + @DisplayName("issueBindToken returns a signed JWT with provider + externalId claims") + void issueBindTokenContainsClaims() { + SsoUserInfo info = new SsoUserInfo("ou_123", "on_union", "张三", null, null, null); + String token = service.issueBindToken("feishu", info); + + assertThat(token).isNotBlank(); + // JWT format: header.payload.signature (3 base64 parts separated by dots) + assertThat(token.split("\\.")).hasSize(3); + } + + @Test + @DisplayName("verifyBindToken succeeds on first consume, inserts jti into sso_state") + void verifyBindTokenSucceedsOnFirstConsume() { + SsoUserInfo info = new SsoUserInfo("ou_456", null, "李四", null, null, null); + String token = service.issueBindToken("feishu", info); + + SsoStateService.BindTokenClaims claims = service.verifyBindToken(token); + + assertThat(claims.provider()).isEqualTo("feishu"); + assertThat(claims.externalId()).isEqualTo("ou_456"); + assertThat(claims.externalName()).isEqualTo("李四"); + + verify(stateMapper, times(1)).insert(any(SsoStateEntity.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(SsoStateEntity.class); + verify(stateMapper).insert(captor.capture()); + assertThat(captor.getValue().getKind()).isEqualTo("bind"); + assertThat(captor.getValue().getConsumed()).isEqualTo(1); + } + + @Test + @DisplayName("verifyBindToken rejects replay when jti already exists (DuplicateKeyException)") + void verifyBindTokenRejectsReplay() { + SsoUserInfo info = new SsoUserInfo("ou_789", null, "王五", null, null, null); + String token = service.issueBindToken("feishu", info); + + // First insert succeeds; second insert (replay) throws DuplicateKeyException + when(stateMapper.insert(any(SsoStateEntity.class))) + .thenReturn(1) + .thenThrow(new org.springframework.dao.DuplicateKeyException("PK violation")); + + // First consume: success + service.verifyBindToken(token); + // Second consume: rejected + assertThatThrownBy(() -> service.verifyBindToken(token)) + .hasMessageContaining("已被使用"); + } + + @Test + @DisplayName("verifyBindToken rejects garbage token") + void verifyBindTokenRejectsGarbage() { + assertThatThrownBy(() -> service.verifyBindToken("not-a-jwt")) + .hasMessageContaining("无效或已过期"); + } + + @Test + @DisplayName("verifyBindToken rejects null/blank") + void verifyBindTokenRejectsNull() { + assertThatThrownBy(() -> service.verifyBindToken(null)) + .hasMessageContaining("缺少 bind_token"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java index ab929f22..7bcb8ef7 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -62,7 +62,8 @@ class ChannelManagerReconcileTest { mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class), mock(vip.mate.channel.feishu.FeishuClientFactory.class), mock(vip.mate.stt.SttService.class), - election); + election, + mock(vip.mate.workspace.core.service.ChatUploadLocationResolver.class)); adapter = new TrackingAdapter(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java index c9c18b20..0d48766a 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.channel.feishu.cards.tool_guard.ToolGuardButtonValue; import vip.mate.channel.feishu.cards.tool_guard.ToolGuardCardKindFactory; @@ -23,6 +24,7 @@ class FeishuCardDispatcherTest { private FeishuCardDispatcher newDispatcher() { ToolGuardCardKindFactory factory = new ToolGuardCardKindFactory( mock(ApprovalService.class), + mock(ApprovalWorkflowService.class), new ObjectMapper()); return new FeishuCardDispatcher(factory); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java new file mode 100644 index 00000000..d3020072 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java @@ -0,0 +1,57 @@ +package vip.mate.channel.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins {@link ChatController#toRelativeUploadPath} to return a root-relative + * path, never the absolute server location. + *

            + * Regression guard for the workspace-aware chat-uploads change: once the upload + * root became absolute (the resolver normalizes via {@code toAbsolutePath()}), + * the {@code path} field — which is rendered into the LLM prompt and returned to + * the client — started leaking the server's absolute filesystem layout. These + * tests lock the value back to {@code chat-uploads/{convId}/{storedName}}. + */ +class ChatControllerUploadPathTest { + + @Test + @DisplayName("default root: returns chat-uploads/{convId}/{storedName}, not absolute") + void defaultRootIsRelative() { + // Mirrors the resolver's default root: absolute + normalized. + Path uploadRoot = Paths.get("data", "chat-uploads").toAbsolutePath().normalize(); + + String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-1", "1777_a.txt"); + + assertThat(path).isEqualTo("chat-uploads/conv-1/1777_a.txt"); + assertThat(Paths.get(path).isAbsolute()).isFalse(); + assertThat(path).doesNotContain(uploadRoot.toString()); + } + + @Test + @DisplayName("workspace-scoped absolute root: still root-relative, no leak") + void scopedRootIsRelative() { + // An absolute workspace basePath somewhere outside the CWD. + Path uploadRoot = Paths.get("/srv/ws/alpha/chat-uploads").toAbsolutePath().normalize(); + + String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-2", "9_b.pdf"); + + assertThat(path).isEqualTo("chat-uploads/conv-2/9_b.pdf"); + assertThat(path).doesNotContain("/srv/ws/alpha"); + } + + @Test + @DisplayName("custom base-dir name is preserved (not hardcoded to chat-uploads)") + void customBaseDirNamePreserved() { + Path uploadRoot = Paths.get("/var/uploads").toAbsolutePath().normalize(); + + String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-3", "f.bin"); + + assertThat(path).isEqualTo("uploads/conv-3/f.bin"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java new file mode 100644 index 00000000..f2cb57d4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java @@ -0,0 +1,223 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.approval.PendingApproval; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.common.result.R; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies ISSUE #413 P1: the WebChat (API-Key) channel can now resolve tool + * approvals. Before the fix a ToolGuard-protected tool would park the turn in + * a pending approval the visitor could never clear — it hung for 30 min until + * the GC timeout and the turn was wasted. + * + *

            Covers the synchronous paths (deny + stop-sweep). The approve path drives + * a live agent replay stream and is exercised separately; the auth + ownership + * guards it shares with deny are validated here. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_approve_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatApprovalInteractionTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" + private static final long CHANNEL_ID = 9_147_310L; + private static final long AGENT_ID = 9_147_3101L; + + @Autowired private WebChatController controller; + @Autowired private ApprovalWorkflowService approvalService; + @Autowired private ChatStreamTracker streamTracker; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-approve-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + private String seedPending(String visitorId, String sessionId) { + controller.createSession(API_KEY, req(visitorId, sessionId)); + String cid = WebChatController.deriveConversationId(API_KEY, visitorId, sessionId); + // The actor stored on the approval is the webchat username, mirroring + // how chatStream sets it via webchatUsername(visitorId). + String actor = "webchat:" + API_KEY.substring(0, 8) + ":" + visitorId; + return approvalService.createPending( + cid, actor, "write_file", "{}", "high-severity edit", + "{}", "[]", String.valueOf(AGENT_ID)); + } + + // ---------------- deny ---------------- + + @Test + @DisplayName("deny resolves a pending approval and broadcasts tool_approval_resolved") + void denyResolvesPending() { + String pendingId = seedPending("visitorA", "s1"); + String cid = WebChatController.deriveConversationId(API_KEY, "visitorA", "s1"); + + // Register the stream so the broadcast has a live subscriber state. + streamTracker.register(cid); + + R> r = controller.denySession( + API_KEY, tokenFor("visitorA"), "visitorA", "s1", pendingId); + + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData().get("resolved")).isEqualTo(Boolean.TRUE); + assertThat(r.getData().get("decision")).isEqualTo("denied"); + + // The approval is no longer pending. Query by the exact pendingId (not + // findPendingByConversation, which returns the earliest pending and + // would be polluted by cross-test map state when several pendings + // coexist for the same conversation). + var after = approvalService.getPending(pendingId); + assertThat(after.isEmpty() || !"pending".equals(after.get().getStatus())) + .as("approval should be resolved, not pending").isTrue(); + } + + @Test + @DisplayName("deny rejects a bad visitor token → 401") + void denyRejectsBadToken() { + String pendingId = seedPending("visitorB", "s1"); + R> r = controller.denySession( + API_KEY, "bogus-token", "visitorB", "s1", pendingId); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("deny rejects an unknown session → 404 (no namespace probing)") + void denyRejectsUnknownSession() { + String pendingId = seedPending("visitorC", "s1"); + R> r = controller.denySession( + API_KEY, tokenFor("visitorC"), "visitorC", "never-created", pendingId); + assertThat(r.getCode()).isEqualTo(404); + } + + @Test + @DisplayName("deny on a bad API Key → 401") + void denyRejectsBadApiKey() { + R> r = controller.denySession( + "bogus-key", "any-token", "visitorD", "s1", "any-pending"); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("deny of an unknown pendingId returns 404 (does not leak existence)") + void denyUnknownPendingIsSafe() { + controller.createSession(API_KEY, req("visitorE", "s1")); + R> r = controller.denySession( + API_KEY, tokenFor("visitorE"), "visitorE", "s1", "wf-ghostthatdoesnotexist"); + // After the IDOR guard (review #415) an unknown / mismatched pendingId + // is rejected with 404 rather than an idempotent 200 — this also avoids + // leaking whether a given pendingId exists. + assertThat(r.getCode()).isEqualTo(404); + } + + // ---------------- IDOR guard (review #415) ---------------- + + @Test + @DisplayName("deny rejects a pendingId belonging to ANOTHER visitor's session → 404") + void denyRejectsCrossVisitorPendingId() { + // Victim owns session victimX and its pending approval. + String pendingIdVictim = seedPending("victimX", "s1"); + // Attacker also has a valid token + own session (ownsConversation passes). + controller.createSession(API_KEY, req("attackerY", "s1")); + + // Attacker tries to deny the victim's pendingId while authenticated as + // the attacker against the attacker's own session. Before the IDOR fix + // this would resolve the victim's approval — a cross-visitor privilege + // escalation. Now the pendingId↔conversationId cross-check returns 404. + R> r = controller.denySession( + API_KEY, tokenFor("attackerY"), "attackerY", "s1", pendingIdVictim); + + assertThat(r.getCode()).isEqualTo(404); + + // The victim's approval is untouched. + String cidVictim = WebChatController.deriveConversationId(API_KEY, "victimX", "s1"); + var stillPending = approvalService.getPending(pendingIdVictim); + assertThat(stillPending).as("victim's approval must not be resolved by attacker").isPresent(); + assertThat(stillPending.get().getStatus()).isEqualTo("pending"); + } + + @Test + @DisplayName("deny rejects a pendingId that does not belong to the caller's session → 404") + void denyRejectsMismatchedPendingId() { + // Visitor owns the session, but passes a pendingId that doesn't match + // the session's pending (e.g. a stale/guessed id). + seedPending("visitorH", "s1"); + R> r = controller.denySession( + API_KEY, tokenFor("visitorH"), "visitorH", "s1", "wf-not-yours-12345"); + assertThat(r.getCode()).isEqualTo(404); + } + + // ---------------- stop sweep (A4) ---------------- + + @Test + @DisplayName("stop denies pending approvals on the conversation (approval sweep)") + void stopSweepsPendingApprovals() { + seedPending("visitorF", "s1"); + String cid = WebChatController.deriveConversationId(API_KEY, "visitorF", "s1"); + + // A pending approval exists before stop. + assertThat(approvalService.findPendingByConversation(cid)).isNotNull(); + + R> r = controller.stopSession( + API_KEY, tokenFor("visitorF"), "visitorF", "s1"); + + assertThat(r.getCode()).isEqualTo(200); + // After the sweep the approval is gone from the pending map. + PendingApproval after = approvalService.findPendingByConversation(cid); + assertThat(after == null || !"pending".equals(after.getStatus())) + .as("stop should have denied the pending approval").isTrue(); + } + + @Test + @DisplayName("stop with no pending approvals is unaffected (sweep is a no-op)") + void stopNoPendingStillWorks() { + controller.createSession(API_KEY, req("visitorG", "s1")); + R> r = controller.stopSession( + API_KEY, tokenFor("visitorG"), "visitorG", "s1"); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData().get("stopped")).isEqualTo(Boolean.FALSE); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java index 6a9fe1b5..03223a72 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockMultipartFile; +import vip.mate.workspace.core.service.ChatUploadLocationResolverTestSupport; import java.io.IOException; import java.nio.file.Files; @@ -27,12 +28,13 @@ class WebChatFileServiceTest { private static final String CONV = "webchat:abcd1234:visitor-1"; private WebChatFileService service(boolean enabled, long maxMb, String exts) { - return new WebChatFileService(enabled, maxMb, exts, 50, 200); + return service(enabled, maxMb, exts, 50, 200); } private WebChatFileService service(boolean enabled, long maxMb, String exts, int maxFiles, long maxTotalMb) { - return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb); + return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb, + ChatUploadLocationResolverTestSupport.legacyDefault()); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java index 77c8b6eb..c3c8c10f 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.PendingApproval; +import vip.mate.approval.ResolveOutcome; import vip.mate.channel.ChannelMessage; import vip.mate.channel.wecom.WeComChannelAdapter; @@ -32,6 +34,7 @@ import static org.mockito.Mockito.*; class ToolGuardCardHandlerTest { private ApprovalService approvalService; + private ApprovalWorkflowService approvalWorkflowService; private WeComChannelAdapter adapter; private ToolGuardButtonKey buttonKey; private ToolGuardCardHandler handler; @@ -39,9 +42,10 @@ class ToolGuardCardHandlerTest { @BeforeEach void setUp() { approvalService = Mockito.mock(ApprovalService.class); + approvalWorkflowService = Mockito.mock(ApprovalWorkflowService.class); adapter = Mockito.mock(WeComChannelAdapter.class); buttonKey = new ToolGuardButtonKey(new ObjectMapper()); - handler = new ToolGuardCardHandler(approvalService, buttonKey); + handler = new ToolGuardCardHandler(approvalService, approvalWorkflowService, buttonKey); } @Test @@ -156,6 +160,68 @@ class ToolGuardCardHandlerTest { verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class)); } + // ---- workflow-scoped (wf-) approval branch (ISSUE #413 P2-B3) ---- + + @Test + @DisplayName("wf- approval: card click resolves inline (no synthetic /approve injection)") + void workflowApprovalResolvesInline() { + // A workflow-scoped approval has userId=null (system-initiated) and a + // wf- pendingId. Before P2-B3 the identity check (requester==clicker) + // rejected every click — wf- approvals could only be resolved from the + // admin console. Now any audience member may resolve, and the handler + // calls resolve() directly (the synthetic injection is a dead end for + // wf- ids since their conversationId is workflow:run:{runId}). + PendingApproval wfPending = new PendingApproval( + "wf-abc123def456", "workflow:run:42", null, + "workflow:manager", "{}", "await manager approval"); + when(approvalService.getPending("wf-abc123def456")).thenReturn(Optional.of(wfPending)); + when(approvalWorkflowService.resolve("wf-abc123def456", "carol", "approved")) + .thenReturn(new ResolveOutcome( + "wf-abc123def456", "workflow:run:42", "workflow:manager", + "approved", null, true, 0)); + + Map frame = inboundFrame("evt_req_wf1", buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, "wf-abc123def456", "workflow:manager", "MEDIUM")); + handler.handle(adapter, frame, tce(frame), fromBlock("carol")); + + // resolve() was called inline — ApprovalResumeBridge resumes the run. + verify(approvalWorkflowService, times(1)).resolve("wf-abc123def456", "carol", "approved"); + // The synthetic /approve injection is NOT used for wf- approvals. + verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class)); + // A resolved card was rendered. + verify(adapter, times(1)).updateTemplateCard(eq("evt_req_wf1"), any()); + } + + @Test + @DisplayName("wf- approval already resolved: renders 'expired' card, no resolve call") + void workflowApprovalAlreadyResolved() { + PendingApproval wfPending = new PendingApproval( + "wf-alreadydone", "workflow:run:43", null, + "workflow:manager", "{}", "await manager approval"); + when(approvalService.getPending("wf-alreadydone")).thenReturn(Optional.of(wfPending)); + // dbSynced=false means the row was already terminal (approved/denied + // via another path). The handler renders 'expired' and does not treat + // it as an error. + when(approvalWorkflowService.resolve(eq("wf-alreadydone"), anyString(), anyString())) + .thenReturn(new ResolveOutcome( + "wf-alreadydone", null, null, + "already_resolved", null, false, 0)); + + Map frame = inboundFrame("evt_req_wf2", buttonKey.encode( + ToolGuardButtonKey.Action.DENY, "wf-alreadydone", "workflow:manager", "LOW")); + handler.handle(adapter, frame, tce(frame), fromBlock("dave")); + + verify(approvalWorkflowService, times(1)).resolve(eq("wf-alreadydone"), eq("dave"), eq("denied")); + verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class)); + // 'expired' card was rendered (title mentions 过期). + ArgumentCaptor> cardCaptor = cardArgCaptor(); + verify(adapter, times(1)).updateTemplateCard(eq("evt_req_wf2"), cardCaptor.capture()); + @SuppressWarnings("unchecked") + Map mainTitle = (Map) cardCaptor.getValue().get("main_title"); + assertTrue(((String) mainTitle.get("title")).contains("过期"), + "already-resolved wf- should show expired card; got: " + mainTitle.get("title")); + } + // ---- helpers ---- private static PendingApproval pendingFor(String pendingId, String requester, String tool) { diff --git a/mateclaw-server/src/test/java/vip/mate/common/net/SsrfAllowlistTest.java b/mateclaw-server/src/test/java/vip/mate/common/net/SsrfAllowlistTest.java new file mode 100644 index 00000000..c6ebb230 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/net/SsrfAllowlistTest.java @@ -0,0 +1,56 @@ +package vip.mate.common.net; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SsrfAllowlistTest { + + @Test + @DisplayName("matchesHost: exact IP and hostname, case-insensitive") + void matchesHostExact() { + assertTrue(SsrfAllowlist.matchesHost("192.168.100.100", List.of("192.168.100.100"))); + assertTrue(SsrfAllowlist.matchesHost("Internal.Corp", List.of("internal.corp"))); + assertFalse(SsrfAllowlist.matchesHost("192.168.100.101", List.of("192.168.100.100"))); + assertFalse(SsrfAllowlist.matchesHost("evil.com", List.of("internal.corp"))); + } + + @Test + @DisplayName("matchesHost: IPv4 CIDR matches contained IP literals only") + void matchesHostCidr() { + assertTrue(SsrfAllowlist.matchesHost("192.168.100.1", List.of("192.168.100.0/24"))); + assertTrue(SsrfAllowlist.matchesHost("192.168.100.254", List.of("192.168.100.0/24"))); + assertFalse(SsrfAllowlist.matchesHost("192.168.101.1", List.of("192.168.100.0/24"))); + // A hostname is not an IP, so it never matches a CIDR entry. + assertFalse(SsrfAllowlist.matchesHost("internal.corp", List.of("192.168.100.0/24"))); + } + + @Test + @DisplayName("matchesHost: bracketed IPv6 literal compares stripped form") + void matchesHostIpv6Brackets() { + assertTrue(SsrfAllowlist.matchesHost("[fd00::1]", List.of("fd00::1"))); + } + + @Test + @DisplayName("matchesAddress: exact IP and CIDR against resolved address") + void matchesAddressIpv4() throws Exception { + InetAddress addr = InetAddress.getByName("192.168.100.100"); + assertTrue(SsrfAllowlist.matchesAddress(addr, List.of("192.168.100.100"))); + assertTrue(SsrfAllowlist.matchesAddress(addr, List.of("192.168.100.0/24"))); + assertFalse(SsrfAllowlist.matchesAddress(addr, List.of("10.0.0.0/8"))); + } + + @Test + @DisplayName("Empty / null allowlist never matches; whitespace and bad entries are ignored") + void emptyAndMalformed() { + assertFalse(SsrfAllowlist.matchesHost("192.168.1.1", List.of())); + assertFalse(SsrfAllowlist.matchesHost("192.168.1.1", null)); + assertFalse(SsrfAllowlist.matchesHost("192.168.1.1", List.of(" ", "not-a-cidr/99", "999.1.1.1"))); + assertTrue(SsrfAllowlist.matchesHost("192.168.1.1", List.of(" ", " 192.168.1.1 "))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/config/OpenApiExposedAccessTest.java b/mateclaw-server/src/test/java/vip/mate/config/OpenApiExposedAccessTest.java new file mode 100644 index 00000000..5493c311 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/config/OpenApiExposedAccessTest.java @@ -0,0 +1,37 @@ +package vip.mate.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Local/dev posture: with {@code mateclaw.openapi.expose-ui=true} (the base + * {@code application.yml} default) the Swagger UI / OpenAPI document stays + * anonymously reachable so developers can browse and debug without a login. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.openapi.expose-ui=true" + } +) +class OpenApiExposedAccessTest { + + @Autowired + private TestRestTemplate rest; + + @Test + @DisplayName("Anonymous OpenAPI JSON is reachable (200) when expose-ui=true") + void anonymousApiDocsReachable() { + ResponseEntity resp = rest.getForEntity("/v3/api-docs", String.class); + assertEquals(HttpStatus.OK, resp.getStatusCode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java new file mode 100644 index 00000000..2ffce683 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java @@ -0,0 +1,59 @@ +package vip.mate.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Production posture: with {@code mateclaw.openapi.expose-ui=false} the Swagger + * UI / OpenAPI document paths must NOT be anonymously reachable. They fall under + * an explicit {@code hasRole('ADMIN')} rule in {@link SecurityConfig}, so an + * unauthenticated request is rejected by the authentication entry point (401) + * instead of leaking the full API surface. + * + *

            Uses a real embedded servlet container ({@code RANDOM_PORT}) because the app + * registers a WebSocket endpoint that requires a servlet {@code ServerContainer}, + * which the MockMvc-only environment does not provide. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.openapi.expose-ui=false" + } +) +class OpenApiLockedDownAccessTest { + + @Autowired + private TestRestTemplate rest; + + @Test + @DisplayName("Anonymous OpenAPI JSON is blocked (401) when expose-ui=false") + void anonymousApiDocsBlocked() { + ResponseEntity resp = rest.getForEntity("/v3/api-docs", String.class); + assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode()); + } + + @Test + @DisplayName("Anonymous Swagger UI is blocked (401) when expose-ui=false") + void anonymousSwaggerUiBlocked() { + ResponseEntity resp = rest.getForEntity("/swagger-ui/index.html", String.class); + assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode()); + } + + @Test + @DisplayName("A genuinely public endpoint stays reachable when Swagger is locked") + void publicEndpointStillReachable() { + // GET /api/v1/settings/language is permitAll (first-paint i18n); proves + // the lockdown is scoped to the OpenAPI paths, not a blanket denial. + ResponseEntity resp = rest.getForEntity("/api/v1/settings/language", String.class); + assertEquals(HttpStatus.OK, resp.getStatusCode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java index 38cbe5ea..cb3ace85 100644 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java @@ -42,7 +42,7 @@ class CronJobRunnerPromptTest { /* senderName */ null, /* channelType */ "feishu", /* chatId */ "group-a", - /* baseUrl */ null); + /* baseUrl */ null, null); String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); assertTrue(prompt.contains("[定时任务执行说明]")); diff --git a/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionSsrfAllowlistTest.java b/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionSsrfAllowlistTest.java new file mode 100644 index 00000000..c77c01c0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionSsrfAllowlistTest.java @@ -0,0 +1,53 @@ +package vip.mate.hook.action; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The hook HTTP action requires the target host to be in {@code trustedDomains} + * AND to not be a private/loopback address. An entry in the shared SSRF + * allowlist lifts the private-address block for that specific host. + */ +class HttpActionSsrfAllowlistTest { + + private static HttpAction action(String host, List trusted, List ssrfAllowlist) { + return new HttpAction( + RestClient.builder().build(), + "POST", + URI.create("http://" + host + "/hook"), + null, + trusted, + ssrfAllowlist, + 3000L, + null, + null); + } + + @Test + @DisplayName("Private host is rejected even when trusted, without an allowlist entry") + void privateHostRejectedWithoutAllowlist() { + HttpAction a = action("192.168.100.100", List.of("192.168.100.100"), List.of()); + assertThrows(IllegalArgumentException.class, a::validate); + } + + @Test + @DisplayName("Allowlisting the private host (with trust) lets validate() pass") + void privateHostAllowedWithAllowlist() { + HttpAction a = action("192.168.100.100", List.of("192.168.100.100"), List.of("192.168.100.0/24")); + assertDoesNotThrow(a::validate); + } + + @Test + @DisplayName("Allowlist does not bypass the trusted-domains requirement") + void allowlistDoesNotBypassTrust() { + HttpAction a = action("192.168.100.100", List.of(), List.of("192.168.100.100")); + assertThrows(IllegalArgumentException.class, a::validate); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyRateLimiterTest.java b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyRateLimiterTest.java new file mode 100644 index 00000000..e72068e6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyRateLimiterTest.java @@ -0,0 +1,66 @@ +package vip.mate.kbopen.auth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link KbApiKeyRateLimiter} (R2): sliding-window admission control. + */ +class KbApiKeyRateLimiterTest { + + @Test + @DisplayName("admits up to limit, then rejects") + void admitsUpToLimitThenRejects() { + KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter(); + Instant now = Instant.now(); + + assertThat(limiter.tryAcquire(1L, 3, now)).isTrue(); + assertThat(limiter.tryAcquire(1L, 3, now)).isTrue(); + assertThat(limiter.tryAcquire(1L, 3, now)).isTrue(); + assertThat(limiter.tryAcquire(1L, 3, now)).isFalse(); // 4th rejected + } + + @Test + @DisplayName("limits are per-key (different keys have independent windows)") + void perKeyIsolation() { + KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter(); + Instant now = Instant.now(); + + limiter.tryAcquire(1L, 2, now); + limiter.tryAcquire(1L, 2, now); + + // Key 1 is full, but key 2 is independent + assertThat(limiter.tryAcquire(1L, 2, now)).isFalse(); + assertThat(limiter.tryAcquire(2L, 2, now)).isTrue(); + } + + @Test + @DisplayName("expired entries are purged — window recovers over time") + void windowRecoversAfterExpiry() { + KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter(); + Instant now = Instant.now(); + + limiter.tryAcquire(1L, 2, now); + limiter.tryAcquire(1L, 2, now); + assertThat(limiter.tryAcquire(1L, 2, now)).isFalse(); + + // 61 seconds later, the window entries have expired + Instant later = now.plusSeconds(61); + assertThat(limiter.tryAcquire(1L, 2, later)).isTrue(); + } + + @Test + @DisplayName("limit <= 0 disables rate limiting") + void zeroLimitDisables() { + KbApiKeyRateLimiter limiter = new KbApiKeyRateLimiter(); + Instant now = Instant.now(); + + for (int i = 0; i < 100; i++) { + assertThat(limiter.tryAcquire(1L, 0, now)).isTrue(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java new file mode 100644 index 00000000..1dd662a7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java @@ -0,0 +1,234 @@ +package vip.mate.kbopen.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.kbopen.auth.KbApiKeyService.AuthResult; +import vip.mate.kbopen.auth.KbApiKeyService.CreatedKey; +import vip.mate.kbopen.auth.model.KbApiKeyBindingEntity; +import vip.mate.kbopen.auth.model.KbApiKeyEntity; +import vip.mate.kbopen.auth.repository.KbApiKeyBindingMapper; +import vip.mate.kbopen.auth.repository.KbApiKeyMapper; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link KbApiKeyService} covering the three P0-A security + * requirements: R1 (auth lookup), R2 (rate-limit context), R3 (empty binding + * rejection). Mirrors the #438/#439 IDOR test style. + */ +class KbApiKeyServiceTest { + + private KbApiKeyMapper keyMapper; + private KbApiKeyBindingMapper bindingMapper; + private TokenHashUtil tokenHashUtil; + private KbApiKeyService service; + + @BeforeEach + void setUp() { + keyMapper = mock(KbApiKeyMapper.class); + bindingMapper = mock(KbApiKeyBindingMapper.class); + tokenHashUtil = new TokenHashUtil(); + service = new KbApiKeyService(keyMapper, bindingMapper, tokenHashUtil); + } + + // ── R3: empty binding rejection ─────────────────────────────────────── + + @Test + @DisplayName("create with empty kbIds → 400 (R3: zero access is useless)") + void createEmptyBindingRejected() { + assertThatThrownBy(() -> service.create(1L, 100L, "test", "kb:*", Set.of(), null)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("At least one"); + } + + @Test + @DisplayName("create with null kbIds → 400") + void createNullBindingRejected() { + assertThatThrownBy(() -> service.create(1L, 100L, "test", "kb:*", null, null)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("updateBindings to empty → 400 (cannot reduce to zero access)") + void updateBindingsEmptyRejected() { + assertThatThrownBy(() -> service.updateBindings(1L, Set.of())) + .isInstanceOf(MateClawException.class); + } + + // ── Create + authenticate round-trip ────────────────────────────────── + + @Test + @DisplayName("create returns plaintext once; authenticate resolves it back") + void createAndAuthenticateRoundTrip() { + // Simulate MyBatis-Plus assigning an id on insert + when(keyMapper.insert(any(KbApiKeyEntity.class))).thenAnswer(inv -> { + KbApiKeyEntity e = inv.getArgument(0); + e.setId(42L); + return 1; + }); + CreatedKey created = service.create(1L, 100L, "test-key", "kb:search", Set.of(10L, 20L), null); + String plaintext = created.plaintext(); + + assertThat(plaintext).startsWith(KbApiKeyService.KEY_PREFIX); + assertThat(created.entity().getId()).isEqualTo(42L); + + // authenticate() hashes the plaintext and looks it up — set up the mapper + // to return the created entity (whose hash matches). + when(keyMapper.selectOne(any())).thenReturn(created.entity()); + when(bindingMapper.selectList(any())).thenReturn(List.of( + bindingFor(42L, 10L), + bindingFor(42L, 20L))); + + Optional result = service.authenticate(plaintext); + + assertThat(result).isPresent(); + KbApiKeyContext ctx = result.get().context(); + assertThat(ctx.keyId()).isEqualTo(42L); + assertThat(ctx.workspaceId()).isEqualTo(1L); + assertThat(ctx.kbIds()).containsExactlyInAnyOrder(10L, 20L); + assertThat(ctx.scopes()).contains("kb:search"); + assertThat(ctx.hasScope("kb:search")).isTrue(); + assertThat(ctx.hasScope("kb:read")).isFalse(); + assertThat(ctx.canAccessKb(10L)).isTrue(); + assertThat(ctx.canAccessKb(99L)).isFalse(); + } + + @Test + @DisplayName("authenticate with wrong prefix → empty (not an mck_ key)") + void authenticateWrongPrefix() { + assertThat(service.authenticate("mc_something")).isEmpty(); + assertThat(service.authenticate("eyJ.jwt.token")).isEmpty(); + } + + @Test + @DisplayName("authenticate with null/blank → empty") + void authenticateNullBlank() { + assertThat(service.authenticate(null)).isEmpty(); + assertThat(service.authenticate("")).isEmpty(); + assertThat(service.authenticate(" ")).isEmpty(); + } + + @Test + @DisplayName("authenticate with hash miss → empty") + void authenticateHashMiss() { + when(keyMapper.selectOne(any())).thenReturn(null); + assertThat(service.authenticate("mck_nonexistent_key_value_here")).isEmpty(); + } + + @Test + @DisplayName("authenticate expired key → empty") + void authenticateExpired() { + String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32); + KbApiKeyEntity entity = entityWithHash(1L, 1L, "kb:*", plaintext); + entity.setExpiresAt(LocalDateTime.now().minusDays(1)); + when(keyMapper.selectOne(any())).thenReturn(entity); + when(bindingMapper.selectList(any())).thenReturn(List.of()); + + assertThat(service.authenticate(plaintext)).isEmpty(); + } + + @Test + @DisplayName("authenticate disabled key → empty") + void authenticateDisabled() { + // The query already filters enabled=true, so selectOne returns null + when(keyMapper.selectOne(any())).thenReturn(null); + + String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32); + assertThat(service.authenticate(plaintext)).isEmpty(); + } + + // ── kb:* wildcard scope ─────────────────────────────────────────────── + + @Test + @DisplayName("kb:* scope grants all individual scopes") + void wildcardScopeGrantsAll() { + String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32); + when(keyMapper.selectOne(any())).thenReturn(entityWithHash(1L, 1L, "kb:*", plaintext)); + when(bindingMapper.selectList(any())).thenReturn(List.of(bindingFor(1L, 10L))); + + KbApiKeyContext ctx = service.authenticate(plaintext).get().context(); + + assertThat(ctx.hasScope("kb:search")).isTrue(); + assertThat(ctx.hasScope("kb:read")).isTrue(); + assertThat(ctx.hasScope("kb:list")).isTrue(); + assertThat(ctx.hasScope("kb:meta")).isTrue(); + } + + @Test + @DisplayName("null scopes defaults to kb:*") + void nullScopesDefaultsToWildcard() { + String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32); + when(keyMapper.selectOne(any())).thenReturn(entityWithHash(1L, 1L, null, plaintext)); + when(bindingMapper.selectList(any())).thenReturn(List.of(bindingFor(1L, 10L))); + + KbApiKeyContext ctx = service.authenticate(plaintext).get().context(); + + assertThat(ctx.hasScope("kb:search")).isTrue(); + } + + // ── Revoke ──────────────────────────────────────────────────────────── + + @Test + @DisplayName("revoke sets enabled=false + deleted=1") + void revokeSoftDeletes() { + KbApiKeyEntity entity = entity(1L, 1L, "kb:*"); + when(keyMapper.selectById(1L)).thenReturn(entity); + + service.revoke(1L, 1L); + + assertThat(entity.getEnabled()).isFalse(); + assertThat(entity.getDeleted()).isEqualTo(1); + verify(keyMapper).updateById(entity); + } + + @Test + @DisplayName("revoke from wrong workspace → 404") + void revokeWrongWorkspace() { + KbApiKeyEntity entity = entity(1L, 1L, "kb:*"); // belongs to ws 1 + when(keyMapper.selectById(1L)).thenReturn(entity); + + assertThatThrownBy(() -> service.revoke(1L, 2L)) // caller in ws 2 + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + /** Create an entity whose tokenHash matches the given plaintext. */ + private KbApiKeyEntity entityWithHash(long id, long workspaceId, String scopes, String plaintext) { + KbApiKeyEntity e = entity(id, workspaceId, scopes); + e.setTokenHash(tokenHashUtil.hash(plaintext)); + return e; + } + + private KbApiKeyEntity entity(long id, long workspaceId, String scopes) { + KbApiKeyEntity e = new KbApiKeyEntity(); + e.setId(id); + e.setWorkspaceId(workspaceId); + e.setScopes(scopes); + e.setEnabled(true); + e.setDeleted(0); + e.setRateLimitPerMin(60); + return e; + } + + private KbApiKeyBindingEntity bindingFor(long apiKeyId, long kbId) { + KbApiKeyBindingEntity b = new KbApiKeyBindingEntity(); + b.setApiKeyId(apiKeyId); + b.setKbId(kbId); + return b; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbOpenApiAuthFilterTest.java b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbOpenApiAuthFilterTest.java new file mode 100644 index 00000000..c9eb94a6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbOpenApiAuthFilterTest.java @@ -0,0 +1,127 @@ +package vip.mate.kbopen.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import vip.mate.kbopen.auth.KbApiKeyService.AuthResult; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link KbOpenApiAuthFilter} — focuses on the R7 SSE token + * fallback and R5 scope limitation added in #446: + *

              + *
            • {@code ?token=} is accepted on SSE stream paths (EventSource can't set + * an Authorization header).
            • + *
            • {@code ?token=} is rejected on non-SSE paths so the key doesn't leak + * into access / proxy logs.
            • + *
            • The SSE stream path bypasses the per-minute rate limiter (reconnects / + * heartbeats must not burn the key's start quota).
            • + *
            + */ +class KbOpenApiAuthFilterTest { + + private static final String KEY = "mck_abcd1234"; + private static final KbApiKeyContext CTX = + new KbApiKeyContext(7L, 1L, Set.of(10L), Set.of("kb:search"), 60); + + private KbApiKeyService keyService; + private KbApiKeyRateLimiter rateLimiter; + private KbOpenApiAuthFilter filter; + + @BeforeEach + void setUp() { + keyService = mock(KbApiKeyService.class); + rateLimiter = mock(KbApiKeyRateLimiter.class); + filter = new KbOpenApiAuthFilter(keyService, rateLimiter); + when(keyService.authenticate(KEY)).thenReturn(Optional.of(new AuthResult(CTX, LocalDateTime.now()))); + when(rateLimiter.tryAcquire(anyLong(), anyInt(), any())).thenReturn(true); + } + + private MockHttpServletRequest startRequest() { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/open/kb/10/research"); + req.setMethod("POST"); + req.addHeader("Authorization", "Bearer " + KEY); + return req; + } + + private MockHttpServletRequest sseRequest() { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/open/kb/10/research/open-research-x/stream"); + req.setMethod("GET"); + req.setQueryString("token=" + KEY); + req.addParameter("token", KEY); + return req; + } + + private int run(MockHttpServletRequest req) throws Exception { + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, new MockFilterChain()); + return res.getStatus(); + } + + @Test + @DisplayName("non-SSE path: header auth passes") + void nonSseHeaderAuth() throws Exception { + assertThat(run(startRequest())).isEqualTo(200); + } + + @Test + @DisplayName("non-SSE path: ?token= is rejected even with a valid key (R5 — no log leak)") + void nonSseQueryTokenRejected() throws Exception { + MockHttpServletRequest req = startRequest(); + req.removeHeader("Authorization"); + req.addParameter("token", KEY); + req.setQueryString("token=" + KEY); + + assertThat(run(req)).isEqualTo(401); + verify(keyService, never()).authenticate(KEY); + } + + @Test + @DisplayName("SSE path: ?token= authenticates (R7 — EventSource fallback)") + void sseQueryTokenAccepted() throws Exception { + assertThat(run(sseRequest())).isEqualTo(200); + verify(keyService).authenticate(KEY); + } + + @Test + @DisplayName("SSE path: missing token → 401") + void sseMissingToken() throws Exception { + MockHttpServletRequest req = sseRequest(); + req.removeParameter("token"); + req.setQueryString(null); + assertThat(run(req)).isEqualTo(401); + } + + @Test + @DisplayName("SSE path: bypasses the per-minute rate limiter (reconnects must not burn quota)") + void sseSkipsRateLimit() throws Exception { + run(sseRequest()); + verify(rateLimiter, never()) + .tryAcquire(anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("non-SSE path: still goes through the rate limiter") + void nonSseHitsRateLimit() throws Exception { + run(startRequest()); + verify(rateLimiter) + .tryAcquire(anyLong(), anyInt(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/kbopen/controller/KbOpenApiControllerTest.java b/mateclaw-server/src/test/java/vip/mate/kbopen/controller/KbOpenApiControllerTest.java new file mode 100644 index 00000000..22ec1bf2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/kbopen/controller/KbOpenApiControllerTest.java @@ -0,0 +1,102 @@ +package vip.mate.kbopen.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.kbopen.dto.KbOpenApiDtos.PageCard; +import vip.mate.kbopen.service.KbOpenApiService; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiChunkMapper; +import vip.mate.wiki.repository.WikiEntityMapper; +import vip.mate.wiki.repository.WikiEntityRelationMapper; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.service.HybridRetriever; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiPageService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link KbOpenApiController}. Focuses on the contract layer: + * 404 on missing page, and that the controller delegates correctly to + * services. Auth/scope/ownership is tested via filter+interceptor integration + * (covered by P0-A tests); here we verify the controller methods themselves. + */ +class KbOpenApiControllerTest { + + private WikiPageService pageService; + private HybridRetriever hybridRetriever; + private WikiKnowledgeBaseService kbService; + private WikiPageCitationMapper citationMapper; + private WikiChunkMapper chunkMapper; + private WikiEntityMapper entityMapper; + private WikiEntityRelationMapper relationMapper; + private KbOpenApiService openApiService; + private KbOpenApiController controller; + + @BeforeEach + void setUp() { + pageService = mock(WikiPageService.class); + hybridRetriever = mock(HybridRetriever.class); + kbService = mock(WikiKnowledgeBaseService.class); + citationMapper = mock(WikiPageCitationMapper.class); + chunkMapper = mock(WikiChunkMapper.class); + entityMapper = mock(WikiEntityMapper.class); + relationMapper = mock(WikiEntityRelationMapper.class); + openApiService = mock(KbOpenApiService.class); + controller = new KbOpenApiController( + pageService, hybridRetriever, kbService, citationMapper, + chunkMapper, entityMapper, relationMapper, openApiService); + } + + @Test + @DisplayName("getPage returns 404 when slug not found") + void getPageNotFound() { + when(pageService.getBySlug(1L, "missing")).thenReturn(null); + + assertThatThrownBy(() -> controller.getPage(1L, "missing", "summary", null)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("getPage delegates to openApiService.assembleCard") + void getPageDelegatesToService() { + WikiPageEntity page = new WikiPageEntity(); + page.setSlug("test"); + page.setTitle("Test Page"); + page.setPageType("concept"); + when(pageService.getBySlug(1L, "test")).thenReturn(page); + PageCard card = new PageCard("test", "Test Page", "concept", "fact", + "Test Page", "summary", null, null, null, 1, null); + when(openApiService.assembleCard(page, "summary", null)).thenReturn(card); + + var result = controller.getPage(1L, "test", "summary", null); + + assertThat(result.getData()).isNotNull(); + } + + @Test + @DisplayName("trace returns 404 when slug not found") + void traceNotFound() { + when(pageService.getBySlug(1L, "missing")).thenReturn(null); + + assertThatThrownBy(() -> controller.trace(1L, "missing")) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("stats returns 404 when KB not found") + void statsKbNotFound() { + when(kbService.getById(99L)).thenReturn(null); + + assertThatThrownBy(() -> controller.stats(99L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/kbopen/research/KbResearchSessionRegistryTest.java b/mateclaw-server/src/test/java/vip/mate/kbopen/research/KbResearchSessionRegistryTest.java new file mode 100644 index 00000000..75f23c20 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/kbopen/research/KbResearchSessionRegistryTest.java @@ -0,0 +1,259 @@ +package vip.mate.kbopen.research; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.kbopen.research.KbResearchSessionRegistry.Session; +import vip.mate.kbopen.research.KbResearchSessionRegistry.Status; +import vip.mate.kbopen.research.KbResearchSessionRegistry.TooManyConcurrentException; +import vip.mate.wiki.service.WikiResearchService.ResearchResult; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link KbResearchSessionRegistry} — session lifecycle, status + * transitions, sticky CANCELLED terminal, per-key concurrency cap, and TTL + * eviction. + */ +class KbResearchSessionRegistryTest { + + private static final ResearchResult RESULT = new ResearchResult("topic", List.of(), "final report"); + + private KbResearchSessionRegistry newRegistry() { + return new KbResearchSessionRegistry(3, Duration.ofMinutes(30)); + } + + @Test + @DisplayName("startIfAllowed creates a RUNNING session") + void registerCreatesRunning() { + KbResearchSessionRegistry registry = newRegistry(); + registry.startIfAllowed("s1", 100L, 10L, "test topic"); + + Optional session = registry.get("s1"); + assertThat(session).isPresent(); + assertThat(session.get().status()).isEqualTo(Status.RUNNING); + assertThat(session.get().keyId()).isEqualTo(100L); + assertThat(session.get().kbId()).isEqualTo(10L); + assertThat(session.get().topic()).isEqualTo("test topic"); + assertThat(session.get().updatedAt()).isNotNull(); + } + + @Test + @DisplayName("complete transitions RUNNING → COMPLETED with result") + void completeTransitionsToCompleted() { + KbResearchSessionRegistry registry = newRegistry(); + registry.startIfAllowed("s1", 100L, 10L, "topic"); + + registry.complete("s1", RESULT); + + Session session = registry.get("s1").get(); + assertThat(session.status()).isEqualTo(Status.COMPLETED); + assertThat(session.result()).isEqualTo(RESULT); + assertThat(session.result().report()).isEqualTo("final report"); + } + + @Test + @DisplayName("fail transitions RUNNING → FAILED with error") + void failTransitionsToFailed() { + KbResearchSessionRegistry registry = newRegistry(); + registry.startIfAllowed("s1", 100L, 10L, "topic"); + + registry.fail("s1", "LLM timeout"); + + Session session = registry.get("s1").get(); + assertThat(session.status()).isEqualTo(Status.FAILED); + assertThat(session.error()).isEqualTo("LLM timeout"); + } + + @Test + @DisplayName("cancel transitions RUNNING → CANCELLED") + void cancelTransitionsToCancelled() { + KbResearchSessionRegistry registry = newRegistry(); + registry.startIfAllowed("s1", 100L, 10L, "topic"); + + boolean cancelled = registry.cancel("s1"); + + assertThat(cancelled).isTrue(); + Session session = registry.get("s1").get(); + assertThat(session.status()).isEqualTo(Status.CANCELLED); + } + + @Test + @DisplayName("cancel on non-running session returns false (no-op)") + void cancelOnCompletedIsNoop() { + KbResearchSessionRegistry registry = newRegistry(); + registry.startIfAllowed("s1", 100L, 10L, "topic"); + registry.complete("s1", RESULT); + + boolean cancelled = registry.cancel("s1"); + + assertThat(cancelled).isFalse(); + Session session = registry.get("s1").get(); + assertThat(session.status()).isEqualTo(Status.COMPLETED); + } + + @Test + @DisplayName("get on unknown session returns empty") + void getUnknownReturnsEmpty() { + KbResearchSessionRegistry registry = newRegistry(); + assertThat(registry.get("nonexistent")).isEmpty(); + } + + // ── Review #446: sticky CANCELLED terminal ──────────────────────────── + + @Test + @DisplayName("complete after cancel is a no-op — CANCELLED is sticky") + void completeAfterCancelIsNoop() { + KbResearchSessionRegistry registry = newRegistry(); + registry.startIfAllowed("s1", 100L, 10L, "topic"); + registry.cancel("s1"); + + // Late complete() arriving from the async pipeline must NOT overwrite + // the CANCELLED terminal the user explicitly requested. + registry.complete("s1", RESULT); + + Session session = registry.get("s1").get(); + assertThat(session.status()).isEqualTo(Status.CANCELLED); + assertThat(session.result()).isNull(); + } + + @Test + @DisplayName("fail after cancel is a no-op — CANCELLED is sticky") + void failAfterCancelIsNoop() { + KbResearchSessionRegistry registry = newRegistry(); + registry.startIfAllowed("s1", 100L, 10L, "topic"); + registry.cancel("s1"); + + registry.fail("s1", "race condition"); + + Session session = registry.get("s1").get(); + assertThat(session.status()).isEqualTo(Status.CANCELLED); + assertThat(session.error()).isNull(); + } + + // ── Review #446: per-key concurrency cap ────────────────────────────── + + @Test + @DisplayName("startIfAllowed throws once the per-key cap is reached") + void startIfAllowedEnforcesCap() { + KbResearchSessionRegistry registry = new KbResearchSessionRegistry(2, Duration.ofMinutes(30)); + registry.startIfAllowed("s1", 100L, 10L, "t1"); + registry.startIfAllowed("s2", 100L, 10L, "t2"); + + // Third running session for the same key should be rejected → 429 upstream. + assertThatThrownBy(() -> registry.startIfAllowed("s3", 100L, 10L, "t3")) + .isInstanceOf(TooManyConcurrentException.class) + .hasMessageContaining("limit is 2"); + + // A different key is unaffected (cap is per-key, not global). + registry.startIfAllowed("s4", 200L, 10L, "t4"); + assertThat(registry.get("s4")).isPresent(); + } + + @Test + @DisplayName("completed sessions do not count toward the running cap") + void completedDoesNotCountTowardCap() { + KbResearchSessionRegistry registry = new KbResearchSessionRegistry(1, Duration.ofMinutes(30)); + registry.startIfAllowed("s1", 100L, 10L, "t1"); + registry.complete("s1", RESULT); + + // The terminal session no longer occupies a slot. + registry.startIfAllowed("s2", 100L, 10L, "t2"); + assertThat(registry.get("s2")).isPresent(); + } + + @Test + @DisplayName("cancelled and failed sessions also release their slot (counter consistency)") + void cancelledAndFailedReleaseSlot() { + KbResearchSessionRegistry registry = new KbResearchSessionRegistry(1, Duration.ofMinutes(30)); + registry.startIfAllowed("s1", 100L, 10L, "t1"); + registry.cancel("s1"); // RUNNING → CANCELLED releases slot + registry.startIfAllowed("s2", 100L, 10L, "t2"); + assertThat(registry.get("s2")).isPresent(); + + registry.fail("s2", "boom"); // RUNNING → FAILED releases slot + registry.startIfAllowed("s3", 100L, 10L, "t3"); + assertThat(registry.get("s3")).isPresent(); + } + + @Test + @DisplayName("concurrent starts never exceed the per-key cap (no check-then-act race)") + void startIfAllowedIsAtomicUnderConcurrency() throws Exception { + int cap = 3; + KbResearchSessionRegistry registry = new KbResearchSessionRegistry(cap, Duration.ofMinutes(30)); + int threads = cap * 4; // far more contenders than slots + CountDownLatch start = new CountDownLatch(1); + AtomicInteger admitted = new AtomicInteger(); + AtomicInteger rejected = new AtomicInteger(); + List workers = new ArrayList<>(); + + for (int i = 0; i < threads; i++) { + String sid = "concurrent-" + i; + Thread t = Thread.ofVirtual().start(() -> { + try { + start.await(); + registry.startIfAllowed(sid, 100L, 10L, "t"); + admitted.incrementAndGet(); + } catch (TooManyConcurrentException e) { + rejected.incrementAndGet(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + workers.add(t); + } + start.countDown(); + for (Thread t : workers) t.join(); + + // The whole point: exactly `cap` sessions get in, no matter the + // scheduling. The old stream-and-count impl could admit more under + // contention. + assertThat(admitted.get()).isEqualTo(cap); + assertThat(rejected.get()).isEqualTo(threads - cap); + } + + // ── Review #446: TTL eviction ───────────────────────────────────────── + + @Test + @DisplayName("evictExpired removes terminal sessions past TTL but keeps RUNNING + fresh terminals") + void evictExpiredRemovesStaleTerminals() { + // Tiny TTL so a fresh terminal (updatedAt≈now) is clearly within window. + Duration ttl = Duration.ofSeconds(60); + KbResearchSessionRegistry registry = new KbResearchSessionRegistry(3, ttl); + registry.startIfAllowed("s1", 100L, 10L, "running"); // RUNNING — never evicted + registry.startIfAllowed("s2", 100L, 10L, "stale-completed"); + registry.complete("s2", RESULT); // terminal, will be aged + registry.startIfAllowed("s3", 100L, 10L, "fresh-cancelled"); + registry.cancel("s3"); // terminal, fresh + + // Horizon well past TTL: both terminals s2 and s3 are now stale. + Instant far = Instant.now().plus(Duration.ofSeconds(120)); + int removed = registry.evictExpired(far); + assertThat(removed).isEqualTo(2); + assertThat(registry.get("s1")).isPresent(); // running always kept + assertThat(registry.get("s2")).isEmpty(); + assertThat(registry.get("s3")).isEmpty(); + } + + @Test + @DisplayName("evictExpired keeps a fresh terminal within the TTL window") + void evictExpiredKeepsFreshTerminal() { + Duration ttl = Duration.ofHours(1); + KbResearchSessionRegistry registry = new KbResearchSessionRegistry(3, ttl); + registry.startIfAllowed("s1", 100L, 10L, "just-completed"); + registry.complete("s1", RESULT); // updatedAt ≈ now + + // Evict only 5 seconds later — well within the 1h TTL. + int removed = registry.evictExpired(Instant.now().plus(Duration.ofSeconds(5))); + assertThat(removed).isZero(); + assertThat(registry.get("s1")).isPresent(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java b/mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java new file mode 100644 index 00000000..c8646346 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java @@ -0,0 +1,82 @@ +package vip.mate.llm.cache; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.metadata.Usage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies reflective extraction of cache / reasoning token counters from the + * provider-native usage shapes (Anthropic top-level accessors, OpenAI-compatible + * and DashScope nested detail records). + */ +class CacheUsageExtractorTest { + + /** Minimal Usage stub whose native payload drives the extraction. */ + private record StubUsage(Object nativeUsage) implements Usage { + @Override public Integer getPromptTokens() { return 0; } + @Override public Integer getCompletionTokens() { return 0; } + @Override public Object getNativeUsage() { return nativeUsage; } + } + + /** Anthropic-style native usage: top-level cache accessors. */ + private record AnthropicStyleUsage(Integer inputTokens, Integer outputTokens, + Integer cacheCreationInputTokens, + Integer cacheReadInputTokens) {} + + /** OpenAI-style native usage: nested prompt/completion detail records. */ + private record OpenAiPromptDetails(Integer audioTokens, Integer cachedTokens) {} + private record OpenAiCompletionDetails(Integer reasoningTokens, Integer audioTokens) {} + private record OpenAiStyleUsage(Integer promptTokens, Integer completionTokens, + OpenAiPromptDetails promptTokensDetails, + OpenAiCompletionDetails completionTokenDetails) {} + + /** DashScope-style native usage: promptTokenDetailed.cachedTokens. */ + private record DashScopePromptDetailed(Integer cachedTokens) {} + private record DashScopeStyleUsage(Integer inputTokens, Integer outputTokens, + DashScopePromptDetailed promptTokenDetailed) {} + + @Test + void anthropicTopLevelCacheFields() { + var usage = new StubUsage(new AnthropicStyleUsage(100, 50, 2000, 66000)); + var tokens = CacheUsageExtractor.extract(usage); + assertEquals(66000, tokens.cacheReadTokens()); + assertEquals(2000, tokens.cacheWriteTokens()); + assertEquals(0, tokens.reasoningTokens()); + } + + @Test + void openAiNestedCachedAndReasoningTokens() { + var usage = new StubUsage(new OpenAiStyleUsage(5000, 800, + new OpenAiPromptDetails(0, 4200), + new OpenAiCompletionDetails(300, 0))); + var tokens = CacheUsageExtractor.extract(usage); + assertEquals(4200, tokens.cacheReadTokens()); + assertEquals(0, tokens.cacheWriteTokens()); + assertEquals(300, tokens.reasoningTokens()); + } + + @Test + void dashScopeNestedCachedTokens() { + var usage = new StubUsage(new DashScopeStyleUsage(9000, 400, + new DashScopePromptDetailed(7500))); + var tokens = CacheUsageExtractor.extract(usage); + assertEquals(7500, tokens.cacheReadTokens()); + assertEquals(0, tokens.cacheWriteTokens()); + assertEquals(0, tokens.reasoningTokens()); + } + + @Test + void unknownProviderYieldsEmpty() { + var tokens = CacheUsageExtractor.extract(new StubUsage(new Object())); + assertTrue(tokens.isEmpty()); + } + + @Test + void nullDetailRecordsYieldZeroNotError() { + var usage = new StubUsage(new OpenAiStyleUsage(5000, 800, null, null)); + var tokens = CacheUsageExtractor.extract(usage); + assertTrue(tokens.isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/probe/ContextLimitErrorParserTest.java b/mateclaw-server/src/test/java/vip/mate/llm/probe/ContextLimitErrorParserTest.java new file mode 100644 index 00000000..13455bde --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/probe/ContextLimitErrorParserTest.java @@ -0,0 +1,62 @@ +package vip.mate.llm.probe; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ContextLimitErrorParser} — the reconciliation + * fallback that learns the model's context window from rejection text. + */ +class ContextLimitErrorParserTest { + + @Test + @DisplayName("vLLM max_model_len rejection yields the limit, not the requested size") + void vllmMaxModelLen() { + OptionalInt limit = ContextLimitErrorParser.extractLimit( + "This request would exceed the max_model_len 32768 (requested 51234 tokens)"); + assertEquals(OptionalInt.of(32768), limit); + } + + @Test + @DisplayName("OpenAI-style maximum context length message") + void openAiStyle() { + OptionalInt limit = ContextLimitErrorParser.extractLimit( + "This model's maximum context length is 4096 tokens. However, your messages resulted in 9012 tokens."); + assertEquals(OptionalInt.of(4096), limit); + } + + @Test + @DisplayName("vLLM alternate wording: maximum model length") + void vllmAlternate() { + OptionalInt limit = ContextLimitErrorParser.extractLimit( + "Input prompt (40000 tokens) is longer than the maximum model length of 16384"); + assertEquals(OptionalInt.of(16384), limit); + } + + @Test + @DisplayName("num_ctx wording in rejection text") + void numCtx() { + OptionalInt limit = ContextLimitErrorParser.extractLimit( + "prompt exceeds server window (num_ctx 8192)"); + assertEquals(OptionalInt.of(8192), limit); + } + + @Test + @DisplayName("no pattern → empty") + void unrelatedMessage() { + assertTrue(ContextLimitErrorParser.extractLimit("connection refused").isEmpty()); + assertTrue(ContextLimitErrorParser.extractLimit("").isEmpty()); + assertTrue(ContextLimitErrorParser.extractLimit(null).isEmpty()); + } + + @Test + @DisplayName("implausible numbers are rejected") + void implausibleNumbers() { + // Below one model page — likely a mis-parse. + assertTrue(ContextLimitErrorParser.extractLimit("maximum context length is 100 tokens").isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java b/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java new file mode 100644 index 00000000..561119fc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java @@ -0,0 +1,135 @@ +package vip.mate.llm.probe; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ModelContextWindowResolver} — priority order, + * caching, disabled flag, and error-text reconciliation. + */ +class ModelContextWindowResolverTest { + + private ContextProbeProperties properties; + private AtomicInteger probeCalls; + + @BeforeEach + void setUp() { + properties = new ContextProbeProperties(); + probeCalls = new AtomicInteger(); + } + + private LocalContextProbe fixedProbe(Integer value) { + return new LocalContextProbe() { + @Override + public boolean supports(ModelProviderEntity provider, ModelConfigEntity model) { + return true; + } + + @Override + public Optional probeContextLength(ModelProviderEntity provider, ModelConfigEntity model) { + probeCalls.incrementAndGet(); + return Optional.ofNullable(value); + } + }; + } + + private static ModelProviderEntity provider(String id) { + ModelProviderEntity provider = new ModelProviderEntity(); + provider.setProviderId(id); + return provider; + } + + private static ModelConfigEntity model(String name, Integer maxInputTokens) { + ModelConfigEntity model = new ModelConfigEntity(); + model.setModelName(name); + model.setMaxInputTokens(maxInputTokens); + return model; + } + + @Test + @DisplayName("explicit maxInputTokens always wins — probe never runs") + void explicitConfigWins() { + ModelContextWindowResolver resolver = + new ModelContextWindowResolver(List.of(fixedProbe(16384)), properties); + Integer resolved = resolver.resolveMaxInputTokens(provider("ollama"), model("m", 128000)); + assertEquals(128000, resolved); + assertEquals(0, probeCalls.get()); + } + + @Test + @DisplayName("no explicit config → probed value used and cached") + void probeFillsGapAndCaches() { + ModelContextWindowResolver resolver = + new ModelContextWindowResolver(List.of(fixedProbe(16384)), properties); + assertEquals(16384, resolver.resolveMaxInputTokens(provider("ollama"), model("m", null))); + assertEquals(16384, resolver.resolveMaxInputTokens(provider("ollama"), model("m", 0))); + assertEquals(1, probeCalls.get(), "second call must hit the cache"); + } + + @Test + @DisplayName("probe miss is negative-cached — the endpoint is not hammered") + void negativeCache() { + ModelContextWindowResolver resolver = + new ModelContextWindowResolver(List.of(fixedProbe(null)), properties); + assertNull(resolver.resolveMaxInputTokens(provider("ollama"), model("m", null))); + assertNull(resolver.resolveMaxInputTokens(provider("ollama"), model("m", null))); + assertEquals(1, probeCalls.get()); + } + + @Test + @DisplayName("disabled → null without probing") + void disabledSkipsProbing() { + properties.setEnabled(false); + ModelContextWindowResolver resolver = + new ModelContextWindowResolver(List.of(fixedProbe(16384)), properties); + assertNull(resolver.resolveMaxInputTokens(provider("ollama"), model("m", null))); + assertEquals(0, probeCalls.get()); + } + + @Test + @DisplayName("a probe that throws is skipped, not fatal") + void throwingProbeIsSkipped() { + LocalContextProbe throwing = new LocalContextProbe() { + @Override + public boolean supports(ModelProviderEntity provider, ModelConfigEntity model) { + return true; + } + + @Override + public Optional probeContextLength(ModelProviderEntity provider, ModelConfigEntity model) { + throw new IllegalStateException("boom"); + } + }; + ModelContextWindowResolver resolver = + new ModelContextWindowResolver(List.of(throwing, fixedProbe(8192)), properties); + assertEquals(8192, resolver.resolveMaxInputTokens(provider("ollama"), model("m", null))); + } + + @Test + @DisplayName("context-limit error text seeds the cache for later turns") + void errorTextReconciliation() { + ModelContextWindowResolver resolver = + new ModelContextWindowResolver(List.of(), properties); + resolver.noteContextLimitError("vllm-local", "m", + "Input prompt (40000 tokens) exceeds the max_model_len 32768"); + assertEquals(32768, resolver.resolveMaxInputTokens(provider("vllm-local"), model("m", null))); + } + + @Test + @DisplayName("unparseable error text changes nothing") + void unparseableErrorIgnored() { + ModelContextWindowResolver resolver = + new ModelContextWindowResolver(List.of(), properties); + resolver.noteContextLimitError("p", "m", "connection refused"); + assertNull(resolver.resolveMaxInputTokens(provider("p"), model("m", null))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/probe/OllamaContextProbeParseTest.java b/mateclaw-server/src/test/java/vip/mate/llm/probe/OllamaContextProbeParseTest.java new file mode 100644 index 00000000..5d738163 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/probe/OllamaContextProbeParseTest.java @@ -0,0 +1,52 @@ +package vip.mate.llm.probe; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Parse-level tests for {@link OllamaContextProbe} against captured + * {@code /api/show} response shapes — no HTTP involved. + */ +class OllamaContextProbeParseTest { + + @Test + @DisplayName("num_ctx from modelfile parameters wins over architecture context_length") + void numCtxWins() { + String body = """ + {"parameters": "num_ctx 8192\\nstop \\"<|im_end|>\\"", + "model_info": {"qwen2.context_length": 32768, "qwen2.embedding_length": 3584}} + """; + assertEquals(OptionalInt.of(8192), OllamaContextProbe.parseShowResponse(body)); + } + + @Test + @DisplayName("architecture context_length used when no num_ctx is set") + void contextLengthFallback() { + String body = """ + {"parameters": "stop \\"<|im_end|>\\"", + "model_info": {"llama.context_length": 131072, "llama.block_count": 32}} + """; + assertEquals(OptionalInt.of(131072), OllamaContextProbe.parseShowResponse(body)); + } + + @Test + @DisplayName("no usable field → empty") + void noUsableField() { + assertTrue(OllamaContextProbe.parseShowResponse("{\"model_info\": {}}").isEmpty()); + assertTrue(OllamaContextProbe.parseShowResponse("not json").isEmpty()); + assertTrue(OllamaContextProbe.parseShowResponse(null).isEmpty()); + } + + @Test + @DisplayName("base URL normalization strips trailing slash and /v1, defaults when blank") + void baseUrlNormalization() { + assertEquals("http://127.0.0.1:11434", OllamaContextProbe.normalizeBaseUrl(null)); + assertEquals("http://127.0.0.1:11434", OllamaContextProbe.normalizeBaseUrl(" ")); + assertEquals("http://192.168.1.5:11434", OllamaContextProbe.normalizeBaseUrl("http://192.168.1.5:11434/v1")); + assertEquals("http://192.168.1.5:11434", OllamaContextProbe.normalizeBaseUrl("http://192.168.1.5:11434/")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/probe/OpenAiCompatibleContextProbeParseTest.java b/mateclaw-server/src/test/java/vip/mate/llm/probe/OpenAiCompatibleContextProbeParseTest.java new file mode 100644 index 00000000..c551b664 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/probe/OpenAiCompatibleContextProbeParseTest.java @@ -0,0 +1,70 @@ +package vip.mate.llm.probe; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Parse-level tests for {@link OpenAiCompatibleContextProbe} against + * {@code /v1/models} response shapes — no HTTP involved. + */ +class OpenAiCompatibleContextProbeParseTest { + + @Test + @DisplayName("vLLM exposes max_model_len per model entry") + void vllmMaxModelLen() { + String body = """ + {"object": "list", "data": [ + {"id": "Qwen/Qwen2.5-7B-Instruct", "object": "model", "max_model_len": 32768}, + {"id": "other-model", "object": "model", "max_model_len": 4096} + ]} + """; + assertEquals(OptionalInt.of(32768), + OpenAiCompatibleContextProbe.parseModelsResponse(body, "Qwen/Qwen2.5-7B-Instruct")); + } + + @Test + @DisplayName("context_length / max_context_length variants are read too") + void contextLengthVariants() { + String contextLength = "{\"data\": [{\"id\": \"m1\", \"context_length\": 16384}]}"; + assertEquals(OptionalInt.of(16384), + OpenAiCompatibleContextProbe.parseModelsResponse(contextLength, "m1")); + + String maxContextLength = "{\"data\": [{\"id\": \"m2\", \"max_context_length\": 8192}]}"; + assertEquals(OptionalInt.of(8192), + OpenAiCompatibleContextProbe.parseModelsResponse(maxContextLength, "m2")); + } + + @Test + @DisplayName("unknown model id or missing fields → empty") + void unknownModelOrMissingField() { + String body = "{\"data\": [{\"id\": \"m1\", \"max_model_len\": 32768}]}"; + assertTrue(OpenAiCompatibleContextProbe.parseModelsResponse(body, "not-there").isEmpty()); + assertTrue(OpenAiCompatibleContextProbe.parseModelsResponse( + "{\"data\": [{\"id\": \"m1\"}]}", "m1").isEmpty()); + assertTrue(OpenAiCompatibleContextProbe.parseModelsResponse("not json", "m1").isEmpty()); + assertTrue(OpenAiCompatibleContextProbe.parseModelsResponse(null, "m1").isEmpty()); + } + + @Test + @DisplayName("local endpoint heuristic: loopback and private ranges yes, public hosts no") + void localEndpointHeuristic() { + assertTrue(LocalEndpoints.isLocal("http://localhost:8000")); + assertTrue(LocalEndpoints.isLocal("http://127.0.0.1:8000/v1")); + assertTrue(LocalEndpoints.isLocal("http://192.168.1.20:1234")); + assertTrue(LocalEndpoints.isLocal("http://10.0.0.3:8000")); + assertTrue(LocalEndpoints.isLocal("http://172.16.0.9:8000")); + assertTrue(LocalEndpoints.isLocal("http://host.docker.internal:11434")); + assertTrue(LocalEndpoints.isLocal("http://mymac.local:1234")); + + assertFalse(LocalEndpoints.isLocal("https://api.openai.com/v1")); + assertFalse(LocalEndpoints.isLocal("https://dashscope.aliyuncs.com/compatible-mode/v1")); + assertFalse(LocalEndpoints.isLocal("http://172.32.0.1:8000")); // outside 172.16/12 + assertFalse(LocalEndpoints.isLocal(null)); + assertFalse(LocalEndpoints.isLocal("")); + assertFalse(LocalEndpoints.isLocal("not a url")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java b/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java index f373ba81..d4b260f9 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java @@ -46,6 +46,12 @@ class ProviderRouterSelectPrimaryTest { return m; } + private static ModelConfigEntity model(String provider, String name, boolean enabled) { + ModelConfigEntity m = model(provider, name); + m.setEnabled(enabled); + return m; + } + private void stubNoCapabilities() { when(bindingService.getBoundSkillIds(AGENT_ID)).thenReturn(Set.of()); } @@ -82,7 +88,7 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("1. Preferred provider wins when no capability requirements") void preferredWinsWithoutCapabilities() { stubNoCapabilities(); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek")); + when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null))); stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat")); ModelConfigEntity global = model("openai", "gpt-4o"); @@ -97,7 +103,7 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("2. Preferred provider satisfying the required capability wins in pass 1") void preferredSatisfyingCapabilityWins() { bindSkillRequiring("vision"); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek")); + when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null))); stubConfiguredProvider("deepseek", model("deepseek", "deepseek-vl")); when(capabilityService.resolve(eq("deepseek-vl"), any())) .thenReturn(EnumSet.of(Modality.VISION)); @@ -114,7 +120,7 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("3. No preferred providers → global default") void noPreferredFallsBackToGlobal() { stubNoCapabilities(); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of()); + when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of()); ModelConfigEntity global = model("openai", "gpt-4o"); ModelConfigEntity result = router.selectPrimary(AGENT_ID, global); @@ -128,7 +134,8 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("4. Unconfigured first preferred is skipped → second preferred wins") void firstPreferredUnavailableSecondWins() { stubNoCapabilities(); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek", "dashscope")); + when(bindingService.getPreferredProviderModels(AGENT_ID)) + .thenReturn(List.of(new ProviderModelRef("deepseek", null), new ProviderModelRef("dashscope", null))); // deepseek has no usable credentials → must be skipped, not selected // and then bounced to the global default. when(modelProviderService.isProviderConfigured("deepseek")).thenReturn(false); @@ -146,7 +153,7 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("5. All preferred unconfigured → global default") void allPreferredUnavailableFallsBackToGlobal() { stubNoCapabilities(); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek")); + when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null))); when(modelProviderService.isProviderConfigured("deepseek")).thenReturn(false); ModelConfigEntity global = model("openai", "gpt-4o"); @@ -168,7 +175,7 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("7. Both preferred and global null → returns null") void allNullReturnsNull() { stubNoCapabilities(); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of()); + when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of()); ModelConfigEntity result = router.selectPrimary(AGENT_ID, null); assertNull(result); @@ -178,7 +185,7 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("8. Preferred misses required capability but global satisfies → global wins in pass 1") void preferredMissesCapabilityGlobalSatisfies() { bindSkillRequiring("vision"); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek")); + when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null))); stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat")); when(capabilityService.resolve(eq("deepseek-chat"), any())) .thenReturn(EnumSet.noneOf(Modality.class)); @@ -198,7 +205,7 @@ class ProviderRouterSelectPrimaryTest { @DisplayName("9. Configured preferred provider without a system-default model still resolves") void preferredResolvesViaPerProviderFallback() { stubNoCapabilities(); - when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek")); + when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null))); // getPrimaryChatModelByProvider encapsulates the system-default → // first-enabled-chat fallback, so a preferred provider that does not // hold the single global default still contributes a primary model. @@ -211,4 +218,94 @@ class ProviderRouterSelectPrimaryTest { assertEquals("deepseek", result.getProvider()); assertEquals("deepseek-chat", result.getModelName()); } + + @Test + @DisplayName("10. Pinned model on a configured provider is honoured verbatim") + void pinnedModelWins() { + stubNoCapabilities(); + when(bindingService.getPreferredProviderModels(AGENT_ID)) + .thenReturn(List.of(new ProviderModelRef("dashscope", 77L))); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + when(modelConfigService.getModel(77L)).thenReturn(model("dashscope", "qwen-vl-max", true)); + + ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o")); + + assertNotNull(result); + assertEquals("dashscope", result.getProvider()); + assertEquals("qwen-vl-max", result.getModelName()); + // provider-default lookup must NOT be consulted when a live pin resolves + verify(modelConfigService, never()).getPrimaryChatModelByProvider("dashscope"); + } + + @Test + @DisplayName("11. Same provider pinned to two models: first entry wins as primary") + void sameProviderTwoModelsFirstWins() { + stubNoCapabilities(); + when(bindingService.getPreferredProviderModels(AGENT_ID)) + .thenReturn(List.of(new ProviderModelRef("dashscope", 1L), new ProviderModelRef("dashscope", 2L))); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + when(modelConfigService.getModel(1L)).thenReturn(model("dashscope", "qwen-max", true)); + + ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o")); + + assertNotNull(result); + assertEquals("qwen-max", result.getModelName()); + } + + @Test + @DisplayName("12. Disabled pinned model falls back to the provider's default") + void pinnedModelDisabledFallsBackToProviderDefault() { + stubNoCapabilities(); + when(bindingService.getPreferredProviderModels(AGENT_ID)) + .thenReturn(List.of(new ProviderModelRef("dashscope", 99L))); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + when(modelConfigService.getModel(99L)).thenReturn(model("dashscope", "qwen-old", false)); + when(modelConfigService.getPrimaryChatModelByProvider("dashscope")) + .thenReturn(model("dashscope", "qwen-max")); + + ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o")); + + assertNotNull(result); + assertEquals("dashscope", result.getProvider()); + assertEquals("qwen-max", result.getModelName()); + } + + @Test + @DisplayName("13. Pinned model that belongs to a different provider falls back to the provider default") + void pinnedModelWrongProviderFallsBack() { + stubNoCapabilities(); + when(bindingService.getPreferredProviderModels(AGENT_ID)) + .thenReturn(List.of(new ProviderModelRef("dashscope", 88L))); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + // The pinned id resolves to a model owned by ANOTHER provider — must not + // be used under dashscope's cooldown/credentials. + when(modelConfigService.getModel(88L)).thenReturn(model("openai", "gpt-4o", true)); + when(modelConfigService.getPrimaryChatModelByProvider("dashscope")) + .thenReturn(model("dashscope", "qwen-max")); + + ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("anthropic", "claude")); + + assertNotNull(result); + assertEquals("dashscope", result.getProvider()); + assertEquals("qwen-max", result.getModelName()); + } + + @Test + @DisplayName("14. Pinned non-chat (embedding) model falls back to the provider default") + void pinnedNonChatModelFallsBack() { + stubNoCapabilities(); + when(bindingService.getPreferredProviderModels(AGENT_ID)) + .thenReturn(List.of(new ProviderModelRef("dashscope", 55L))); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + ModelConfigEntity embedding = model("dashscope", "text-embedding-v3", true); + embedding.setModelType("embedding"); + when(modelConfigService.getModel(55L)).thenReturn(embedding); + when(modelConfigService.getPrimaryChatModelByProvider("dashscope")) + .thenReturn(model("dashscope", "qwen-max")); + + ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o")); + + assertNotNull(result); + assertEquals("qwen-max", result.getModelName()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelCapabilityServiceTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelCapabilityServiceTest.java index 0c485548..3b231e4a 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelCapabilityServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelCapabilityServiceTest.java @@ -98,17 +98,24 @@ class ModelCapabilityServiceTest { } @Test - @DisplayName("DeepSeek V4 / V4-Pro → VIDEO; V3 (text-only) gets nothing") - void deepseekV4_supportsVideo() { - // DeepSeek V4 (Apr 2026) introduced native multimodal incl. video to the line. - // V3 and earlier remain text-only and must NOT match the V4 entry. - assertTrue(service.supports("deepseek-v4", null, Modality.VIDEO)); - assertTrue(service.supports("deepseek-v4-pro", null, Modality.VIDEO)); - assertTrue(service.supports("deepseek-v4-flash", null, Modality.VIDEO)); - assertFalse(service.supports("deepseek-v3", null, Modality.VIDEO), - "V3 must NOT inherit V4 capabilities — text-only base differs from V4 entirely"); + @DisplayName("DeepSeek is text-only by default — no speculative vision entry (issue #288)") + void deepseek_textOnlyByDefault() { + // DeepSeek's released chat models (deepseek-chat / deepseek-reasoner / + // deepseek-v3.x) are text-only. A hardcoded deepseek-v4 → vision/video entry + // made the router send image_url to a text model, which DeepSeek rejects with + // a 400 (and it never fell back to the vision sidecar). Default must be + // text-only; a genuinely multimodal model is opted in via the DB modalities + // declaration, not assumed here. + assertFalse(service.supports("deepseek-v4", null, Modality.VISION), + "deepseek must not be assumed vision-capable (issue #288)"); + assertFalse(service.supports("deepseek-v4", null, Modality.VIDEO)); + assertFalse(service.supports("deepseek-chat", null, Modality.VISION)); + assertFalse(service.supports("deepseek-reasoner", null, Modality.VISION)); assertFalse(service.supports("deepseek-v3.2", null, Modality.VIDEO)); - assertFalse(service.supports("deepseek-r1", null, Modality.VIDEO)); + assertEquals(EnumSet.of(Modality.TEXT), service.resolve("deepseek-v4", null)); + // A real multimodal model can still be declared explicitly via DB modalities. + assertTrue(service.supports("deepseek-v4", "[\"vision\"]", Modality.VISION), + "an explicit DB declaration must still grant vision when the model truly has it"); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerBudgetTest.java new file mode 100644 index 00000000..3c042afb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerBudgetTest.java @@ -0,0 +1,96 @@ +package vip.mate.memory; + +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.memory.spi.MemoryProvider; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the token-budgeted system-prompt block assembly in + * {@link MemoryManager} — provider order is priority order, later providers + * drop whole once the budget is spent, a partially fitting block truncates + * at a line boundary. + */ +class MemoryManagerBudgetTest { + + /** CJK chars estimate ≈ 1 token each, which makes budgets easy to reason about. */ + private static final String BLOCK_A = "甲".repeat(300); + private static final String BLOCK_B = "乙".repeat(300); + + private static MemoryProvider provider(String id, String block) { + return new MemoryProvider() { + @Override + public String id() { + return id; + } + + @Override + public String systemPromptBlock(Long agentId) { + return block; + } + }; + } + + private static MemoryManager manager(MemoryProvider... providers) { + ObjectProvider noRegistry = new ObjectProvider<>() { + @Override + public MeterRegistry getObject(Object... args) { + throw new UnsupportedOperationException(); + } + + @Override + public MeterRegistry getIfAvailable() { + return null; + } + }; + return new MemoryManager(List.of(providers), new MemoryProperties(), noRegistry); + } + + @Test + @DisplayName("unbudgeted call joins every provider block — previous behavior") + void unbudgetedJoinsAll() { + MemoryManager manager = manager(provider("a", BLOCK_A), provider("b", BLOCK_B)); + String result = manager.buildSystemPromptBlock(1L); + assertTrue(result.contains(BLOCK_A)); + assertTrue(result.contains(BLOCK_B)); + } + + @Test + @DisplayName("budget exhausted after the first block → later provider dropped whole") + void budgetDropsLaterProviders() { + MemoryManager manager = manager(provider("a", BLOCK_A), provider("b", BLOCK_B)); + // 300 CJK chars ≈ 300 tokens; 350 fits block A but not A+B. + String result = manager.buildSystemPromptBlock(1L, 350); + assertTrue(result.contains(BLOCK_A)); + assertFalse(result.contains("乙")); + } + + @Test + @DisplayName("single multi-line block over budget truncates at a line boundary with a marker") + void oversizedBlockTruncatesAtLineBoundary() { + String multiLine = ("行".repeat(100) + "\n").repeat(10).trim(); + MemoryManager manager = manager(provider("a", multiLine)); + String result = manager.buildSystemPromptBlock(1L, 350); + assertTrue(result.contains("[memory truncated to fit the model context window]")); + // Only whole lines are kept — every kept content line is the full 100-char line. + for (String line : result.split("\n")) { + if (line.startsWith("行")) { + assertEquals(100, line.length()); + } + } + assertTrue(result.length() < multiLine.length()); + } + + @Test + @DisplayName("zero budget yields an empty block, not an exception") + void zeroBudgetYieldsEmpty() { + MemoryManager manager = manager(provider("a", BLOCK_A)); + assertEquals("", manager.buildSystemPromptBlock(1L, 0)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java new file mode 100644 index 00000000..74f0d576 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/search/SessionSearchIsolationTest.java @@ -0,0 +1,98 @@ +package vip.mate.memory.search; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Isolation regression test for {@link SessionSearchService}. + *

            + * Concurrent sessions of the same agent must not leak into each other's + * session_search results: a still-running sibling conversation + * ({@code stream_status='running'}) and the caller's own current conversation + * are both excluded from {@code listRecent} and {@code search}. Guards the fix + * for cross-conversation memory contamination. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:session_search_iso_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=session-search-iso-secret-0123456789" +}) +class SessionSearchIsolationTest { + + private static final long AGENT_ID = 9_458_001L; + private static final String CONV_CURRENT = "conv-current-458"; + private static final String CONV_COMPLETED = "conv-completed-458"; + private static final String CONV_RUNNING = "conv-running-458"; + + @Autowired private SessionSearchService service; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_message WHERE conversation_id IN (?, ?, ?)", + CONV_CURRENT, CONV_COMPLETED, CONV_RUNNING); + jdbc.update("DELETE FROM mate_conversation WHERE agent_id = ?", AGENT_ID); + + // Three sessions of the same agent: the caller's current one, a finished + // sibling, and a still-running sibling. + insertConversation(9_458_101L, CONV_CURRENT, "current chat", "idle"); + insertConversation(9_458_102L, CONV_COMPLETED, "yesterday nanjing weather", "idle"); + insertConversation(9_458_103L, CONV_RUNNING, "sibling running nanjing task", "running"); + + insertMessage(9_458_201L, CONV_CURRENT, "user", "what is the weather"); + insertMessage(9_458_202L, CONV_COMPLETED, "assistant", "Nanjing is rainy today"); + insertMessage(9_458_203L, CONV_RUNNING, "assistant", "Nanjing forecast in progress"); + } + + private void insertConversation(long id, String convId, String title, String streamStatus) { + jdbc.update("INSERT INTO mate_conversation (id, conversation_id, title, agent_id, message_count, " + + "last_active_time, stream_status, workspace_id, create_time, update_time, deleted) " + + "VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, convId, title, AGENT_ID, streamStatus); + } + + private void insertMessage(long id, String convId, String role, String content) { + jdbc.update("INSERT INTO mate_message (id, conversation_id, role, content, status, " + + "create_time, update_time, deleted) " + + "VALUES (?, ?, ?, ?, 'completed', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, convId, role, content); + } + + @Test + @DisplayName("listRecent excludes the running sibling and the current conversation") + void listRecentExcludesRunningAndCurrent() { + List> recent = service.listRecent(AGENT_ID, CONV_CURRENT, 50); + List ids = recent.stream().map(r -> (String) r.get("conversationId")).toList(); + + assertThat(ids).contains(CONV_COMPLETED); + assertThat(ids).doesNotContain(CONV_RUNNING); // still-running sibling must not leak + assertThat(ids).doesNotContain(CONV_CURRENT); // caller's own conversation excluded + } + + @Test + @DisplayName("search excludes the running sibling and the current conversation") + void searchExcludesRunningAndCurrent() { + List results = service.search(AGENT_ID, CONV_CURRENT, "Nanjing", 50); + List ids = results.stream().map(SessionSearchResult::conversationId).toList(); + + assertThat(ids).contains(CONV_COMPLETED); + assertThat(ids).doesNotContain(CONV_RUNNING); // running sibling's match filtered out + assertThat(ids).doesNotContain(CONV_CURRENT); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryRecallFilenameTruncationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryRecallFilenameTruncationTest.java new file mode 100644 index 00000000..67a50524 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryRecallFilenameTruncationTest.java @@ -0,0 +1,111 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Guards the {@code mate_memory_recall.filename} VARCHAR(256) ceiling against + * over-long section keys (file path + '#' + a long H2 heading slug). See #461. + *

            + * Two boundaries are covered as pure functions, no Spring context needed: + *

              + *
            • {@link MemoryRecallTracker#sanitizeSectionKey} — slug-side cap
            • + *
            • {@link MemoryRecallService#truncateFilename} — write-side cap
            • + *
            + */ +class MemoryRecallFilenameTruncationTest { + + /** Repeated CJK filler so a heading can be grown past any threshold. */ + private static final String CN = "用户要求设置每日财经早报定时任务,每天早上推送汇总报告到指定群组"; + + @Nested + @DisplayName("sanitizeSectionKey — slug-side cap") + class SanitizeSectionKey { + + @Test + @DisplayName("normal CJK heading is slugified untouched (no false truncation)") + void normalCjkHeadingPreserved() { + String slug = MemoryRecallTracker.sanitizeSectionKey("## 08:30 用户设置每日财经早报定时任务"); + // "## " stripped, ':' and spaces → '-', CJK kept; "08-30-用户设置每日财经早报定时任务" + assertEquals("08-30-用户设置每日财经早报定时任务", slug); + assertTrue(slug.length() <= MemoryRecallTracker.MAX_SECTION_SLUG); + } + + @Test + @DisplayName("over-long CJK heading slug is capped at MAX_SECTION_SLUG and never throws") + void overLongCjkHeadingCapped() { + StringBuilder heading = new StringBuilder("## "); + while (heading.length() < MemoryRecallTracker.MAX_SECTION_SLUG + 200) { + heading.append(CN); + } + String slug = assertDoesNotThrow(() -> MemoryRecallTracker.sanitizeSectionKey(heading.toString())); + assertTrue(slug.length() <= MemoryRecallTracker.MAX_SECTION_SLUG, + "slug must not exceed MAX_SECTION_SLUG, was " + slug.length()); + } + + @Test + @DisplayName("ascii-only heading collapses runs of non-alnum to a single '-'") + void asciiHeadingSlugified() { + assertEquals("some-title-here", MemoryRecallTracker.sanitizeSectionKey("## Some Title Here")); + } + } + + @Nested + @DisplayName("truncateFilename — write-side cap") + class TruncateFilename { + + @Test + @DisplayName("filename at/below the cap is returned unchanged") + void underCapUnchanged() { + String filename = "memory/2026-06-05.md#08-30-用户设置每日财经早报定时任务"; + assertTrue(filename.length() <= MemoryRecallService.MAX_FILENAME_LENGTH); + assertSame(filename, MemoryRecallService.truncateFilename(filename), + "under-cap values must pass through without copying"); + } + + @Test + @DisplayName("filename over the cap is truncated to MAX_FILENAME_LENGTH") + void overCapTruncated() { + StringBuilder filename = new StringBuilder("memory/2026-06-05.md#"); + while (filename.length() < MemoryRecallService.MAX_FILENAME_LENGTH + 100) { + filename.append(CN); + } + String out = MemoryRecallService.truncateFilename(filename.toString()); + assertEquals(MemoryRecallService.MAX_FILENAME_LENGTH, out.length(), + "over-cap value must be exactly MAX_FILENAME_LENGTH"); + } + + @Test + @DisplayName("truncation keeps the date prefix intact (computeFreshness still parses it)") + void datePrefixPreserved() { + StringBuilder filename = new StringBuilder("memory/2026-06-05.md#"); + while (filename.length() < MemoryRecallService.MAX_FILENAME_LENGTH + 100) { + filename.append(CN); + } + String out = MemoryRecallService.truncateFilename(filename.toString()); + // The leading path/date — the only part computeFreshness uses — survives. + assertTrue(out.startsWith("memory/2026-06-05.md"), "date prefix must survive truncation"); + int hash = out.indexOf('#'); + assertTrue(hash > 0 && hash < out.length(), "section anchor must still be present"); + } + } + + @Test + @DisplayName("full daily-note section key stays under the DB column ceiling end-to-end") + void endToEndWithinColumnCeiling() { + // Reproduce MemoryRecallTracker's key assembly on an over-long heading. + String dailyFile = "memory/2026-06-05.md"; + StringBuilder heading = new StringBuilder("## 08:30 "); + while (heading.length() < MemoryRecallTracker.MAX_SECTION_SLUG + 300) { + heading.append(CN); + } + String sectionKey = dailyFile + "#" + MemoryRecallTracker.sanitizeSectionKey(heading.toString()); + // Even after the write-side fallback, the stored value must fit VARCHAR(256). + String stored = MemoryRecallService.truncateFilename(sectionKey); + assertTrue(stored.length() <= 255, + "stored filename must fit VARCHAR(256), was " + stored.length()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java new file mode 100644 index 00000000..7605ceef --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/PluginContextImplSearchTest.java @@ -0,0 +1,103 @@ +package vip.mate.plugin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelManager; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.plugin.api.PluginException; +import vip.mate.plugin.api.PluginManifest; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.search.PluginSearchProvider; +import vip.mate.plugin.api.search.PluginSearchQuery; +import vip.mate.plugin.api.search.PluginSearchResult; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * PluginContextImpl#registerSearchProvider: wraps the plugin SPI in a bridge, + * registers it into SearchProviderRegistry, and records the id on LoadedPlugin + * so disable/rollback can unregister it. + */ +class PluginContextImplSearchTest { + + private SearchProviderRegistry registry; + private PluginContextImpl context; + private LoadedPlugin loadedPlugin; + + @BeforeEach + void setUp() { + registry = new SearchProviderRegistry(List.of()); + + PluginManifest manifest = new PluginManifest(); + manifest.setName("test-plugin"); + manifest.setVersion("1.0.0"); + manifest.setType("search"); + manifest.setEntrypoint("x.Y"); + + MateClawPlugin plugin = new MateClawPlugin() { + @Override public void onLoad(PluginContext ctx) { } + @Override public void onEnable() { } + @Override public void onDisable() { } + }; + loadedPlugin = new LoadedPlugin(manifest, plugin, + new URLClassLoader(new URL[0], getClass().getClassLoader())); + + context = new PluginContextImpl( + loadedPlugin, manifest, + mock(ToolRegistry.class), mock(ChannelManager.class), + mock(MemoryManager.class), mock(ModelProviderService.class), + registry, + null); + } + + private static PluginSearchProvider provider(String id) { + return new PluginSearchProvider() { + @Override public String id() { return id; } + @Override public String label() { return id; } + @Override public boolean isAvailable() { return true; } + @Override public List search(PluginSearchQuery query) { + return List.of(); + } + }; + } + + @Test + @DisplayName("registers into the registry and records the id on LoadedPlugin") + void registersAndRecords() { + context.registerSearchProvider(provider("my-search")); + + assertNotNull(registry.getById("my-search")); + assertEquals(List.of("my-search"), loadedPlugin.getRegisteredSearchProviders()); + } + + @Test + @DisplayName("id conflict surfaces as PluginException and is not recorded") + void conflictBecomesPluginException() { + context.registerSearchProvider(provider("my-search")); + + assertThrows(PluginException.class, + () -> context.registerSearchProvider(provider("my-search"))); + assertEquals(1, loadedPlugin.getRegisteredSearchProviders().size()); + } + + @Test + @DisplayName("blank id is rejected with PluginException") + void blankIdRejected() { + assertThrows(PluginException.class, + () -> context.registerSearchProvider(provider(" "))); + assertTrue(loadedPlugin.getRegisteredSearchProviders().isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerSearchLookupTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerSearchLookupTest.java new file mode 100644 index 00000000..d4e2951b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerSearchLookupTest.java @@ -0,0 +1,112 @@ +package vip.mate.plugin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelManager; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.PluginManifest; +import vip.mate.plugin.repository.PluginMapper; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.lang.reflect.Field; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; + +/** + * PluginManager#getPluginNameForSearchProvider: reverse-lookup which loaded + * plugin registered a given search provider id, used by the settings catalog + * endpoint to show "managed by plugin X". + */ +class PluginManagerSearchLookupTest { + + private PluginManager manager() { + // Constructor param order MUST match PluginManager's field declaration order + // (Lombok @RequiredArgsConstructor): pluginProperties, pluginMapper, toolRegistry, + // channelManager, memoryManager, modelProviderService, searchProviderRegistry, workspaceService. + return new PluginManager( + mock(PluginProperties.class), + mock(PluginMapper.class), + mock(ToolRegistry.class), + mock(ChannelManager.class), + mock(MemoryManager.class), + mock(ModelProviderService.class), + new SearchProviderRegistry(List.of()), + Optional.empty()); + } + + private LoadedPlugin loadedPluginWithSearchIds(String name, String... searchIds) { + PluginManifest manifest = new PluginManifest(); + manifest.setName(name); + manifest.setVersion("1.0.0"); + manifest.setType("search"); + manifest.setEntrypoint("x.Y"); + MateClawPlugin plugin = new MateClawPlugin() { + @Override public void onLoad(PluginContext ctx) { } + @Override public void onEnable() { } + @Override public void onDisable() { } + }; + LoadedPlugin loaded = new LoadedPlugin(manifest, plugin, + new URLClassLoader(new URL[0], getClass().getClassLoader())); + loaded.getRegisteredSearchProviders().addAll(List.of(searchIds)); + return loaded; + } + + @SuppressWarnings("unchecked") + private void seedPlugins(PluginManager manager, LoadedPlugin... loaded) throws Exception { + Field f = PluginManager.class.getDeclaredField("plugins"); + f.setAccessible(true); + Map map = (Map) f.get(manager); + for (LoadedPlugin l : loaded) { + map.put(l.getManifest().getName(), l); + } + } + + @Test + @DisplayName("finds the plugin name that registered the given search provider id") + void findsOwningPlugin() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithSearchIds("plugin-a", "my-search")); + + assertEquals("plugin-a", manager.getPluginNameForSearchProvider("my-search")); + } + + @Test + @DisplayName("discriminates between multiple loaded plugins, returning only the one that owns the id") + void discriminatesAmongMultiplePlugins() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, + loadedPluginWithSearchIds("plugin-a", "other-search"), + loadedPluginWithSearchIds("plugin-b", "my-search")); + + assertEquals("plugin-b", manager.getPluginNameForSearchProvider("my-search")); + } + + @Test + @DisplayName("returns null when no loaded plugin registered that id") + void returnsNullWhenNotFound() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithSearchIds("plugin-a", "other-search")); + + assertNull(manager.getPluginNameForSearchProvider("my-search")); + } + + @Test + @DisplayName("returns null for a built-in id no plugin ever registered") + void returnsNullForBuiltinId() throws Exception { + PluginManager manager = manager(); + + assertNull(manager.getPluginNameForSearchProvider("serper")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java new file mode 100644 index 00000000..56b6f068 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java @@ -0,0 +1,184 @@ +package vip.mate.plugin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.channel.ChannelManager; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.PluginException; +import vip.mate.plugin.api.PluginManifest; +import vip.mate.plugin.model.PluginEntity; +import vip.mate.plugin.repository.PluginMapper; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.lang.reflect.Field; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * PluginManager#updateConfig must MERGE the incoming partial config onto the existing + * stored config, not overwrite it wholesale — the new Plugins.vue config dialog + * intentionally omits unchanged secret fields (it never receives plaintext secrets + * back from the backend to resubmit them), so "omitted" must mean "keep the old + * value", not "delete it". + */ +class PluginManagerUpdateConfigTest { + + private static final String PLUGIN_NAME = "plugin-a"; + private static final String ORIGINAL_CONFIG_JSON = + "{\"baseUrl\":\"https://example.com\",\"apiKey\":\"secret123\"}"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private PluginMapper pluginMapper; + + private PluginManager manager() { + // Constructor param order MUST match PluginManager's field declaration order + // (Lombok @RequiredArgsConstructor): pluginProperties, pluginMapper, toolRegistry, + // channelManager, memoryManager, modelProviderService, searchProviderRegistry, workspaceService. + pluginMapper = mock(PluginMapper.class); + return new PluginManager( + mock(PluginProperties.class), + pluginMapper, + mock(ToolRegistry.class), + mock(ChannelManager.class), + mock(MemoryManager.class), + mock(ModelProviderService.class), + new SearchProviderRegistry(List.of()), + Optional.empty()); + } + + private PluginEntity fixtureEntity(String configJson) { + PluginEntity entity = new PluginEntity(); + entity.setName(PLUGIN_NAME); + entity.setConfigJson(configJson); + entity.setEnabled(true); + return entity; + } + + private LoadedPlugin loadedPluginWithRequiredField(String name, String requiredKey) { + PluginManifest manifest = new PluginManifest(); + manifest.setName(name); + manifest.setVersion("1.0.0"); + manifest.setType("search"); + manifest.setEntrypoint("x.Y"); + + PluginManifest.ConfigField field = new PluginManifest.ConfigField(); + field.setType("string"); + field.setRequired(true); + field.setSecret(true); + manifest.setConfig(Map.of(requiredKey, field)); + + MateClawPlugin plugin = new MateClawPlugin() { + @Override public void onLoad(PluginContext ctx) { } + @Override public void onEnable() { } + @Override public void onDisable() { } + }; + return new LoadedPlugin(manifest, plugin, + new URLClassLoader(new URL[0], getClass().getClassLoader())); + } + + @SuppressWarnings("unchecked") + private void seedPlugins(PluginManager manager, LoadedPlugin... loaded) throws Exception { + Field f = PluginManager.class.getDeclaredField("plugins"); + f.setAccessible(true); + Map map = (Map) f.get(manager); + for (LoadedPlugin l : loaded) { + map.put(l.getManifest().getName(), l); + } + } + + @Test + @DisplayName("merges new values over existing config, preserving omitted keys") + void mergesNewValuesOverExistingConfig() throws Exception { + PluginManager manager = manager(); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://new.example.com")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PluginEntity.class); + verify(pluginMapper).updateById(captor.capture()); + + Map persisted = objectMapper.readValue(captor.getValue().getConfigJson(), Map.class); + assertEquals("https://new.example.com", persisted.get("baseUrl")); + assertEquals("secret123", persisted.get("apiKey"), "omitted secret must be retained, not deleted"); + } + + @Test + @DisplayName("overwrites a key when explicitly provided, keeping other stored keys untouched") + void overwritesAKeyWhenExplicitlyProvided() throws Exception { + PluginManager manager = manager(); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + manager.updateConfig(PLUGIN_NAME, Map.of("apiKey", "newSecret456")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PluginEntity.class); + verify(pluginMapper).updateById(captor.capture()); + + Map persisted = objectMapper.readValue(captor.getValue().getConfigJson(), Map.class); + assertEquals("newSecret456", persisted.get("apiKey")); + assertEquals("https://example.com", persisted.get("baseUrl")); + } + + @Test + @DisplayName("required-field check passes when omitted but already present in stored config") + void requiredFieldCheckPassesWhenOmittedButAlreadyStoredFromBefore() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey")); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + assertDoesNotThrow(() -> + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://only-this-changed.com"))); + } + + @Test + @DisplayName("required-field check still fails when the field has never been configured") + void requiredFieldCheckStillFailsWhenNeverConfigured() throws Exception { + PluginManager manager = manager(); + seedPlugins(manager, loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey")); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity("{}")); + + assertThrows(PluginException.class, () -> + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://only-this-changed.com"))); + } + + @Test + @DisplayName("running plugin's context sees the new config immediately after save (no restart needed)") + void runningPluginContextIsRefreshedAfterSave() throws Exception { + PluginManager manager = manager(); + LoadedPlugin loaded = loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey"); + PluginContextImpl context = new PluginContextImpl( + loaded, loaded.getManifest(), + mock(ToolRegistry.class), mock(ChannelManager.class), + mock(MemoryManager.class), mock(ModelProviderService.class), + new SearchProviderRegistry(List.of()), + ORIGINAL_CONFIG_JSON); + loaded.setContext(context); + seedPlugins(manager, loaded); + when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON)); + + assertEquals("https://example.com", context.getConfig("baseUrl", String.class)); + + manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://new.example.com")); + + assertEquals("https://new.example.com", context.getConfig("baseUrl", String.class), + "getConfig must serve the saved value without a disable/enable cycle"); + assertEquals("secret123", context.getConfig("apiKey", String.class), + "omitted secret must survive the refresh via merge semantics"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java new file mode 100644 index 00000000..baf6582e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginSearchBridgeTest.java @@ -0,0 +1,167 @@ +package vip.mate.plugin.bridge; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.plugin.api.search.PluginSearchProvider; +import vip.mate.plugin.api.search.PluginSearchQuery; +import vip.mate.plugin.api.search.PluginSearchResult; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.tool.search.SearchQuery; +import vip.mate.tool.search.SearchResult; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link PluginSearchBridge} adapts the self-contained plugin SPI + * ({@code PluginSearchProvider}) to the platform's {@code SearchProvider} + * without leaking server types into plugin land. + */ +class PluginSearchBridgeTest { + + @Test + @DisplayName("query fields pass through and results are converted with the plugin's providerId") + void convertsQueryAndResults() { + AtomicReference received = new AtomicReference<>(); + PluginSearchProvider plugin = new PluginSearchProvider() { + @Override public String id() { return "my-search"; } + @Override public String label() { return "My Search"; } + @Override public boolean isAvailable() { return true; } + @Override public List search(PluginSearchQuery query) { + received.set(query); + return List.of(new PluginSearchResult( + "T1", "https://example.com/a", "snippet-1", "example.com", "2026-07-01")); + } + }; + + PluginSearchBridge bridge = new PluginSearchBridge(plugin); + List results = bridge.search( + new SearchQuery("kw", "week", "zh-CN", 3), new SystemSettingsDTO()); + + assertEquals("kw", received.get().query()); + assertEquals("week", received.get().freshness()); + assertEquals("zh-CN", received.get().language()); + assertEquals(3, received.get().count()); + + assertEquals(1, results.size()); + SearchResult r = results.get(0); + assertEquals("T1", r.getTitle()); + assertEquals("https://example.com/a", r.getUrl()); + assertEquals("snippet-1", r.getSnippet()); + assertEquals("example.com", r.getSource()); + assertEquals("2026-07-01", r.getDate()); + assertEquals("my-search", r.getProviderId()); + } + + @Test + @DisplayName("count is clamped via SearchQuery.resolvedCount before reaching the plugin") + void countIsClamped() { + AtomicReference received = new AtomicReference<>(); + PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> { + received.set(q); + return List.of(); + })); + + bridge.search(new SearchQuery("kw", null, null, 99), new SystemSettingsDTO()); + assertEquals(10, received.get().count()); // MAX_COUNT + + bridge.search(new SearchQuery("kw", null, null, null), new SystemSettingsDTO()); + assertEquals(5, received.get().count()); // DEFAULT_COUNT + } + + @Test + @DisplayName("delegates id/label/order/credential and maps isAvailable() ignoring the DTO") + void delegatesMetadata() { + PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> List.of())); + assertEquals("stub-search", bridge.id()); + assertEquals("Stub Search", bridge.label()); + assertTrue(bridge.requiresCredential()); + assertEquals(500, bridge.autoDetectOrder()); + assertTrue(bridge.isAvailable(new SystemSettingsDTO())); + } + + @Test + @DisplayName("a null result list from a sloppy plugin is normalised to empty") + void nullResultListNormalised() { + PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> null)); + List results = bridge.search(SearchQuery.of("kw"), new SystemSettingsDTO()); + assertTrue(results.isEmpty()); + } + + @Test + @DisplayName("plugin exceptions propagate so WebSearchService's fallback chain can react") + void exceptionsPropagate() { + PluginSearchBridge bridge = new PluginSearchBridge(stub(q -> { + throw new IllegalStateException("plugin boom"); + })); + assertThrows(IllegalStateException.class, + () -> bridge.search(SearchQuery.of("kw"), new SystemSettingsDTO())); + } + + @Test + @DisplayName("metadata is snapshotted at construction — plugin code never runs on sort/catalog reads") + void metadataSnapshottedAtConstruction() { + AtomicInteger metadataCalls = new AtomicInteger(); + PluginSearchProvider delegate = new PluginSearchProvider() { + @Override public String id() { metadataCalls.incrementAndGet(); return "snap-search"; } + @Override public String label() { metadataCalls.incrementAndGet(); return "Snap Search"; } + @Override public boolean requiresCredential() { metadataCalls.incrementAndGet(); return true; } + @Override public int autoDetectOrder() { metadataCalls.incrementAndGet(); return 500; } + @Override public boolean isAvailable() { return true; } + @Override public List search(PluginSearchQuery query) { return List.of(); } + }; + + PluginSearchBridge bridge = new PluginSearchBridge(delegate); + int callsAfterConstruction = metadataCalls.get(); + + // Repeated reads (what resolve()'s sort comparator and the catalog do) must + // serve the snapshot, not re-enter plugin code. + for (int i = 0; i < 3; i++) { + assertEquals("snap-search", bridge.id()); + assertEquals("Snap Search", bridge.label()); + assertTrue(bridge.requiresCredential()); + assertEquals(500, bridge.autoDetectOrder()); + } + assertEquals(callsAfterConstruction, metadataCalls.get(), + "metadata getters must not invoke plugin code after construction"); + } + + @Test + @DisplayName("a throwing isAvailable() degrades to unavailable instead of breaking resolve()") + void throwingIsAvailableDegradesToFalse() { + PluginSearchProvider delegate = new PluginSearchProvider() { + @Override public String id() { return "broken-search"; } + @Override public String label() { return "Broken Search"; } + @Override public boolean isAvailable() { throw new IllegalStateException("availability boom"); } + @Override public List search(PluginSearchQuery query) { return List.of(); } + }; + + PluginSearchBridge bridge = new PluginSearchBridge(delegate); + + assertFalse(bridge.isAvailable(new SystemSettingsDTO()), + "isAvailable runs inside resolve() on every web_search call with no per-provider guard — a plugin exception must degrade to false, not propagate"); + } + + // ---- helpers ---- + + private interface SearchFn { + List apply(PluginSearchQuery q); + } + + private static PluginSearchProvider stub(SearchFn fn) { + return new PluginSearchProvider() { + @Override public String id() { return "stub-search"; } + @Override public String label() { return "Stub Search"; } + @Override public boolean isAvailable() { return true; } + @Override public List search(PluginSearchQuery query) { + return fn.apply(query); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java index 6e5606d0..73a6ed90 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java @@ -267,4 +267,38 @@ class ZipSkillFetcherTest { assertEquals(1, ex.references().size(), "GBK-named reference should survive the charset fallback"); assertEquals("# 中文内容\n", ex.references().get("中文说明.md")); } + + @Test + @DisplayName("configurable per-entry cap: entry over the default 1MB survives when the cap is raised") + void raisedEntryCapKeepsLargeEntry() throws IOException { + String bigDoc = "x".repeat(2_000_000); // 2MB, over the 1MB default + byte[] zip = zipOf(List.of( + new Entry("SKILL.md", SKILL_MD), + new Entry("references/big.md", bigDoc))); + + ZipSkillFetcher.ExtractedSkill withDefaults = ZipSkillFetcher.extract(zip); + assertTrue(withDefaults.references().isEmpty(), + "default 1MB cap should drop the 2MB entry"); + + ZipSkillFetcher.ExtractedSkill withRaisedCap = ZipSkillFetcher.extract( + zip, ZipSkillFetcher.Limits.ofMb(5, 50)); + assertEquals(bigDoc, withRaisedCap.references().get("big.md"), + "raised cap should keep the 2MB entry intact"); + } + + @Test + @DisplayName("configurable total cap: error message names the effective limit and the config knob") + void totalCapErrorNamesConfiguredLimit() throws IOException { + byte[] zip = zipOf(List.of( + new Entry("SKILL.md", SKILL_MD), + new Entry("references/a.md", "y".repeat(900_000)), + new Entry("references/b.md", "z".repeat(900_000)))); + + IOException ex = assertThrows(IOException.class, + () -> ZipSkillFetcher.extract(zip, ZipSkillFetcher.Limits.ofMb(1, 1))); + assertTrue(ex.getMessage().contains("1MB"), + "message should carry the configured total cap; got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("max-total-size-mb"), + "message should point at the config property; got: " + ex.getMessage()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java new file mode 100644 index 00000000..bcec7f67 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java @@ -0,0 +1,179 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; +import vip.mate.tool.builtin.SkillManageTool; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the deterministic behaviour of {@link SkillConsolidationService}: + * the opt-in gate, the minimum-candidate floor, dry-run vs applied, the + * "only archive in-scope skills" guard, and the real-merge count rule. + */ +class SkillConsolidationServiceTest { + + private SkillService skillService; + private SkillManageTool skillManageTool; + private SkillLifecycleService lifecycleService; + private ModelConfigService modelConfigService; + private AgentGraphBuilder agentGraphBuilder; + private SkillLifecycleProperties properties; + private SkillConsolidationService service; + + @BeforeEach + void setUp() { + skillService = mock(SkillService.class); + skillManageTool = mock(SkillManageTool.class); + lifecycleService = mock(SkillLifecycleService.class); + modelConfigService = mock(ModelConfigService.class); + agentGraphBuilder = mock(AgentGraphBuilder.class); + properties = new SkillLifecycleProperties(); + properties.setConsolidate(true); + service = new SkillConsolidationService(skillService, skillManageTool, lifecycleService, + modelConfigService, agentGraphBuilder, properties, new ObjectMapper()); + } + + private void stubLlm(String json) { + ChatModel chatModel = (ChatModel) (Prompt p) -> + new ChatResponse(List.of(new Generation(new AssistantMessage(json)))); + when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel); + when(modelConfigService.getDefaultModel()).thenReturn(null); + } + + private SkillEntity skill(String name) { + SkillEntity s = new SkillEntity(); + s.setName(name); + s.setDescription("desc of " + name); + s.setSkillContent("---\nname: " + name + "\n---\n# " + name + "\nbody"); + s.setSourceConversationId("conv-" + name); + return s; + } + + private List candidates(int n) { + List list = new ArrayList<>(); + for (int i = 1; i <= n; i++) { + list.add(skill("spring-rest-" + i)); + } + return list; + } + + @Test + @DisplayName("disabled → no reviewer call") + void disabledNoop() { + properties.setConsolidate(false); + service.consolidate(candidates(6), LocalDateTime.now(), false, SkillCuratorReport.builder()); + verify(agentGraphBuilder, never()).buildRuntimeChatModel(any()); + } + + @Test + @DisplayName("below min-skills floor → no reviewer call") + void belowFloorNoop() { + properties.setConsolidateMinSkills(4); + service.consolidate(candidates(3), LocalDateTime.now(), false, SkillCuratorReport.builder()); + verify(agentGraphBuilder, never()).buildRuntimeChatModel(any()); + } + + @Test + @DisplayName("applied merge: new umbrella created, absorbed skills archived") + void appliesMerge() { + stubLlm("[{\"umbrella_name\":\"spring-rest\",\"umbrella_content\":\"---\\nname: spring-rest\\n---\\n# X\"," + + "\"absorb\":[\"spring-rest-1\",\"spring-rest-2\"],\"reason\":\"dupes\"}]"); + when(skillService.findByName("spring-rest")).thenReturn(null); + when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + .thenReturn("Skill 'spring-rest' created successfully (security scan: PASSED)."); + + SkillCuratorReport.Builder report = SkillCuratorReport.builder(); + service.consolidate(candidates(4), LocalDateTime.now(), false, report); + + verify(skillManageTool, times(1)) + .skill_manage(eq("create"), eq("spring-rest"), any(), any(), any(), any(), any()); + verify(lifecycleService, times(1)) + .applyManual(argSkill("spring-rest-1"), eq(LifecycleTransition.TO_ARCHIVED), any(), any()); + verify(lifecycleService, times(1)) + .applyManual(argSkill("spring-rest-2"), eq(LifecycleTransition.TO_ARCHIVED), any(), any()); + + List rows = report.build().getConsolidations(); + assertEquals(1, rows.size()); + assertTrue(rows.get(0).applied()); + assertTrue(rows.get(0).umbrellaCreated()); + } + + @Test + @DisplayName("dry-run: records the plan but writes nothing") + void dryRunPreviewOnly() { + stubLlm("[{\"umbrella_name\":\"spring-rest\",\"umbrella_content\":\"---\\nname: spring-rest\\n---\\n# X\"," + + "\"absorb\":[\"spring-rest-1\",\"spring-rest-2\"],\"reason\":\"dupes\"}]"); + + SkillCuratorReport.Builder report = SkillCuratorReport.builder(); + service.consolidate(candidates(4), LocalDateTime.now(), true, report); + + verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(lifecycleService, never()).applyManual(any(), any(), any(), any()); + List rows = report.build().getConsolidations(); + assertEquals(1, rows.size()); + assertFalse(rows.get(0).applied()); + } + + @Test + @DisplayName("guard: out-of-scope absorb names are ignored; lone valid name is not a real merge for a new umbrella") + void ignoresOutOfScopeNames() { + stubLlm("[{\"umbrella_name\":\"brand-new\",\"umbrella_content\":\"---\\nname: brand-new\\n---\\n# X\"," + + "\"absorb\":[\"spring-rest-1\",\"not-a-candidate\"],\"reason\":\"x\"}]"); + when(skillService.findByName("brand-new")).thenReturn(null); + + SkillCuratorReport.Builder report = SkillCuratorReport.builder(); + service.consolidate(candidates(4), LocalDateTime.now(), false, report); + + // Only spring-rest-1 is in scope → 1 absorbed for a NEW umbrella → not a real merge → skipped. + verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + verify(lifecycleService, never()).applyManual(any(), any(), any(), any()); + } + + @Test + @DisplayName("maxGroupsPerRun caps how many groups apply") + void capsGroups() { + properties.setConsolidateMaxGroupsPerRun(1); + String g1 = "{\"umbrella_name\":\"u1\",\"umbrella_content\":\"---\\nname: u1\\n---\\n#\"," + + "\"absorb\":[\"spring-rest-1\",\"spring-rest-2\"],\"reason\":\"a\"}"; + String g2 = "{\"umbrella_name\":\"u2\",\"umbrella_content\":\"---\\nname: u2\\n---\\n#\"," + + "\"absorb\":[\"spring-rest-3\",\"spring-rest-4\"],\"reason\":\"b\"}"; + stubLlm("[" + g1 + "," + g2 + "]"); + when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + .thenReturn("created successfully"); + + SkillCuratorReport.Builder report = SkillCuratorReport.builder(); + service.consolidate(candidates(4), LocalDateTime.now(), false, report); + + verify(skillManageTool, times(1)).skill_manage(any(), any(), any(), any(), any(), any(), any()); + } + + /** Mockito arg matcher for a SkillEntity with the given name. */ + private static SkillEntity argSkill(String name) { + return org.mockito.ArgumentMatchers.argThat(s -> s != null && name.equals(s.getName())); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java index ba00a062..93dacba6 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java @@ -53,6 +53,8 @@ class SkillCuratorJobTest { private SkillWorkspaceManager workspaceManager; @Mock private CuratorRunNotifier notifier; + @Mock + private SkillConsolidationService consolidationService; private SkillLifecycleProperties properties; private SkillCuratorJob job; @@ -70,7 +72,7 @@ class SkillCuratorJobTest { void setUp() { properties = new SkillLifecycleProperties(); job = new SkillCuratorJob(lifecycleService, skillMapper, reportStore, properties, - systemSettingService, agentBindingService, workspaceManager, notifier); + systemSettingService, agentBindingService, workspaceManager, notifier, consolidationService); } private SkillEntity candidate(long id, String state, LocalDateTime lastActivity) { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java new file mode 100644 index 00000000..102a6532 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java @@ -0,0 +1,173 @@ +package vip.mate.skill.reflection; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.skill.service.SkillService; +import vip.mate.tool.builtin.SkillManageTool; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.ArrayList; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for the deterministic gating and action-routing logic of + * {@link SkillReflectionService} — cadence, tool-call floor, cooldown, the + * maxActionsPerRun cap, and the "never delete" rule. + */ +class SkillReflectionServiceTest { + + private ConversationService conversationService; + private SkillService skillService; + private SkillManageTool skillManageTool; + private ModelConfigService modelConfigService; + private AgentGraphBuilder agentGraphBuilder; + private SkillReflectionProperties properties; + private SkillReflectionService service; + + @BeforeEach + void setUp() { + conversationService = mock(ConversationService.class); + skillService = mock(SkillService.class); + skillManageTool = mock(SkillManageTool.class); + modelConfigService = mock(ModelConfigService.class); + agentGraphBuilder = mock(AgentGraphBuilder.class); + properties = new SkillReflectionProperties(); + service = new SkillReflectionService(conversationService, skillService, skillManageTool, + modelConfigService, agentGraphBuilder, properties, new ObjectMapper()); + + when(skillService.listEnabledSkills()).thenReturn(List.of()); + } + + private void stubLlm(String json) { + ChatModel chatModel = (ChatModel) (Prompt p) -> + new ChatResponse(List.of(new Generation(new AssistantMessage(json)))); + when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel); + when(modelConfigService.getDefaultModel()).thenReturn(null); + } + + /** Build {@code turns} substantive user/assistant pairs. */ + private List transcriptWithTurns(int turns) { + List messages = new ArrayList<>(); + for (int i = 0; i < turns; i++) { + MessageEntity user = new MessageEntity(); + user.setRole("user"); + user.setContent("step " + i + ": how do I scaffold a spring boot module?"); + messages.add(user); + MessageEntity assistant = new MessageEntity(); + assistant.setRole("assistant"); + assistant.setContent("step " + i + ": run mvn archetype, then add the starter, then ..."); + messages.add(assistant); + } + return messages; + } + + @Test + @DisplayName("disabled → no LLM call, no skill write") + void disabledShortCircuits() { + properties.setEnabled(false); + service.maybeReflect(1L, "conv-1", 8); + verify(conversationService, never()).listMessages(any()); + verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("cadence gate: messageCount not on interval → skip") + void cadenceGateSkips() { + properties.setReviewTurnInterval(8); + service.maybeReflect(1L, "conv-1", 7); + verify(conversationService, never()).listMessages(any()); + } + + @Test + @DisplayName("assistant-turn floor not met → no LLM call") + void assistantTurnFloorSkips() { + properties.setReviewTurnInterval(8); + properties.setMinAssistantTurns(2); + when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(1)); + service.maybeReflect(1L, "conv-1", 8); + verify(agentGraphBuilder, never()).buildRuntimeChatModel(any()); + verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("happy path: a create action routes through skill_manage") + void appliesCreateAction() { + properties.setReviewTurnInterval(8); + properties.setMinAssistantTurns(2); + when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); + stubLlm("[{\"action\":\"create\",\"name\":\"spring-scaffold\",\"reason\":\"reusable\"," + + "\"content\":\"---\\nname: spring-scaffold\\n---\\n# X\"}]"); + when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + .thenReturn("Skill 'spring-scaffold' created successfully (security scan: PASSED)."); + + service.maybeReflect(1L, "conv-1", 8); + + verify(skillManageTool, times(1)) + .skill_manage(eq("create"), eq("spring-scaffold"), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("delete actions are ignored — reflection only creates/improves") + void ignoresDelete() { + properties.setReviewTurnInterval(8); + properties.setMinAssistantTurns(2); + when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); + stubLlm("[{\"action\":\"delete\",\"name\":\"old-skill\",\"reason\":\"stale\"}]"); + + service.maybeReflect(1L, "conv-1", 8); + + verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("maxActionsPerRun caps how many actions are applied") + void capsActions() { + properties.setReviewTurnInterval(8); + properties.setMinAssistantTurns(2); + properties.setMaxActionsPerRun(2); + when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); + String body = "\"content\":\"---\\nname: s\\n---\\n# X\""; + stubLlm("[{\"action\":\"create\",\"name\":\"s1\"," + body + "}," + + "{\"action\":\"create\",\"name\":\"s2\"," + body + "}," + + "{\"action\":\"create\",\"name\":\"s3\"," + body + "}]"); + when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any())) + .thenReturn("created successfully"); + + service.maybeReflect(1L, "conv-1", 8); + + verify(skillManageTool, times(2)).skill_manage(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("cooldown blocks a second review for the same conversation") + void cooldownBlocksSecondRun() { + properties.setReviewTurnInterval(8); + properties.setMinAssistantTurns(2); + when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3)); + stubLlm("[]"); + + service.maybeReflect(1L, "conv-1", 8); + service.maybeReflect(1L, "conv-1", 16); + + // listMessages is only reached on the first (non-cooled-down) run. + verify(conversationService, times(1)).listMessages("conv-1"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java index 6aaffcdb..a4c6f55c 100644 --- a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java @@ -10,13 +10,18 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.plugin.PluginManager; import vip.mate.system.model.SystemSettingEntity; import vip.mate.system.repository.SystemSettingMapper; +import vip.mate.tool.search.SearchProviderRegistry; + +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -41,7 +46,7 @@ class SystemSettingBoolApiTest { @BeforeEach void setUp() { - service = new SystemSettingService(mapper); + service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()), mock(PluginManager.class)); } private SystemSettingEntity row(String value) { diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java new file mode 100644 index 00000000..999b2d3f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java @@ -0,0 +1,150 @@ +package vip.mate.system.service; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.plugin.PluginManager; +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.search.SearchProvider; +import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.tool.search.SearchResult; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * SystemSettingService#getSearchProviderCatalog: aggregates SearchProviderRegistry + * (builtin + plugin providers) with PluginManager (owning-plugin lookup) into the + * catalog payload the settings UI renders. + */ +@ExtendWith(MockitoExtension.class) +class SystemSettingServiceCatalogTest { + + @Mock private SystemSettingMapper mapper; + @Mock private PluginManager pluginManager; + + private SystemSettingService service; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SystemSettingEntity.class); + } + + private static SearchProvider stub(String id, int order, boolean credentialed, boolean available) { + return new SearchProvider() { + @Override public String id() { return id; } + @Override public String label() { return id + "-label"; } + @Override public boolean requiresCredential() { return credentialed; } + @Override public int autoDetectOrder() { return order; } + @Override public boolean isAvailable(SystemSettingsDTO config) { return available; } + @Override public List search(String query, SystemSettingsDTO config) { return List.of(); } + }; + } + + @BeforeEach + void setUp() { + when(mapper.selectOne(any())).thenReturn(null); // no DB rows -> defaults used by getSearchSettings() + } + + @Test + @DisplayName("marks builtin providers as builtin=true with no pluginName") + void builtinEntry() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false))); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertEquals(1, catalog.providers().size()); + var entry = catalog.providers().get(0); + assertEquals("serper", entry.id()); + assertTrue(entry.builtin()); + assertNull(entry.pluginName()); + assertFalse(entry.available()); // not configured + } + + @Test + @DisplayName("marks plugin-registered providers as builtin=false with the owning pluginName") + void pluginEntry() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of()); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin"); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + var entry = catalog.providers().get(0); + assertEquals("my-search", entry.id()); + assertFalse(entry.builtin()); + assertEquals("my-plugin", entry.pluginName()); + assertTrue(entry.available()); + } + + @Test + @DisplayName("mixed catalog: builtin and plugin providers both appear, correctly labeled and ordered") + void mixedBuiltinAndPluginCatalog() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of( + stub("serper", 300, true, true), + stub("duckduckgo", 100, false, true))); + registry.registerPluginProvider(stub("my-search", 200, true, true)); + when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin"); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertEquals(3, catalog.providers().size()); + // Sorted by autoDetectOrder ascending: duckduckgo(100), my-search(200), serper(300) + assertEquals("duckduckgo", catalog.providers().get(0).id()); + assertTrue(catalog.providers().get(0).builtin()); + assertNull(catalog.providers().get(0).pluginName()); + + assertEquals("my-search", catalog.providers().get(1).id()); + assertFalse(catalog.providers().get(1).builtin()); + assertEquals("my-plugin", catalog.providers().get(1).pluginName()); + + assertEquals("serper", catalog.providers().get(2).id()); + assertTrue(catalog.providers().get(2).builtin()); + assertNull(catalog.providers().get(2).pluginName()); + } + + @Test + @DisplayName("surfaces the resolved provider id and source alongside the catalog") + void resolvedSurfaced() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("duckduckgo", 100, false, true))); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertEquals("duckduckgo", catalog.resolvedId()); + assertEquals("keyless-fallback", catalog.resolvedSource()); + } + + @Test + @DisplayName("resolvedId/resolvedSource are null when no provider is available at all") + void resolvedNullWhenNothingAvailable() { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false))); + service = new SystemSettingService(mapper, registry, pluginManager); + + SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog(); + + assertNull(catalog.resolvedId()); + assertNull(catalog.resolvedSource()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java index fb81aa7d..446ca1c7 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java @@ -3,6 +3,7 @@ package vip.mate.tool.browser; import com.microsoft.playwright.Browser; import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; +import vip.mate.common.net.SsrfProperties; import java.nio.file.Files; import java.nio.file.Path; @@ -25,7 +26,7 @@ public final class BrowserLauncherManualProbe { System.out.println("user = " + System.getProperty("user.name")); BrowserProperties props = new BrowserProperties(); - BrowserLauncher launcher = new BrowserLauncher(props); + BrowserLauncher launcher = new BrowserLauncher(props, new SsrfProperties()); System.out.println("\nCandidate paths on this OS:"); for (Path p : BrowserLauncher.systemBrowserCandidates()) { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserPropertiesTest.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserPropertiesTest.java new file mode 100644 index 00000000..6070b25e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserPropertiesTest.java @@ -0,0 +1,92 @@ +package vip.mate.tool.browser; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the configurable {@link BrowserProperties} fields: SSRF / + * TLS relaxation toggles, Playwright timeouts, and snapshot length cap. + * + *

            Defaults must keep deployments unchanged from before this feature: + * strict SSRF, strict TLS, 30s timeouts, 20000-char snapshot cap. + */ +class BrowserPropertiesTest { + + @Test + @DisplayName("Defaults: allowPrivateNetwork=false, ignoreHttpsErrors=false, ssrfCheckEnabled=true") + void defaultsAreStrict() { + BrowserProperties props = new BrowserProperties(); + assertFalse(props.isAllowPrivateNetwork(), + "allowPrivateNetwork must default to false (strict SSRF mode)"); + assertFalse(props.isIgnoreHttpsErrors(), + "ignoreHttpsErrors must default to false (strict TLS validation)"); + assertTrue(props.isSsrfCheckEnabled(), + "ssrfCheckEnabled must remain true (untouched by this feature)"); + } + + @Test + @DisplayName("Defaults: timeouts=30s, snapshotMaxLength=20000") + void defaultsForTimeoutsAndSnapshot() { + BrowserProperties props = new BrowserProperties(); + assertEquals(30, props.getDefaultTimeoutSeconds(), + "defaultTimeoutSeconds must default to 30 (Playwright default)"); + assertEquals(30, props.getDefaultNavigationTimeoutSeconds(), + "defaultNavigationTimeoutSeconds must default to 30 (Playwright default)"); + assertEquals(20_000, props.getSnapshotMaxLength(), + "snapshotMaxLength must default to 20000 (legacy MAX_SNAPSHOT_LENGTH)"); + } + + @Test + @DisplayName("Setter round-trip: allowPrivateNetwork") + void setterAllowPrivateNetwork() { + BrowserProperties props = new BrowserProperties(); + props.setAllowPrivateNetwork(true); + assertTrue(props.isAllowPrivateNetwork()); + props.setAllowPrivateNetwork(false); + assertFalse(props.isAllowPrivateNetwork()); + } + + @Test + @DisplayName("Setter round-trip: ignoreHttpsErrors") + void setterIgnoreHttpsErrors() { + BrowserProperties props = new BrowserProperties(); + props.setIgnoreHttpsErrors(true); + assertTrue(props.isIgnoreHttpsErrors()); + props.setIgnoreHttpsErrors(false); + assertFalse(props.isIgnoreHttpsErrors()); + } + + @Test + @DisplayName("Setter round-trip: defaultTimeoutSeconds") + void setterDefaultTimeoutSeconds() { + BrowserProperties props = new BrowserProperties(); + props.setDefaultTimeoutSeconds(120); + assertEquals(120, props.getDefaultTimeoutSeconds()); + props.setDefaultTimeoutSeconds(5); + assertEquals(5, props.getDefaultTimeoutSeconds()); + } + + @Test + @DisplayName("Setter round-trip: defaultNavigationTimeoutSeconds") + void setterDefaultNavigationTimeoutSeconds() { + BrowserProperties props = new BrowserProperties(); + props.setDefaultNavigationTimeoutSeconds(60); + assertEquals(60, props.getDefaultNavigationTimeoutSeconds()); + props.setDefaultNavigationTimeoutSeconds(15); + assertEquals(15, props.getDefaultNavigationTimeoutSeconds()); + } + + @Test + @DisplayName("Setter round-trip: snapshotMaxLength") + void setterSnapshotMaxLength() { + BrowserProperties props = new BrowserProperties(); + props.setSnapshotMaxLength(5_000); + assertEquals(5_000, props.getSnapshotMaxLength()); + props.setSnapshotMaxLength(100_000); + assertEquals(100_000, props.getSnapshotMaxLength()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java new file mode 100644 index 00000000..ee75c0c9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java @@ -0,0 +1,266 @@ +package vip.mate.tool.browser; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit tests for {@link UrlSafetyChecker}. + * + *

            Covers: + *

              + *
            • The three check overloads ({@code check(url)}, {@code check(url, allowlist)}, + * {@code check(url, boolean)}, {@code check(url, allowlist, boolean)}).
            • + *
            • Strict mode (default) — blocks loopback / private / link-local / multicast / metadata.
            • + *
            • Private-network-allow mode — permits loopback / private / link-local but still + * blocks cloud-metadata endpoints (IPv4 literals and the AWS IPv6 IMDS prefix + * {@code fd00:ec2::/64}).
            • + *
            • Allowlist short-circuit, scheme/host validation, IPv6 AWS IMDS prefix matching.
            • + *
            + */ +class UrlSafetyCheckerTest { + + // ==================== Strict mode (default) ==================== + + @Test + @DisplayName("Rejects private, loopback and metadata addresses by default") + void blocksRestrictedAddressesWithoutAllowlist() { + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://192.168.100.100/admin")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://10.0.0.5/")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://127.0.0.1:8080/")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://localhost/")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://169.254.169.254/latest/meta-data/")); + } + + @Test + @DisplayName("Rejects non-http schemes") + void blocksNonHttpSchemes() { + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("file:///etc/passwd")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("ftp://192.168.1.1/")); + } + + @Test + @DisplayName("Allowlisting a literal private IP lets it through") + void allowlistExactIp() { + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.100.100/admin", List.of("192.168.100.100"))); + } + + @Test + @DisplayName("Allowlisting a CIDR block lets matching private IPs through") + void allowlistCidr() { + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.100.100/x", List.of("192.168.100.0/24"))); + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.100.250/y", List.of("192.168.100.0/24"))); + } + + @Test + @DisplayName("An allowlist entry does not open up addresses outside it") + void allowlistIsNarrow() { + // 192.168.100.0/24 must not unblock a different private subnet. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://192.168.200.5/", List.of("192.168.100.0/24"))); + // Exact-IP allowlist must not unblock a sibling host. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://192.168.100.101/", List.of("192.168.100.100"))); + } + + @Test + @DisplayName("Public addresses are always allowed") + void allowsPublicAddresses() { + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://8.8.8.8/")); + assertDoesNotThrow(() -> UrlSafetyChecker.check("https://1.1.1.1/")); + } + + // ==================== AWS IPv6 IMDS prefix (new) ==================== + + @Test + @DisplayName("Blocks AWS IPv6 IMDS literal fd00:ec2::254 in strict mode") + void blocksAwsIpv6ImdsLiteral() { + // Before the prefix match, only the exact string "fd00:ec2::254" was blocked. + // Now any address in fd00:ec2::/64 is blocked — this test guards the literal too. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://[fd00:ec2::254]/latest/meta-data/")); + } + + @Test + @DisplayName("Blocks any address in AWS IPv6 IMDS prefix fd00:ec2::/64 (strict mode)") + void blocksAwsIpv6ImdsPrefix() { + // These were NOT blocked before the prefix match was added — they would slip through + // because InetAddress.isSiteLocalAddress() returns false for IPv6. The new + // isMetadataIp byte-prefix check closes that gap. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://[fd00:ec2::1]/")); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://[fd00:ec2::ffff]/")); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://[fd00:ec2:0:0:0:0:0:1]/")); + } + + // ==================== Private-network-allow mode (new) ==================== + + @Nested + @DisplayName("Private-network-allow mode (allowPrivateNetwork=true)") + class PrivateNetworkAllowMode { + + @Test + @DisplayName("Permits loopback IPv4") + void permitsLoopbackIpv4() { + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://127.0.0.1:18080/", true)); + } + + @Test + @DisplayName("Permits private IPv4 ranges (10.x / 172.16-31.x / 192.168.x)") + void permitsPrivateIpv4Ranges() { + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://10.0.0.5/", true)); + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://192.168.1.1/", true)); + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://172.16.0.1/", true)); + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://172.31.255.255/", true)); + } + + @Test + @DisplayName("Permits link-local IPv4 (169.254.0.0/16) except cloud metadata") + void permitsLinkLocalIpv4() { + // 169.254.x.x is link-local — typically used for LAN service discovery. + // The metadata endpoint 169.254.169.254 is excluded separately below. + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://169.254.1.1/", true)); + } + + @Test + @DisplayName("Still blocks cloud metadata IPv4 endpoints") + void stillBlocksMetadataIpv4() { + // Cloud metadata endpoints must remain blocked in every mode. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://169.254.169.254/latest/meta-data/", true)); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://100.100.100.200/", true)); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://192.0.0.192/", true)); + } + + @Test + @DisplayName("Still blocks AWS IPv6 IMDS endpoints (literal and prefix)") + void stillBlocksAwsIpv6Imds() { + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://[fd00:ec2::254]/", true)); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://[fd00:ec2::1]/", true)); + } + + @Test + @DisplayName("Still blocks hard-coded blocked hostnames (localhost, ::1)") + void stillBlocksHardcodedHostnames() { + // BLOCKED_HOSTNAMES is a hard blacklist that allowPrivateNetwork cannot bypass. + // Operators who need these hosts must use the explicit allowlist instead. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://localhost/", true)); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://[::1]/", true)); + } + + @Test + @DisplayName("Permits public IPv4 addresses (no regression)") + void permitsPublicIpv4() { + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://8.8.8.8/", true)); + assertDoesNotThrow(() -> UrlSafetyChecker.check("https://1.1.1.1/", true)); + } + + @Test + @DisplayName("Three-arg overload combines allowlist + allowPrivateNetwork") + void combinesAllowlistAndAllowPrivateNetwork() { + // Allowlist short-circuits even in strict mode — but here allowPrivateNetwork=true + // already permits the private IP, so the allowlist is redundant. Verify both + // paths reach the same outcome. + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.100.100/", List.of(), true)); + // Allowlist can still unblock a blocked hostname in private-network mode. + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://localhost/", List.of("localhost"), true)); + } + + @Test + @DisplayName("Boolean overload equals three-arg overload with empty allowlist") + void booleanOverloadMatchesThreeArg() { + // Sanity: check(url, true) behaves identically to check(url, List.of(), true). + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://127.0.0.1/", true)); + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://127.0.0.1/", List.of(), true)); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://169.254.169.254/", true)); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://169.254.169.254/", List.of(), true)); + } + } + + // ==================== Input validation ==================== + + @Test + @DisplayName("Rejects null or blank URL") + void rejectsNullOrlBlankUrl() { + SecurityException nullEx = assertThrows(SecurityException.class, () -> UrlSafetyChecker.check(null)); + assertEquals("URL is required", nullEx.getMessage()); + SecurityException blankEx = assertThrows(SecurityException.class, () -> UrlSafetyChecker.check(" ")); + assertEquals("URL is required", blankEx.getMessage()); + } + + @Test + @DisplayName("Rejects malformed URL") + void rejectsMalformedUrl() { + // URI.create rejects strings that are not valid URIs. + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://[invalid")); + } + + @Test + @DisplayName("Rejects URL without a host") + void rejectsUrlWithoutHost() { + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("https:///path")); + } + + @Test + @DisplayName("Rejects blocked hostname metadata.google.internal") + void blocksMetadataHostname() { + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://metadata.google.internal/computeMetadata/v1/")); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://metadata.google.internal/", true)); + } + + @Test + @DisplayName("An allowlist entry can never open a cloud-metadata endpoint") + void allowlistCannotOverrideMetadata() { + // Exact-IP allowlist of the metadata address must not let it through. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://169.254.169.254/latest/meta-data/", + List.of("169.254.169.254"))); + // A CIDR that covers the metadata IP must not let it through either. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://169.254.169.254/", List.of("169.254.0.0/16"))); + // Allowlisting the metadata hostname must not bypass the block. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://metadata.google.internal/", + List.of("metadata.google.internal"))); + // Even with private-network mode enabled AND an allowlist entry, metadata stays blocked. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://169.254.169.254/", List.of("169.254.0.0/16"), true)); + // Alibaba and Oracle metadata IPs are equally non-overridable. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://100.100.100.200/", List.of("100.100.100.200"), true)); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://192.0.0.192/", List.of("192.0.0.0/24"), true)); + } + + @Test + @DisplayName("Allowlisting a non-metadata private host still works after the metadata-first reorder") + void allowlistStillWorksForNonMetadata() { + // Regression guard: making metadata unconditional must not break legitimate + // allowlisting of ordinary private hosts. + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.50.10/", List.of("192.168.50.0/24"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java index 6444d4a1..38e62ff0 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java @@ -24,7 +24,7 @@ class CodeExecuteToolArgsTest { /** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */ private final CodeExecuteTool tool = - new CodeExecuteTool(null, null, null, objectMapper); + new CodeExecuteTool(null, null, null, objectMapper, null); @Test @DisplayName("null / blank / empty-array args yield no argument list") diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java new file mode 100644 index 00000000..7ea04621 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java @@ -0,0 +1,51 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * execute_code embeds artifact download links into its result; the chat layer + * then extracts them (issue #191). This pins the link shape so a JSON-array + * regression can't sneak back: an array's own '[' sits next to the markdown '[' + * and the extractor would capture '"[name' as the filename. + */ +class CodeExecuteToolArtifactTest { + + // Mirror of ChatController.GENERATED_FILE_LINK_PATTERN. + private static final Pattern LINK = Pattern.compile( + "\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)"); + + @Test + @DisplayName("formatResult embeds links so extraction yields the clean filename, not '\"[name'") + void formatResultExtractsCleanFilename() { + CodeExecuteTool tool = new CodeExecuteTool(null, null, null, null, null); + var result = vip.mate.skill.runtime.SkillScriptExecutionService.ScriptResult.error(0, ""); + String out = tool.formatResult(result, List.of( + "[report.csv](http://localhost:18088/api/v1/files/generated/abc-123)", + "[data.xlsx](http://localhost:18088/api/v1/files/generated/def-456)")); + + List names = new ArrayList<>(); + Matcher m = LINK.matcher(out); + while (m.find()) { + names.add(m.group(1)); + } + assertEquals(List.of("report.csv", "data.xlsx"), names, + "extracted filenames must be clean, full result was: " + out); + } + + @Test + @DisplayName("No artifacts → no generatedFiles field") + void noArtifactsNoField() { + CodeExecuteTool tool = new CodeExecuteTool(null, null, null, null, null); + var result = vip.mate.skill.runtime.SkillScriptExecutionService.ScriptResult.error(0, "ok"); + String out = tool.formatResult(result, List.of()); + assertEquals(false, out.contains("generatedFiles"), out); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java index 355e71b6..39fc2adf 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java @@ -57,7 +57,8 @@ class DelegateAgentToolDenyListTest { vip.mate.task.AsyncTaskService asyncTaskService = mock(vip.mate.task.AsyncTaskService.class); tool = new DelegateAgentTool(agentService, agentMapper, streamTracker, conversationService, - objectMapper, registry, auditEventService, asyncTaskService); + objectMapper, registry, auditEventService, asyncTaskService, + new vip.mate.agent.delegation.DelegatedUsageAccumulator()); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java index a8e6d1ec..4e0f47a3 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java @@ -15,6 +15,7 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import vip.mate.agent.AgentService; +import vip.mate.agent.AgentService.ChatResult; import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; @@ -40,6 +41,8 @@ class DelegateAgentToolTest { @Mock ConversationService conversationService; @Mock AuditEventService auditEventService; @Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); + @Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator = + new vip.mate.agent.delegation.DelegatedUsageAccumulator(); @InjectMocks DelegateAgentTool delegateAgentTool; @@ -180,9 +183,9 @@ class DelegateAgentToolTest { // the test thread returns immediately. The orphan keeps sleeping on a // virtual thread until JVM teardown — that's the same behavior as // production (cancel is best-effort). - when(agentService.chat(anyLong(), anyString(), anyString(), any())).thenAnswer(invocation -> { + when(agentService.chatWithUsage(anyLong(), anyString(), anyString(), any())).thenAnswer(invocation -> { Thread.sleep(10_000); - return "should not reach here"; + return ChatResult.contentOnly("should not reach here"); }); // Set a conversationId so resolveParentConversationId works @@ -239,13 +242,13 @@ class DelegateAgentToolTest { when(streamTracker.isRunning(any())).thenReturn(false); // FastAgent completes immediately - when(agentService.chat(eq(10L), anyString(), anyString(), any())) - .thenReturn("Fast result completed successfully"); + when(agentService.chatWithUsage(eq(10L), anyString(), anyString(), any())) + .thenReturn(ChatResult.contentOnly("Fast result completed successfully")); // SlowAgent blocks longer than the (test-overridden) 3 s budget. - when(agentService.chat(eq(11L), anyString(), anyString(), any())).thenAnswer(invocation -> { + when(agentService.chatWithUsage(eq(11L), anyString(), anyString(), any())).thenAnswer(invocation -> { Thread.sleep(10_000); - return "should not reach here"; + return ChatResult.contentOnly("should not reach here"); }); ToolExecutionContext.set("parent-mixed", "admin"); @@ -263,4 +266,165 @@ class DelegateAgentToolTest { assertTrue(result.contains("超时") || result.contains("✗"), "Should contain timeout indicator for SlowAgent: " + result); } + + // ===== delegateParallel: fail-fast on required failure (RFC 05 Q3) ===== + + @Test + @DisplayName("delegateParallel fails fast: required failure cancels a slow sibling") + void delegateParallelRequiredFailureCancelsSibling() { + AgentEntity failAgent = new AgentEntity(); + failAgent.setId(20L); + failAgent.setName("FailAgent"); + failAgent.setEnabled(true); + failAgent.setWorkspaceId(1L); + + AgentEntity slowAgent = new AgentEntity(); + slowAgent.setId(21L); + slowAgent.setName("SlowAgent"); + slowAgent.setEnabled(true); + slowAgent.setWorkspaceId(1L); + + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(failAgent) + .thenReturn(slowAgent); + when(streamTracker.isRunning(any())).thenReturn(false); + // Required FailAgent errors immediately → arms fail-fast. + when(agentService.chatWithUsage(eq(20L), anyString(), anyString(), any())) + .thenThrow(new RuntimeException("boom")); + // SlowAgent would block well past the 3 s test budget; fail-fast cancels it. + when(agentService.chatWithUsage(eq(21L), anyString(), anyString(), any())).thenAnswer(inv -> { + Thread.sleep(10_000); + return ChatResult.contentOnly("unreachable"); + }); + + ToolExecutionContext.set("parent-ff", "admin"); + String json = "[{\"agentName\":\"FailAgent\",\"task\":\"a\"},{\"agentName\":\"SlowAgent\",\"task\":\"b\"}]"; + + long start = System.currentTimeMillis(); + String result = delegateAgentTool.delegateParallel(json, null); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(elapsed < 2500, "fail-fast should return well before the budget, took " + elapsed + "ms"); + assertTrue(result.contains("cancelled=1"), "slow sibling should be cancelled: " + result); + assertTrue(result.contains("已取消"), "should label the cancelled sibling: " + result); + } + + @Test + @DisplayName("delegateParallel: optional task failure does not abort the batch") + void delegateParallelOptionalFailureDoesNotAbort() { + AgentEntity optAgent = new AgentEntity(); + optAgent.setId(30L); + optAgent.setName("OptAgent"); + optAgent.setEnabled(true); + optAgent.setWorkspaceId(1L); + + AgentEntity okAgent = new AgentEntity(); + okAgent.setId(31L); + okAgent.setName("OkAgent"); + okAgent.setEnabled(true); + okAgent.setWorkspaceId(1L); + + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(optAgent) + .thenReturn(okAgent); + when(streamTracker.isRunning(any())).thenReturn(false); + when(agentService.chatWithUsage(eq(30L), anyString(), anyString(), any())) + .thenThrow(new RuntimeException("opt boom")); + when(agentService.chatWithUsage(eq(31L), anyString(), anyString(), any())) + .thenReturn(ChatResult.contentOnly("ok result done")); + + ToolExecutionContext.set("parent-opt", "admin"); + String json = "[{\"agentName\":\"OptAgent\",\"task\":\"a\",\"optional\":true}," + + "{\"agentName\":\"OkAgent\",\"task\":\"b\"}]"; + String result = delegateAgentTool.delegateParallel(json, null); + + // An optional failure must not cancel anything; the other task completes normally. + assertTrue(result.contains("cancelled=0"), "no cancellation expected: " + result); + assertTrue(result.contains("OkAgent"), "ok task should be reported: " + result); + } + + // ===== delegateParallel: per-task timeout override (RFC 05 Q2) ===== + + @Test + @DisplayName("delegateParallel: per-task timeout_seconds widens the batch budget") + void delegateParallelTimeoutOverrideWidensBudget() { + AgentEntity longAgent = new AgentEntity(); + longAgent.setId(40L); + longAgent.setName("LongAgent"); + longAgent.setEnabled(true); + longAgent.setWorkspaceId(1L); + + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(longAgent); + when(streamTracker.isRunning(any())).thenReturn(false); + // Sleeps 4 s — beyond the 3 s test budget, but within the 6 s override. + when(agentService.chatWithUsage(eq(40L), anyString(), anyString(), any())).thenAnswer(inv -> { + Thread.sleep(4_000); + return ChatResult.contentOnly("long task finished ok"); + }); + + ToolExecutionContext.set("parent-to", "admin"); + String json = "[{\"agentName\":\"LongAgent\",\"task\":\"a\",\"timeout_seconds\":6}]"; + String result = delegateAgentTool.delegateParallel(json, null); + + // With the override the child finishes instead of timing out at 3 s. + assertTrue(result.contains("long task finished ok") || result.contains("success=1"), + "long task should complete within the widened budget: " + result); + assertFalse(result.contains("timeout=1"), "should not time out with the override: " + result); + } + + // ===== token usage surfacing ===== + + @Test + @DisplayName("delegateToAgent surfaces the child's token usage in the reply") + void delegateToAgentSurfacesTokenUsage() { + AgentEntity agent = new AgentEntity(); + agent.setId(50L); + agent.setName("Worker"); + agent.setEnabled(true); + agent.setWorkspaceId(1L); + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(agent); + when(streamTracker.isRunning(any())).thenReturn(false); + when(agentService.chatWithUsage(eq(50L), anyString(), anyString(), any())) + .thenReturn(new ChatResult("done with work", 120, 45, null, null)); + + ToolExecutionContext.set("parent-usage", "admin"); + String result = delegateAgentTool.delegateToAgent("Worker", "do the thing", null, null); + + assertTrue(result.contains("tokensIn=120"), "reply should surface prompt tokens: " + result); + assertTrue(result.contains("tokensOut=45"), "reply should surface completion tokens: " + result); + } + + @Test + @DisplayName("delegateParallel aggregates child token usage in the header and per-row lines") + void delegateParallelAggregatesTokenUsage() { + AgentEntity a = new AgentEntity(); + a.setId(60L); + a.setName("AgentA"); + a.setEnabled(true); + a.setWorkspaceId(1L); + AgentEntity b = new AgentEntity(); + b.setId(61L); + b.setName("AgentB"); + b.setEnabled(true); + b.setWorkspaceId(1L); + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(a) + .thenReturn(b); + when(streamTracker.isRunning(any())).thenReturn(false); + when(agentService.chatWithUsage(eq(60L), anyString(), anyString(), any())) + .thenReturn(new ChatResult("result A", 100, 30, null, null)); + when(agentService.chatWithUsage(eq(61L), anyString(), anyString(), any())) + .thenReturn(new ChatResult("result B", 80, 20, null, null)); + + ToolExecutionContext.set("parent-usage-parallel", "admin"); + String json = "[{\"agentName\":\"AgentA\",\"task\":\"a\"},{\"agentName\":\"AgentB\",\"task\":\"b\"}]"; + String result = delegateAgentTool.delegateParallel(json, null); + + // Machine header carries the batch totals (180 in, 50 out). + assertTrue(result.contains("tokensIn=180"), "header should aggregate prompt tokens: " + result); + assertTrue(result.contains("tokensOut=50"), "header should aggregate completion tokens: " + result); + // Per-row lines carry each child's own usage. + assertTrue(result.contains("tokensIn=100"), "row A should carry its prompt tokens: " + result); + assertTrue(result.contains("tokensIn=80"), "row B should carry its prompt tokens: " + result); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java index c336a0ef..9c7d8389 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java @@ -66,7 +66,8 @@ class DelegateAsyncTaskOutputAttributionTest { void setUp() { tool = new DelegateAgentTool( agentService, agentMapper, streamTracker, conversationService, - objectMapper, subagentRegistry, auditEventService, asyncTaskService); + objectMapper, subagentRegistry, auditEventService, asyncTaskService, + new vip.mate.agent.delegation.DelegatedUsageAccumulator()); } @AfterEach @@ -195,7 +196,7 @@ class DelegateAsyncTaskOutputAttributionTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false, null, null, null, null); + 1L, conversationId, requester, null, null, null, null, false, null, null, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java index 2faa923e..acbe68b6 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -69,7 +69,8 @@ class DelegateAsyncToolTest { void setUp() { tool = new DelegateAgentTool( agentService, agentMapper, streamTracker, conversationService, - objectMapper, subagentRegistry, auditEventService, asyncTaskService); + objectMapper, subagentRegistry, auditEventService, asyncTaskService, + new vip.mate.agent.delegation.DelegatedUsageAccumulator()); // resolveParentConversationId reads from ToolExecutionContext first; // seed it so the async delegation has a parent to attach the task to. ToolExecutionContext.set("parent-conv-1", "user-1"); @@ -388,7 +389,7 @@ class DelegateAsyncToolTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false, null, null, null, null); + 1L, conversationId, requester, null, null, null, null, false, null, null, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java index f36cabe2..d2d98d5d 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java @@ -16,6 +16,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.Spy; import vip.mate.agent.AgentService; +import vip.mate.agent.AgentService.ChatResult; import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; @@ -49,6 +50,8 @@ class DelegateEventSequenceTest { @Mock ConversationService conversationService; @Mock AuditEventService auditEventService; @Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); + @Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator = + new vip.mate.agent.delegation.DelegatedUsageAccumulator(); @InjectMocks DelegateAgentTool delegateAgentTool; @@ -108,14 +111,14 @@ class DelegateEventSequenceTest { }); // During chat(), simulate the child broadcasting a tool_call_started event - when(agentService.chat(eq(100L), eq("summarize the report"), anyString(), any())) + when(agentService.chatWithUsage(eq(100L), eq("summarize the report"), anyString(), any())) .thenAnswer(invocation -> { // The relay listener should have been registered by now — fire it BiConsumer relay = relayRef.get(); assertNotNull(relay, "Relay should be registered before child chat starts"); relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}"); relay.accept("tool_call_completed", "{\"name\":\"searchWeb\",\"success\":true}"); - return "The report shows growth of 15% YoY."; + return ChatResult.contentOnly("The report shows growth of 15% YoY."); }); // Act @@ -170,8 +173,10 @@ class DelegateEventSequenceTest { when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any())) .thenReturn(() -> {}); - when(agentService.chat(eq(101L), anyString(), anyString(), any())).thenReturn("Result A"); - when(agentService.chat(eq(102L), anyString(), anyString(), any())).thenReturn("Result B"); + when(agentService.chatWithUsage(eq(101L), anyString(), anyString(), any())) + .thenReturn(ChatResult.contentOnly("Result A")); + when(agentService.chatWithUsage(eq(102L), anyString(), anyString(), any())) + .thenReturn(ChatResult.contentOnly("Result B")); String json = "[{\"agentName\":\"AgentA\",\"task\":\"task A\"},{\"agentName\":\"AgentB\",\"task\":\"task B\"}]"; @@ -203,7 +208,8 @@ class DelegateEventSequenceTest { ToolExecutionContext.set("inactive-parent", "admin"); when(streamTracker.isRunning("inactive-parent")).thenReturn(false); - when(agentService.chat(eq(200L), anyString(), anyString(), any())).thenReturn("done"); + when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any())) + .thenReturn(ChatResult.contentOnly("done")); // Act delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null); @@ -235,7 +241,7 @@ class DelegateEventSequenceTest { return (Runnable) () -> {}; }); - when(agentService.chat(eq(300L), anyString(), anyString(), any())) + when(agentService.chatWithUsage(eq(300L), anyString(), anyString(), any())) .thenAnswer(invocation -> { BiConsumer relay = relayRef.get(); // These should produce delegation_progress: @@ -244,7 +250,7 @@ class DelegateEventSequenceTest { // These should be ignored by the relay filter: relay.accept("heartbeat", "{}"); relay.accept("token", "{\"text\":\"hello\"}"); - return "filtered result"; + return ChatResult.contentOnly("filtered result"); }); delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null); @@ -294,18 +300,19 @@ class DelegateEventSequenceTest { // ToolExecutionContext to the Child's own conversation. Reproduce that so // the grandchild's immediate parent resolves to childConv, while its // events must still target rootConv (carried via DelegationContext). - when(agentService.chat(eq(100L), anyString(), anyString(), any())) + when(agentService.chatWithUsage(eq(100L), anyString(), anyString(), any())) .thenAnswer(inv -> { String childConv = inv.getArgument(2); ToolExecutionContext.set(childConv, "admin"); try { - return delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null); + return ChatResult.contentOnly( + delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null)); } finally { ToolExecutionContext.set(rootConv, "admin"); } }); - when(agentService.chat(eq(200L), anyString(), anyString(), any())) - .thenReturn("grandchild done"); + when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any())) + .thenReturn(ChatResult.contentOnly("grandchild done")); delegateAgentTool.delegateToAgent("Child", "ctask", null, null); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java new file mode 100644 index 00000000..a7aa80c5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java @@ -0,0 +1,136 @@ +package vip.mate.tool.builtin; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SessionListTool} — the read-only "list" leg of the + * spawn / send / list triad, with DB-backed discovery of persisted child + * sessions overlaid by live registry status. + * + * @author MateClaw Team + */ +@ExtendWith(MockitoExtension.class) +class SessionListToolTest { + + @Mock ConversationMapper conversationMapper; + + private SubagentRegistry registry; + private SessionListTool tool; + + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), + ConversationEntity.class); + } + + @BeforeEach + void setUp() { + registry = new SubagentRegistry(); + tool = new SessionListTool(registry, conversationMapper); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + ToolExecutionContext.clear(); + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + private static ConversationEntity child(String conversationId, Long agentId, String title) { + ConversationEntity c = new ConversationEntity(); + c.setConversationId(conversationId); + c.setParentConversationId("conv-root"); + c.setAgentId(agentId); + c.setTitle(title); + c.setLastActiveTime(LocalDateTime.now()); + return c; + } + + @Test + void reportsNoContextWhenConversationUnknown() { + String out = tool.listSubagents(null); + assertTrue(out.contains("no conversation context"), out); + } + + @Test + void reportsEmptyWhenNoSessions() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectList(any())).thenReturn(List.of()); + String out = tool.listSubagents(null); + assertTrue(out.contains("No sub-agent sessions for this conversation"), out); + } + + @Test + void listsPersistedSessionsWithSessionIds() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectList(any())).thenReturn(List.of( + child("child-1", 11L, "research the topic"), + child("child-2", 22L, "draft the summary"))); + + String out = tool.listSubagents(null); + + assertTrue(out.contains("session_id=child-1"), out); + assertTrue(out.contains("session_id=child-2"), out); + assertTrue(out.contains("send_to_subagent"), out); + // Finished sessions stay discoverable even though the live registry is empty. + assertTrue(out.contains("idle"), out); + } + + @Test + void overlaysLiveStatusOnPersistedSession() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectList(any())).thenReturn(List.of( + child("child-1", 11L, "research the topic"), + child("child-2", 22L, "draft the summary"))); + // child-2 is still running according to the live registry. + registry.register("conv-root", "child-2", 22L, "draft the summary", null); + + String out = tool.listSubagents(null); + + assertTrue(out.contains("session_id=child-2"), out); + assertTrue(out.contains("running"), "live child should show running status: " + out); + // child-1 (no live record) renders as idle, child-2 as running — child-2 not duplicated. + assertEquals(1, out.split("session_id=child-2", -1).length - 1, "child-2 listed once: " + out); + } + + @Test + void prefersDelegationRootOverCurrentConversation() { + // Inside a delegated layer, the tree root is the human-facing conversation. + ToolExecutionContext.set("child-conv", "tester"); + DelegationContext.enter("child-conv", java.util.Set.of(), "conv-root", "sa-1", 1); + try { + when(conversationMapper.selectList(any())).thenReturn(List.of(child("child-1", 11L, "research"))); + String out = tool.listSubagents(null); + assertTrue(out.contains("session_id=child-1"), out); + assertFalse(out.contains("no conversation context"), out); + } finally { + DelegationContext.exit(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java new file mode 100644 index 00000000..00b6bf70 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java @@ -0,0 +1,147 @@ +package vip.mate.tool.builtin; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SessionSendTool} — the multi-turn "send" leg of the + * spawn / send / list delegation triad. + * + * @author MateClaw Team + */ +@ExtendWith(MockitoExtension.class) +class SessionSendToolTest { + + @Mock AgentService agentService; + @Mock ConversationMapper conversationMapper; + + private SubagentRegistry registry; + private SessionSendTool tool; + + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), + ConversationEntity.class); + } + + @BeforeEach + void setUp() { + registry = new SubagentRegistry(); + tool = new SessionSendTool(agentService, conversationMapper, registry); + ToolExecutionContext.clear(); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + private static ConversationEntity child(String conversationId, String parentConversationId, Long agentId) { + ConversationEntity c = new ConversationEntity(); + c.setConversationId(conversationId); + c.setParentConversationId(parentConversationId); + c.setAgentId(agentId); + return c; + } + + @Test + void rejectsUnknownSession() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(null); + String out = tool.sendToSubagent("child-x", "do more", null); + assertTrue(out.contains("Unknown session_id"), out); + verify(agentService, never()).chat(any(), any(), any(), any()); + } + + @Test + void rejectsNonSubagentSession() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(child("conv-root", null, 7L)); + String out = tool.sendToSubagent("conv-root", "do more", null); + assertTrue(out.contains("not a sub-agent session"), out); + verify(agentService, never()).chat(any(), any(), any(), any()); + } + + @Test + void rejectsSessionOwnedByAnotherConversation() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(child("child-1", "other-conv", 7L)); + String out = tool.sendToSubagent("child-1", "do more", null); + assertTrue(out.contains("does not belong to this conversation"), out); + verify(agentService, never()).chat(any(), any(), any(), any()); + } + + @Test + void rejectsWhenDepthLimitReached() { + ToolExecutionContext.set("conv-root", "tester"); + DelegationContext.enter("conv", java.util.Set.of(), "root", "sa", DelegateAgentTool.MAX_DELEGATION_DEPTH); + try { + String out = tool.sendToSubagent("child-1", "do more", null); + assertTrue(out.contains("depth limit"), out); + verify(conversationMapper, never()).selectOne(any()); + } finally { + DelegationContext.exit(); + } + } + + @Test + void continuesChildSessionForOwningConversation() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(child("child-1", "conv-root", 7L)); + when(agentService.chat(eq(7L), eq("refine it"), eq("child-1"), any(ChatOrigin.class))) + .thenReturn("refined result"); + + String out = tool.sendToSubagent("child-1", "refine it", null); + + assertTrue(out.contains("Sub-agent reply"), out); + assertTrue(out.contains("child-1"), out); + assertTrue(out.contains("refined result"), out); + verify(agentService).chat(eq(7L), eq("refine it"), eq("child-1"), any(ChatOrigin.class)); + } + + @Test + void registersDuringContinuationAndUnregistersAfter() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(child("child-1", "conv-root", 7L)); + // While the child runs, the continuation must be visible in the registry + // (so SessionListTool / the control API can see and interrupt it). + when(agentService.chat(eq(7L), any(), eq("child-1"), any(ChatOrigin.class))).thenAnswer(inv -> { + assertFalse(registry.snapshot("conv-root").isEmpty(), + "continuation should be registered while running"); + return "done"; + }); + + tool.sendToSubagent("child-1", "keep going", null); + + // ...and cleaned up afterwards so it never leaks. + assertTrue(registry.snapshot("conv-root").isEmpty(), + "continuation should be unregistered after completion"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java new file mode 100644 index 00000000..c7af0fbe --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java @@ -0,0 +1,115 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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.SkillService; +import vip.mate.skill.workspace.SkillWorkspaceManager; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for the {@code write_file} action of {@link SkillManageTool}: writing + * supporting files under a skill, and its guards (missing path, builtin, + * unknown skill, unsafe path). + */ +class SkillManageToolWriteFileTest { + + private SkillService skillService; + private SkillSecurityService securityService; + private SkillWorkspaceManager workspaceManager; + private SkillManageTool tool; + + @BeforeEach + void setUp() { + skillService = mock(SkillService.class); + securityService = mock(SkillSecurityService.class); + workspaceManager = mock(SkillWorkspaceManager.class); + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + tool = new SkillManageTool(skillService, securityService, workspaceManager, runtimeService); + } + + private SkillEntity skill(String name, boolean builtin) { + SkillEntity s = new SkillEntity(); + s.setName(name); + s.setBuiltin(builtin); + s.setSkillContent("---\nname: " + name + "\n---\n# x"); + return s; + } + + private void scanPasses() { + SkillValidationResult ok = mock(SkillValidationResult.class); + when(ok.isBlocked()).thenReturn(false); + when(ok.getWarnings()).thenReturn(List.of()); + when(securityService.scanContent(any(), any())).thenReturn(ok); + } + + @Test + @DisplayName("write_file writes a supporting file under the skill") + void writesSupportingFile() { + when(skillService.findByName("my-skill")).thenReturn(skill("my-skill", false)); + scanPasses(); + + String result = tool.skill_manage("write_file", "my-skill", "echo hi", + null, null, "scripts/run.sh", null); + + assertTrue(result.startsWith("File 'scripts/run.sh' written"), result); + verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "scripts/run.sh", "echo hi"); + } + + @Test + @DisplayName("write_file without filePath is rejected") + void rejectsMissingPath() { + String result = tool.skill_manage("write_file", "my-skill", "body", + null, null, null, null); + assertTrue(result.startsWith("Error"), result); + verify(workspaceManager, never()).writeWorkspaceFile(any(), any(), any()); + } + + @Test + @DisplayName("write_file into a builtin skill is rejected") + void rejectsBuiltin() { + when(skillService.findByName("core")).thenReturn(skill("core", true)); + String result = tool.skill_manage("write_file", "core", "body", + null, null, "references/x.md", null); + assertTrue(result.contains("builtin"), result); + verify(workspaceManager, never()).writeWorkspaceFile(any(), any(), any()); + } + + @Test + @DisplayName("write_file for an unknown skill is rejected") + void rejectsUnknownSkill() { + when(skillService.findByName("ghost")).thenReturn(null); + String result = tool.skill_manage("write_file", "ghost", "body", + null, null, "references/x.md", null); + assertTrue(result.contains("not found"), result); + verify(workspaceManager, never()).writeWorkspaceFile(any(), any(), any()); + } + + @Test + @DisplayName("write_file surfaces an unsafe-path rejection from the workspace manager") + void surfacesUnsafePath() { + when(skillService.findByName("my-skill")).thenReturn(skill("my-skill", false)); + scanPasses(); + doThrow(new IllegalArgumentException("Unsafe file path rejected: ../etc/passwd")) + .when(workspaceManager).writeWorkspaceFile(eq("my-skill"), any(), any()); + + String result = tool.skill_manage("write_file", "my-skill", "body", + null, null, "../etc/passwd", null); + assertTrue(result.startsWith("Error"), result); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java index 15ba8aad..bd343fb6 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java @@ -7,6 +7,7 @@ import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.annotation.Tool; import org.springframework.test.util.ReflectionTestUtils; import vip.mate.agent.AgentToolSet; +import vip.mate.agent.context.TokenEstimator; import vip.mate.tool.ToolRegistry; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.service.McpServerService; @@ -88,7 +89,7 @@ class ToolDisclosureServiceTest { lenient().when(ms.listAll()).thenReturn(servers); lenient().when(as.listAvailable()).thenReturn(available); lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); - return new DefaultToolDisclosureService(ts, ms, as, tr); + return new DefaultToolDisclosureService(ts, ms, as, tr, new ToolUsageRecencyTracker()); } @Test @@ -202,4 +203,105 @@ class ToolDisclosureServiceTest { private static List names(List cbs) { return cbs.stream().map(c -> c.getToolDefinition().name()).toList(); } + + // ==================== budget-driven auto-demotion ==================== + + /** Three plain core tools for demotion-ranking tests. */ + static class ManyCoreTools { + @Tool(description = "core tool a") + public String tool_a() { return ""; } + + @Tool(description = "core tool b") + public String tool_b() { return ""; } + + @Tool(description = "core tool c") + public String tool_c() { return ""; } + } + + private static AgentToolSet manyCoreSet() { + return AgentToolSet.fromCallbacks(List.of(new ManyCoreTools()), + List.of(ToolCallbacks.from(new ManyCoreTools()))); + } + + @Test + @DisplayName("no demotion when the core schemas fit the budget, or when budget is absent") + void noDemotionWhenBudgetFits() { + var svc = service(List.of(), List.of(), List.of()); + AgentToolSet set = manyCoreSet(); + assertTrue(svc.computeAutoDemotions(set, Integer.MAX_VALUE).isEmpty()); + assertTrue(svc.computeAutoDemotions(set, null).isEmpty()); + assertTrue(svc.computeAutoDemotions(set, 1_000_000).isEmpty()); + } + + @Test + @DisplayName("tiny budget demotes every demotable tool, alphabetical when nothing was ever used") + void tinyBudgetDemotesAll() { + var svc = service(List.of(), List.of(), List.of()); + var demoted = svc.computeAutoDemotions(manyCoreSet(), 1); + assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted); + } + + @Test + @DisplayName("budget one tool short demotes exactly the first never-used candidate") + void partialDemotionTakesFirstCandidate() { + var svc = service(List.of(), List.of(), List.of()); + AgentToolSet set = manyCoreSet(); + int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks()); + var demoted = svc.computeAutoDemotions(set, coreTokens - 1); + assertEquals(Set.of("tool_a"), demoted); + } + + @Test + @DisplayName("recently used tools demote last") + void recencyProtectsRecentlyUsed() { + ToolUsageRecencyTracker tracker = new ToolUsageRecencyTracker(); + tracker.recordUse("tool_a"); + ToolService ts = mock(ToolService.class); + McpServerService ms = mock(McpServerService.class); + AvailableToolService as = mock(AvailableToolService.class); + ToolRegistry tr = mock(ToolRegistry.class); + lenient().when(ts.listTools()).thenReturn(List.of()); + lenient().when(ms.listAll()).thenReturn(List.of()); + lenient().when(as.listAvailable()).thenReturn(List.of()); + lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); + var svc = new DefaultToolDisclosureService(ts, ms, as, tr, tracker); + + AgentToolSet set = manyCoreSet(); + int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks()); + // One tool over budget: the never-used tool_b (alphabetically first + // among never-used) demotes, the recently used tool_a survives. + var demoted = svc.computeAutoDemotions(set, coreTokens - 1); + assertEquals(Set.of("tool_b"), demoted); + } + + @Test + @DisplayName("explicit core DB row and meta-tools are never demoted") + void explicitCoreProtected() { + var svc = service(List.of(toolRow("tool_a", "builtin", "core")), List.of(), List.of()); + var demoted = svc.computeAutoDemotions(manyCoreSet(), 1); + assertEquals(Set.of("tool_b", "tool_c"), demoted); + } + + @Test + @DisplayName("auto-demoted tools behave as extension in split and can be enabled back") + void splitHonorsAutoDemotions() { + var svc = service(List.of(), List.of(), List.of()); + AgentToolSet set = manyCoreSet(); + + var split = svc.split(set, Set.of(), Set.of("tool_b")); + assertEquals(List.of("tool_a", "tool_c"), names(split.activeCallbacks())); + assertEquals(List.of("tool_b"), names(split.extensionCatalog())); + + var enabledBack = svc.split(set, Set.of("tool_b"), Set.of("tool_b")); + assertTrue(names(enabledBack.activeCallbacks()).contains("tool_b")); + } + + @Test + @DisplayName("catalog rendering lists auto-demoted tools for discoverability") + void catalogListsAutoDemoted() { + var svc = service(List.of(), List.of(), List.of()); + String catalog = svc.renderExtensionCatalog(manyCoreSet(), 8192, Set.of("tool_b")); + assertTrue(catalog.contains("tool_b")); + assertFalse(catalog.contains("| `tool_a`"), "non-demoted core tools stay out of the catalog"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java new file mode 100644 index 00000000..a7ed653b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java @@ -0,0 +1,98 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin {@link GeneratedFileCache#linkifyBareReferences}: bare generated-file + * URLs echoed by the model as plain text must be wrapped into + * {@code [filename](url)} markdown links so chat surfaces show the file name + * instead of the raw id, while URLs already inside a markdown link stay + * untouched. + */ +class GeneratedFileCacheLinkifyTest { + + private GeneratedFileCache cache; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + cache = new GeneratedFileCache(tempDir); + } + + private String putFile(String filename) { + return cache.put("dummy".getBytes(), filename, "application/octet-stream"); + } + + @Test + @DisplayName("bare relative URL with a live id → wrapped into [filename](url)") + void bareRelativeUrlWrapped() { + String id = putFile("智能体技术培训_红色版.pptx"); + String url = "/api/v1/files/generated/" + id; + String out = cache.linkifyBareReferences("下载链接:" + url + "(10 分钟内有效)"); + assertEquals("下载链接:[智能体技术培训_红色版.pptx](" + url + ")(10 分钟内有效)", out); + } + + @Test + @DisplayName("bare absolute URL keeps its host inside the link destination") + void bareAbsoluteUrlWrapped() { + String id = putFile("report.docx"); + String url = "http://localhost:55793/api/v1/files/generated/" + id; + String out = cache.linkifyBareReferences("下载:" + url); + assertEquals("下载:[report.docx](" + url + ")", out); + } + + @Test + @DisplayName("URL already used as a markdown link destination is left untouched") + void markdownLinkLeftUntouched() { + String id = putFile("slides.pptx"); + String text = "演示文稿已生成:[自定义标题](/api/v1/files/generated/" + id + ")"; + assertEquals(text, cache.linkifyBareReferences(text)); + } + + @Test + @DisplayName("angle-bracket autolink is left untouched") + void angleAutolinkLeftUntouched() { + String id = putFile("a.xlsx"); + String text = "见 处"; + assertEquals(text, cache.linkifyBareReferences(text)); + } + + @Test + @DisplayName("unknown id is left for the missing-reference scrubber") + void unknownIdLeftUntouched() { + String text = "文件:/api/v1/files/generated/a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + assertEquals(text, cache.linkifyBareReferences(text)); + } + + @Test + @DisplayName("square brackets in the stored filename are stripped from the link text") + void bracketsInFilenameStripped() { + String id = putFile("[草稿]方案.docx"); + String out = cache.linkifyBareReferences("/api/v1/files/generated/" + id); + assertTrue(out.startsWith("[草稿方案.docx]("), "brackets must be stripped; got: " + out); + } + + @Test + @DisplayName("mixed text: markdown link kept, bare duplicate of the same URL wrapped") + void mixedMarkdownAndBare() { + String id = putFile("数据.csv"); + String url = "/api/v1/files/generated/" + id; + String out = cache.linkifyBareReferences("[数据.csv](" + url + ") 备用地址 " + url); + assertEquals("[数据.csv](" + url + ") 备用地址 [数据.csv](" + url + ")", out); + } + + @Test + @DisplayName("null / empty / no-URL text passes through") + void passThrough() { + assertNull(cache.linkifyBareReferences(null)); + assertEquals("", cache.linkifyBareReferences("")); + String plain = "没有链接的普通回答"; + assertSame(plain, cache.linkifyBareReferences(plain)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java new file mode 100644 index 00000000..66934476 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java @@ -0,0 +1,92 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Files a tool run writes to the working dir should surface as one-click + * downloads (issue #191): registered in the generated-file cache and returned as + * {@code [name](url)} links the chat layer scans for. Pre-existing, scratch, and + * hidden files are excluded. + */ +class WorkspaceArtifactSurfacerTest { + + private static final Pattern LINK = Pattern.compile( + "\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)"); + + private Path tmp; + private Path cacheDir; + + @AfterEach + void cleanup() throws Exception { + for (Path root : new Path[]{tmp, cacheDir}) { + if (root != null && Files.exists(root)) { + try (var s = Files.walk(root)) { + s.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (Exception ignore) { } + }); + } + } + } + } + + @Test + @DisplayName("Files written during the run surface as links; old/noise files do not") + void surfacesNewlyWrittenFiles() throws Exception { + tmp = Files.createTempDirectory("artifacts-"); + cacheDir = Files.createTempDirectory("cache-"); + GeneratedFileCache cache = new GeneratedFileCache(cacheDir); + + Path old = Files.write(tmp.resolve("old.txt"), "old".getBytes()); + Files.setLastModifiedTime(old, FileTime.fromMillis(1_000L)); + + long runStart = System.currentTimeMillis(); + Thread.sleep(5); + + Files.write(tmp.resolve("report.xlsx"), "PK fake-xlsx".getBytes()); + Files.write(tmp.resolve("data.csv"), "a,b\n1,2\n".getBytes()); + Files.write(tmp.resolve("scratch.tmp"), "x".getBytes()); + Files.write(tmp.resolve(".hidden"), "x".getBytes()); + + List links = WorkspaceArtifactSurfacer.collect(cache, tmp, runStart, null); + + assertEquals(2, links.size(), "expected the two real artifacts, got: " + links); + assertTrue(links.stream().anyMatch(l -> l.contains("report.xlsx"))); + assertTrue(links.stream().anyMatch(l -> l.contains("data.csv"))); + assertFalse(links.stream().anyMatch(l -> l.contains("old.txt")), "pre-existing file must not surface"); + assertFalse(links.stream().anyMatch(l -> l.contains(".tmp")), ".tmp must not surface"); + assertFalse(links.stream().anyMatch(l -> l.contains(".hidden")), "hidden file must not surface"); + + for (String link : links) { + Matcher m = LINK.matcher(link); + assertTrue(m.matches(), "link not in extractable form: " + link); + String id = m.group(2).substring(m.group(2).lastIndexOf('/') + 1); + Optional entry = cache.get(id); + assertTrue(entry.isPresent(), "cached artifact must be retrievable: " + link); + } + } + + @Test + @DisplayName("Null / non-existent working dir and null cache are safe no-ops") + void edgeCasesAreSafe() throws Exception { + cacheDir = Files.createTempDirectory("cache-"); + GeneratedFileCache cache = new GeneratedFileCache(cacheDir); + assertTrue(WorkspaceArtifactSurfacer.collect(cache, null, 0L, null).isEmpty()); + assertTrue(WorkspaceArtifactSurfacer.collect(cache, Path.of("/no/such/dir/xyz"), 0L, null).isEmpty()); + assertTrue(WorkspaceArtifactSurfacer.collect(null, Path.of("."), 0L, null).isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardSandboxTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardSandboxTest.java index 1a4b4871..89d6f526 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardSandboxTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardSandboxTest.java @@ -38,6 +38,7 @@ class WorkspacePathGuardSandboxTest { ToolExecutionContext.clear(); WorkspacePathGuard.setDefaultRoot(null); WorkspacePathGuard.setSkillRoot(null); + WorkspacePathGuard.clearTrustedRoots(); } // ==================== Defect 1: fail-closed default root ==================== @@ -117,6 +118,61 @@ class WorkspacePathGuardSandboxTest { } } + // ============ Tool-result spill dir is trusted outside the boundary (issue #403) ============ + + @Nested + @DisplayName("A registered tool-result spill root is readable outside the workspace boundary") + class TrustedSpillRoot { + + private static final String SPILL_ROOT = "/tmp/mate-tool-result-spill/tool-results"; + + @BeforeEach + void setup() { + // A conversation bound to a workspace, with a central spill dir that + // lives outside that workspace — the production scenario from #403. + ToolExecutionContext.set("conv", "user", WORKSPACE); + WorkspacePathGuard.addTrustedRoot(SPILL_ROOT); + } + + @Test + @DisplayName("validatePath: reading a spilled tool result outside the workspace is allowed") + void validatePathSpill_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validatePath(SPILL_ROOT + "/conv/call_2.txt")); + } + + @Test + @DisplayName("findPathBoundaryViolation: spill path reports no violation") + void findPathBoundaryViolationSpill_null() { + org.junit.jupiter.api.Assertions.assertNull( + WorkspacePathGuard.findPathBoundaryViolation( + SPILL_ROOT + "/conv/call_2.txt", WORKSPACE)); + } + + @Test + @DisplayName("Shell: cat-ing a spilled tool result outside the workspace is allowed") + void shellSpill_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("cat " + SPILL_ROOT + "/conv/call_2.txt")); + } + + @Test + @DisplayName("A non-spill path outside the workspace is still blocked") + void unrelatedOutside_stillBlocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validatePath("/etc/passwd")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat /etc/passwd")); + } + + @Test + @DisplayName("Deleting inside the trusted spill root is not a root-deletion escape") + void deleteInsideSpillRoot_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("rm -rf " + SPILL_ROOT + "/conv")); + } + } + // ==================== Defect 2: workspace-root deletion guard ==================== @Nested diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java index 218d9018..02a58a73 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java @@ -8,6 +8,8 @@ import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; import vip.mate.tool.builtin.ToolExecutionContext; +import java.nio.file.Path; + import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -26,8 +28,16 @@ class WorkspacePathGuardShellTest { private static final String WORKSPACE = "/tmp/ws-guard-shell-test"; private static final String SKILL_ROOT = "/tmp/ws-guard-skill-root"; + // The global fallback sandbox root is process-wide mutable static state that + // another test (or the app context, in a full-suite run) may have set. Save + // and restore it so the "no workspace configured" cases here are deterministic + // rather than depending on whatever the previous test left behind. + private Path savedDefaultRoot; + @BeforeEach void setup() { + savedDefaultRoot = WorkspacePathGuard.getDefaultRoot(); + WorkspacePathGuard.setDefaultRoot(null); ToolExecutionContext.set("conv-test", "test-user", WORKSPACE); } @@ -35,6 +45,7 @@ class WorkspacePathGuardShellTest { void teardown() { ToolExecutionContext.clear(); WorkspacePathGuard.setSkillRoot(null); + WorkspacePathGuard.setDefaultRoot(savedDefaultRoot == null ? null : savedDefaultRoot.toString()); } // ==================== No-op when sandbox absent ==================== diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java index 8d527d05..42347e92 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java @@ -47,6 +47,13 @@ class WorkspaceBoundaryGuardianTest { .withWorkspaceBasePath(basePath); } + private ToolInvocationContext code(String language, String src, String basePath) { + String args = "{\"language\":\"" + language + "\",\"code\":\"" + + src.replace("\"", "\\\"") + "\"}"; + return ToolInvocationContext.of("execute_code", args, "conv", "agent") + .withWorkspaceBasePath(basePath); + } + private void assertBlocked(List findings) { assertFalse(findings.isEmpty(), "expected a boundary finding"); GuardFinding f = findings.get(0); @@ -79,6 +86,50 @@ class WorkspaceBoundaryGuardianTest { assertTrue(guardian.evaluate(shell("rm -rf " + WORKSPACE + "/subdir", WORKSPACE)).isEmpty()); } + // ==================== Inline code execution (execute_code) ==================== + + @Test + @DisplayName("execute_code bash escaping the workspace → CRITICAL BLOCK finding") + void codeBashEscape_blocked() { + // The #403 reproduction: `cat /tmp/...` and reading /etc/passwd from + // shell-language code must be blocked just like the shell tool. + assertBlocked(guardian.evaluate(code("bash", "cat /etc/passwd", WORKSPACE))); + assertBlocked(guardian.evaluate(code("sh", "cat /tmp/mate-tool-result-spill/x", WORKSPACE))); + assertBlocked(guardian.evaluate(code("shell", "ls ..", WORKSPACE))); + } + + @Test + @DisplayName("execute_code bash deleting the workspace root → CRITICAL BLOCK finding") + void codeBashRootDeletion_blocked() { + assertBlocked(guardian.evaluate(code("bash", "rm -rf " + WORKSPACE, WORKSPACE))); + } + + @Test + @DisplayName("execute_code bash inside the workspace → no finding") + void codeBashInBounds_pass() { + assertTrue(guardian.evaluate(code("bash", "ls -la", WORKSPACE)).isEmpty()); + assertTrue(guardian.evaluate(code("bash", "cat " + WORKSPACE + "/foo.txt", WORKSPACE)).isEmpty()); + } + + @Test + @DisplayName("execute_code reports the violating param as 'code'") + void codeViolation_paramName() { + List findings = guardian.evaluate(code("bash", "cat /etc/passwd", WORKSPACE)); + assertFalse(findings.isEmpty()); + assertEquals("code", findings.get(0).paramName()); + } + + @Test + @DisplayName("execute_code python/node is not path-scanned (avoids string-literal false positives)") + void codeNonShell_notScanned() { + // A Python/Node literal containing an absolute path must NOT be treated + // as a shell boundary escape — the static shell scan doesn't apply. + assertTrue(guardian.evaluate(code("python", "open('/etc/passwd')", WORKSPACE)).isEmpty()); + assertTrue(guardian.evaluate(code("node", "fs.readFileSync('/etc/passwd')", WORKSPACE)).isEmpty()); + // Unknown / missing language is likewise not scanned. + assertTrue(guardian.evaluate(code("ruby", "File.read('/etc/passwd')", WORKSPACE)).isEmpty()); + } + // ==================== File path tools ==================== @Test @@ -112,11 +163,31 @@ class WorkspaceBoundaryGuardianTest { } @Test - @DisplayName("supports() only fires for shell and file-path tools") + @DisplayName("supports() fires for shell, code, and file-path tools") void supports_scope() { assertTrue(guardian.supports(shell("ls", WORKSPACE))); + assertTrue(guardian.supports(code("bash", "ls", WORKSPACE))); assertTrue(guardian.supports(write("a.txt", WORKSPACE))); assertFalse(guardian.supports( ToolInvocationContext.of("web_search", "{}", "conv", "agent"))); } + + // ==================== Tool-result spill dir stays reachable (issue #403) ==================== + + @Test + @DisplayName("execute_code can still cat a legitimate spilled tool result outside the workspace") + void codeBashSpill_pass() { + String spillRoot = "/tmp/mate-tool-result-spill/tool-results"; + WorkspacePathGuard.addTrustedRoot(spillRoot); + try { + // The trusted-root mechanism added for #403 must keep working once + // execute_code is brought under the boundary guard: reading a real + // spill path is allowed, an unrelated outside path is still blocked. + assertTrue(guardian.evaluate( + code("bash", "cat " + spillRoot + "/conv/call_2.txt", WORKSPACE)).isEmpty()); + assertBlocked(guardian.evaluate(code("bash", "cat /etc/passwd", WORKSPACE))); + } finally { + WorkspacePathGuard.clearTrustedRoots(); + } + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java index 6c92d813..e3105b61 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import vip.mate.workspace.core.service.ChatUploadLocationResolverTestSupport; import java.io.IOException; import java.nio.file.Files; @@ -38,7 +39,7 @@ class ImageFileDownloaderTest { @BeforeEach void setUp() { - downloader = new ImageFileDownloader(); + downloader = new ImageFileDownloader(ChatUploadLocationResolverTestSupport.legacyDefault()); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java index 5690c069..eeea593a 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import vip.mate.common.net.SsrfProperties; import vip.mate.workspace.conversation.ConversationService; import java.io.IOException; @@ -33,7 +34,7 @@ class ImageReferenceLoaderTest { @BeforeEach void setUp() throws IOException { - loader = new ImageReferenceLoader(mock(ConversationService.class)); + loader = new ImageReferenceLoader(mock(ConversationService.class), new SsrfProperties()); tmpDir = Files.createTempDirectory("img-ref-loader-test-"); } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallbackTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallbackTest.java new file mode 100644 index 00000000..2a053f04 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/IdentityForwardingToolCallbackTest.java @@ -0,0 +1,72 @@ +package vip.mate.tool.mcp.runtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link IdentityForwardingToolCallback#withClaim} — the in-band + * JSON merge that carries identity to a STDIO MCP server. Pure string/JSON + * behavior; identity resolution (plaintext vs token) is tested in + * {@link McpIdentityForwardServiceTest}. + */ +class IdentityForwardingToolCallbackTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String KEY = McpIdentityForwardProperties.USER_ARG; + + @Test + @DisplayName("merges the claim into JSON args under the given key") + void mergesClaim() throws Exception { + JsonNode n = MAPPER.readTree(IdentityForwardingToolCallback.withClaim("{\"q\":\"hi\"}", KEY, "alice")); + assertThat(n.get("q").asText()).isEqualTo("hi"); + assertThat(n.get(KEY).asText()).isEqualTo("alice"); + } + + @Test + @DisplayName("overwrites an LLM-supplied value of the reserved key (no spoofing)") + void overwritesLlmSuppliedValue() throws Exception { + String out = IdentityForwardingToolCallback.withClaim( + "{\"q\":\"hi\",\"" + KEY + "\":\"attacker\"}", KEY, "alice"); + assertThat(MAPPER.readTree(out).get(KEY).asText()).isEqualTo("alice"); + } + + @Test + @DisplayName("blank/empty input becomes a fresh object carrying the claim") + void emptyInputGetsObject() throws Exception { + for (String in : new String[]{null, "", " "}) { + String out = IdentityForwardingToolCallback.withClaim(in, KEY, "bob"); + assertThat(MAPPER.readTree(out).get(KEY).asText()).isEqualTo("bob"); + } + } + + @Test + @DisplayName("non-object args (array/scalar) are forwarded unchanged, not corrupted") + void nonObjectInputUnchanged() { + assertThat(IdentityForwardingToolCallback.withClaim("[1,2,3]", KEY, "alice")).isEqualTo("[1,2,3]"); + assertThat(IdentityForwardingToolCallback.withClaim("\"plain\"", KEY, "alice")).isEqualTo("\"plain\""); + } + + @Test + @DisplayName("malformed JSON is forwarded unchanged (surfaces downstream, not masked)") + void malformedJsonUnchanged() { + assertThat(IdentityForwardingToolCallback.withClaim("{not json", KEY, "alice")).isEqualTo("{not json"); + } + + @Test + @DisplayName("opt-in matching: by id or name, empty set never forwards") + void optInMatching() { + McpIdentityForwardProperties p = new McpIdentityForwardProperties(); + assertThat(p.forwardsTo(42L, "svc")).isFalse(); // empty set + p.setServers(Set.of("svc")); + assertThat(p.forwardsTo(42L, "svc")).isTrue(); // by name + assertThat(p.forwardsTo(42L, "other")).isFalse(); + p.setServers(Set.of("42")); + assertThat(p.forwardsTo(42L, "other")).isTrue(); // by id + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java index e62a57fd..96c127c2 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java @@ -37,7 +37,8 @@ class McpClientManagerSnapshotTest { @SuppressWarnings("unchecked") void staleListToolsServesSnapshotAndRequestsReconnect() throws Exception { ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); - McpClientManager manager = new McpClientManager(publisher); + McpClientManager manager = new McpClientManager(publisher, + new McpIdentityForwardService(new McpIdentityForwardProperties())); // A client whose connection went stale: every listTools() throws. McpSyncClient deadClient = mock(McpSyncClient.class); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpIdentityForwardServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpIdentityForwardServiceTest.java new file mode 100644 index 00000000..8aea890f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpIdentityForwardServiceTest.java @@ -0,0 +1,308 @@ +package vip.mate.tool.mcp.runtime; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.builtin.ToolExecutionContext; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests {@link McpIdentityForwardService}: identity typing across channels, + * plaintext vs signed-token resolution, and fail-closed behaviour. + * + *

            The core of this suite is the {@code trust}/{@code channel_type} typing — + * making sure an authenticated MateClaw user, a webchat visitor, an IM sender, + * and a cron run are forwarded with distinguishable, non-fabricated identities + * (see RFC: on-behalf-of identity typing, issue #459 review). + */ +class McpIdentityForwardServiceTest { + + @AfterEach + void clear() { + ToolExecutionContext.clear(); + } + + private McpIdentityForwardService svc(McpIdentityForwardProperties p) { + return new McpIdentityForwardService(p); + } + + /** Build a ToolContext carrying the given origin, mirroring ToolExecutionExecutor. */ + private static ToolContext ctx(ChatOrigin origin) { + return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin)); + } + + private static KeyPair rsaKeyPair() { + try { + return KeyPairGenerator.getInstance("RSA").genKeyPair(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** Token-mode config signed with {@code kp}'s private key. */ + private static McpIdentityForwardProperties tokenProps(KeyPair kp) { + var p = new McpIdentityForwardProperties(); + p.getToken().setEnabled(true); + p.getToken().setIssuer("mateclaw"); + p.getToken().setTtlSeconds(60); + p.getToken().setPrivateKeyPem(Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); + return p; + } + + // ==================== Identity typing across channels ==================== + + @Nested + @DisplayName("classify: identity typing per channel") + class Classify { + + @Test + @DisplayName("authenticated web user → sub=userId, trust=authenticated") + void authenticatedWebUser() { + ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L); + McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin)); + assertThat(id.subject()).isEqualTo("42"); + assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_AUTHENTICATED); + assertThat(id.channelType()).isEqualTo("web"); + } + + @Test + @DisplayName("webchat visitor (channelType=api) → trust=anonymous, sub=visitorId") + void webchatVisitor() { + // webchat sets requesterId=visitorId and channelType=api via withSender + ChatOrigin origin = ChatOrigin.web("c1", "visitor-xyz", 1L, null) + .withSender(null, "api", null); + McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin)); + assertThat(id.subject()).isEqualTo("visitor-xyz"); + assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_ANONYMOUS); + assertThat(id.channelType()).isEqualTo("api"); + } + + @Test + @DisplayName("IM sender (channelType=feishu) → trust=external") + void imSender() { + ChatOrigin origin = new ChatOrigin(null, "c1", "im_user_1", 1L, null, + 9L, null, false, "张三", "feishu", "grp1", null, null); + McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin)); + assertThat(id.subject()).isEqualTo("im_user_1"); + assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_EXTERNAL); + assertThat(id.channelType()).isEqualTo("feishu"); + } + + @Test + @DisplayName("cron origin → NONE (never assert identity for a non-user)") + void cronOrigin() { + ChatOrigin origin = ChatOrigin.cron("c1", 1L, null, 9L, null); + assertThat(svc(new McpIdentityForwardProperties()).classify(ctx(origin))) + .isEqualTo(McpIdentityForwardService.ResolvedIdentity.NONE); + } + + @Test + @DisplayName("system requesterId → NONE") + void systemRequester() { + // An origin whose requesterId is "system" but cronOrigin=false (defensive) + ChatOrigin origin = new ChatOrigin(null, "c1", "system", 1L, null, + null, null, false, null, null, null, null, null); + assertThat(svc(new McpIdentityForwardProperties()).classify(ctx(origin))) + .isEqualTo(McpIdentityForwardService.ResolvedIdentity.NONE); + } + + @Test + @DisplayName("web origin without userId falls back to ThreadLocal username (legacy path)") + void webOriginLegacyThreadLocal() { + ToolExecutionContext.set("c1", "alice"); + // 5-arg web(): no requesterUserId → falls back to ThreadLocal + ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null); + McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin)); + assertThat(id.subject()).isEqualTo("alice"); + assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_AUTHENTICATED); + } + + // ---- Fail-closed: an unknown / unattributed channel must NEVER be + // promoted to authenticated, even when a stale ThreadLocal username + // is present. This is the regression for the privilege-escalation + // gap where channel==null used to fall into the web branch and + // stamp an untrusted identifier with authenticated trust. ---- + + @Test + @DisplayName("unknown channelType (null) → NONE, even with a polluted ThreadLocal (no privilege escalation)") + void unknownChannelIsFailClosedEvenWithThreadLocal() { + // A stale username from a prior request reused this thread. + ToolExecutionContext.set("c1", "attacker"); + // System-style origin built with no channelType (e.g. SkillConsolidation + // / SkillReflection internal tasks): requesterId="", channelType=null. + ChatOrigin origin = new ChatOrigin(null, "c1", "", 1L, null, + null, null, false, null, null, null, null, null); + assertThat(svc(new McpIdentityForwardProperties()).classify(ctx(origin))) + .isEqualTo(McpIdentityForwardService.ResolvedIdentity.NONE); + } + + @Test + @DisplayName("blank channelType → NONE (fail-closed, not authenticated)") + void blankChannelIsFailClosed() { + ToolExecutionContext.set("c1", "attacker"); + ChatOrigin origin = new ChatOrigin(null, "c1", "someone", 1L, null, + null, null, false, null, " ", null, null, null); + assertThat(svc(new McpIdentityForwardProperties()).classify(ctx(origin))) + .isEqualTo(McpIdentityForwardService.ResolvedIdentity.NONE); + } + + @Test + @DisplayName("unrecognised non-web channel → downgraded to external (never authenticated)") + void novelChannelIsDowngradedNotAuthenticated() { + // A channel the classifier doesn't recognise is treated as external + // (explicit trust downgrade), never as authenticated. The backend sees + // an untrusted id and decides for itself — this is the key guarantee: + // no unknown channel can ever acquire authenticated trust. + ChatOrigin origin = new ChatOrigin(null, "c1", "u1", 1L, null, + null, null, false, null, "future-net", null, null, null); + McpIdentityForwardService.ResolvedIdentity id = + svc(new McpIdentityForwardProperties()).classify(ctx(origin)); + assertThat(id.subject()).isEqualTo("u1"); + assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_EXTERNAL); + assertThat(id.channelType()).isEqualTo("future-net"); + } + } + + // ==================== Resolution: plaintext & token modes ==================== + + @Test + @DisplayName("plaintext mode: injects ':' under USER_ARG") + void plaintextTyped() { + ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L); + var p = new McpIdentityForwardProperties(); + Optional inj = svc(p).resolve(ctx(origin), "my-api"); + assertThat(inj).isPresent(); + assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.USER_ARG); + assertThat(inj.get().value()).isEqualTo("authenticated:42"); + } + + @Test + @DisplayName("plaintext mode: anonymous visitor carries trust=anonymous prefix") + void plaintextAnonymous() { + ChatOrigin origin = ChatOrigin.web("c1", "visitor-xyz", 1L, null).withSender(null, "api", null); + Optional inj = + svc(new McpIdentityForwardProperties()).resolve(ctx(origin), "my-api"); + assertThat(inj).isPresent(); + assertThat(inj.get().value()).startsWith("anonymous:visitor-xyz"); + } + + @Test + @DisplayName("no usable identity (cron): nothing injected") + void noIdentity() { + ChatOrigin origin = ChatOrigin.cron("c1", 1L, null, 9L, null); + assertThat(svc(new McpIdentityForwardProperties()).resolve(ctx(origin), "my-api")).isEmpty(); + } + + @Test + @DisplayName("token mode but no key: fail-closed (nothing injected)") + void tokenModeNoKey() { + ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L); + var p = new McpIdentityForwardProperties(); + p.getToken().setEnabled(true); // no private-key-pem + assertThat(svc(p).resolve(ctx(origin), "my-api")).isEmpty(); + } + + @Test + @DisplayName("signing key self-heals after a bad PEM is corrected (no restart needed)") + void signingKeySelfHealsAfterConfigFix() { + KeyPair kp = rsaKeyPair(); + String goodPem = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()); + ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L); + + var p = new McpIdentityForwardProperties(); + p.getToken().setEnabled(true); + p.getToken().setIssuer("mateclaw"); + p.getToken().setTtlSeconds(60); + + // 1. Malformed key → fail-closed. + p.getToken().setPrivateKeyPem("not-a-valid-pem"); + McpIdentityForwardService service = svc(p); + assertThat(service.resolve(ctx(origin), "my-api")).isEmpty(); + + // 2. Operator fixes the config (or a reload pushes a good key) → next + // call re-parses and issues a token, without needing an app restart. + p.getToken().setPrivateKeyPem(goodPem); + Optional inj = service.resolve(ctx(origin), "my-api"); + assertThat(inj).isPresent(); + assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.TOKEN_ARG); + + // The minted token verifies against the matching public key. + Claims claims = Jwts.parser() + .verifyWith(kp.getPublic()) + .requireIssuer("mateclaw") + .requireAudience("my-api") + .build() + .parseSignedClaims(inj.get().value()) + .getPayload(); + assertThat(claims.getSubject()).isEqualTo("42"); + } + + @Test + @DisplayName("token mode: mints RS256 JWT that verifies with trust/channel_type claims") + void tokenMintAndVerify() throws Exception { + KeyPair kp = rsaKeyPair(); + var p = tokenProps(kp); + ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L); + + Optional inj = svc(p).resolve(ctx(origin), "my-api"); + assertThat(inj).isPresent(); + assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.TOKEN_ARG); + + // The REST backend verifies with the public key and reads the typed claims. + Claims claims = Jwts.parser() + .verifyWith(kp.getPublic()) + .requireIssuer("mateclaw") + .requireAudience("my-api") + .build() + .parseSignedClaims(inj.get().value()) + .getPayload(); + + assertThat(claims.getSubject()).isEqualTo("42"); + assertThat(claims.get("trust", String.class)).isEqualTo(McpIdentityForwardService.TRUST_AUTHENTICATED); + assertThat(claims.get("channel_type", String.class)).isEqualTo("web"); + assertThat(claims.getExpiration()).isAfter(new Date()); + assertThat(claims.getId()).isNotBlank(); + } + + @Test + @DisplayName("token mode: anonymous visitor token carries trust=anonymous") + void tokenAnonymousVisitor() throws Exception { + KeyPair kp = rsaKeyPair(); + var p = tokenProps(kp); + ChatOrigin origin = ChatOrigin.web("c1", "visitor-xyz", 1L, null).withSender(null, "api", null); + + Optional inj = svc(p).resolve(ctx(origin), "my-api"); + assertThat(inj).isPresent(); + + Claims claims = Jwts.parser() + .verifyWith(kp.getPublic()) + .build() + .parseSignedClaims(inj.get().value()) + .getPayload(); + assertThat(claims.getSubject()).isEqualTo("visitor-xyz"); + assertThat(claims.get("trust", String.class)).isEqualTo(McpIdentityForwardService.TRUST_ANONYMOUS); + assertThat(claims.get("channel_type", String.class)).isEqualTo("api"); + } + + @Test + @DisplayName("audienceFor: explicit mapping wins, else server name") + void audienceResolution() { + var p = new McpIdentityForwardProperties(); + assertThat(p.audienceFor(42L, "svc")).isEqualTo("svc"); // default = name + p.getToken().setAudiences(Map.of("svc", "https://api.internal")); + assertThat(p.audienceFor(42L, "svc")).isEqualTo("https://api.internal"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java b/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java new file mode 100644 index 00000000..ededf670 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/search/SearchProviderRegistryPluginTest.java @@ -0,0 +1,224 @@ +package vip.mate.tool.search; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.system.model.SystemSettingsDTO; + +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Plugin-provider mutability of {@link SearchProviderRegistry}: + * plugin JARs register/unregister providers at runtime; the registry must merge + * them with the Spring-injected built-ins and reject id conflicts. + */ +class SearchProviderRegistryPluginTest { + + /** Minimal stub standing in for both built-in and plugin-bridged providers. */ + private static SearchProvider stub(String id, int order, boolean credentialed, boolean available) { + return new SearchProvider() { + @Override public String id() { return id; } + @Override public String label() { return id; } + @Override public boolean requiresCredential() { return credentialed; } + @Override public int autoDetectOrder() { return order; } + @Override public boolean isAvailable(SystemSettingsDTO config) { return available; } + @Override public List search(String query, SystemSettingsDTO config) { return List.of(); } + }; + } + + private static SearchProviderRegistry registryWithBuiltins() { + // Mirrors the real built-in landscape: one credentialed, one keyless. + return new SearchProviderRegistry(List.of( + stub("serper", 300, true, false), // credentialed but NOT configured + stub("duckduckgo", 100, false, true) // keyless, available + )); + } + + @Test + @DisplayName("registered plugin provider shows up in allSorted, ordered by autoDetectOrder") + void pluginProviderAppearsInMergedSortedView() { + SearchProviderRegistry registry = registryWithBuiltins(); + SearchProvider plugin = stub("my-search", 500, true, true); + + registry.registerPluginProvider(plugin); + + List all = registry.allSorted(); + assertEquals(3, all.size()); + assertEquals("duckduckgo", all.get(0).id()); // order 100 + assertEquals("serper", all.get(1).id()); // order 300 + assertSame(plugin, all.get(2)); // order 500 + } + + @Test + @DisplayName("getById finds plugin providers") + void getByIdFindsPluginProvider() { + SearchProviderRegistry registry = registryWithBuiltins(); + SearchProvider plugin = stub("my-search", 500, true, true); + registry.registerPluginProvider(plugin); + + assertSame(plugin, registry.getById("my-search")); + } + + @Test + @DisplayName("plugin id clashing with a built-in id is rejected") + void builtinIdConflictRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("serper", 500, true, true))); + } + + @Test + @DisplayName("plugin id clashing with an already-registered plugin id is rejected") + void pluginIdConflictRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("my-search", 501, true, true))); + } + + @Test + @DisplayName("blank or null plugin id is rejected") + void blankIdRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub(" ", 500, true, true))); + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub(null, 500, true, true))); + } + + @Test + @DisplayName("id with leading/trailing whitespace is rejected, not trimmed") + void paddedIdRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub(" my-search", 500, true, true))); + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("my-search ", 500, true, true))); + } + + @Test + @DisplayName("case-variant of a built-in id is rejected (no visual spoofing)") + void caseVariantOfBuiltinRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("Serper", 500, true, true))); + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("DUCKDUCKGO", 500, true, true))); + } + + @Test + @DisplayName("case-variant of an already-registered plugin id is rejected") + void caseVariantOfPluginIdRejected() { + SearchProviderRegistry registry = registryWithBuiltins(); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + + assertThrows(IllegalArgumentException.class, + () -> registry.registerPluginProvider(stub("My-Search", 501, true, true))); + } + + @Test + @DisplayName("concurrent registration of case-variants admits exactly one (no TOCTOU bypass)") + void concurrentCaseVariantRegistrationAdmitsExactlyOne() throws Exception { + // Plugins may call registerSearchProvider from arbitrary threads, so the + // case-insensitive conflict check must be atomic with the insert: without + // the registration lock, two threads registering "Foo"/"foo" could both + // pass the pre-check and land in different map keys. + for (int round = 0; round < 20; round++) { + SearchProviderRegistry registry = new SearchProviderRegistry(List.of()); + var barrier = new CyclicBarrier(2); + var successes = new AtomicInteger(); + Runnable register = () -> { + String id = Thread.currentThread().getName().endsWith("-a") ? "Race-Search" : "race-search"; + try { + barrier.await(); + registry.registerPluginProvider(stub(id, 500, true, true)); + successes.incrementAndGet(); + } catch (IllegalArgumentException expected) { + // the loser — expected + } catch (Exception e) { + throw new IllegalStateException(e); + } + }; + Thread t1 = new Thread(register, "race-" + round + "-a"); + Thread t2 = new Thread(register, "race-" + round + "-b"); + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + assertEquals(1, successes.get(), + "exactly one of the case-variant registrations may win (round " + round + ")"); + } + } + + @Test + @DisplayName("isPluginProvider distinguishes built-in ids from plugin-registered ids") + void isPluginProviderDistinguishesSource() { + SearchProviderRegistry registry = registryWithBuiltins(); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + + assertTrue(registry.isPluginProvider("my-search")); + assertFalse(registry.isPluginProvider("serper")); + assertFalse(registry.isPluginProvider("duckduckgo")); + assertFalse(registry.isPluginProvider("does-not-exist")); + } + + @Test + @DisplayName("resolve honours an explicitly configured plugin provider") + void resolvePicksConfiguredPluginProvider() { + SearchProviderRegistry registry = registryWithBuiltins(); + SearchProvider plugin = stub("my-search", 500, true, true); + registry.registerPluginProvider(plugin); + + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSearchProvider("my-search"); + + SearchProviderRegistry.ResolvedProvider resolved = registry.resolve(config); + assertSame(plugin, resolved.provider()); + assertEquals("configured", resolved.source()); + } + + @Test + @DisplayName("resolve auto-detects an available credentialed plugin provider") + void resolveAutoDetectsPluginProvider() { + SearchProviderRegistry registry = registryWithBuiltins(); + SearchProvider plugin = stub("my-search", 500, true, true); + registry.registerPluginProvider(plugin); + + // No explicit provider configured; serper (credentialed) is unavailable, + // so auto-detect must reach the plugin provider before keyless fallback. + SearchProviderRegistry.ResolvedProvider resolved = registry.resolve(new SystemSettingsDTO()); + assertSame(plugin, resolved.provider()); + assertEquals("auto-detect", resolved.source()); + } + + @Test + @DisplayName("after unregister, an explicitly configured plugin id falls back to auto-detect") + void unregisteredConfiguredProviderFallsBackToAutoDetect() { + SearchProviderRegistry registry = registryWithBuiltins(); + registry.registerPluginProvider(stub("my-search", 500, true, true)); + registry.unregisterPluginProvider("my-search"); + + assertNull(registry.getById("my-search")); + + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSearchProvider("my-search"); + SearchProviderRegistry.ResolvedProvider resolved = registry.resolve(config); + // Plugin gone; keyless duckduckgo is the only available provider left. + assertEquals("duckduckgo", resolved.provider().id()); + assertEquals("keyless-fallback", resolved.source()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiEntityControllerIdorTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiEntityControllerIdorTest.java new file mode 100644 index 00000000..91dcece2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiEntityControllerIdorTest.java @@ -0,0 +1,102 @@ +package vip.mate.wiki.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.service.WikiEntityExtractionService; +import vip.mate.wiki.service.WikiEntityGraphService; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the IDOR guard added for ISSUE #438 on + * {@link WikiEntityController}: entity-graph endpoints must reject requests + * whose target KB belongs to a different workspace than the caller's + * {@code X-Workspace-Id} header. + */ +class WikiEntityControllerIdorTest { + + private WikiKnowledgeBaseService kbService; + private WikiEntityController controller; + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + controller = new WikiEntityController( + mock(WikiEntityGraphService.class), + mock(WikiEntityExtractionService.class), + kbService); + } + + @Test + @DisplayName("listEntities on another workspace's KB → 403") + void listEntitiesCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.listEntities(10L, null, 100, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("kbEntityGraph on another workspace's KB → 403") + void kbEntityGraphCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.kbEntityGraph(10L, 150, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("entityGraph (ego) on another workspace's KB → 403") + void entityEgoGraphCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.entityGraph(10L, 77L, 50, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("extract (member-level write) on another workspace's KB → 403") + void extractCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.extract(10L, false, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("unknown kbId → 404") + void unknownKbReturns404() { + when(kbService.getById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.listEntities(999L, null, 100, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("KB in the caller's workspace passes the guard (no MateClawException)") + void sameWorkspaceAllowed() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + + // Guard passed → no exception. (graphService is stubbed to return an + // empty list so the method returns normally.) + assertThatCode(() -> controller.listEntities(10L, null, 100, 1L)) + .doesNotThrowAnyException(); + } + + // ---------------- helpers ---------------- + + private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) { + WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity(); + entity.setId(id); + entity.setWorkspaceId(workspaceId); + return entity; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiRelationControllerIdorTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiRelationControllerIdorTest.java new file mode 100644 index 00000000..c49f478e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiRelationControllerIdorTest.java @@ -0,0 +1,299 @@ +package vip.mate.wiki.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.exception.MateClawException; +import vip.mate.wiki.dto.PageSearchResult; +import vip.mate.wiki.job.WikiProcessingJobService; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiChunkMapper; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.repository.WikiProcessingJobMapper; +import vip.mate.wiki.service.HybridRetriever; +import vip.mate.wiki.service.WikiEmbeddingService; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiPageService; +import vip.mate.wiki.service.WikiRawMaterialService; +import vip.mate.wiki.service.WikiRelationService; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the IDOR guard added for ISSUE #438: {@link WikiRelationController} + * endpoints must reject requests whose target KB belongs to a different + * workspace than the caller's {@code X-Workspace-Id} header. + * + *

            These are unit-level checks of the {@code verifyKBWorkspace} / + * {@code verifyRawWorkspace} / {@code verifyChunkWorkspace} helpers — the + * same cross-check pattern that closed the WebChat approval IDOR (#415). + * The {@code @RequireWorkspaceRole} annotation layer is validated separately + * via the interceptor; here we assert the resource-ownership guard. + */ +class WikiRelationControllerIdorTest { + + private WikiKnowledgeBaseService kbService; + private WikiRawMaterialService rawService; + private WikiChunkMapper chunkMapper; + private WikiProcessingJobMapper jobMapper; + private HybridRetriever hybridRetriever; + private WikiPageService pageService; + private WikiPageCitationMapper citationMapper; + private WikiRelationController controller; + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + rawService = mock(WikiRawMaterialService.class); + chunkMapper = mock(WikiChunkMapper.class); + jobMapper = mock(WikiProcessingJobMapper.class); + hybridRetriever = mock(HybridRetriever.class); + pageService = mock(WikiPageService.class); + citationMapper = mock(WikiPageCitationMapper.class); + when(hybridRetriever.search(anyLong(), anyString(), anyString(), anyInt())) + .thenReturn(List.of()); + controller = new WikiRelationController( + mock(WikiRelationService.class), + mock(WikiProcessingJobService.class), + jobMapper, + pageService, + citationMapper, + hybridRetriever, + mock(ApplicationEventPublisher.class), + new ObjectMapper(), + mock(WikiEmbeddingService.class), + kbService, + rawService, + chunkMapper); + } + + // ---------------- kbId endpoints ---------------- + + @Test + @DisplayName("search-preview in the caller's workspace succeeds") + void searchPreviewSameWorkspaceAllowed() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + + assertThatCode(() -> + controller.searchPreview(10L, Map.of("query", "x"), 1L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("search-preview on another workspace's KB → 403") + void searchPreviewCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB belongs to ws 2 + + assertThatThrownBy(() -> + controller.searchPreview(10L, Map.of("query", "x"), 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("工作区"); + } + + @Test + @DisplayName("stats on another workspace's KB → 403") + void statsCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.kbStats(10L, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("enrich (member-level write) on another workspace's KB → 403") + void enrichCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.enrichPage(10L, "some-slug", 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("repair on another workspace's KB → 403") + void repairCrossWorkspaceRejected() { + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); + + assertThatThrownBy(() -> controller.repairPage(10L, "some-slug", 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("unknown kbId → 404 (does not leak existence via workspace mismatch)") + void unknownKbReturns404() { + when(kbService.getById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.kbStats(999L, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + // ---------------- getJobs rawId cross-KB filter ---------------- + + @Test + @DisplayName("getJobs: rawId belonging to the same KB is returned") + void getJobsRawIdSameKbReturned() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + WikiProcessingJobEntity job = new WikiProcessingJobEntity(); + job.setId(1L); + job.setKbId(10L); // same KB as the path → allowed + when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(job)); + + List result = controller.getJobs(10L, 50L, 1L); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("getJobs: rawId pointing at another KB's job is filtered out") + void getJobsRawIdCrossKbFiltered() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB + WikiProcessingJobEntity foreignJob = new WikiProcessingJobEntity(); + foreignJob.setId(2L); + foreignJob.setKbId(99L); // job belongs to a different KB → dropped + when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(foreignJob)); + + List result = controller.getJobs(10L, 50L, 1L); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("null X-Workspace-Id header falls back to default ws=1") + void missingHeaderFallsBackToDefaultWorkspace() { + // KB in default workspace (id=1), no header → should pass the guard. + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + + assertThatCode(() -> + controller.searchPreview(10L, Map.of("query", "x"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("KB with null workspaceId is not rejected (legacy / shared KBs)") + void nullWorkspaceKbAllowed() { + WikiKnowledgeBaseEntity legacy = new WikiKnowledgeBaseEntity(); + legacy.setId(10L); + legacy.setWorkspaceId(null); // pre-workspace KB + when(kbService.getById(10L)).thenReturn(legacy); + + assertThatCode(() -> + controller.searchPreview(10L, Map.of("query", "x"), 99L)) + .doesNotThrowAnyException(); + } + + // ---------------- pageCitations cross-KB filter (review on #439) ---------------- + + @Test + @DisplayName("pageCitations: pageId belonging to the same KB is returned") + void pageCitationsSameKbReturned() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + WikiPageEntity page = new WikiPageEntity(); + page.setId(77L); + page.setKbId(10L); // same KB as the path → allowed + when(pageService.getById(77L)).thenReturn(page); + when(citationMapper.listWithRawByPageId(77L)).thenReturn(List.of()); + + var result = controller.pageCitations(10L, 77L, 1L); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("pageCitations: pageId pointing at another KB is filtered out") + void pageCitationsCrossKbFiltered() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB + WikiPageEntity foreignPage = new WikiPageEntity(); + foreignPage.setId(88L); + foreignPage.setKbId(99L); // page belongs to a different KB → dropped + when(pageService.getById(88L)).thenReturn(foreignPage); + + var result = controller.pageCitations(10L, 88L, 1L); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("pageCitations: unknown pageId → empty list") + void pageCitationsUnknownPageReturnsEmpty() { + when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); + when(pageService.getById(999L)).thenReturn(null); + + var result = controller.pageCitations(10L, 999L, 1L); + + assertThat(result).isEmpty(); + } + + // ---------------- rawId / chunkId endpoints (resolve owning KB) ---------------- + + @Test + @DisplayName("pagesByRawId resolves KB and rejects cross-workspace") + void pagesByRawIdCrossWorkspaceRejected() { + WikiRawMaterialEntity raw = new WikiRawMaterialEntity(); + raw.setId(50L); + raw.setKbId(10L); + when(rawService.getById(50L)).thenReturn(raw); + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2 + + assertThatThrownBy(() -> controller.pagesByRawId(50L, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("pagesByChunkId resolves KB and rejects cross-workspace") + void pagesByChunkIdCrossWorkspaceRejected() { + WikiChunkEntity chunk = new WikiChunkEntity(); + chunk.setId(60L); + chunk.setKbId(10L); + when(chunkMapper.selectById(60L)).thenReturn(chunk); + when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2 + + assertThatThrownBy(() -> controller.pagesByChunkId(60L, 1L)) + .isInstanceOf(MateClawException.class); + } + + @Test + @DisplayName("unknown rawId → 404") + void unknownRawReturns404() { + when(rawService.getById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.pagesByRawId(999L, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("unknown chunkId → 404") + void unknownChunkReturns404() { + when(chunkMapper.selectById(999L)).thenReturn(null); + + assertThatThrownBy(() -> controller.pagesByChunkId(999L, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + // ---------------- helpers ---------------- + + private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) { + WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity(); + entity.setId(id); + entity.setWorkspaceId(workspaceId); + return entity; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java new file mode 100644 index 00000000..774be46e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java @@ -0,0 +1,77 @@ +package vip.mate.wiki.job.strategy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.system.service.SystemSettingService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WikiLightModelStrategyTest { + + private SystemSettingService settings; + private WikiLightModelStrategy strategy; + + @BeforeEach + void setUp() { + settings = mock(SystemSettingService.class); + strategy = new WikiLightModelStrategy(new ObjectMapper(), settings); + } + + private WikiKnowledgeBaseEntity kb(String configContent) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setConfigContent(configContent); + return kb; + } + + @Test + @DisplayName("supports() only the cheap, high-volume steps") + void supportsOnlyCheapSteps() { + assertTrue(strategy.supports(WikiJobStep.ROUTE)); + assertTrue(strategy.supports(WikiJobStep.ENRICH)); + assertTrue(strategy.supports(WikiJobStep.SUMMARY)); + assertTrue(strategy.supports(WikiJobStep.ENTITY_EXTRACTION)); + assertFalse(strategy.supports(WikiJobStep.CREATE_PAGE)); + assertFalse(strategy.supports(WikiJobStep.MERGE_PAGE)); + } + + @Test + @DisplayName("No light model configured anywhere → null (behavior unchanged)") + void noLightModelConfigured() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn(null); + assertNull(strategy.selectModelId(null, null, WikiJobStep.ROUTE)); + assertNull(strategy.selectModelId(null, kb("{}"), WikiJobStep.SUMMARY)); + } + + @Test + @DisplayName("System light model applies to cheap steps") + void systemLightModelApplies() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("777"); + assertEquals(777L, strategy.selectModelId(null, null, WikiJobStep.ENRICH)); + // Strong steps are never routed here even if asked directly. + assertNull(strategy.selectModelId(null, null, WikiJobStep.CREATE_PAGE)); + } + + @Test + @DisplayName("Per-KB light model overrides the system light model") + void perKbOverridesSystem() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("777"); + Long picked = strategy.selectModelId(null, kb("{\"wikiLightModelId\": 555}"), WikiJobStep.SUMMARY); + assertEquals(555L, picked); + } + + @Test + @DisplayName("Invalid system setting is ignored (null, no crash)") + void invalidSystemSetting() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("not-a-number"); + assertNull(strategy.selectModelId(null, null, WikiJobStep.ROUTE)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeDefStageInstructionsTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeDefStageInstructionsTest.java new file mode 100644 index 00000000..6257ebdf --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeDefStageInstructionsTest.java @@ -0,0 +1,56 @@ +package vip.mate.wiki.profile; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Deserialization tests for {@link WikiPageTypeDef.StageInstructions}: a stage + * field (route / create / merge) may be written either as a plain prompt string + * or as a full object with {@code instructions} + {@code template}. Both forms + * must be equivalent, and existing object configs must keep parsing. + */ +class WikiPageTypeDefStageInstructionsTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void plainStringShorthandMapsToInstructions() throws Exception { + WikiPageTypeDef.StageInstructions si = + mapper.readValue("\"just a prompt\"", WikiPageTypeDef.StageInstructions.class); + assertEquals("just a prompt", si.getInstructions()); + assertNull(si.getTemplate()); + } + + @Test + void fullObjectParsesBothFields() throws Exception { + WikiPageTypeDef.StageInstructions si = mapper.readValue( + "{\"instructions\":\"do X\",\"template\":\"tpl-key\"}", + WikiPageTypeDef.StageInstructions.class); + assertEquals("do X", si.getInstructions()); + assertEquals("tpl-key", si.getTemplate()); + } + + @Test + void unknownFieldInObjectIsSkipped() throws Exception { + WikiPageTypeDef.StageInstructions si = mapper.readValue( + "{\"instructions\":\"keep\",\"extra\":{\"nested\":1}}", + WikiPageTypeDef.StageInstructions.class); + assertEquals("keep", si.getInstructions()); + assertNull(si.getTemplate()); + } + + @Test + void stringAndObjectFormsCoexistInsidePageTypeDef() throws Exception { + // route as the string shorthand, create as the full object — both on one def. + String json = "{\"route\":\"route prompt\"," + + "\"create\":{\"instructions\":\"create prompt\",\"template\":\"t1\"}}"; + WikiPageTypeDef def = mapper.readValue(json, WikiPageTypeDef.class); + assertEquals("route prompt", def.getRoute().getInstructions()); + assertNull(def.getRoute().getTemplate()); + assertEquals("create prompt", def.getCreate().getInstructions()); + assertEquals("t1", def.getCreate().getTemplate()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java new file mode 100644 index 00000000..f9287f8b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java @@ -0,0 +1,95 @@ +package vip.mate.wiki.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import vip.mate.wiki.dto.WikiFailureItem; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates the centralized failure queries against H2: the NEEDS_ATTENTION + * predicate must capture failed / partial / warning rows and exclude clean + * completed and pending ones, and the list must join the KB display name. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +class WikiRawMaterialFailuresMapperE2ETest { + + @Autowired + private WikiRawMaterialMapper rawMapper; + @Autowired + private WikiKnowledgeBaseMapper kbMapper; + + private static final java.util.concurrent.atomic.AtomicLong SEQ = + new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); + + private long newKb(String name) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + long id = SEQ.incrementAndGet(); + kb.setId(id); + kb.setName(name); + kb.setStatus("active"); + kb.setWorkspaceId(1L); + kb.setCreateTime(LocalDateTime.now()); + kb.setUpdateTime(LocalDateTime.now()); + kb.setDeleted(0); + kbMapper.insert(kb); + return id; + } + + private long raw(long kbId, String status, String errorCode, String warningCode) { + WikiRawMaterialEntity r = new WikiRawMaterialEntity(); + long id = SEQ.incrementAndGet(); + r.setId(id); + r.setKbId(kbId); + r.setTitle("raw-" + id); + r.setSourceType("text"); + r.setProcessingStatus(status); + r.setErrorCode(errorCode); + r.setWarningCode(warningCode); + r.setCreateTime(LocalDateTime.now()); + r.setUpdateTime(LocalDateTime.now()); + r.setDeleted(0); + rawMapper.insert(r); + return id; + } + + @Test + void needsAttentionPredicateCapturesTheRightRows() { + long kb = newKb("KB-Failures"); + long failed = raw(kb, "failed", "AUTH_ERROR", null); + long partial = raw(kb, "partial", null, null); + long degraded = raw(kb, "completed", null, "EMBEDDING_FAILED"); + long clean = raw(kb, "completed", null, null); + long pending = raw(kb, "pending", null, null); + + long countBefore = rawMapper.countFailures(); + assertTrue(countBefore >= 3, "count should include the 3 attention-needing rows"); + + List mine = rawMapper.listFailures(500).stream() + .filter(i -> i.kbId().equals(kb)) + .toList(); + + List ids = mine.stream().map(WikiFailureItem::rawId).toList(); + assertTrue(ids.contains(failed), "failed row must surface"); + assertTrue(ids.contains(partial), "partial row must surface"); + assertTrue(ids.contains(degraded), "degraded (warning) row must surface"); + assertFalse(ids.contains(clean), "clean completed row must not surface"); + assertFalse(ids.contains(pending), "pending row must not surface"); + + // Join carries the KB display name through to the projection. + assertEquals("KB-Failures", mine.get(0).kbName()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceBudgetTest.java new file mode 100644 index 00000000..e5047a6e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceBudgetTest.java @@ -0,0 +1,87 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.PageSearchResult; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; + +/** + * Tests for the token-budgeted knowledge-base relevance injection in + * {@link WikiContextService}. + */ +class WikiContextServiceBudgetTest { + + private WikiKnowledgeBaseService kbService; + private HybridRetriever hybridRetriever; + private WikiProperties properties; + private WikiContextService service; + + @BeforeEach + void setUp() { + kbService = Mockito.mock(WikiKnowledgeBaseService.class); + hybridRetriever = Mockito.mock(HybridRetriever.class); + WikiPageService pageService = Mockito.mock(WikiPageService.class); + properties = new WikiProperties(); + service = new WikiContextService(kbService, pageService, hybridRetriever, properties); + + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(7L); + Mockito.when(kbService.resolvePrimaryKb(anyLong())).thenReturn(kb); + } + + private void stubHits(int count, int excerptChars) { + List hits = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + hits.add(PageSearchResult.of("page-" + i, "页面" + i, null, + "摘".repeat(excerptChars), List.of("keyword"), null, 1.0)); + } + Mockito.when(hybridRetriever.search(any(), anyString(), anyString(), anyInt())) + .thenReturn(hits); + } + + @Test + @DisplayName("null budget keeps the chars-only behavior — all hits injected") + void nullBudgetKeepsAllHits() { + stubHits(3, 200); + String result = service.buildRelevantContext(1L, "如何配置数据库连接", null); + assertTrue(result.contains("page-0")); + assertTrue(result.contains("page-2")); + } + + @Test + @DisplayName("token budget cuts the tail hits and appends the search hint") + void budgetCutsTailHits() { + stubHits(3, 400); // each entry ≈ 400+ tokens (CJK) + String result = service.buildRelevantContext(1L, "如何配置数据库连接", 600); + assertTrue(result.contains("page-0")); + assertFalse(result.contains("page-2")); + assertTrue(result.contains("use wiki_search_pages for more")); + } + + @Test + @DisplayName("budget too small for even one entry → injection skipped entirely") + void tinyBudgetSkipsInjection() { + stubHits(3, 400); + String result = service.buildRelevantContext(1L, "如何配置数据库连接", 50); + assertEquals("", result); + } + + @Test + @DisplayName("zero or negative budget short-circuits without retrieval") + void zeroBudgetShortCircuits() { + String result = service.buildRelevantContext(1L, "如何配置数据库连接", 0); + assertEquals("", result); + Mockito.verifyNoInteractions(hybridRetriever); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java new file mode 100644 index 00000000..f11a960d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java @@ -0,0 +1,139 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.sse.WikiProgressBus; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Covers the structured error-code path added so the frontend can localize + * Wiki processing failures instead of echoing raw exception text: + *

              + *
            • {@link WikiProcessingService#classifyErrorCode} maps exceptions to the + * stable vocabulary, and
            • + *
            • a real failure propagates that code into both the persisted row + * (4-arg {@code updateProcessingStatus}) and the {@code RAW_FAILED} + * SSE payload.
            • + *
            + */ +class WikiProcessingServiceErrorCodeTest { + + private WikiKnowledgeBaseService kbService; + private WikiRawMaterialService rawService; + private WikiChunkService chunkService; + private WikiEmbeddingService embeddingService; + private WikiProgressBus progressBus; + private WikiProcessingService service; + + private static final Long KB_ID = 7L; + private static final Long RAW_ID = 99L; + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + rawService = mock(WikiRawMaterialService.class); + chunkService = mock(WikiChunkService.class); + embeddingService = mock(WikiEmbeddingService.class); + progressBus = mock(WikiProgressBus.class); + ObjectMapper om = new ObjectMapper(); + service = new WikiProcessingService( + kbService, rawService, mock(WikiPageService.class), chunkService, + embeddingService, new WikiLinkService(om), + new WikiProperties(), mock(ModelConfigService.class), + mock(AgentGraphBuilder.class), om, progressBus, + mock(WikiCitationService.class), + mock(org.springframework.context.ApplicationEventPublisher.class), + mock(WikiEntityExtractionService.class)); + } + + @Test + @DisplayName("classifyErrorCode maps the stable failure vocabulary") + void classifyErrorCode_mapsVocabulary() { + assertEquals("AUTH_ERROR", service.classifyErrorCode(new RuntimeException("401 Unauthorized"))); + assertEquals("AUTH_ERROR", service.classifyErrorCode(new RuntimeException("invalid api key"))); + assertEquals("BILLING", service.classifyErrorCode(new RuntimeException("insufficient_quota"))); + assertEquals("MODEL_NOT_FOUND", service.classifyErrorCode(new RuntimeException("model not found"))); + assertEquals("RATE_LIMIT", service.classifyErrorCode(new RuntimeException("429 too many requests"))); + assertEquals("TIMEOUT", service.classifyErrorCode(new RuntimeException("Read timed out"))); + assertEquals("SERVER_ERROR", service.classifyErrorCode(new RuntimeException("503 Service Unavailable"))); + assertEquals("CONTENT_FILTER", service.classifyErrorCode(new RuntimeException("data_inspection_failed"))); + assertEquals("UNKNOWN", service.classifyErrorCode(new RuntimeException("something odd"))); + // Unwraps nested causes. + assertEquals("AUTH_ERROR", + service.classifyErrorCode(new RuntimeException("wrap", new IllegalStateException("403 forbidden")))); + } + + @Test + @DisplayName("lazy failure persists the classified code and includes it in RAW_FAILED") + void lazyFailure_propagatesErrorCode() { + WikiRawMaterialEntity raw = new WikiRawMaterialEntity(); + raw.setId(RAW_ID); + raw.setKbId(KB_ID); + raw.setProcessingStatus("pending"); + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB_ID); + kb.setConfigContent("{\"ingestMode\":\"lazy\"}"); + + when(rawService.claimForProcessing(RAW_ID)).thenReturn(true); + when(rawService.getById(RAW_ID)).thenReturn(raw); + when(rawService.getTextContent(raw)).thenReturn("Some real document text. ".repeat(20)); + when(kbService.getById(KB_ID)).thenReturn(kb); + // Chunk persistence blows up with an auth-shaped error. + doThrow(new RuntimeException("401 Unauthorized")) + .when(chunkService).persistChunks(eq(KB_ID), eq(RAW_ID), any(), any()); + + service.processRawMaterial(RAW_ID); + + verify(rawService).updateProcessingStatus(eq(RAW_ID), eq("failed"), eq("AUTH_ERROR"), eq("401 Unauthorized")); + verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_FAILED), + argThat((Map m) -> "AUTH_ERROR".equals(m.get("errorCode")) + && "401 Unauthorized".equals(m.get("error")))); + } + + @Test + @DisplayName("async embedding failure surfaces a non-blocking warning, not a failed status") + void embeddingFailure_surfacesWarning() throws InterruptedException { + WikiRawMaterialEntity raw = new WikiRawMaterialEntity(); + raw.setId(RAW_ID); + raw.setKbId(KB_ID); + raw.setProcessingStatus("pending"); + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB_ID); + kb.setConfigContent("{\"ingestMode\":\"lazy\"}"); + + when(rawService.claimForProcessing(RAW_ID)).thenReturn(true); + when(rawService.getById(RAW_ID)).thenReturn(raw); + when(rawService.getTextContent(raw)).thenReturn("Some real document text. ".repeat(20)); + when(kbService.getById(KB_ID)).thenReturn(kb); + + // The async embedding sweep fails; recordWarning fires from that thread, + // so latch on it to make the assertion deterministic. + CountDownLatch warned = new CountDownLatch(1); + when(embeddingService.embedMissingChunks(KB_ID)).thenThrow(new RuntimeException("embed boom")); + doAnswer(inv -> { warned.countDown(); return null; }) + .when(rawService).recordWarning(eq(RAW_ID), eq("EMBEDDING_FAILED"), any()); + + service.processRawMaterial(RAW_ID); + + assertTrue(warned.await(5, TimeUnit.SECONDS), "recordWarning should have been invoked"); + // Material itself completed — the warning must not have flipped it to failed. + verify(rawService, never()).updateProcessingStatus(eq(RAW_ID), eq("failed"), any(), any()); + verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_WARNING), + argThat((Map m) -> "EMBEDDING_FAILED".equals(m.get("warningCode")))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java index 075407fc..c0d41cf9 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java @@ -195,8 +195,10 @@ class WikiProcessingServiceLazyTest { service.processRawMaterial(RAW_ID); verify(chunkService, never()).persistChunks(anyLong(), anyLong(), anyList(), anyList()); - verify(rawService).updateProcessingStatus(eq(RAW_ID), eq("failed"), eq("No text content available")); - verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_FAILED), any()); + verify(rawService).updateProcessingStatus(eq(RAW_ID), eq("failed"), eq("NO_CONTENT"), eq("No text content available")); + // RAW_FAILED now carries the structured errorCode alongside the message. + verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_FAILED), + argThat((Map m) -> "NO_CONTENT".equals(m.get("errorCode")))); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java new file mode 100644 index 00000000..1249f37c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java @@ -0,0 +1,100 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.system.featureflag.FeatureFlagService; +import vip.mate.tool.builtin.DocumentExtractTool; +import vip.mate.tool.image.vision.ImageVisionService; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiRawMaterialMapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Covers the structured error-code + non-blocking warning state on + * {@link WikiRawMaterialService}: the 4-arg status update persists the code, + * {@code recordWarning} flags a degraded-but-completed row without changing its + * status, and {@code claimForProcessing} wipes any stale failure/warning so a + * re-run starts clean (required because these columns are FieldStrategy.ALWAYS). + */ +class WikiRawMaterialFailureStateTest { + + private WikiRawMaterialMapper rawMapper; + private WikiRawMaterialService service; + + private static final Long ID = 99L; + + @BeforeEach + void setUp() { + rawMapper = mock(WikiRawMaterialMapper.class); + WikiProperties props = new WikiProperties(); + props.setAutoProcessOnUpload(false); + service = new WikiRawMaterialService(rawMapper, mock(WikiKnowledgeBaseService.class), props, + mock(ApplicationEventPublisher.class), mock(DocumentExtractTool.class), + mock(WikiChunkService.class), mock(ImageVisionService.class), + mock(PdfImageExtractor.class), mock(FeatureFlagService.class)); + } + + private WikiRawMaterialEntity row(String status) { + WikiRawMaterialEntity e = new WikiRawMaterialEntity(); + e.setId(ID); + e.setProcessingStatus(status); + e.setCancelRequested(Boolean.FALSE); + return e; + } + + @Test + @DisplayName("updateProcessingStatus(4-arg) persists the structured error code") + void updateStatus_persistsErrorCode() { + when(rawMapper.selectById(ID)).thenReturn(row("processing")); + + service.updateProcessingStatus(ID, "failed", "AUTH_ERROR", "401 Unauthorized"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiRawMaterialEntity.class); + verify(rawMapper).updateById(captor.capture()); + assertEquals("AUTH_ERROR", captor.getValue().getErrorCode()); + assertEquals("401 Unauthorized", captor.getValue().getErrorMessage()); + assertEquals("failed", captor.getValue().getProcessingStatus()); + } + + @Test + @DisplayName("recordWarning flags a degraded row without touching its status") + void recordWarning_persistsWithoutStatusChange() { + when(rawMapper.selectById(ID)).thenReturn(row("completed")); + + service.recordWarning(ID, "EMBEDDING_FAILED", "circuit breaker open"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiRawMaterialEntity.class); + verify(rawMapper).updateById(captor.capture()); + assertEquals("EMBEDDING_FAILED", captor.getValue().getWarningCode()); + assertEquals("circuit breaker open", captor.getValue().getWarningMessage()); + assertEquals("completed", captor.getValue().getProcessingStatus()); + } + + @Test + @DisplayName("claimForProcessing wipes stale error + warning state for a clean re-run") + void claim_clearsFailureState() { + WikiRawMaterialEntity stale = row("pending"); + stale.setErrorCode("AUTH_ERROR"); + stale.setErrorMessage("old error"); + stale.setWarningCode("EMBEDDING_FAILED"); + stale.setWarningMessage("old warning"); + when(rawMapper.selectById(ID)).thenReturn(stale); + + assertTrue(service.claimForProcessing(ID)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiRawMaterialEntity.class); + verify(rawMapper).updateById(captor.capture()); + WikiRawMaterialEntity persisted = captor.getValue(); + assertNull(persisted.getErrorCode()); + assertNull(persisted.getErrorMessage()); + assertNull(persisted.getWarningCode()); + assertNull(persisted.getWarningMessage()); + assertEquals("processing", persisted.getProcessingStatus()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiTransformationStarterPackGlobalE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiTransformationStarterPackGlobalE2ETest.java new file mode 100644 index 00000000..8facec06 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiTransformationStarterPackGlobalE2ETest.java @@ -0,0 +1,131 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiTransformationEntity; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; +import vip.mate.wiki.repository.WikiTransformationMapper; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression for the starter-pack visibility bug: the 7 built-in transformation + * templates were seeded with a hardcoded {@code workspace_id = 1}, so any other + * workspace saw an empty Transformations list. V165 clears their workspace_id + * (NULL = global) and the queries treat NULL as visible everywhere — this test + * boots H2 with the real Flyway migrations (V108 seed + V165 fix) and asserts + * the templates show up regardless of workspace, while workspace-scoped + * templates stay isolated. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +class WikiTransformationStarterPackGlobalE2ETest { + + private static final Set STARTER_PACK = Set.of( + "contract-risk-extract", "meeting-action-items", "customer-profile", + "competitor-update", "resume-structured-extract", "incident-postmortem", "paper-imrad"); + + private static final AtomicLong SEQ = new AtomicLong(System.nanoTime()); + + @Autowired + private WikiTransformationService service; + @Autowired + private WikiKnowledgeBaseMapper kbMapper; + @Autowired + private WikiTransformationMapper transformationMapper; + + private long newKb(long workspaceId) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + long id = SEQ.incrementAndGet(); + kb.setId(id); + kb.setName("kb-" + id); + kb.setStatus("active"); + kb.setWorkspaceId(workspaceId); + kb.setCreateTime(LocalDateTime.now()); + kb.setUpdateTime(LocalDateTime.now()); + kb.setDeleted(0); + kbMapper.insert(kb); + return id; + } + + private void insertWorkspaceTemplate(long workspaceId, String name) { + WikiTransformationEntity t = new WikiTransformationEntity(); + t.setId(SEQ.incrementAndGet()); + t.setKbId(null); // workspace-wide + t.setWorkspaceId(workspaceId); // but scoped to one workspace + t.setName(name); + t.setTitle(name); + t.setPromptTemplate("do something"); + t.setEnabled(true); + t.setCreateTime(LocalDateTime.now()); + t.setUpdateTime(LocalDateTime.now()); + t.setDeleted(0); + transformationMapper.insert(t); + } + + private Set visibleNames(long kbId, long workspaceId) { + return service.listForKb(kbId, workspaceId).stream() + .map(WikiTransformationEntity::getName) + .collect(Collectors.toSet()); + } + + @Test + @DisplayName("Starter pack is visible from a non-default workspace (the bug)") + void starterPackVisibleFromOtherWorkspace() { + long kb = newKb(999L); + Set names = visibleNames(kb, 999L); + for (String expected : STARTER_PACK) { + assertTrue(names.contains(expected), + "workspace 999 should see starter-pack template '" + expected + "', got: " + names); + } + } + + @Test + @DisplayName("Starter pack still visible from workspace 1 (no regression)") + void starterPackStillVisibleFromWorkspaceOne() { + long kb = newKb(1L); + assertTrue(visibleNames(kb, 1L).containsAll(STARTER_PACK)); + } + + @Test + @DisplayName("A workspace-scoped template stays isolated; globals are seen by both") + void workspaceScopedTemplateStaysIsolated() { + String unique = "ws-only-" + SEQ.incrementAndGet(); + insertWorkspaceTemplate(777L, unique); + + long kb777 = newKb(777L); + long kb888 = newKb(888L); + + assertTrue(visibleNames(kb777, 777L).contains(unique), "owner workspace should see its template"); + assertFalse(visibleNames(kb888, 888L).contains(unique), "other workspace must NOT see it"); + + // Global starter pack reaches both workspaces. + assertTrue(visibleNames(kb777, 777L).containsAll(STARTER_PACK)); + assertTrue(visibleNames(kb888, 888L).containsAll(STARTER_PACK)); + } + + @Test + @DisplayName("listByWorkspace surfaces globals alongside the workspace's own") + void listByWorkspaceIncludesGlobals() { + List rows = service.listByWorkspace(424242L); + Set names = rows.stream().map(WikiTransformationEntity::getName).collect(Collectors.toSet()); + assertTrue(names.containsAll(STARTER_PACK), + "listByWorkspace for an arbitrary workspace should still include the global starter pack"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workflow/runtime/AwaitApprovalNotifyTest.java b/mateclaw-server/src/test/java/vip/mate/workflow/runtime/AwaitApprovalNotifyTest.java new file mode 100644 index 00000000..e4c2f5fb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workflow/runtime/AwaitApprovalNotifyTest.java @@ -0,0 +1,157 @@ +package vip.mate.workflow.runtime; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.workflow.compiler.WorkflowParser; +import vip.mate.workflow.compiler.ir.WorkflowGraph; +import vip.mate.workflow.model.WorkflowRunPauseEntity; +import vip.mate.workflow.repository.WorkflowRunPauseMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies ISSUE #413 P0-B1: an {@code await_approval} step pushes a notice to + * every channel listed in {@code approverChannels} that carries a target. Before + * the fix, {@code approverChannels} was write-only metadata — a workflow that + * declared {@code ["feishu:oc_xxx"]} silently dropped the notice. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:workflow_notify_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.workflow.trigger.async-dispatch=false" +}) +@Import({StubAgentInvokerConfig.class, + AwaitApprovalNotifyTest.StubChannelDispatcherConfig.class}) +class AwaitApprovalNotifyTest { + + @Autowired private WorkflowRunner runner; + @Autowired private WorkflowParser parser; + @Autowired private WorkflowRunPauseMapper pauseMapper; + @Autowired private StubChannelDispatcher stubDispatcher; + + @Test + @DisplayName("approverChannels with target dispatches a notice; bare 'web' does not.") + void dispatchesNoticeToTargetedChannels() { + stubDispatcher.reset(); + + WorkflowGraph graph = parser.parse(""" + { + "steps": [ + {"name":"approve", + "mode":{"type":"await_approval","approvalKind":"manager", + "approverChannels":["feishu:oc_manager_group","web","email:ops@acme.com"], + "approvalMessage":"请经理审批新客户入驻"}} + ] + } + """); + + WorkflowRunResult result = runner.run(graph, + new WorkflowRunRequest(70L, 1L, 99L, "manual", Map.of())); + assertEquals("paused", result.state()); + + // feishu + email both carry a target → two dispatches; "web" has no + // target → skipped (operator uses the admin console). + List sent = stubDispatcher.sentList(); + assertEquals(2, sent.size(), "only channels with an explicit target should be notified"); + assertTrue(sent.stream().anyMatch(s -> "feishu".equals(s.channel()) + && "oc_manager_group".equals(s.target()))); + assertTrue(sent.stream().anyMatch(s -> "email".equals(s.channel()) + && "ops@acme.com".equals(s.target()))); + + // The notice body carries the approval message + runId so the approver + // can correlate it with the inbox entry. + assertTrue(sent.get(0).content().contains("请经理审批新客户入驻")); + assertTrue(sent.get(0).content().contains("runId"), + "notice should mention runId: " + sent.get(0).content()); + + // The pause row still exists — the notice is non-fatal best-effort. + WorkflowRunPauseEntity pause = pauseMapper.selectOne( + new LambdaQueryWrapper() + .eq(WorkflowRunPauseEntity::getRunId, result.runId())); + assertNotNull(pause); + } + + @Test + @DisplayName("A channel dispatch failure does not fail the await_approval step.") + void channelFailureIsNonFatal() { + stubDispatcher.reset(); + stubDispatcher.makeFail("feishu", "rate limited"); + + WorkflowGraph graph = parser.parse(""" + { + "steps": [ + {"name":"approve", + "mode":{"type":"await_approval","approvalKind":"k", + "approverChannels":["feishu:oc_group"]}} + ] + } + """); + + WorkflowRunResult result = runner.run(graph, + new WorkflowRunRequest(71L, 1L, 99L, "manual", Map.of())); + // The step still pauses successfully — a delivery hiccup must not abort + // the run (pause row + REST resume are the canonical recovery path). + assertEquals("paused", result.state()); + } + + @TestConfiguration + static class StubChannelDispatcherConfig { + @Bean + @Primary + StubChannelDispatcher stubChannelDispatcher() { + return new StubChannelDispatcher(); + } + } + + static class StubChannelDispatcher implements ChannelDispatcher { + record Sent(String channel, String target, String content) {} + + private final List sent = new ArrayList<>(); + private final Map failures = new ConcurrentHashMap<>(); + + synchronized void reset() { + sent.clear(); + failures.clear(); + } + + synchronized List sentList() { + return List.copyOf(sent); + } + + void makeFail(String channelType, String message) { + failures.put(channelType, message); + } + + @Override + public synchronized DispatchResult dispatch(long workspaceId, String channelType, + String targetId, String content) { + String forced = failures.get(channelType); + if (forced != null) { + return DispatchResult.fail(forced); + } + sent.add(new Sent(channelType, targetId, content)); + return DispatchResult.ok(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java index 36f2251e..992a115f 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceCleanAttachmentFilesTest.java @@ -2,6 +2,7 @@ package vip.mate.workspace.conversation; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,6 +13,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import vip.mate.agent.repository.AgentMapper; import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.io.IOException; import java.nio.file.Files; @@ -19,12 +21,15 @@ import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; +import java.util.List; import java.util.UUID; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; /** * Regression coverage for issue #36: deleting a CRON-task conversation throws @@ -47,11 +52,21 @@ class ConversationServiceCleanAttachmentFilesTest { @Mock private MessageMapper messageMapper; @Mock private AgentMapper agentMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @Mock private ChatUploadLocationResolver chatUploadLocationResolver; @InjectMocks private ConversationService service; private Path createdDir; + @BeforeEach + void stubResolver() { + // cleanAttachmentFiles now resolves the upload root via the resolver. + // Point its candidate roots at the legacy default dir so both the + // happy-path and unrepresentable-id cases exercise the real filesystem. + when(chatUploadLocationResolver.resolveCandidateUploadRoots(any())) + .thenReturn(List.of(Paths.get("data", "chat-uploads"))); + } + @AfterEach void cleanup() throws IOException { if (createdDir != null && Files.exists(createdDir)) { diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java new file mode 100644 index 00000000..900f7078 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java @@ -0,0 +1,203 @@ +package vip.mate.workspace.core.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.config.ChatUploadProperties; +import vip.mate.workspace.core.model.WorkspaceEntity; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link ChatUploadLocationResolver}'s resolution precedence + * (agent override → workspace basePath → configurable default) and its + * dual-lookup candidate ordering (workspace-scoped root first, then the + * default fallback root). + */ +class ChatUploadLocationResolverTest { + + @TempDir + Path tempDir; + + private ConversationMapper conversationMapper = mock(ConversationMapper.class); + private WorkspaceService workspaceService = mock(WorkspaceService.class); + private AgentService agentService = mock(AgentService.class); + + private ChatUploadLocationResolver resolver(Path defaultDir) { + ChatUploadProperties props = new ChatUploadProperties(); + props.setBaseDir(defaultDir.toAbsolutePath().toString()); + return new ChatUploadLocationResolver(conversationMapper, workspaceService, props, agentService); + } + + private void stubConversation(String convId, Long workspaceId, Long agentId) { + ConversationEntity conv = new ConversationEntity(); + conv.setConversationId(convId); + conv.setWorkspaceId(workspaceId); + conv.setAgentId(agentId); + conv.setDeleted(0); + when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(conv); + } + + private WorkspaceEntity workspace(Long id, String basePath) { + WorkspaceEntity ws = new WorkspaceEntity(); + ws.setId(id); + ws.setBasePath(basePath); + return ws; + } + + private AgentEntity agent(Long id, String workspaceBasePath, Long workspaceId) { + AgentEntity a = new AgentEntity(); + a.setId(id); + a.setWorkspaceBasePath(workspaceBasePath); + a.setWorkspaceId(workspaceId); + return a; + } + + @Test + @DisplayName("no agent and no workspace basePath → configurable default root") + void resolvesToDefaultWhenNothingConfigured() { + stubConversation("c1", 7L, null); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, null)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c1"); + + // The default root IS the chat-uploads dir (no extra subdir appended), + // so conversation dirs land directly under it: {defaultDir}/{convId}/. + assertThat(root).isEqualTo(tempDir.toAbsolutePath().normalize()); + } + + @Test + @DisplayName("workspace basePath set, no agent override → {basePath}/chat-uploads") + void resolvesToWorkspaceBasePath() { + stubConversation("c2", 7L, null); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c2"); + + assertThat(root).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("agent override wins over workspace basePath") + void agentOverrideWinsOverWorkspace() { + stubConversation("c3", 7L, 99L); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + // Absolute override that sits inside the workspace root — allowed, and wins. + Path agentOverride = wsBase.resolve("agent-override"); + when(agentService.getAgent(99L)).thenReturn(agent(99L, agentOverride.toString(), 7L)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c3"); + + assertThat(root).isEqualTo(agentOverride.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("agent override that escapes the workspace root falls back to workspace basePath") + void agentOverrideEscapingWorkspaceFallsBackToWorkspace() { + stubConversation("c4", 7L, 99L); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + // Override points outside the workspace root — resolveAgentBasePath rejects it; + // the resolver falls back to the workspace basePath. + when(agentService.getAgent(99L)).thenReturn(agent(99L, "/etc", 7L)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c4"); + + assertThat(root).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("relative agent override is resolved under the workspace basePath") + void relativeAgentOverrideResolvedUnderWorkspace() { + stubConversation("c5", 7L, 99L); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + when(agentService.getAgent(99L)).thenReturn(agent(99L, "subdir", 7L)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c5"); + + assertThat(root).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve("subdir") + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("relative agent override that escapes the workspace root via ../ falls back to workspace basePath") + void relativeAgentOverrideEscapingWorkspaceFallsBackToWorkspace() { + stubConversation("c5b", 7L, 99L); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + // Relative override climbs out of the workspace root — resolveAgentBasePath + // rejects it; the resolver falls back to the workspace basePath. + when(agentService.getAgent(99L)).thenReturn(agent(99L, "../../escape", 7L)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("c5b"); + + assertThat(root).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + } + + @Test + @DisplayName("candidate roots: workspace-scoped first, then default (dual-lookup order)") + void candidateRootsOrderedScopedThenDefault() { + stubConversation("c6", 7L, null); + Path wsBase = tempDir.resolve("ws-root"); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, wsBase.toString())); + + ChatUploadLocationResolver r = resolver(tempDir); + List candidates = r.resolveCandidateUploadRoots("c6"); + + assertThat(candidates).hasSize(2); + assertThat(candidates.get(0)).isEqualTo(wsBase.toAbsolutePath().normalize() + .resolve(ChatUploadLocationResolver.UPLOAD_SUBDIR)); + assertThat(candidates.get(1)).isEqualTo(tempDir.toAbsolutePath().normalize()); + } + + @Test + @DisplayName("candidate roots: only the default when nothing configured (no duplicate)") + void candidateRootsOnlyDefaultWhenUnconfigured() { + stubConversation("c7", 7L, null); + when(workspaceService.getById(7L)).thenReturn(workspace(7L, null)); + + ChatUploadLocationResolver r = resolver(tempDir); + List candidates = r.resolveCandidateUploadRoots("c7"); + + assertThat(candidates).hasSize(1); + assertThat(candidates.get(0)).isEqualTo(tempDir.toAbsolutePath().normalize()); + } + + @Test + @DisplayName("unknown conversation → falls back to default root (no NPE)") + void unknownConversationFallsBackToDefault() { + when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null)); + + ChatUploadLocationResolver r = resolver(tempDir); + Path root = r.resolveUploadRoot("nonexistent"); + + assertThat(root).isEqualTo(tempDir.toAbsolutePath().normalize()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTestSupport.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTestSupport.java new file mode 100644 index 00000000..5ae027b5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTestSupport.java @@ -0,0 +1,47 @@ +package vip.mate.workspace.core.service; + +import vip.mate.agent.AgentService; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.config.ChatUploadProperties; + +import java.nio.file.Path; + +import static org.mockito.Mockito.mock; + +/** + * Test helper that builds a {@link ChatUploadLocationResolver} whose default + * upload root points at a caller-chosen directory. Dependencies are Mockito + * mocks — when neither a workspace nor an agent base path is configured (the + * common unit-test case), the resolver never consults them and just returns + * {@link ChatUploadLocationResolver#defaultRoot()}. + * + *

            Production code paths that DO resolve against a workspace/agent base path + * should configure the mocks via the accessors below. + */ +public final class ChatUploadLocationResolverTestSupport { + + private ChatUploadLocationResolverTestSupport() {} + + /** + * Build a resolver whose {@link ChatUploadLocationResolver#defaultRoot()} + * is {@code defaultDir}, with mocked {@link ConversationMapper} / + * {@link WorkspaceService} / {@link AgentService}. + */ + public static ChatUploadLocationResolver withDefaultRoot(Path defaultDir) { + ChatUploadProperties props = new ChatUploadProperties(); + props.setBaseDir(defaultDir.toAbsolutePath().normalize().toString()); + return new ChatUploadLocationResolver( + mock(ConversationMapper.class), + mock(WorkspaceService.class), + props, + mock(AgentService.class)); + } + + /** + * Build a resolver whose default root is the legacy {@code data/chat-uploads} + * (matching out-of-the-box behaviour), with mocked dependencies. + */ + public static ChatUploadLocationResolver legacyDefault() { + return withDefaultRoot(Path.of("data", "chat-uploads")); + } +} diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 14ada119..939af2a1 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -1,6 +1,6 @@ { "name": "mateclaw-ui", - "version": "1.6.0", + "version": "1.7.0", "private": true, "type": "module", "description": "MateClaw - Personal AI Assistant Web Console", diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 59348033..5321165d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -110,6 +110,20 @@ export const authApi = { http.put(`/auth/users/${id}/password`, null, { params: { oldPassword, newPassword } }), } +// ==================== SSO ==================== +export const ssoApi = { + /** List enabled SSO providers (for rendering login buttons) */ + providers: () => http.get('/auth/sso/providers'), + /** Get the authorize URL + state for a provider */ + authorize: (provider: string) => http.get(`/auth/sso/${provider}/authorize`), + /** Exchange OAuth2 code for JWT */ + callback: (provider: string, code: string, state: string) => + http.post(`/auth/sso/${provider}/callback`, { code, state }), + /** Bind an SSO identity to an existing account (link-only mode) */ + bind: (bindToken: string, username: string, password: string) => + http.post('/auth/sso/bind', { bindToken, username, password }), +} + // ==================== Agent ==================== export const agentApi = { /** @@ -280,6 +294,9 @@ export const skillApi = { http.post('/skills/curator/activate', null, { params: { activate } }), curatorPause: () => http.post('/skills/curator/pause'), curatorResume: () => http.post('/skills/curator/resume'), + /** Enable or disable the consolidation (merge near-duplicate skills) pass. */ + curatorConsolidate: (enabled: boolean) => + http.post('/skills/curator/consolidate', null, { params: { enabled } }), /** List recent curator run report ids. */ curatorReports: () => http.get('/skills/curator/reports'), /** Read one curator run report (parsed run.json). */ @@ -373,6 +390,7 @@ export const liveApi = { export interface NotificationSummary { pendingApprovals: number stuckAgents: number + failedWikiJobs: number failedCrons: number downChannels: number downMcps: number @@ -657,6 +675,7 @@ export const settingsApi = { // sidestep JS Number precision loss on 19-digit Snowflake IDs. updateSidecar: (data: { defaultVisionModelId: number | string | null; defaultVideoModelId: number | string | null }) => http.put('/settings/sidecar', data), + getSearchProviders: () => http.get('/settings/search-providers'), } // ==================== Global outbound proxy ==================== @@ -765,6 +784,22 @@ export const cronJobApi = { } // ==================== Wiki Knowledge Base ==================== +// One row in the cross-KB failure center. ids are strings (global Long→String +// Jackson config) to avoid Snowflake precision loss. +export interface WikiFailureItem { + rawId: string + kbId: string + kbName: string + workspaceId: string | null + title: string + processingStatus: string + errorCode: string | null + errorMessage: string | null + warningCode: string | null + warningMessage: string | null + updateTime: string | null +} + export const wikiApi = { // Knowledge Base listKBs: () => http.get('/wiki/knowledge-bases'), @@ -785,6 +820,9 @@ export const wikiApi = { http.put(`/wiki/knowledge-bases/${id}/source-directory`, { path }), scanDirectory: (id: number) => http.post(`/wiki/knowledge-bases/${id}/scan`), + // Centralized cross-KB failure center (admin only) + listFailures: (limit = 100) => http.get<{ data: WikiFailureItem[] }>(`/wiki/admin/failures?limit=${limit}`), + // Raw Materials listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`), addRawText: (kbId: number, data: { title: string; content: string }) => @@ -1014,11 +1052,16 @@ export const agentBindingApi = { unbindSkill: (agentId: string | number, skillId: number) => http.delete(`/agents/${agentId}/skills/${skillId}`), listTools: (agentId: string | number) => http.get(`/agents/${agentId}/tools`), setTools: (agentId: string | number, toolNames: string[]) => http.put(`/agents/${agentId}/tools`, toolNames), - // RFC-009 PR-3: per-agent provider preference order. Empty list = use global chain order. + // Per-agent preferred-model chain (provider + model). Empty list = use the + // global chain order. modelId null = the provider's default model; the same + // provider may appear multiple times with different models. modelId is a + // string to preserve Snowflake precision. listProviderPreferences: (agentId: string | number) => http.get(`/agents/${agentId}/provider-preferences`), - setProviderPreferences: (agentId: string | number, providerIds: string[]) => - http.put(`/agents/${agentId}/provider-preferences`, providerIds), + setProviderPreferences: ( + agentId: string | number, + preferences: Array<{ providerId: string; modelId: string | null }>, + ) => http.put(`/agents/${agentId}/provider-preferences`, preferences), // Per-agent knowledge base access scope. Empty array = unrestricted // (agent can reach every KB in its workspace). IDs are kept as strings // for the Snowflake-precision contract. @@ -1036,6 +1079,28 @@ export const dashboardApi = { recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }), } +// ==================== Operational Data Export ==================== +export const operationalApi = { + generate: (startDate: string, endDate: string) => + http.post('/operational-data/generate', null, { params: { startDate, endDate } }), + progress: (taskId: string) => + http.get('/operational-data/progress', { params: { taskId } }), + /** Download file — uses native fetch to avoid axios R interceptor */ + download: async (taskId: string, token: string): Promise => { + const jwt = localStorage.getItem('token') + const resp = await fetch(`/api/v1/operational-data/download?taskId=${taskId}&token=${token}`, { + headers: { Authorization: jwt ? `Bearer ${jwt}` : '' }, + }) + if (!resp.ok) throw new Error(`Download failed: ${resp.status}`) + const blob = await resp.blob() + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `ops_data.zip` + a.click() + URL.revokeObjectURL(a.href) + }, +} + // ==================== Plugins ==================== export const pluginApi = { list: () => http.get('/plugins'), @@ -1457,6 +1522,8 @@ export const approvalApi = { export interface DocMeta { slug: string title: string + /** Group label (e.g. 开始 / 使用 / 扩展), mirroring the docs site sidebar sections. */ + group: string } export interface DocContent { diff --git a/mateclaw-ui/src/components/agents/PlanBoard.vue b/mateclaw-ui/src/components/agents/PlanBoard.vue index 515883dd..f14be5ba 100644 --- a/mateclaw-ui/src/components/agents/PlanBoard.vue +++ b/mateclaw-ui/src/components/agents/PlanBoard.vue @@ -73,28 +73,29 @@ v-for="group in visibleGroups(lane, col.key)" :key="group.key" class="pb-card" + :class="{ 'is-spill': isSpill(group.latest, col.key) }" + :title="isSpill(group.latest, col.key) ? t('plans.queuedSpillHint') : undefined" @click="openDetail(group.latest)" >

            {{ group.goal }}
            -
            +
            {{ group.latest.completedSteps }}/{{ group.latest.totalSteps }} ×{{ group.plans.length }}
            - -
            - - {{ stepDist(group.latest).running }} {{ t('plans.col.running') }} - - - {{ stepDist(group.latest).pending }} {{ t('plans.col.pending') }} - - - {{ stepDist(group.latest).completed }} {{ t('plans.col.completed') }} - + +
            + {{ chip.n }} {{ chip.label }}
            @@ -219,12 +220,23 @@ function planTs(p: Plan): number { return p.createTime ? new Date(p.createTime).getTime() : 0 } -// Drop the appended "[Follow-up guidance] ..." block so re-runs of one objective -// share a title — and therefore a group. +// Recover the user's actual request for display/grouping. Plans created before +// the server-side scrub (and any not yet migrated) persisted the fully-assembled +// prompt: a recall block, a scheduled-run wrapper whose payload +// follows [任务指令], and a trailing [Follow-up guidance] block. Strip all three +// so the card shows the task and re-runs of one objective share a group. Mirrors +// the backend PlanGenerationNode.displayGoal scrubber; a no-op on clean goals. function cleanGoal(goal?: string): string { if (!goal) return '' - const i = goal.indexOf('[Follow-up guidance]') - return (i >= 0 ? goal.slice(0, i) : goal).trim() + let s = goal + .replace(/<\s*memory-context\s*>[\s\S]*?<\s*\/\s*memory-context\s*>/gi, '') + .replace(/<\/?\s*memory-context\s*>/gi, '') + const task = s.lastIndexOf('[任务指令]') + if (task >= 0) s = s.slice(task + '[任务指令]'.length) + const followup = s.indexOf('[Follow-up guidance]') + if (followup >= 0) s = s.slice(0, followup) + s = s.trim() + return s || goal.trim() } // Group a column's plans by cleaned goal, newest run first; groups ordered by @@ -232,7 +244,7 @@ function cleanGoal(goal?: string): string { function groupsIn(lane: Lane, status: PlanStatus): PlanGroup[] { const map = new Map() for (const p of lane.plans) { - if (p.status !== status) continue + if (!belongsToColumn(p, status)) continue const key = cleanGoal(p.goal) || String(p.id) const arr = map.get(key) if (arr) arr.push(p) @@ -283,10 +295,44 @@ function stepDist(plan: Plan): { pending: number; running: number; completed: nu return { pending, running, completed } } -// Only worth showing for multi-step plans that are still active; a finished or -// single-step plan is already fully described by the progress bar. -function showDist(plan: Plan): boolean { - return (plan.totalSteps ?? 0) > 1 && (plan.status === 'running' || plan.status === 'pending') +// A multi-step plan spans columns: its one running step belongs in 执行中 while +// its queued steps belong in 待执行. So a running plan that still has queued +// steps also surfaces in the pending column — otherwise that column sits empty +// next to a card whose badge claims "N pending", which reads as a bug. +function belongsToColumn(plan: Plan, status: PlanStatus): boolean { + if (status === 'pending') { + return plan.status === 'pending' || (plan.status === 'running' && stepDist(plan).pending > 0) + } + return plan.status === status +} + +// True when this card is the queued-steps mirror of an in-progress plan shown in +// the pending column (vs. the plan's primary card in 执行中). +function isSpill(plan: Plan, colKey: PlanStatus): boolean { + return colKey === 'pending' && plan.status === 'running' +} + +// The progress bar follows the plan's real status, except the queued mirror in +// the pending column uses the muted pending tone so it doesn't look "running" +// while sitting in 待执行. +function barStatus(plan: Plan, colKey: PlanStatus): PlanStatus { + return isSpill(plan, colKey) ? 'pending' : (plan.status as PlanStatus) +} + +// Distribution chips scoped to the column the card is rendered in: the pending +// column shows only the queued count, the running column shows the active step +// (and any completed steps). Only multi-step, still-active plans get chips. +function columnChips(plan: Plan, colKey: PlanStatus): { cls: string; n: number; label: string }[] { + if ((plan.totalSteps ?? 0) <= 1) return [] + if (plan.status !== 'running' && plan.status !== 'pending') return [] + const d = stepDist(plan) + if (colKey === 'pending') { + return d.pending ? [{ cls: 'is-pending', n: d.pending, label: t('plans.col.pending') }] : [] + } + const chips: { cls: string; n: number; label: string }[] = [] + if (d.running) chips.push({ cls: 'is-running', n: d.running, label: t('plans.col.running') }) + if (d.completed) chips.push({ cls: 'is-completed', n: d.completed, label: t('plans.col.completed') }) + return chips } function laneLetter(name: string): string { @@ -543,6 +589,13 @@ onMounted(reload) border-color: var(--mc-border-strong); box-shadow: var(--mc-shadow-soft); } +/* Queued-steps mirror of an in-progress plan (shown in the pending column): + dashed + sunken so it reads as a secondary view of a card that also lives in + 执行中, not a duplicate. */ +.pb-card.is-spill { + border-style: dashed; + background: var(--mc-bg-sunken); +} .pb-card__goal { font-size: 12.5px; line-height: 1.45; diff --git a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue index fd075c77..8aad7c72 100644 --- a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue +++ b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue @@ -134,13 +134,22 @@ function statusLabel(status: string): string { return t(`plans.col.${status}`, status) } -// The persisted goal can carry an appended "[Follow-up guidance] ..." block -// (added when a goal follow-up re-enters planning). Strip it for display so the -// title reads as the original task, not the internal re-prompt. +// Recover the user's actual request for the title. Plans persisted before the +// server-side scrub carry the fully-assembled prompt — a recall +// block, a scheduled-run wrapper whose payload follows [任务指令], and a trailing +// [Follow-up guidance] block. Strip all three so the title reads as the task. +// Mirrors the backend PlanGenerationNode.displayGoal scrubber; a no-op on clean goals. function cleanGoal(goal: string): string { if (!goal) return '' - const i = goal.indexOf('[Follow-up guidance]') - return (i >= 0 ? goal.slice(0, i) : goal).trim() + let s = goal + .replace(/<\s*memory-context\s*>[\s\S]*?<\s*\/\s*memory-context\s*>/gi, '') + .replace(/<\/?\s*memory-context\s*>/gi, '') + const task = s.lastIndexOf('[任务指令]') + if (task >= 0) s = s.slice(task + '[任务指令]'.length) + const followup = s.indexOf('[Follow-up guidance]') + if (followup >= 0) s = s.slice(0, followup) + s = s.trim() + return s || goal.trim() } function letter(name?: string): string { diff --git a/mateclaw-ui/src/components/chat/ContentSegment.vue b/mateclaw-ui/src/components/chat/ContentSegment.vue index 0e3c3906..fe58e562 100644 --- a/mateclaw-ui/src/components/chat/ContentSegment.vue +++ b/mateclaw-ui/src/components/chat/ContentSegment.vue @@ -1,14 +1,18 @@ diff --git a/mateclaw-ui/src/components/chat/DelegationNodeView.vue b/mateclaw-ui/src/components/chat/DelegationNodeView.vue index cab4906e..885e0f87 100644 --- a/mateclaw-ui/src/components/chat/DelegationNodeView.vue +++ b/mateclaw-ui/src/components/chat/DelegationNodeView.vue @@ -42,6 +42,13 @@ const progress = computed(() => { return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : '' }) +// Compact token cost, shown once the subagent finishes (e.g. "3.2k tok"). +const tokenLabel = computed(() => { + const t = (props.node.promptTokens || 0) + (props.node.completionTokens || 0) + if (!t) return '' + return `${t >= 1000 ? (t / 1000).toFixed(1) + 'k' : t} tok` +}) + function stepStatus(i: number): 'pending' | 'running' | 'completed' { const p = plan.value if (!p) return 'pending' @@ -63,6 +70,7 @@ function stepStatus(i: number): 'pending' | 'running' | 'completed' { {{ node.agentName }} {{ progress }} + {{ tokenLabel }} @@ -408,6 +409,100 @@ class="action-model" :title="replyModelTitle" >{{ replyModel }} + + + +
            +
            + {{ $t('chat.usageDetail.title') }} + {{ $t('chat.usageDetail.total') }} + {{ tokenUsage.total.toLocaleString() }} +
            +
            + + {{ $t('chat.usageDetail.input') }} + {{ tokenUsage.input.toLocaleString() }} +
            + +
            +
            + + {{ $t('chat.usageDetail.output') }} + {{ tokenUsage.output.toLocaleString() }} +
            +
            + {{ $t('chat.usageDetail.reasoning') }} + {{ tokenUsage.reasoning.toLocaleString() }} +
            +
            + {{ $t('chat.usageDetail.reply') }} + {{ tokenUsage.reply.toLocaleString() }} +
            +
            + {{ $t('chat.usageDetail.delegated') }} + {{ tokenUsage.delegated.toLocaleString() }} +
            + +
            +
            { if (text && isApprovalPlaceholder(text)) return '' // 有错误卡片时隐藏 [错误] 原始文本,避免重复展示 if (status.value === 'failed' && errorInfo.value && text.startsWith('[错误]')) return '' - return text + return linkifyGeneratedFileUrls(text, generatedFileNames.value) }) +// id → filename map for generated-file downloads, sourced from the metadata +// the server builds out of tool results. Used to rewrite bare download URLs +// the model echoed as plain text into [name](url) links (the persisted copy +// is rewritten server-side; this covers the live-streamed bubble). +const generatedFileNames = computed(() => + buildGeneratedFileNameMap((props.message.metadata as any)?.generatedFiles)) + // --- parse_error detection --- const parseErrorText = computed(() => { const errorPart = props.message.contentParts?.find(p => p.type === 'parse_error') @@ -1001,6 +1104,68 @@ const useSegmentedView = computed(() => segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→')) ) +/** + * Total token consumption for this assistant turn, rolled up the way a + * multi-agent orchestrator should report it: the parent message's own usage + * PLUS every delegated sub-agent's usage (depth-1 delegation segments and + * their nested children). Returns null when nothing is known yet. + */ +const tokenUsage = computed(() => { + const m = props.message + if (m.role !== 'assistant') return null + // Base usage comes from the message, which the backend already rolls + // delegated sub-agent tokens into (so live and reloaded values match and there + // is no double counting against the segment sum below). + const prompt = m.promptTokens || 0 + const output = m.completionTokens || 0 + if (prompt + output <= 0) return null + const cacheRead = m.cacheReadTokens || 0 + const cacheWrite = m.cacheWriteTokens || 0 + const reasoning = m.reasoningTokens || 0 + // Provider accounting differs: the native Anthropic API reports input_tokens + // EXCLUDING the cache read/write segments (additive), while OpenAI-compatible + // and DashScope responses report prompt_tokens INCLUDING cached hits. + const additive = (m.runtimeProvider || '').toLowerCase().includes('anthropic') + const input = additive ? prompt + cacheRead + cacheWrite : prompt + const cacheMiss = Math.max(0, input - cacheRead - cacheWrite) + const reply = Math.max(0, output - reasoning) + const hitRate = input > 0 ? cacheRead / input : 0 + const hasCacheData = cacheRead > 0 || cacheWrite > 0 + const total = input + output + // Informational breakdown for the tooltip: how much of that total came from + // delegated sub-agents. Derived from the delegation segments, so it is present + // live and degrades to 0 after reload (the segments are not persisted). + let delegated = 0 + const addNodes = (nodes?: DelegationNode[]) => { + if (!nodes) return + for (const n of nodes) { + delegated += (n.promptTokens || 0) + (n.completionTokens || 0) + addNodes(n.children) + } + } + for (const s of segments.value) { + if (s.type !== 'tool_call') continue + delegated += (s.delegPromptTokens || 0) + (s.delegCompletionTokens || 0) + addNodes(s.childTimeline?.children) + } + return { + input, output, total, delegated: Math.min(delegated, total), + cacheRead, cacheWrite, cacheMiss, reasoning, reply, hitRate, hasCacheData, + } +}) + +/** Width of a cache-bar segment as a percentage of total input tokens. */ +function usageBarPct(part: number): string { + const u = tokenUsage.value + if (!u || u.input <= 0) return '0%' + return (part / u.input * 100).toFixed(2) + '%' +} + +/** Compact token count, e.g. 67890 → "67.9k". */ +function fmtTokens(n: number): string { + return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n) +} + /** * Group segments by iterationIndex so each ReAct iteration renders as its own * thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket @@ -1772,6 +1937,29 @@ watch(isGenerating, (generating) => { white-space: nowrap; } +.action-tokens { + font-size: 11px; + color: var(--mc-text-tertiary, #94a3b8); + margin-left: 4px; + padding: 1px 6px; + border-radius: 4px; + background: var(--mc-fill-2, rgba(100, 116, 139, 0.08)); + font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace); + user-select: text; + white-space: nowrap; +} + +/* The token chip is a popover trigger button — keep the chip look, add affordance. */ +.usage-trigger { + border: none; + cursor: pointer; + line-height: inherit; +} +.usage-trigger:hover { + color: var(--mc-text-secondary, #64748b); + background: var(--mc-fill-3, rgba(100, 116, 139, 0.14)); +} + .action-routing { font-size: 11px; color: var(--mc-primary, #d96d46); @@ -2524,3 +2712,108 @@ watch(isGenerating, (generating) => { } } + + + diff --git a/mateclaw-ui/src/components/chat/MessageList.vue b/mateclaw-ui/src/components/chat/MessageList.vue index 06fad2ae..a7ef3061 100644 --- a/mateclaw-ui/src/components/chat/MessageList.vue +++ b/mateclaw-ui/src/components/chat/MessageList.vue @@ -88,13 +88,33 @@
            + + + +
            + +
            +
            diff --git a/mateclaw-ui/src/components/dashboard/OperationalExport.vue b/mateclaw-ui/src/components/dashboard/OperationalExport.vue new file mode 100644 index 00000000..cca0c38b --- /dev/null +++ b/mateclaw-ui/src/components/dashboard/OperationalExport.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/mateclaw-ui/src/composables/__tests__/useSearchProviderCatalog.test.ts b/mateclaw-ui/src/composables/__tests__/useSearchProviderCatalog.test.ts new file mode 100644 index 00000000..a0e94527 --- /dev/null +++ b/mateclaw-ui/src/composables/__tests__/useSearchProviderCatalog.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + buildProviderOptions, + builtinFallbackCatalog, + resolveDefaultExpandedId, + resolveSourceLabelKey, +} from '../useSearchProviderCatalog' +import type { SearchProviderCatalog } from '@/types' + +const catalog: SearchProviderCatalog = { + providers: [ + { id: 'serper', label: 'Serper (Google)', builtin: true, requiresCredential: true, available: false, pluginName: null }, + { id: 'duckduckgo', label: 'DuckDuckGo', builtin: true, requiresCredential: false, available: true, pluginName: null }, + { id: 'my-search', label: 'My Search', builtin: false, requiresCredential: true, available: true, pluginName: 'my-plugin' }, + ], + resolvedId: 'duckduckgo', + resolvedSource: 'keyless-fallback', +} + +describe('buildProviderOptions', () => { + it('prepends an auto option with empty-string value', () => { + const options = buildProviderOptions(catalog, 'auto-label') + expect(options[0]).toEqual({ value: '', label: 'auto-label' }) + expect(options).toHaveLength(4) + }) + + it('maps each catalog entry to a value/label pair preserving order', () => { + const options = buildProviderOptions(catalog, 'auto-label') + expect(options.slice(1)).toEqual([ + { value: 'serper', label: 'Serper (Google)' }, + { value: 'duckduckgo', label: 'DuckDuckGo' }, + { value: 'my-search', label: 'My Search' }, + ]) + }) + + it('returns just the auto option when the catalog is empty', () => { + const options = buildProviderOptions({ providers: [], resolvedId: null, resolvedSource: null }, 'auto-label') + expect(options).toEqual([{ value: '', label: 'auto-label' }]) + }) + + it('appends the saved provider id when it is missing from the catalog', () => { + const options = buildProviderOptions(catalog, 'auto-label', 'vanished-plugin-search') + expect(options[options.length - 1]).toEqual({ value: 'vanished-plugin-search', label: 'vanished-plugin-search' }) + }) + + it('does not duplicate the saved provider id when it is already in the catalog', () => { + const options = buildProviderOptions(catalog, 'auto-label', 'serper') + expect(options.filter((o) => o.value === 'serper')).toHaveLength(1) + }) + + it('does not append anything for an empty or null saved value', () => { + expect(buildProviderOptions(catalog, 'auto-label', '')).toHaveLength(4) + expect(buildProviderOptions(catalog, 'auto-label', null)).toHaveLength(4) + }) +}) + +describe('builtinFallbackCatalog', () => { + it('contains exactly the four built-in providers, all marked builtin with no resolution', () => { + const fallback = builtinFallbackCatalog() + expect(fallback.providers.map((p) => p.id)).toEqual(['searxng', 'duckduckgo', 'serper', 'tavily']) + expect(fallback.providers.every((p) => p.builtin && p.pluginName === null)).toBe(true) + expect(fallback.resolvedId).toBeNull() + expect(fallback.resolvedSource).toBeNull() + }) +}) + +describe('resolveDefaultExpandedId', () => { + it('expands the resolved provider when present', () => { + expect(resolveDefaultExpandedId(catalog)).toBe('duckduckgo') + }) + + it('falls back to the first provider when nothing is resolved', () => { + const noneResolved = { ...catalog, resolvedId: null, resolvedSource: null } + expect(resolveDefaultExpandedId(noneResolved)).toBe('serper') + }) + + it('returns null when the catalog has no providers at all', () => { + expect(resolveDefaultExpandedId({ providers: [], resolvedId: null, resolvedSource: null })).toBeNull() + }) +}) + +describe('resolveSourceLabelKey', () => { + it('maps "configured" to "configured"', () => { + expect(resolveSourceLabelKey('configured')).toBe('configured') + }) + + it('maps "auto-detect" to "autoDetect"', () => { + expect(resolveSourceLabelKey('auto-detect')).toBe('autoDetect') + }) + + it('falls back to "keylessFallback" for "keyless-fallback"', () => { + expect(resolveSourceLabelKey('keyless-fallback')).toBe('keylessFallback') + }) + + it('falls back to "keylessFallback" for null or unrecognized values', () => { + expect(resolveSourceLabelKey(null)).toBe('keylessFallback') + expect(resolveSourceLabelKey('something-new')).toBe('keylessFallback') + }) +}) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 507ba630..f1611ab1 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -16,7 +16,7 @@ import { useMessageQueue } from './useMessageQueue' import { useGoalStore } from '@/stores/useGoalStore' import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' import { storeToRefs } from 'pinia' -import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta } from '@/types' +import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta, GeneratedFile } from '@/types' import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError' import { http } from '@/api' @@ -417,6 +417,21 @@ export function useChat(options: UseChatOptions): UseChatReturn { return null } + /** Markdown link pointing at a generated-file download URL. */ + const GENERATED_FILE_LINK_RE = /\[([^\]]+)\]\(((?:https?:\/\/[^/\s)\]]+)?\/api\/v1\/files\/generated\/[A-Za-z0-9-]+)\)/g + + /** Extract generated-file artifacts from a tool result string. */ + function extractGeneratedFiles(result: unknown, toolName: string): GeneratedFile[] { + if (typeof result !== 'string' || !result) return [] + const files: GeneratedFile[] = [] + let m: RegExpExecArray | null + GENERATED_FILE_LINK_RE.lastIndex = 0 + while ((m = GENERATED_FILE_LINK_RE.exec(result)) !== null) { + files.push({ filename: m[1], url: m[2], toolName }) + } + return files + } + // ===== SSE event handlers ===== stream.on('content_delta', (data) => { @@ -625,6 +640,9 @@ export function useChat(options: UseChatOptions): UseChatReturn { const msg = messages.value[msgIndex] if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens + if (data.cacheReadTokens !== undefined) msg.cacheReadTokens = data.cacheReadTokens + if (data.cacheWriteTokens !== undefined) msg.cacheWriteTokens = data.cacheWriteTokens + if (data.reasoningTokens !== undefined) msg.reasoningTokens = data.reasoningTokens if (data.runtimeModel) msg.runtimeModel = data.runtimeModel if (data.runtimeProvider) msg.runtimeProvider = data.runtimeProvider // Replace the local temp ID with the backend-persisted ID so reconcile can match by ID @@ -860,9 +878,21 @@ export function useChat(options: UseChatOptions): UseChatReturn { status: 'completed' } } + // Extract generated-file links from the tool result for the run-overview rail. + // De-duplicate by URL so a link echoed in later tool results doesn't + // produce duplicate entries. + const newFiles = extractGeneratedFiles(data.result, data.toolName) + const existingFiles = (metadata?.generatedFiles || []) as GeneratedFile[] + const existingUrls = new Set(existingFiles.map(f => f.url)) + const dedupedNew = newFiles.filter(f => !existingUrls.has(f.url)) + const generatedFiles = dedupedNew.length + ? [...existingFiles, ...dedupedNew] + : existingFiles.length + ? existingFiles + : undefined updateMessage(currentAssistantId.value, { ...msg, - metadata: { ...metadata, toolCalls, runningToolName: undefined } + metadata: { ...metadata, toolCalls, runningToolName: undefined, generatedFiles } } as any) } // Segments: prefer toolCallId match, fall back to first-running by toolName. @@ -1045,14 +1075,29 @@ export function useChat(options: UseChatOptions): UseChatReturn { } /** Mark a subagent (segment or nested node) complete by subagentId. */ + // Compact "(12s · 3.2k tok)" meta suffix for a completed delegation segment. + function delegMetaSuffix(durationMs?: number, promptTokens?: number, completionTokens?: number): string { + const parts: string[] = [] + if (durationMs) parts.push(`${Math.round(durationMs / 1000)}s`) + const tok = (promptTokens || 0) + (completionTokens || 0) + if (tok > 0) parts.push(`${tok >= 1000 ? (tok / 1000).toFixed(1) + 'k' : tok} tok`) + return parts.length ? ` (${parts.join(' · ')})` : '' + } + function markDelegComplete(segs: MessageSegment[], subagentId: string | undefined, childConvId: string | undefined, - success: boolean, resultPreview?: string, durationMs?: number): boolean { + success: boolean, resultPreview?: string, durationMs?: number, + promptTokens?: number, completionTokens?: number): boolean { const seg = findDelegSegment(segs, subagentId, childConvId) if (seg) { seg.status = success ? 'completed' : 'error' seg.toolSuccess = success if (resultPreview) seg.toolResult = resultPreview - if (durationMs) seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${Math.round(durationMs / 1000)}s)` + const suffix = delegMetaSuffix(durationMs, promptTokens, completionTokens) + if (suffix) seg.toolArgs = (seg.toolArgs || '').trimEnd() + suffix + // Keep tokens as numbers too so the message footer can roll this child + // up into the turn total (the suffix above is display-only). + if (promptTokens) seg.delegPromptTokens = promptTokens + if (completionTokens) seg.delegCompletionTokens = completionTokens return true } if (subagentId) { @@ -1062,6 +1107,8 @@ export function useChat(options: UseChatOptions): UseChatReturn { node.status = success ? 'completed' : 'error' if (resultPreview) node.result = resultPreview if (durationMs) node.durationMs = durationMs + if (promptTokens) node.promptTokens = promptTokens + if (completionTokens) node.completionTokens = completionTokens return true } } @@ -1205,7 +1252,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (isStaleEvent(data)) return if (!currentAssistantId.value) return markDelegComplete(currentSegments.value, data.subagentId, data.childConversationId, - !!data.success, data.resultPreview, data.durationMs) + !!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens) flushSegmentsToMessage() }) @@ -1223,7 +1270,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { : !!(cr.subagentId && findNode(segs.flatMap(s => s.childTimeline?.children || []), cr.subagentId)?.status === 'running') if (stillRunning) { markDelegComplete(segs, cr.subagentId, cr.childConversationId, !!cr.success, - cr.error || undefined, cr.durationMs) + cr.error || undefined, cr.durationMs, cr.promptTokens, cr.completionTokens) } } } else { @@ -1234,7 +1281,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { } } else { markDelegComplete(segs, data.subagentId, data.childConversationId, - !!data.success, data.resultPreview, data.durationMs) + !!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens) } flushSegmentsToMessage() }) @@ -2028,7 +2075,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { // Reconnect to a stream that is already running on the backend const reconnectStream = async (conversationId: string) => { - if (isGenerating.value) return + if (isGenerating.value && streamConversationId === conversationId) return // Clear any leftover stop fallback timer if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null } @@ -2054,9 +2101,28 @@ export function useChat(options: UseChatOptions): UseChatReturn { } } - const assistantMessage = createAssistantMessage('', conversationId) - ;(assistantMessage as any)._turnId = activeTurnId - currentAssistantId.value = assistantMessage.id as string + const existingAsst = [...messages.value].reverse().find( + m => m.role === 'assistant' + && m.conversationId === conversationId + && (m.status === 'generating' || m.status === 'awaiting_approval') + ) + if (existingAsst) { + updateMessage(existingAsst.id as string, { + ...existingAsst, + content: '', + contentParts: [], + _turnId: activeTurnId, + metadata: { + ...((existingAsst as any).metadata || {}), + segments: [], + }, + } as any) + currentAssistantId.value = existingAsst.id as string + } else { + const assistantMessage = createAssistantMessage('', conversationId) + ;(assistantMessage as any)._turnId = activeTurnId + currentAssistantId.value = assistantMessage.id as string + } try { // reconnectStream always rebuilds from an EMPTY placeholder (above), so it diff --git a/mateclaw-ui/src/composables/chat/useMessages.ts b/mateclaw-ui/src/composables/chat/useMessages.ts index 147bb452..0fd792fd 100644 --- a/mateclaw-ui/src/composables/chat/useMessages.ts +++ b/mateclaw-ui/src/composables/chat/useMessages.ts @@ -1,6 +1,5 @@ /** * 消息状态管理 Composable - * 参考 @agentscope-ai/chat 的消息管理实现 */ import { ref, computed } from 'vue' import type { Message, MessageContentPart } from '@/types' diff --git a/mateclaw-ui/src/composables/chat/useStickToBottom.ts b/mateclaw-ui/src/composables/chat/useStickToBottom.ts index 9c3339db..283cd397 100644 --- a/mateclaw-ui/src/composables/chat/useStickToBottom.ts +++ b/mateclaw-ui/src/composables/chat/useStickToBottom.ts @@ -1,6 +1,6 @@ /** * 智能滚动 Composable - * 参考 @agentscope-ai/chat 的 StickToBottom 实现,提供智能的自动滚动体验 + * 提供"贴底/脱离锁定"的智能自动滚动体验:内容增长时自动贴底,用户上滚后释放锁定 */ import { ref, computed, onMounted, onUnmounted } from 'vue' @@ -32,6 +32,7 @@ export interface StickToBottomReturn { stopScroll: () => void /** 检查是否在底部 */ checkIsAtBottom: () => boolean + resetLock: () => void } // 默认配置 @@ -129,6 +130,14 @@ export function useStickToBottom( const handleScroll = () => { if (!scrollRef.value) return if (isScrolling) { + // User scrolled up during a programmatic scroll — release the lock so the + // view stops snapping back to the bottom. + const currentScrollTop = scrollRef.value.scrollTop + if (currentScrollTop < lastScrollTop) { + isScrolling = false + escapedFromLock.value = true + isAtBottom.value = false + } lastScrollTop = scrollRef.value.scrollTop return } @@ -183,6 +192,11 @@ export function useStickToBottom( }, 100) } + const resetLock = () => { + escapedFromLock.value = false + isAtBottom.value = true + } + // ResizeObserver 监听内容变化 let resizeObserver: ResizeObserver | null = null @@ -245,6 +259,7 @@ export function useStickToBottom( scrollToBottom, stopScroll, checkIsAtBottom, + resetLock, } } diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index 3dbcb251..f1f00ce4 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -1,6 +1,6 @@ /** * SSE 流处理 Composable - * 参考 @agentscope-ai/chat 的 Stream 实现,提供标准的 SSE 解析 + * 提供标准的 SSE 流式解析 */ import { ref, computed } from 'vue' import { handleAuthFailure, updateTokenFromHeader } from '@/utils/auth' diff --git a/mateclaw-ui/src/composables/chat/useTyping.ts b/mateclaw-ui/src/composables/chat/useTyping.ts index aad289fe..830be894 100644 --- a/mateclaw-ui/src/composables/chat/useTyping.ts +++ b/mateclaw-ui/src/composables/chat/useTyping.ts @@ -1,6 +1,6 @@ /** * 打字机效果 Composable - * 参考 @agentscope-ai/chat 的实现,提供流畅的逐字显示效果 + * 提供流畅的逐字打字显示效果 */ import { ref, computed, watch, nextTick } from 'vue' diff --git a/mateclaw-ui/src/composables/useNotificationCenter.ts b/mateclaw-ui/src/composables/useNotificationCenter.ts index 2ce7e2e5..d31789fc 100644 --- a/mateclaw-ui/src/composables/useNotificationCenter.ts +++ b/mateclaw-ui/src/composables/useNotificationCenter.ts @@ -18,6 +18,7 @@ const POLL_INTERVAL_MS = 15_000 const summary = ref({ pendingApprovals: 0, stuckAgents: 0, + failedWikiJobs: 0, failedCrons: 0, downChannels: 0, downMcps: 0, @@ -57,6 +58,7 @@ async function refresh(): Promise { summary.value = { pendingApprovals: toCount(raw.pendingApprovals), stuckAgents: toCount(raw.stuckAgents), + failedWikiJobs: toCount(raw.failedWikiJobs), failedCrons: toCount(raw.failedCrons), downChannels: toCount(raw.downChannels), downMcps: toCount(raw.downMcps), @@ -97,6 +99,7 @@ export function useNotificationCenter() { summary: computed(() => summary.value), pendingApprovals: computed(() => summary.value.pendingApprovals), stuckAgents: computed(() => summary.value.stuckAgents), + failedWikiJobs: computed(() => summary.value.failedWikiJobs), refresh, } } diff --git a/mateclaw-ui/src/composables/useSearchProviderCatalog.ts b/mateclaw-ui/src/composables/useSearchProviderCatalog.ts new file mode 100644 index 00000000..c3ead147 --- /dev/null +++ b/mateclaw-ui/src/composables/useSearchProviderCatalog.ts @@ -0,0 +1,84 @@ +import type { SearchProviderCatalog } from '@/types' + +export interface ProviderOption { + value: string + label: string +} + +/** + * Turns a catalog into still shows the real stored value. Without it the + * dropdown would render blank and a subsequent save would silently rewrite the + * setting to '' (auto) even though the admin never chose to change it. + */ +export function buildProviderOptions( + catalog: SearchProviderCatalog, + autoLabel: string, + currentId?: string | null, +): ProviderOption[] { + const options: ProviderOption[] = [{ value: '', label: autoLabel }] + for (const entry of catalog.providers) { + options.push({ value: entry.id, label: entry.label }) + } + if (currentId && !options.some((opt) => opt.value === currentId)) { + options.push({ value: currentId, label: currentId }) + } + return options +} + +/** Which provider card should be expanded by default: the currently-resolved one, else the first. */ +export function resolveDefaultExpandedId(catalog: SearchProviderCatalog): string | null { + if (catalog.resolvedId) return catalog.resolvedId + return catalog.providers.length > 0 ? catalog.providers[0].id : null +} + +/** + * Maps the backend's resolvedSource value to the i18n key suffix used under + * settings.searchResolvedSource.*. Falls back to 'keylessFallback' for any + * unrecognized or null value — but that fallback is now a named, visible + * decision here rather than an implicit template ternary. + */ +export function resolveSourceLabelKey(source: string | null): string { + if (source === 'configured') return 'configured' + if (source === 'auto-detect') return 'autoDetect' + return 'keylessFallback' +} + +/** + * Static stand-in for the four built-in providers, used when the catalog endpoint + * fails: the built-in config forms (which bind to plain SystemSettings fields and + * never depended on the catalog) stay reachable instead of the whole search section + * silently vanishing. `available` is unknown in this mode — callers should hide + * status badges rather than show a guessed state. + * + * ⚠ DRIFT COUPLING — these entries are a hand-maintained mirror of the backend's + * built-in providers and MUST be kept in sync when the backend changes them: + * - Source of truth: mateclaw-server/.../tool/search/*SearchProvider.java + * (id() + autoDetectOrder()). + * - id/label/requiresCredential must match each provider exactly. + * - Order must match ascending autoDetectOrder (searxng=50, duckduckgo=100, + * serper=300, tavily=400 today), because resolveDefaultExpandedId() falls + * back to providers[0] and the UI's default-expanded card should be the + * highest-priority keyless one. + * - If a built-in is added/removed/renamed, update BOTH this list AND the + * @@ -197,6 +203,7 @@ import { useRouter } from 'vue-router' import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue' import { dashboardApi, modelApi, http } from '@/api' import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons' +import OperationalExport from '@/components/dashboard/OperationalExport.vue' import * as echarts from 'echarts/core' import { LineChart } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components' @@ -207,6 +214,10 @@ echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, Canvas const { t, locale } = useI18n() const router = useRouter() +// Connected database product name (e.g. "MySQL" / "H2" / "PostgreSQL"), shown as +// a subtle chip in the page header. Empty string hides it when unavailable. +const dbLabel = ref('') + const overview = ref>({}) const recentRuns = ref([]) const trendData = ref([]) @@ -224,9 +235,7 @@ const todayStats = reactive({ // ── Model configuration card ── const modelProviders = ref([]) const activeModel = ref<{ providerId: string; model: string } | null>(null) -// Connected database product name (e.g. "MySQL" / "H2" / "PostgreSQL"), surfaced -// as a subtle line in the page header. Empty string hides it when unavailable. -const dbLabel = ref('') + const readyProviderCount = computed( () => modelProviders.value.filter((p) => providerChipStatus(p) === 'ready').length, @@ -280,15 +289,6 @@ onMounted(async () => { // Dashboard data is non-critical } - // Connected database label — independent and non-critical. Reuses the - // existing system health endpoint, which already reports the product name. - try { - const healthRes: any = await http.get('/system/health') - dbLabel.value = (healthRes?.data || healthRes)?.database || '' - } catch { - dbLabel.value = '' - } - // Model configuration card — loaded independently so a failure here never // blanks the analytics above, and vice versa. try { @@ -301,6 +301,15 @@ onMounted(async () => { } catch { // Non-critical } + + // Connected database label — independent and non-critical. Reuses the existing + // system health endpoint, which already reports the product name. + try { + const healthRes: any = await http.get('/system/health') + dbLabel.value = (healthRes?.data || healthRes)?.database || '' + } catch { + dbLabel.value = '' + } }) onUnmounted(() => { @@ -402,6 +411,7 @@ function calcDuration(run: any): string { if (ms < 1000) return ms + 'ms' return (ms / 1000).toFixed(1) + 's' } + diff --git a/mateclaw-ui/src/views/Login.vue b/mateclaw-ui/src/views/Login.vue index 17515cea..6a32f596 100644 --- a/mateclaw-ui/src/views/Login.vue +++ b/mateclaw-ui/src/views/Login.vue @@ -51,20 +51,53 @@ + + + + +
            +
            +

            首次使用 {{ bindDialog.provider }} 登录

            +

            请绑定你的 MateClaw 账号

            + + +
            {{ bindDialog.error }}
            + + +
            +
            + diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index 2dba1300..2700443f 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -180,10 +180,16 @@ {{ t('wiki.cancelledHint') }} - {{ raw.errorMessage }} + {{ friendlyError(raw) }} + + + ⚠ {{ friendlyWarning(raw) }}
            @@ -327,6 +333,30 @@ const workspace = useWorkspaceStore() const canManageWiki = computed(() => workspace.can('manage:wiki')) const fileInput = ref(null) +// Map a structured backend errorCode to a localized, user-friendly hint. +// Falls back to the raw backend message, then to a generic failure label, so +// the user always sees something meaningful — never a blank "failed" badge. +function friendlyError(raw: { errorCode?: string | null; errorMessage?: string | null }): string { + const code = raw.errorCode + if (code) { + const key = `wiki.errorCode.${code}` + const msg = t(key) + if (msg !== key) return msg + } + return raw.errorMessage || t('wiki.errorCode.UNKNOWN') +} + +// Same idea for the non-blocking warning surface (degraded-but-usable rows). +function friendlyWarning(raw: { warningCode?: string | null; warningMessage?: string | null }): string { + const code = raw.warningCode + if (code) { + const key = `wiki.warningCode.${code}` + const msg = t(key) + if (msg !== key) return msg + } + return raw.warningMessage || t('wiki.warningCode.UNKNOWN') +} + // While raw materials are active, subscribe to the backend SSE progress stream. // A slower polling fallback keeps the UI in sync if SSE reconnects or misses a // terminal event. The database remains the source of truth. @@ -390,12 +420,32 @@ function openSse(kbId: number) { try { const data = JSON.parse(ev.data) const raw = store.rawMaterials.find(r => r.id === data.rawId) - if (raw) raw.processingStatus = 'failed' + if (raw) { + raw.processingStatus = 'failed' + // Surface the failure immediately from the event payload instead of + // waiting for the refresh round-trip — and never drop it: a null + // message would otherwise leave the user with a blank "failed" badge. + if (typeof data.error === 'string') raw.errorMessage = data.error + if (typeof data.errorCode === 'string') raw.errorCode = data.errorCode + } // Clear stale job entry delete rawJobs[data.rawId] if (store.currentKB) void store.refreshCurrentKB() } catch { /* ignore */ } }) + es.addEventListener('raw.warning', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data) + const raw = store.rawMaterials.find(r => r.id === data.rawId) + // A warning lands async after the material already completed, so the + // refresh round-trip on raw.completed has already happened — apply it + // live here, otherwise it would only appear on the next manual reload. + if (raw) { + if (typeof data.warning === 'string') raw.warningMessage = data.warning + if (typeof data.warningCode === 'string') raw.warningCode = data.warningCode + } + } catch { /* ignore */ } + }) es.onerror = () => { // Browser EventSource auto-reconnects; just log // console.debug('Wiki SSE error/reconnect', kbId) @@ -439,6 +489,14 @@ onBeforeUnmount(() => { clearInterval(fallbackTimer) fallbackTimer = null } + // Stop the per-raw job poller too. Without this the setTimeout chain keeps + // running after the panel unmounts (e.g. switching to the config tab while a + // raw is still processing), calling refreshCurrentKB() every 3s and snapping + // the user back to this tab. + if (jobPoller != null) { + clearTimeout(jobPoller) + jobPoller = null + } }) // RFC-033: Job polling per raw material @@ -847,6 +905,7 @@ async function handleScanDir() { .raw-item-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .raw-item-actions { display: flex; gap: 4px; flex-shrink: 0; } .error-hint { font-size: 11px; color: var(--mc-danger); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.warning-hint { font-size: 11px; color: var(--mc-warning, #d98e00); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .page-count-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 500; color: var(--mc-text-secondary); background: var(--mc-bg-sunken); border-radius: 9999px; padding: 2px 7px; } /* Two-phase digest progress bar (RFC-012 M2 v2 UI) */ diff --git a/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue b/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue index 6558616b..80047ade 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiEntityGraphView.vue @@ -2,6 +2,15 @@
            + + +
            @@ -51,13 +60,14 @@ + + diff --git a/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue b/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue index d041da99..710df34b 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue @@ -24,7 +24,17 @@ /> -
            +
            + + +
            +
            { .filter(Boolean) as WikiPage[] }) +// Candidates for the node search box: page title + type label + node color. +const pageSearchNodes = computed(() => + nodes.value.map(p => ({ + id: p.slug, + name: p.title, + type: formatPageTypeLabel(p.pageType || 'other'), + color: typeColor(p.pageType), + })), +) + +// Search hit: select the page (opens its panel) and emphasize its node — +// focus:'adjacency' dims the rest so the match is easy to spot. +function focusPageNode(slug: string) { + const page = slugToPage.value.get(slug) + if (page) selectedNode.value = page + if (!chart) return + const idx = nodes.value.findIndex(p => p.slug === slug) + chart.dispatchAction({ type: 'downplay', seriesIndex: 0 }) + if (idx >= 0) chart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: idx }) +} + +function clearHighlight() { + chart?.dispatchAction({ type: 'downplay', seriesIndex: 0 }) +} + function buildOption() { const labelColor = cssVar('--mc-text-secondary', '#665245') const nodeSet = new Set(nodes.value.map(p => p.slug)) @@ -403,11 +439,24 @@ watch(graphMode, (mode) => { background: var(--mc-bg-base, #1a1a1a); } +.graph-canvas-wrap { + position: relative; + flex: 1; + min-height: 0; + display: flex; +} .graph-canvas { flex: 1; min-height: 0; width: 100%; } +/* Search overlay anchored to the canvas top-left, clear of the detail panel. */ +.graph-search { + position: absolute; + top: 12px; + left: 12px; + z-index: 5; +} .graph-empty { position: absolute; diff --git a/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue b/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue index e5d9be72..22687146 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiWorkspace.vue @@ -157,8 +157,15 @@ const tabs = computed<{ key: WikiTab; label: string }[]>(() => { // Snap to each view's default tab whenever the KB or the view mode changes, so // the user never lands on a stale tab (or one that doesn't exist in this mode). +// Use an array of getters (not a single getter returning an array): the latter +// returns a fresh array reference on every evaluation, so Vue's Object.is check +// always reports a change and the callback fires on *any* currentKB +// reassignment — including background refreshes (refreshCurrentKB) that keep the +// same id. That would yank the user off the config tab back to 'raw' every time +// a poll/SSE refresh reassigned the KB object. The array-of-getters form +// compares each source individually, so it fires only on a real id/mode change. watch( - () => [store.currentKB?.id, store.workspaceMode], + [() => store.currentKB?.id, () => store.workspaceMode], () => { activeTab.value = store.workspaceMode === 'manage' ? 'raw' : 'pages' }, { immediate: true }, ) @@ -187,7 +194,7 @@ async function onOpenPage(slug: string) { .tab-btn:hover { color: var(--mc-text-primary); } .tab-btn.active { color: var(--mc-primary); background: var(--mc-bg-elevated); box-shadow: 0 1px 4px rgba(0,0,0,0.08); font-weight: 600; } .tab-content { flex: 1; min-height: 0; overflow-y: auto; padding-right: 2px; } -.tab-content--config { overflow: hidden; padding-right: 0; } +.tab-content--config { overflow-y: auto; padding-right: 0; } .tab-content--graph { overflow: hidden; padding: 0; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 200px; color: var(--mc-text-tertiary); text-align: center; } diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue index ffa92a5d..190258e0 100644 --- a/mateclaw-ui/src/views/Wiki/index.vue +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -2,6 +2,10 @@
            +

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