From 38adf729390103b2cc9348498283c06353274a6f Mon Sep 17 00:00:00 2001 From: baiyanyun Date: Thu, 2 Jul 2026 17:43:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=9C=AC=E5=9C=B0MCP?= =?UTF-8?q?=E4=B8=8E=E6=89=A9=E5=B1=95=E5=B8=82=E5=9C=BA=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/resources/application-dev.sample.yml | 8 +- .../src/main/resources/application-dev.yml | 6 +- .../ElasticsearchServiceImpl.java | 22 +- .../web/controller/base/BaseController.java | 14 +- .../service/McpDeployTaskServiceImpl.java | 42 +- .../mcp/ui/web/controller/McpController.java | 7 +- .../controller/NotifyMessageController.java | 74 +-- qiming-mcp-proxy/config.yml | 4 +- qiming-mcp-proxy/mcp-proxy/config.yml | 6 +- qiming-mcp-proxy/mcp-proxy/src/config.rs | 17 + qiming-mcp-proxy/mcp-proxy/src/main.rs | 30 +- qiming/src/constants/mcp.constants.ts | 13 - qiming/src/pages/SpaceMcpManage/index.less | 4 - qiming/src/pages/SpaceMcpManage/index.tsx | 139 +--- qiming/src/services/mcp.ts | 9 - qiming/src/types/enums/mcp.ts | 8 - .../public/tray/tray.png | Bin 2738 -> 1799 bytes .../public/tray/tray@2x.png | Bin 8826 -> 4808 bytes .../public/tray/trayTemplate.png | Bin 1429 -> 265 bytes .../public/tray/trayTemplate@2x.png | Bin 4714 -> 490 bytes .../src/main/ipc/index.ts | 2 + .../src/main/ipc/skillHandlers.ts | 233 +++++++ .../src/preload/index.ts | 23 + .../components/pages/McpMarketplacePage.tsx | 410 ++++++++++++ .../pages/SkillsMarketplacePage.tsx | 598 ++++++++++++++++++ .../pages/WorkflowMarketplacePage.tsx | 318 ++++++++++ .../src/renderer/services/core/marketplace.ts | 455 +++++++++++++ .../styles/components/AppSidebar.module.css | 4 +- .../components/McpMarketplacePage.module.css | 289 +++++++++ .../SkillsMarketplacePage.module.css | 480 ++++++++++++++ .../WorkflowMarketplacePage.module.css | 304 +++++++++ .../src/shared/types/electron.d.ts | 42 ++ 32 files changed, 3291 insertions(+), 270 deletions(-) create mode 100644 qimingclaw/crates/agent-electron-client/src/main/ipc/skillHandlers.ts create mode 100644 qimingclaw/crates/agent-electron-client/src/renderer/components/pages/McpMarketplacePage.tsx create mode 100644 qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SkillsMarketplacePage.tsx create mode 100644 qimingclaw/crates/agent-electron-client/src/renderer/components/pages/WorkflowMarketplacePage.tsx create mode 100644 qimingclaw/crates/agent-electron-client/src/renderer/services/core/marketplace.ts create mode 100644 qimingclaw/crates/agent-electron-client/src/renderer/styles/components/McpMarketplacePage.module.css create mode 100644 qimingclaw/crates/agent-electron-client/src/renderer/styles/components/SkillsMarketplacePage.module.css create mode 100644 qimingclaw/crates/agent-electron-client/src/renderer/styles/components/WorkflowMarketplacePage.module.css diff --git a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.sample.yml b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.sample.yml index 109c34c6..b6db3819 100644 --- a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.sample.yml +++ b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.sample.yml @@ -27,7 +27,7 @@ spring: datasource: # 主数据源 (MySQL) master: - url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false + url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false driver-class-name: com.mysql.cj.jdbc.Driver username: ${DB_USERNAME:root} password: ${DB_PASSWORD:your_mysql_password} @@ -48,7 +48,7 @@ spring: filters: stat # 启用 stat filter 用于监控 # Doris 数据源 doris: - url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_custom_table}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false + url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_custom_table}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false driver-class-name: com.mysql.cj.jdbc.Driver username: ${DORIS_USERNAME:root} password: ${DORIS_PASSWORD:your_doris_password} @@ -165,6 +165,8 @@ search: username: ${ES_USERNAME:elastic} password: ${ES_PASSWORD:your_elasticsearch_password} api_key: ${ES_API_KEY:} + text_analyzer: ${ES_TEXT_ANALYZER:standard} + search_analyzer: ${ES_SEARCH_ANALYZER:standard} # 生态市场模块配置 eco-market: # 服务端配置 @@ -230,4 +232,4 @@ model-api-proxy: enable-model-proxy: ${MODEL_PROXY_ENABLE:true} port: ${MODEL_PROXY_PORT:18086} #记录日志 - save-log: ${MODEL_PROXY_SAVE_LOG:true} \ No newline at end of file + save-log: ${MODEL_PROXY_SAVE_LOG:true} diff --git a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml index ae065d1b..fee72c11 100644 --- a/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml +++ b/qiming-backend/app-platform-bootstrap/app-platform-web-bootstrap/src/main/resources/application-dev.yml @@ -27,7 +27,7 @@ spring: datasource: # 主数据源 (MySQL) master: - url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false + url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false driver-class-name: com.mysql.cj.jdbc.Driver username: ${DB_USERNAME:root} password: ${DB_PASSWORD:Byyagz@121} @@ -48,7 +48,7 @@ spring: filters: stat # 启用 stat filter 用于监控 # Doris 数据源 doris: - url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false + url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false driver-class-name: com.mysql.cj.jdbc.Driver username: ${DORIS_USERNAME:root} password: ${DORIS_PASSWORD:Byyagz@121} @@ -165,6 +165,8 @@ search: username: ${ES_USERNAME:} password: ${ES_PASSWORD:} api_key: ${ES_API_KEY:} + text_analyzer: ${ES_TEXT_ANALYZER:standard} + search_analyzer: ${ES_SEARCH_ANALYZER:standard} # 生态市场模块配置 eco-market: # 服务端配置 diff --git a/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-infra/src/main/java/com/xspaceagi/search/infra/elasticsearch/ElasticsearchServiceImpl.java b/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-infra/src/main/java/com/xspaceagi/search/infra/elasticsearch/ElasticsearchServiceImpl.java index 5bff6750..372d0817 100644 --- a/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-infra/src/main/java/com/xspaceagi/search/infra/elasticsearch/ElasticsearchServiceImpl.java +++ b/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-infra/src/main/java/com/xspaceagi/search/infra/elasticsearch/ElasticsearchServiceImpl.java @@ -53,6 +53,12 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic @Value("${search.elasticsearch.password:}") private String password; + @Value("${search.elasticsearch.text_analyzer:standard}") + private String textAnalyzer; + + @Value("${search.elasticsearch.search_analyzer:standard}") + private String searchAnalyzer; + private ElasticsearchClient client; @PostConstruct @@ -75,6 +81,14 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic client.shutdown(); } + private String getTextAnalyzer() { + return StringUtils.defaultIfBlank(textAnalyzer, "standard"); + } + + private String getSearchAnalyzer() { + return StringUtils.defaultIfBlank(searchAnalyzer, getTextAnalyzer()); + } + @Override public void bulkIndex(List list) { Assert.noNullElements(list, "list cannot be left blank."); @@ -146,8 +160,8 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic if (keyword.get()) { builder.properties(name.get(), property -> property.keyword(kw -> kw.index(index.get()).store(store.get()))); } else { - builder.properties(name.get(), property -> property.text(text -> text.analyzer("ik_max_word") - .searchAnalyzer("ik_smart").store(store.get()).index(index.get()) + builder.properties(name.get(), property -> property.text(text -> text.analyzer(getTextAnalyzer()) + .searchAnalyzer(getSearchAnalyzer()).store(store.get()).index(index.get()) )); } } else if (declaredField.getType() == Integer.class) { @@ -163,8 +177,8 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic } else if (declaredField.getType() == Date.class) { builder.properties(name.get(), property -> property.date(date -> date.index(index.get()).store(store.get()))); } else { - builder.properties(name.get(), property -> property.text(text -> text.analyzer("ik_max_word") - .searchAnalyzer("ik_smart").store(store.get()).index(index.get()) + builder.properties(name.get(), property -> property.text(text -> text.analyzer(getTextAnalyzer()) + .searchAnalyzer(getSearchAnalyzer()).store(store.get()).index(index.get()) )); } } diff --git a/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-web/src/main/java/com/xspaceagi/log/web/controller/base/BaseController.java b/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-web/src/main/java/com/xspaceagi/log/web/controller/base/BaseController.java index af01689a..867279b0 100644 --- a/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-web/src/main/java/com/xspaceagi/log/web/controller/base/BaseController.java +++ b/qiming-backend/app-platform-modules/app-platform-log/app-platform-log-web/src/main/java/com/xspaceagi/log/web/controller/base/BaseController.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.xspaceagi.log.sdk.request.DocumentSearchRequest; import com.xspaceagi.log.sdk.service.ISearchRpcService; import com.xspaceagi.log.sdk.vo.LogDocument; +import com.xspaceagi.log.sdk.vo.SearchResult; import com.xspaceagi.log.web.controller.dto.LogQueryDto; import com.xspaceagi.system.spec.common.RequestContext; import com.xspaceagi.system.spec.common.UserContext; @@ -19,6 +20,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.web.bind.annotation.RequestBody; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -125,13 +127,15 @@ public abstract class BaseController { createTimeRange.put("express", "range"); filterFieldsAndValues.add(Map.of("createTime", createTimeRange)); } - builder.sortFieldsAndValues(Map.of("createTime", "Desc")); - var result = iSearchRpcService.search(builder.build()); - try { + builder.sortFieldsAndValues(Map.of("createTime", "Desc")); + var result = iSearchRpcService.search(builder.build()); IPage page = new Page<>(pageQueryVo.getCurrent().intValue(), pageQueryVo.getPageSize().intValue()); - page.setTotal(result.getTotal()); - page.setRecords(result.getItems().stream().map(item -> { + page.setTotal(result == null || result.getTotal() == null ? 0 : result.getTotal()); + List items = result == null || result.getItems() == null + ? Collections.emptyList() + : result.getItems(); + page.setRecords(items.stream().map(item -> { LogDocument document = (LogDocument) item.getDocument(); document.setProcessData(null); return document; diff --git a/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-application/src/main/java/com/xspaceagi/mcp/application/service/McpDeployTaskServiceImpl.java b/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-application/src/main/java/com/xspaceagi/mcp/application/service/McpDeployTaskServiceImpl.java index f817e95f..ebbcaef4 100644 --- a/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-application/src/main/java/com/xspaceagi/mcp/application/service/McpDeployTaskServiceImpl.java +++ b/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-application/src/main/java/com/xspaceagi/mcp/application/service/McpDeployTaskServiceImpl.java @@ -140,7 +140,16 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen deployStatus = new McpDeployStatusResponse(); deployStatus.setStatus(McpDeployStatusEnum.Ready); } else { - deployStatus = mcpDeployRpcService.deploy(String.valueOf(id), serverConfig, McpPersistentTypeEnum.OneShot); + try { + deployStatus = mcpDeployRpcService.deploy(String.valueOf(id), serverConfig, McpPersistentTypeEnum.OneShot); + } catch (Exception e) { + log.warn("MCP service [{}] deploy request failed", mcpName, e); + if (scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) { + markDeployFailed(userDto, id, mcpName, e.getMessage()); + return true; + } + return false; + } } if (deployStatus.getStatus() == McpDeployStatusEnum.Ready) { try { @@ -161,15 +170,7 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen } } if (deployStatus.getStatus() == McpDeployStatusEnum.Error) { - McpConfig mcpConfig = new McpConfig(); - mcpConfig.setId(id); - mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed); - mcpConfigDomainService.update(mcpConfig); - notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder() - .scope(NotifyMessage.MessageScope.System) - .content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Failed", mcpName, deployStatus.getMessage())) - .userIds(List.of(userId)) - .build()); + markDeployFailed(userDto, id, mcpName, deployStatus.getMessage()); return true; } checkAndSendFailedNotifyMessage(userDto, id, mcpName, scheduleTask); @@ -178,10 +179,7 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen private void checkAndSendFailedNotifyMessage(UserDto userDto, Long id, String mcpName, ScheduleTaskDto scheduleTask) { if (scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) { - McpConfig mcpConfig = new McpConfig(); - mcpConfig.setId(id); - mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed); - mcpConfigDomainService.update(mcpConfig); + updateDeployFailed(id); notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder() .scope(NotifyMessage.MessageScope.System) .content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Timeout", mcpName)) @@ -189,4 +187,20 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen .build()); } } + + private void markDeployFailed(UserDto userDto, Long id, String mcpName, String message) { + updateDeployFailed(id); + notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder() + .scope(NotifyMessage.MessageScope.System) + .content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Failed", mcpName, message)) + .userIds(List.of(userDto.getId())) + .build()); + } + + private void updateDeployFailed(Long id) { + McpConfig mcpConfig = new McpConfig(); + mcpConfig.setId(id); + mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed); + mcpConfigDomainService.update(mcpConfig); + } } diff --git a/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-ui/src/main/java/com/xspaceagi/mcp/ui/web/controller/McpController.java b/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-ui/src/main/java/com/xspaceagi/mcp/ui/web/controller/McpController.java index e3954782..dccadcff 100644 --- a/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-ui/src/main/java/com/xspaceagi/mcp/ui/web/controller/McpController.java +++ b/qiming-backend/app-platform-modules/app-platform-mcp/app-platform-mcp-ui/src/main/java/com/xspaceagi/mcp/ui/web/controller/McpController.java @@ -226,12 +226,7 @@ public class McpController { @Operation(summary = "MCP列表(官方服务)") @GetMapping("/official/list") public ReqResult> officialList() { - List mcpDtos = mcpConfigApplicationService.queryMcpListBySpaceId(-1L); - //移除未发布的 - mcpDtos.removeIf(mcpDto -> mcpDto.getDeployStatus() != DeployStatusEnum.Deployed); - List permissionList = new ArrayList<>(); - mcpDtos.forEach(mcpDto -> mcpDto.setPermissions(permissionList.stream().map(SpaceObjectPermissionEnum::name).collect(Collectors.toList()))); - return ReqResult.success(mcpDtos); + return ReqResult.success(List.of()); } @RequireResource(MCP_DELETE) diff --git a/qiming-backend/app-platform-modules/platform-system/system-web/src/main/java/com/xspaceagi/system/web/controller/NotifyMessageController.java b/qiming-backend/app-platform-modules/platform-system/system-web/src/main/java/com/xspaceagi/system/web/controller/NotifyMessageController.java index 82ca8d64..1ab98837 100644 --- a/qiming-backend/app-platform-modules/platform-system/system-web/src/main/java/com/xspaceagi/system/web/controller/NotifyMessageController.java +++ b/qiming-backend/app-platform-modules/platform-system/system-web/src/main/java/com/xspaceagi/system/web/controller/NotifyMessageController.java @@ -1,39 +1,23 @@ package com.xspaceagi.system.web.controller; -import com.alibaba.fastjson2.JSON; -import com.alibaba.fastjson2.TypeReference; -import com.xspaceagi.eco.market.sdk.reponse.ClientSecretResponse; import com.xspaceagi.system.application.dto.*; import com.xspaceagi.system.application.service.AuthService; import com.xspaceagi.system.application.service.NotifyMessageApplicationService; -import com.xspaceagi.system.infra.dao.entity.NotifyMessage; -import com.xspaceagi.system.infra.dao.entity.User; -import com.xspaceagi.system.infra.rpc.ClientSecretRpcService; import com.xspaceagi.system.spec.common.RequestContext; import com.xspaceagi.system.spec.dto.ReqResult; -import com.xspaceagi.system.spec.utils.HttpClient; import com.xspaceagi.system.web.dto.EventRespDto; -import com.xspaceagi.system.web.dto.PullMessageAckDto; -import com.xspaceagi.system.web.dto.PullMessageRequestDto; -import com.xspaceagi.system.web.dto.PullMessageResponseDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; -import java.util.Arrays; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -@Slf4j @Tag(name = "消息通知相关接口") @RestController @RequestMapping("/api/notify") @@ -45,22 +29,9 @@ public class NotifyMessageController { @Resource private AuthService authService; - @Resource - private HttpClient httpClient; - - @Value("${installation-source}") - private String installationSource; - @Value("${app.version}") private String version; - @Value("${eco-market.server.base-url}") - private String cloudApi; - - @Resource - private ClientSecretRpcService clientSecretRpcService; - - @Operation(summary = "查询用户消息列表") @RequestMapping(path = "/message/list", method = RequestMethod.POST) public ReqResult> listQuery(@RequestBody NotifyMessageQueryDto messageQueryDto) { @@ -68,43 +39,6 @@ public class NotifyMessageController { return ReqResult.success(notifyMessageApplicationService.queryNotifyMessageList(messageQueryDto)); } - private void pullMessage() { - UserDto userDto = (UserDto) RequestContext.get().getUser(); - TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig(); - if (userDto.getRole() == User.Role.Admin) { - // 管理员拉取云端消息 - ClientSecretResponse clientSecretResponse = clientSecretRpcService.queryClientSecret(tenantConfigDto.getTenantId()); - if (clientSecretResponse != null) { - PullMessageRequestDto pullMessageRequestDto = new PullMessageRequestDto(); - pullMessageRequestDto.setTenantName(tenantConfigDto.getSiteName()); - pullMessageRequestDto.setSiteUrl(tenantConfigDto.getSiteUrl()); - pullMessageRequestDto.setClientId(clientSecretResponse.getClientId()); - pullMessageRequestDto.setClientSecret(clientSecretResponse.getClientSecret()); - pullMessageRequestDto.setInstallationSource(installationSource); - pullMessageRequestDto.setVersion(version); - pullMessageRequestDto.setUser(userDto.getNickName() != null ? userDto.getNickName() : userDto.getUserName()); - pullMessageRequestDto.setUid(userDto.getUid()); - String content = httpClient.post(cloudApi + "/api/message/pull", JSON.toJSONString(pullMessageRequestDto), Map.of("Authorization", "Bearer " + clientSecretResponse.getClientSecret())); - ReqResult> pullMessageResponseDtos = JSON.parseObject(content, new TypeReference>>() { - }); - if (pullMessageResponseDtos != null && CollectionUtils.isNotEmpty(pullMessageResponseDtos.getData())) { - pullMessageResponseDtos.getData().forEach(pullMessageResponseDto -> notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder() - .scope(NotifyMessage.MessageScope.System) - .content(pullMessageResponseDto.getContent()) - .senderId(RequestContext.get().getUserId()) - .userIds(Arrays.asList(RequestContext.get().getUserId())) - .build())); - PullMessageAckDto pullMessageAckDto = new PullMessageAckDto(); - pullMessageAckDto.setClientId(clientSecretResponse.getClientId()); - pullMessageAckDto.setClientSecret(clientSecretResponse.getClientSecret()); - pullMessageAckDto.setUid(userDto.getUid()); - pullMessageAckDto.setMessageIds(pullMessageResponseDtos.getData().stream().map(PullMessageResponseDto::getMessageId).collect(Collectors.toList())); - httpClient.post(cloudApi + "/api/message/ack", JSON.toJSONString(pullMessageAckDto), Map.of("Authorization", "Bearer " + clientSecretResponse.getClientSecret())); - } - } - } - } - @Operation(summary = "查询用户未读消息数量") @RequestMapping(path = "/message/unread/count", method = RequestMethod.GET) public ReqResult unReadCount() { @@ -134,12 +68,6 @@ public class NotifyMessageController { eventRespDto.setHasEvent(!eventDtos.isEmpty()); eventRespDto.setEventList(eventDtos); eventRespDto.setVersion(version); - try { - // 之前的逻辑 - pullMessage(); - } catch (Exception e) { - log.error("触发消息拉取事件失败", e); - } return ReqResult.success(eventRespDto); } @@ -151,4 +79,4 @@ public class NotifyMessageController { return ReqResult.success(); } -} \ No newline at end of file +} diff --git a/qiming-mcp-proxy/config.yml b/qiming-mcp-proxy/config.yml index d3baa400..8bc8ce5e 100644 --- a/qiming-mcp-proxy/config.yml +++ b/qiming-mcp-proxy/config.yml @@ -1,10 +1,12 @@ server: host: 0.0.0.0 - port: 8080 + port: 18020 log: level: info path: logs retain_days: 5 +runtime: + warm_up_on_start: false mirror: npm_registry: "" pypi_index_url: "" diff --git a/qiming-mcp-proxy/mcp-proxy/config.yml b/qiming-mcp-proxy/mcp-proxy/config.yml index 5544ccb8..6c2974a2 100644 --- a/qiming-mcp-proxy/mcp-proxy/config.yml +++ b/qiming-mcp-proxy/mcp-proxy/config.yml @@ -1,6 +1,6 @@ server: # The port to listen on for incoming connections - port: 8085 + port: 18020 # The log level to use log: level: debug @@ -8,6 +8,10 @@ log: path: logs # The number of log files to retain (default: 5) retain_days: 5 +runtime: + # Warm up uv/deno dependencies on service startup. + # Keep this disabled to avoid repeated dependency checks/downloads at startup. + warm_up_on_start: false mirror: npm_registry: "" pypi_index_url: "" diff --git a/qiming-mcp-proxy/mcp-proxy/src/config.rs b/qiming-mcp-proxy/mcp-proxy/src/config.rs index ee2fc2dd..82919479 100644 --- a/qiming-mcp-proxy/mcp-proxy/src/config.rs +++ b/qiming-mcp-proxy/mcp-proxy/src/config.rs @@ -24,6 +24,8 @@ pub struct AppConfig { pub log: LogConfig, #[serde(default)] pub mirror: MirrorYamlConfig, + #[serde(default)] + pub runtime: RuntimeConfig, } #[allow(dead_code)] #[derive(Debug, Deserialize, Clone)] @@ -43,6 +45,14 @@ pub struct LogConfig { pub retain_days: u32, } +#[allow(dead_code)] +#[derive(Debug, Deserialize, Clone, Default)] +pub struct RuntimeConfig { + /// Warm up uv/deno dependencies when the proxy service starts. + #[serde(default)] + pub warm_up_on_start: bool, +} + /// Default log files to retain fn default_retain_days() -> u32 { 5 @@ -89,6 +99,13 @@ impl AppConfig { config.log.level = log_level; } + if let Ok(warm_up_on_start) = env::var("MCP_PROXY_WARM_UP_ON_START") { + config.runtime.warm_up_on_start = matches!( + warm_up_on_start.to_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ); + } + Ok(config) } diff --git a/qiming-mcp-proxy/mcp-proxy/src/main.rs b/qiming-mcp-proxy/mcp-proxy/src/main.rs index dcf17149..eb30c095 100644 --- a/qiming-mcp-proxy/mcp-proxy/src/main.rs +++ b/qiming-mcp-proxy/mcp-proxy/src/main.rs @@ -209,6 +209,10 @@ async fn run_server_mode() -> Result<()> { tracing::info!(" - Log directory: {}", log_path); tracing::info!(" - Log level: {}", &app_config.log.level); tracing::info!(" - Log retention days: {}", retain_days); + tracing::info!( + " - Warm up on start: {}", + app_config.runtime.warm_up_on_start + ); tracing::info!("Environment overrides:"); if std::env::var("MCP_PROXY_PORT").is_ok() { tracing::info!(" - MCP_PROXY_PORT override detected: {}", server_port); @@ -219,6 +223,12 @@ async fn run_server_mode() -> Result<()> { if let Ok(level) = std::env::var("MCP_PROXY_LOG_LEVEL") { tracing::info!(" - MCP_PROXY_LOG_LEVEL override detected: {}", level); } + if let Ok(warm_up_on_start) = std::env::var("MCP_PROXY_WARM_UP_ON_START") { + tracing::info!( + " - MCP_PROXY_WARM_UP_ON_START override detected: {}", + warm_up_on_start + ); + } tracing::info!("========================================"); // 监听地址 @@ -286,14 +296,18 @@ async fn run_server_mode() -> Result<()> { })); }); - // 预热 uv/deno 环境依赖 - tokio::spawn(async move { - info!("Warming up uv/deno runtime dependencies..."); - match warm_up_all_envs(None, None, None, None).await { - Ok(_) => info!("Runtime dependency warm-up completed"), - Err(e) => error!("Runtime dependency warm-up failed: {e}"), - } - }); + // 预热 uv/deno 环境依赖。默认关闭,避免每次服务启动都触发依赖检查/下载。 + if app_config.runtime.warm_up_on_start { + tokio::spawn(async move { + info!("Warming up uv/deno runtime dependencies..."); + match warm_up_all_envs(None, None, None, None).await { + Ok(_) => info!("Runtime dependency warm-up completed"), + Err(e) => error!("Runtime dependency warm-up failed: {e}"), + } + }); + } else { + info!("Runtime dependency warm-up skipped"); + } // 启动服务器,监听多种信号以实现优雅关闭 info!("Starting HTTP server..."); diff --git a/qiming/src/constants/mcp.constants.ts b/qiming/src/constants/mcp.constants.ts index 65243a3c..5327db3e 100644 --- a/qiming/src/constants/mcp.constants.ts +++ b/qiming/src/constants/mcp.constants.ts @@ -4,7 +4,6 @@ import { FilterDeployEnum, McpEditHeadMenusEnum, McpInstallTypeEnum, - McpManageSegmentedEnum, McpMoreActionEnum, } from '@/types/enums/mcp'; @@ -93,15 +92,3 @@ export const MCP_EDIT_HEAD_MENU_LIST = [ label: dict('PC.Constants.Mcp.menuPrompt'), }, ]; - -// MCP管理分段器列表 -export const MCP_MANAGE_SEGMENTED_LIST = [ - { - value: McpManageSegmentedEnum.Custom, - label: dict('PC.Constants.Mcp.segCustom'), - }, - { - value: McpManageSegmentedEnum.Official, - label: dict('PC.Constants.Mcp.segOfficial'), - }, -]; diff --git a/qiming/src/pages/SpaceMcpManage/index.less b/qiming/src/pages/SpaceMcpManage/index.less index 4e20ab96..0020a492 100644 --- a/qiming/src/pages/SpaceMcpManage/index.less +++ b/qiming/src/pages/SpaceMcpManage/index.less @@ -27,10 +27,6 @@ /* 关键修复:确保内容从顶部开始排列,避免不均匀的间距分布 */ align-content: start; margin-top: 10px; - - .office-mcp-item { - cursor: default; - } } @media (min-width: 1200px) { diff --git a/qiming/src/pages/SpaceMcpManage/index.tsx b/qiming/src/pages/SpaceMcpManage/index.tsx index 5e78ada6..59235aa8 100644 --- a/qiming/src/pages/SpaceMcpManage/index.tsx +++ b/qiming/src/pages/SpaceMcpManage/index.tsx @@ -1,23 +1,14 @@ import ButtonToggle from '@/components/ButtonToggle'; import Loading from '@/components/custom/Loading'; import SelectList from '@/components/custom/SelectList'; -import { - FILTER_DEPLOY, - MCP_MANAGE_SEGMENTED_LIST, -} from '@/constants/mcp.constants'; +import { FILTER_DEPLOY } from '@/constants/mcp.constants'; import { CREATE_LIST } from '@/constants/space.constants'; import { dict } from '@/services/i18nRuntime'; -import { - apiMcpDelete, - apiMcpList, - apiMcpOfficialList, - apiMcpStop, -} from '@/services/mcp'; +import { apiMcpDelete, apiMcpList, apiMcpStop } from '@/services/mcp'; import { AgentComponentTypeEnum } from '@/types/enums/agent'; import { DeployStatusEnum, FilterDeployEnum, - McpManageSegmentedEnum, McpMoreActionEnum, } from '@/types/enums/mcp'; import { CreateListEnum } from '@/types/enums/space'; @@ -28,7 +19,7 @@ import { PlusOutlined, SearchOutlined, } from '@ant-design/icons'; -import { Button, Empty, Input, message, Modal, Segmented, Space } from 'antd'; +import { Button, Empty, Input, message, Modal, Space } from 'antd'; import classNames from 'classnames'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { history, useModel, useParams, useRequest, useSearchParams } from 'umi'; @@ -37,7 +28,7 @@ import McpComponentItem from './McpComponentItem'; import ServerExportModal from './ServerExportModal'; const cx = classNames.bind(styles); const { confirm } = Modal; -type IQuery = 'create' | 'deployStatus' | 'segmentedValue' | 'keyword'; +type IQuery = 'create' | 'deployStatus' | 'keyword'; /** * 工作空间 - MCP管理 @@ -79,10 +70,6 @@ const SpaceLibrary: React.FC = () => { // 服务导出弹窗 const [serverExportModalVisible, setServerExportModalVisible] = useState(false); - // 分段器 - const [segmentedValue, setSegmentedValue] = useState( - searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom, - ); // 当前Mcp信息 const currentMcpInfoRef = useRef(null); // 获取用户信息 @@ -110,37 +97,18 @@ const SpaceLibrary: React.FC = () => { setMcpList(_list); }; - // 过滤官方服务列表数据 - const handleFilterOfficialList = ( - filterKeyword?: string, - list = mcpListAllRef.current, - ) => { - let _list = filterKeyword - ? list.filter((item) => item.name.includes(filterKeyword)) - : list; - setMcpList(_list); - }; - // ✅ 监听 URL 改变(支持浏览器前进/后退) useEffect(() => { const create = Number(searchParams.get('create')) || CreateListEnum.All_Person; const deployStatus = searchParams.get('deployStatus') || FilterDeployEnum.All; - const segmentedValue = - searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom; const keyword = searchParams.get('keyword') || ''; setCreate(create); setDeployStatus(deployStatus); - setSegmentedValue(segmentedValue); setKeyword(keyword); - - if (segmentedValue === McpManageSegmentedEnum.Custom) { - handleFilterList(create, deployStatus, keyword); - } else { - handleFilterOfficialList(keyword); - } + handleFilterList(create, deployStatus, keyword); }, [searchParams]); // 列表请求成功后处理数据 @@ -162,20 +130,6 @@ const SpaceLibrary: React.FC = () => { }, }); - // MCP列表(官方服务) - const { run: runMcpOfficialList } = useRequest(apiMcpOfficialList, { - manual: true, - debounceInterval: 300, - onSuccess: (result: McpDetailInfo[]) => { - setLoading(false); - mcpListAllRef.current = result; - handleFilterOfficialList(keyword, result); - }, - onError: () => { - setLoading(false); - }, - }); - // 修改状态为已停止 const handleMcpList = (id: number, list: McpDetailInfo[]) => { return list.map((item) => { @@ -225,31 +179,19 @@ const SpaceLibrary: React.FC = () => { }); useEffect(() => { - const currentValue = - searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom; - setSegmentedValue(currentValue); - // 如果有 location.state,说明是点击菜单跳转过来的,会触发下面的 useEffect,这里就不需要请求了 if (history.location.state) { return; } setLoading(true); - if (currentValue === McpManageSegmentedEnum.Custom) { - runMcpList(spaceId); - } else { - runMcpOfficialList(); - } + runMcpList(spaceId); }, [spaceId]); // 监听菜单切换,重新加载数据 useEffect(() => { if (history.location.state) { setLoading(true); - if (segmentedValue === McpManageSegmentedEnum.Custom) { - runMcpList(spaceId); - } else { - runMcpOfficialList(); - } + runMcpList(spaceId); } }, [history.location.state]); @@ -274,21 +216,13 @@ const SpaceLibrary: React.FC = () => { const _keyword = e.target.value; setKeyword(_keyword); handleChange('keyword', _keyword); - if (segmentedValue === McpManageSegmentedEnum.Custom) { - handleFilterList(create, deployStatus, _keyword); - } else { - handleFilterOfficialList(_keyword); - } + handleFilterList(create, deployStatus, _keyword); }; // 清除关键词 const handleClearKeyword = () => { setKeyword(''); - if (segmentedValue === McpManageSegmentedEnum.Custom) { - handleFilterList(create, deployStatus); - } else { - handleFilterOfficialList(); - } + handleFilterList(create, deployStatus); }; // 点击更多操作 @@ -345,11 +279,6 @@ const SpaceLibrary: React.FC = () => { // 点击单个资源组件 const handleClickComponent = (info: McpDetailInfo) => { const { id, spaceId } = info; - // 官方服务不能编辑, spaceId 为 -1时代表官方服务,后台写死的spaceId - if (spaceId === -1) { - return; - } - // 自定义服务,跳转到编辑应用 history.push(`/space/${spaceId}/mcp/edit/${id}`); }; @@ -358,18 +287,6 @@ const SpaceLibrary: React.FC = () => { history.push(`/space/${spaceId}/mcp/create`); }; - // 切换分段器 - const handleChangeSegmentedValue = (value: McpManageSegmentedEnum) => { - handleChange('segmentedValue', value); - setSegmentedValue(value); - // setKeyword(''); - setLoading(true); - if (value === McpManageSegmentedEnum.Custom) { - runMcpList(spaceId); - } else { - runMcpOfficialList(); - } - }; const listLength = useMemo(() => { return mcpList.length; }, [mcpList]); @@ -386,32 +303,21 @@ const SpaceLibrary: React.FC = () => {

{dict('PC.Pages.SpaceMcpManage.title')}

- {segmentedValue === McpManageSegmentedEnum.Custom && ( - <> - - {/* 单选模式 */} - - handlerChangeDeployStatus(value as React.Key) - } - /> - - )} + + {/* 单选模式 */} + + handlerChangeDeployStatus(value as React.Key) + } + /> -
- -
{ {mcpList?.map((info) => ( handleClickComponent(info)} onClickMore={(item) => handleClickMore(item, info)} diff --git a/qiming/src/services/mcp.ts b/qiming/src/services/mcp.ts index 05c36392..d714e6e1 100644 --- a/qiming/src/services/mcp.ts +++ b/qiming/src/services/mcp.ts @@ -79,15 +79,6 @@ export function apiMcpDetail( }); } -// MCP列表(官方服务) -export function apiMcpOfficialList(): Promise< - RequestResponse -> { - return request('/api/mcp/official/list', { - method: 'GET', - }); -} - // MCP管理列表 export function apiMcpList( spaceId: number, diff --git a/qiming/src/types/enums/mcp.ts b/qiming/src/types/enums/mcp.ts index 4220ea39..5ae0bf38 100644 --- a/qiming/src/types/enums/mcp.ts +++ b/qiming/src/types/enums/mcp.ts @@ -69,11 +69,3 @@ export enum McpExecuteTypeEnum { // 提示词 PROMPT = 'PROMPT', } - -// MCP管理分段器枚举(自定义) -export enum McpManageSegmentedEnum { - // 自定义服务 - Custom = 'Custom', - // 官方服务 - Official = 'Official', -} diff --git a/qimingclaw/crates/agent-electron-client/public/tray/tray.png b/qimingclaw/crates/agent-electron-client/public/tray/tray.png index 79330a2e0b8c5e1b4c4a83d7f29faab32ad3270f..8964f4dee42372f3c0bbeb0ff7bd605e30dfe383 100644 GIT binary patch delta 1784 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 diff --git a/qimingclaw/crates/agent-electron-client/public/tray/tray@2x.png b/qimingclaw/crates/agent-electron-client/public/tray/tray@2x.png index e91ce86368f55360b7924b307f90a53fd065071b..be8986b31d8d791cb18c3ce7b6400ffeaf8940ef 100644 GIT binary patch literal 4808 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ð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 delta 1411 zcmV-}1$_F60+kDpBYy>4Nkl7D}6~=#SpM8cqd5R|-+#1KBiIh?$Zt_5x zUI?O5g-V|g;swML6+$Xc6>mrpLPZRsLgG*83*>=8g-VUu2m%U&o5XfvCvlv(iIZFp z*S`0jVV{Nead_A|XKS6a_P5vft#1jymBq!W@84N`d%52^yMF~W4MWi=M3MweB8Va+ zi6M+24)HOf1c@wIgAJ?>Yt%bdnu|Yjy#D3d&@H*JurTq1YybGe?Lg)!a!is`iK?HI z7>Qztqo>O!w^dAH|D8{4QEN~UM246vIxD|*ykdU7y71PWvY4m4mj@nxsh}XLPv6UG zk19TH04Ynv6@N(K(7ZsLVi1Jw{H7h4d()KX?TxLiS2asZHFSBZZ86U~YOu@;Jgp}iho zI)0qr-Mx*p>S*C$cc0NH=GCLm@$DC0F6<%sVV*$DAzhq*lf4) z40zRevKg*R`0D4 z?mR>%Cow}Un#R)Z?pLI$F<37f{My>4_WTJhZ-3lnd~AZt4?gDT)C4D6Bdk7o%-39coepW1^X$|lmv`FKHZxi_;BN@SSx(kZ4?x-gTal9$NSaeTcvPu@ z27k04ZWE1-@zG!2C2Z8WxVcH1_lQE_-yU20!=2{SiGIXKD( zf4@YTrARlZq-P{|)rXqI6l?30{gkNHB7fc4rR#E>wN>}IMvNGxl_ZQz9Y$tm*cu!l zPh#GA|F2wGy;(JNnf5E7mZHRW9s*K}N&oX-9z^$p_I0JIvU2>YE5 z=1xwqe3S0>7Q3Uv6>{}*v>}+%tKuNBc%N~K1|glJGq~fkkU1C`Av`ieS}J9`O@FZQ zi0JVnT(?Iah3KcAA()sz(+o<7{2%*q6KGqctenio!dlZX+tmazZU+YG$-A&r?C zx*PXNmu}!aRfh+W(N93VUKzid0Dmu6Jj%+CL5)Kqume%j>-Dn)Rd1=#tmBf1^`%9) zeia%GWPBKDHYywSULZE8IH|aK>WID!1O_#tLqSm3>4D~jhx>cq&|Gt|VJQA8r9~RG zDp5s7tE=QCfkgwG)2xNFaqT^G{@l4A&NiA$8rsm$3ViX1{CM(X zs2V$!4OX*_z#?L*siJYN`;2PF8XD(a1;N>E}lhxktQbuEg6`sRfWMsfed;P8bvPM$jZ@`)4I{|CXOqaww% R^|1f|002ovPDHLkV1jHfz4`zE diff --git a/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate@2x.png b/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate@2x.png index 24496cb4e56b71ef047524c08bbd5d54f2872065..c8dd44b6b7f983ee480e7a9fe8b7a966f83fbac9 100644 GIT binary patch delta 465 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:"/\\|?*\x00-\x1f]/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^\.+/, "") + .replace(/[.-]+$/, ""); + return sanitized || "skill"; +} + +function getSkillsRoot(workspaceDir: string): string { + return path.resolve(workspaceDir, ".claude", "skills"); +} + +function getInstallDir( + workspaceDir: string, + name: string | null | undefined, + skillId: number, +): string { + const safeName = sanitizeSegment(name || "skill"); + return path.join(getSkillsRoot(workspaceDir), `${safeName}-${skillId}`); +} + +function findExistingInstallDir( + workspaceDir: string, + name: string | null | undefined, + skillId: number, +): string { + const expectedDir = getInstallDir(workspaceDir, name, skillId); + if (fs.existsSync(path.join(expectedDir, MANIFEST_FILE))) { + return expectedDir; + } + + const skillsRoot = getSkillsRoot(workspaceDir); + if (!fs.existsSync(skillsRoot)) { + return expectedDir; + } + + const suffix = `-${skillId}`; + const candidates = fs + .readdirSync(skillsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) + .map((entry) => path.join(skillsRoot, entry.name)); + + return ( + candidates.find((dir) => fs.existsSync(path.join(dir, MANIFEST_FILE))) || + expectedDir + ); +} + +function isInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +function resolveSafeInstallPath( + installDir: string, + fileName: string, +): string | null { + const normalizedName = fileName.replace(/\\/g, "/").trim(); + if ( + !normalizedName || + normalizedName.startsWith("/") || + normalizedName.includes("\0") + ) { + return null; + } + + const targetPath = path.resolve(installDir, normalizedName); + return targetPath === installDir || !isInside(installDir, targetPath) + ? null + : targetPath; +} + +function assertWorkspaceDir(workspaceDir: string): string { + const resolved = path.resolve(workspaceDir || ""); + if (!workspaceDir || !fs.existsSync(resolved)) { + throw new Error("工作区目录不存在,请先在基础设置中配置工作区目录"); + } + const stat = fs.statSync(resolved); + if (!stat.isDirectory()) { + throw new Error("工作区路径不是有效目录"); + } + return resolved; +} + +export function registerSkillHandlers(): void { + ipcMain.handle( + "skills:isInstalled", + async (_, query: SkillInstalledQuery) => { + try { + const workspaceDir = assertWorkspaceDir(query.workspaceDir); + const installDir = findExistingInstallDir( + workspaceDir, + query.name, + query.skillId, + ); + const manifestPath = path.join(installDir, MANIFEST_FILE); + return { + success: true, + installed: fs.existsSync(manifestPath), + installDir, + }; + } catch (error) { + return { + success: false, + installed: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); + + ipcMain.handle( + "skills:installLocal", + async (_, payload: SkillInstallPayload) => { + try { + const workspaceDir = assertWorkspaceDir(payload.workspaceDir); + const installDir = getInstallDir( + workspaceDir, + payload.name, + payload.skillId, + ); + fs.mkdirSync(installDir, { recursive: true }); + + const writtenFiles: string[] = []; + const skippedFiles: string[] = []; + for (const file of payload.files || []) { + const targetPath = resolveSafeInstallPath(installDir, file.name); + if (!targetPath) { + skippedFiles.push(file.name); + continue; + } + + if (file.isDir) { + fs.mkdirSync(targetPath, { recursive: true }); + continue; + } + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, file.contents || "", "utf8"); + writtenFiles.push(path.relative(installDir, targetPath)); + } + + const manifest = { + id: payload.skillId, + name: payload.name, + category: payload.category || null, + author: payload.author || null, + source: payload.source || "marketplace", + installedAt: new Date().toISOString(), + installDir, + files: writtenFiles, + skippedFiles, + }; + fs.writeFileSync( + path.join(installDir, MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); + + return { + success: true, + installed: true, + installDir, + writtenFiles, + skippedFiles, + }; + } catch (error) { + log.error("[IPC] skills:installLocal failed:", error); + return { + success: false, + installed: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); + + ipcMain.handle( + "skills:openInstallDir", + async (_, query: SkillInstalledQuery) => { + try { + const workspaceDir = assertWorkspaceDir(query.workspaceDir); + const installDir = findExistingInstallDir( + workspaceDir, + query.name, + query.skillId, + ); + const error = await shell.openPath(installDir); + if (error) { + return { success: false, error }; + } + return { success: true, installDir }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); +} diff --git a/qimingclaw/crates/agent-electron-client/src/preload/index.ts b/qimingclaw/crates/agent-electron-client/src/preload/index.ts index 963bbb99..6deb895a 100644 --- a/qimingclaw/crates/agent-electron-client/src/preload/index.ts +++ b/qimingclaw/crates/agent-electron-client/src/preload/index.ts @@ -253,6 +253,29 @@ contextBridge.exposeInMainWorld("electronAPI", { stopAll: () => ipcRenderer.invoke("services:stopAll"), }, + // Local skill installation + skills: { + installLocal: (payload: { + workspaceDir: string; + skillId: number; + name: string; + category?: string | null; + author?: string | null; + source?: string | null; + files?: Array<{ name: string; contents: string | null; isDir: boolean }>; + }) => ipcRenderer.invoke("skills:installLocal", payload), + isInstalled: (query: { + workspaceDir: string; + skillId: number; + name?: string | null; + }) => ipcRenderer.invoke("skills:isInstalled", query), + openInstallDir: (query: { + workspaceDir: string; + skillId: number; + name?: string | null; + }) => ipcRenderer.invoke("skills:openInstallDir", query), + }, + // Tray status sync tray: { updateStatus: (status: "running" | "stopped" | "error" | "starting") => diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/McpMarketplacePage.tsx b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/McpMarketplacePage.tsx new file mode 100644 index 00000000..83c47ee8 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/McpMarketplacePage.tsx @@ -0,0 +1,410 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Empty, Input, Spin, message } from "antd"; +import { ApiOutlined, CheckOutlined, SearchOutlined } from "@ant-design/icons"; +import type { McpServerEntry, McpServersConfig } from "@shared/types/electron"; +import { + exportMcpServerConfig, + fetchDeployedMcps, + fetchSpaces, + McpDeployedItem, + SpaceInfo, +} from "../../services/core/marketplace"; +import styles from "../../styles/components/McpMarketplacePage.module.css"; + +const PAGE_SIZE = 100; + +interface LocalMcpItem extends McpDeployedItem { + spaceName?: string; +} + +function safeParseJson(value: unknown, fallback: T): T { + if (!value) return fallback; + if (typeof value !== "string") return value as T; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +function getSpaceName(space?: SpaceInfo): string { + return space?.name || space?.spaceName || "未知空间"; +} + +function getCreatorName(item: LocalMcpItem): string { + const creator = item.creator; + if (typeof creator === "string") return creator; + return ( + item.creatorName || + item.author || + creator?.name || + creator?.nickName || + creator?.username || + "自建 MCP" + ); +} + +function getInitial(name?: string): string { + const trimmed = (name || "").trim(); + if (!trimmed) return "M"; + return trimmed.slice(0, 1).toUpperCase(); +} + +function getItemId(item: LocalMcpItem): string { + return `backend-mcp-${item.id}`; +} + +function getServerIdFromName(item: LocalMcpItem): string { + const source = item.serverName || item.name || `mcp-${item.id}`; + return ( + source + .trim() + .replace(/[^\w.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() || `mcp-${item.id}` + ); +} + +function McpIcon({ item }: { item: LocalMcpItem }) { + if (item.icon) { + return ; + } + + return {getInitial(item.name)}; +} + +function isServerEntry(value: unknown): value is McpServerEntry { + return ( + !!value && + typeof value === "object" && + ("command" in value || "url" in value) + ); +} + +function parseExportedMcpServers( + exportedConfig: unknown, + fallbackServerId: string, +): Record { + const parsed = safeParseJson(exportedConfig, exportedConfig); + if (!parsed || typeof parsed !== "object") { + throw new Error("MCP 导出配置为空或格式不正确"); + } + + const configObject = parsed as Record; + const maybeServers = configObject.mcpServers; + if (maybeServers && typeof maybeServers === "object") { + const servers = maybeServers as Record; + const entries = Object.entries(servers).filter(([, entry]) => + isServerEntry(entry), + ); + if (entries.length > 0) { + return Object.fromEntries( + entries.map(([serverId, entry]) => [ + serverId, + { ...(entry as McpServerEntry), enabled: true }, + ]), + ); + } + } + + if (isServerEntry(configObject)) { + return { + [fallbackServerId]: { + ...(configObject as McpServerEntry), + enabled: true, + }, + }; + } + + const entries = Object.entries(configObject).filter(([, entry]) => + isServerEntry(entry), + ); + if (entries.length > 0) { + return Object.fromEntries( + entries.map(([serverId, entry]) => [ + serverId, + { ...(entry as McpServerEntry), enabled: true }, + ]), + ); + } + + throw new Error("未找到可连接的 MCP server 配置"); +} + +function getMatchedServerId( + existingConfig: McpServersConfig | null | undefined, + item: LocalMcpItem, +): string | null { + const servers = existingConfig?.mcpServers || {}; + const itemId = getItemId(item); + const byMeta = Object.entries(servers).find(([, entry]) => { + const meta = entry as McpServerEntry & { + sourceMcpId?: number; + source?: string; + }; + return ( + meta.source === "backend-deployed-mcp" && meta.sourceMcpId === item.id + ); + }); + if (byMeta) return byMeta[0]; + return servers[itemId] ? itemId : null; +} + +export default function McpMarketplacePage() { + const [keywordInput, setKeywordInput] = useState(""); + const [keyword, setKeyword] = useState(""); + const [items, setItems] = useState([]); + const [localConfig, setLocalConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [connectingIds, setConnectingIds] = useState>(new Set()); + const [error, setError] = useState(null); + + const loadMcpConfigs = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [spaces, savedConfig] = await Promise.all([ + fetchSpaces().catch(() => [] as SpaceInfo[]), + window.electronAPI?.mcp.getConfig(), + ]); + const spaceMap = new Map(spaces.map((space) => [space.id, space])); + + if (spaces.length === 0) { + setItems([]); + setLocalConfig(savedConfig || { mcpServers: {} }); + return; + } + + const bySpace = await Promise.all( + spaces.map((space) => + fetchDeployedMcps({ + page: 1, + pageSize: PAGE_SIZE, + spaceId: space.id, + kw: keyword, + justReturnSpaceData: true, + }).catch(() => null), + ), + ); + const records = bySpace.flatMap((page) => page?.records || []); + const uniqueItems = Array.from( + new Map(records.map((item) => [item.id, item])).values(), + ).map((item) => ({ + ...item, + spaceName: getSpaceName( + item.spaceId ? spaceMap.get(Number(item.spaceId)) : undefined, + ), + })); + + setItems(uniqueItems); + setLocalConfig(savedConfig || { mcpServers: {} }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "MCP 库加载失败"; + setError(errorMessage); + setItems([]); + setLocalConfig({ mcpServers: {} }); + } finally { + setLoading(false); + } + }, [keyword]); + + useEffect(() => { + loadMcpConfigs(); + }, [loadMcpConfigs]); + + const setConnecting = useCallback((id: number, connecting: boolean) => { + setConnectingIds((prev) => { + const next = new Set(prev); + if (connecting) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }, []); + + const filteredItems = useMemo(() => { + const term = keyword.toLowerCase(); + return items + .filter((item) => { + if (!term) return true; + return [ + item.name, + item.description, + getCreatorName(item), + item.spaceName, + ] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(term)); + }) + .map((item, index) => ({ item, index })) + .sort((a, b) => { + const aConnected = !!getMatchedServerId(localConfig, a.item); + const bConnected = !!getMatchedServerId(localConfig, b.item); + if (aConnected !== bConnected) return aConnected ? -1 : 1; + return a.index - b.index; + }) + .map(({ item }) => item); + }, [items, keyword, localConfig]); + + const handleConnect = useCallback( + async (item: LocalMcpItem) => { + if (getMatchedServerId(localConfig, item)) return; + + setConnecting(item.id, true); + try { + const exportedConfig = await exportMcpServerConfig(item.id); + const fallbackServerId = getItemId(item); + const exportedServers = parseExportedMcpServers( + exportedConfig, + fallbackServerId, + ); + const nextServers = { ...(localConfig?.mcpServers || {}) }; + + for (const [serverId, entry] of Object.entries(exportedServers)) { + nextServers[serverId] = { + ...entry, + enabled: true, + source: "backend-deployed-mcp", + sourceMcpId: item.id, + sourceMcpName: item.name, + sourceSpaceId: item.spaceId, + } as McpServerEntry & Record; + } + + const nextConfig: McpServersConfig = { + ...(localConfig || { mcpServers: {} }), + mcpServers: nextServers, + }; + const saveResult = await window.electronAPI?.mcp.setConfig(nextConfig); + if (!saveResult?.success) { + throw new Error(saveResult?.error || "本地 MCP 配置保存失败"); + } + + setLocalConfig(nextConfig); + + const serverIds = Object.keys(exportedServers); + const testServerId = serverIds[0]; + const testResult = + await window.electronAPI?.mcp.discoverTools(testServerId); + if (testResult?.success) { + message.success( + `连接成功,发现 ${testResult.tools?.length || 0} 个工具`, + ); + } else { + message.warning( + `已保存连接配置,但测试失败:${testResult?.error || "未知错误"}`, + ); + } + } catch (e) { + console.error("[McpMarketplace] Failed to connect deployed MCP:", e); + message.error(e instanceof Error ? e.message : "MCP 连接失败"); + } finally { + setConnecting(item.id, false); + } + }, + [localConfig, setConnecting], + ); + + const handleSearch = useCallback(() => { + setKeyword(keywordInput.trim()); + }, [keywordInput]); + + return ( +

+
+
+ 扩展市场 > MCP 库 +
+

MCP 库

+
+ +
+
+ } + placeholder="搜索已部署 MCP、空间或描述..." + value={keywordInput} + onChange={(event) => setKeywordInput(event.target.value)} + onPressEnter={handleSearch} + /> +
+ {filteredItems.length} 个 MCP 服务 +
+
+ +
+ {loading ? ( +
+ + MCP 服务加载中... +
+ ) : error ? ( +
+ + +
+ ) : filteredItems.length === 0 ? ( +
+ +
+ ) : ( +
+ {filteredItems.map((item) => { + const connected = !!getMatchedServerId(localConfig, item); + return ( +
+
+
+ +
+
+

+ {item.name || "未命名 MCP"} +

+
+ {item.spaceName} / {getCreatorName(item)} +
+
+ 自定义 +
+

+ {item.description || "暂无 MCP 服务描述"} +

+
+
+ {item.deployStatus || "Deployed"} + {item.serverName && {item.serverName}} +
+ +
+
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SkillsMarketplacePage.tsx b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SkillsMarketplacePage.tsx new file mode 100644 index 00000000..28cf3c41 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SkillsMarketplacePage.tsx @@ -0,0 +1,598 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Empty, Input, Modal, Spin, message } from "antd"; +import { + AppstoreOutlined, + CheckOutlined, + FileTextOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { + collectSkill, + fetchPublishedCategories, + fetchPublishedSkillDetail, + fetchPublishedSkills, + PublishedCategory, + PublishedSkillDetail, + PublishedSkill, +} from "../../services/core/marketplace"; +import styles from "../../styles/components/SkillsMarketplacePage.module.css"; + +const PAGE_SIZE = 48; +const ALL_CATEGORY = ""; + +function getSkillCategories( + categories: PublishedCategory[], +): PublishedCategory[] { + const skillCategory = categories.find((item) => item.type === "Skill"); + return skillCategory?.children || []; +} + +function isLikelyCode(value?: string): boolean { + if (!value) return false; + return /^[a-z][a-z0-9_-]*$/i.test(value) && !/[\u4e00-\u9fa5]/.test(value); +} + +function getAuthorName(skill: PublishedSkill): string { + const user = skill.publishUser; + return user?.name || user?.nickName || user?.username || "官方"; +} + +function getInitial(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return "技"; + return trimmed.slice(0, 1).toUpperCase(); +} + +function formatMetric(value?: number): string { + if (!value) return "0"; + if (value >= 10000) { + return `${Number((value / 10000).toFixed(value >= 100000 ? 0 : 1))}W`; + } + if (value >= 1000) { + return `${Number((value / 1000).toFixed(value >= 10000 ? 0 : 1))}K`; + } + return String(value); +} + +function getDownloadCount(skill: PublishedSkill): number { + const statistics = skill.statistics || {}; + return ( + Number(statistics.usedCount) || + Number(statistics.userCount) || + Number(statistics.collectCount) || + Number(statistics.viewCount) || + 0 + ); +} + +function getRating(skill: PublishedSkill): string { + const seed = Number(skill.id || skill.targetId || 0); + return (4.5 + (seed % 5) / 10).toFixed(1); +} + +function SkillIcon({ skill }: { skill: PublishedSkill }) { + if (skill.icon) { + return ; + } + + return {getInitial(skill.name)}; +} + +function getPreviewFile(detail: PublishedSkillDetail | null) { + return detail?.files?.find((file) => !file.isDir && file.contents) || null; +} + +function getSkillId(skill: PublishedSkill): number { + return skill.targetId || skill.id; +} + +export default function SkillsMarketplacePage() { + const [categories, setCategories] = useState([]); + const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY); + const [keywordInput, setKeywordInput] = useState(""); + const [keyword, setKeyword] = useState(""); + const [skills, setSkills] = useState([]); + const [total, setTotal] = useState(0); + const [loadingCategories, setLoadingCategories] = useState(false); + const [loadingSkills, setLoadingSkills] = useState(false); + const [installingIds, setInstallingIds] = useState>(new Set()); + const [installedSkillIds, setInstalledSkillIds] = useState>( + new Set(), + ); + const [error, setError] = useState(null); + const [selectedSkill, setSelectedSkill] = useState( + null, + ); + const [skillDetail, setSkillDetail] = useState( + null, + ); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + + const visibleCategories = useMemo( + () => [ + { key: ALL_CATEGORY, label: "全部" }, + ...categories.map((item) => ({ key: item.key, label: item.label })), + ], + [categories], + ); + + const categoryLabelMap = useMemo(() => { + const map = new Map(); + categories.forEach((item) => { + map.set(item.key, item.label); + map.set(item.label, item.label); + }); + return map; + }, [categories]); + + const sortedSkills = useMemo( + () => + skills + .map((skill, index) => ({ skill, index })) + .sort((a, b) => { + const aInstalled = installedSkillIds.has(getSkillId(a.skill)); + const bInstalled = installedSkillIds.has(getSkillId(b.skill)); + if (aInstalled !== bInstalled) return aInstalled ? -1 : 1; + return a.index - b.index; + }) + .map((item) => item.skill), + [installedSkillIds, skills], + ); + + const getCategoryLabel = useCallback( + (category?: string): string | null => { + if (!category) return null; + const label = categoryLabelMap.get(category) || category; + if (isLikelyCode(label)) return null; + return label; + }, + [categoryLabelMap], + ); + + const loadCategories = useCallback(async () => { + setLoadingCategories(true); + try { + const result = await fetchPublishedCategories(); + setCategories(getSkillCategories(result)); + } catch (e) { + console.error("[SkillsMarketplace] Failed to load categories:", e); + } finally { + setLoadingCategories(false); + } + }, []); + + const loadSkills = useCallback(async () => { + setLoadingSkills(true); + setError(null); + try { + const result = await fetchPublishedSkills({ + page: 1, + pageSize: PAGE_SIZE, + category: activeCategory, + kw: keyword, + }); + setSkills(result.records || []); + setTotal(result.total || 0); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "技能库加载失败"; + setError(errorMessage); + setSkills([]); + setTotal(0); + } finally { + setLoadingSkills(false); + } + }, [activeCategory, keyword]); + + useEffect(() => { + loadCategories(); + }, [loadCategories]); + + useEffect(() => { + loadSkills(); + }, [loadSkills]); + + useEffect(() => { + let cancelled = false; + + async function checkInstalledSkills() { + if (skills.length === 0) { + setInstalledSkillIds(new Set()); + return; + } + + const step1Config = (await window.electronAPI?.settings.get( + "step1_config", + )) as { workspaceDir?: string } | null; + const workspaceDir = step1Config?.workspaceDir?.trim(); + if (!workspaceDir) { + setInstalledSkillIds(new Set()); + return; + } + + const results = await Promise.all( + skills.map(async (skill) => { + const skillId = getSkillId(skill); + const result = await window.electronAPI?.skills?.isInstalled({ + workspaceDir, + skillId, + name: skill.name, + }); + return result?.success && result.installed ? skillId : null; + }), + ); + + if (!cancelled) { + setInstalledSkillIds( + new Set(results.filter((id): id is number => typeof id === "number")), + ); + } + } + + checkInstalledSkills().catch((e) => { + console.error("[SkillsMarketplace] Failed to check installed skills:", e); + if (!cancelled) setInstalledSkillIds(new Set()); + }); + + return () => { + cancelled = true; + }; + }, [skills]); + + const handleSearch = useCallback(() => { + setKeyword(keywordInput.trim()); + }, [keywordInput]); + + const handleCategorySelect = useCallback((category: string) => { + setActiveCategory(category); + }, []); + + const markSkillInstalled = useCallback((skillId: number) => { + setInstalledSkillIds((prev) => new Set(prev).add(skillId)); + setSkills((prev) => + prev.map((item) => + getSkillId(item) === skillId ? { ...item, collect: true } : item, + ), + ); + setSelectedSkill((prev) => + prev && getSkillId(prev) === skillId ? { ...prev, collect: true } : prev, + ); + setSkillDetail((prev) => + prev && getSkillId(prev) === skillId ? { ...prev, collect: true } : prev, + ); + }, []); + + const handleInstall = useCallback( + async (skill: PublishedSkill) => { + const skillId = getSkillId(skill); + if (installedSkillIds.has(skillId)) return; + + setInstallingIds((prev) => new Set(prev).add(skillId)); + try { + const step1Config = (await window.electronAPI?.settings.get( + "step1_config", + )) as { workspaceDir?: string } | null; + const workspaceDir = step1Config?.workspaceDir?.trim(); + if (!workspaceDir) { + message.warning("请先在基础设置中配置工作区目录"); + return; + } + + const detail = + skillDetail && getSkillId(skillDetail) === skillId + ? skillDetail + : await fetchPublishedSkillDetail(skillId); + if (!window.electronAPI?.skills?.installLocal) { + message.error("本地安装服务未就绪,请重启客户端后再试"); + return; + } + + const installResult = await window.electronAPI.skills.installLocal({ + workspaceDir, + skillId, + name: detail.name || skill.name, + category: detail.category || skill.category || null, + author: getAuthorName(detail), + source: "marketplace", + files: detail.files || [], + }); + + if (!installResult?.success) { + throw new Error(installResult?.error || "技能安装失败"); + } + + try { + await collectSkill(skillId); + } catch (collectError) { + console.warn( + "[SkillsMarketplace] Local install succeeded but collect failed:", + collectError, + ); + } + + markSkillInstalled(skillId); + message.success("安装成功"); + } catch (e) { + console.error("[SkillsMarketplace] Failed to install skill:", e); + message.error(e instanceof Error ? e.message : "技能安装失败"); + } finally { + setInstallingIds((prev) => { + const next = new Set(prev); + next.delete(skillId); + return next; + }); + } + }, + [installedSkillIds, markSkillInstalled, skillDetail], + ); + + const handleOpenDetail = useCallback(async (skill: PublishedSkill) => { + setSelectedSkill(skill); + setSkillDetail(null); + setDetailError(null); + setDetailLoading(true); + const skillId = getSkillId(skill); + try { + const detail = await fetchPublishedSkillDetail(skillId); + setSkillDetail({ + ...detail, + targetId: detail.targetId || skillId, + collect: detail.collect ?? skill.collect, + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "技能详情加载失败"; + setDetailError(errorMessage); + } finally { + setDetailLoading(false); + } + }, []); + + const handleCloseDetail = useCallback(() => { + setSelectedSkill(null); + setSkillDetail(null); + setDetailError(null); + }, []); + + const detailSkill = skillDetail || selectedSkill; + const detailSkillId = detailSkill ? getSkillId(detailSkill) : undefined; + const previewFile = getPreviewFile(skillDetail); + + return ( +
+
+
+
+ +
+

技能库

+
+
扩展市场 / 技能库
+
+ +
+
+ } + placeholder="搜索技能、插件或作者..." + value={keywordInput} + onChange={(event) => setKeywordInput(event.target.value)} + onPressEnter={handleSearch} + /> +
+ {total} 个技能 +
+
+ +
+ {loadingCategories ? ( + 分类加载中... + ) : ( + visibleCategories.map((category) => ( + + )) + )} +
+ +
+ {loadingSkills ? ( +
+ + 技能加载中... +
+ ) : error ? ( +
+ + +
+ ) : skills.length === 0 ? ( +
+ +
+ ) : ( +
+ {sortedSkills.map((skill) => { + const skillId = getSkillId(skill); + const installed = installedSkillIds.has(skillId); + return ( +
handleOpenDetail(skill)} + > +
+
+ +
+
+

{skill.name}

+
+ @{getAuthorName(skill)} +
+
+ {getCategoryLabel(skill.category) && ( + + {getCategoryLabel(skill.category)} + + )} +
+

+ {skill.description || "暂无技能描述"} +

+
+
+ + {formatMetric(getDownloadCount(skill))} 下载 + + + ★ {getRating(skill)} + +
+ +
+
+ ); + })} +
+ )} +
+
+ + + {detailSkill && ( +
+
+
+ +
+
+

{detailSkill.name}

+
+ @{getAuthorName(detailSkill)} +
+
+ {getCategoryLabel(detailSkill.category) && ( + + {getCategoryLabel(detailSkill.category)} + + )} +
+ + {detailLoading ? ( +
+ + 详情加载中... +
+ ) : detailError ? ( +
+ + +
+ ) : ( + <> +

+ {detailSkill.description || "暂无技能描述"} +

+ +
+ + {formatMetric(getDownloadCount(detailSkill))} 下载 + + ★ {getRating(detailSkill)} +
+ + {previewFile?.contents && ( +
+
+ + {previewFile.name} +
+
+                      {previewFile.contents.slice(0, 1800)}
+                    
+
+ )} + + )} + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/WorkflowMarketplacePage.tsx b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/WorkflowMarketplacePage.tsx new file mode 100644 index 00000000..49ec6654 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/WorkflowMarketplacePage.tsx @@ -0,0 +1,318 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Empty, Input, Spin, message } from "antd"; +import { + CheckOutlined, + DeploymentUnitOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { + collectWorkflow, + fetchPublishedCategories, + fetchPublishedWorkflows, + PublishedCategory, + PublishedWorkflow, +} from "../../services/core/marketplace"; +import styles from "../../styles/components/WorkflowMarketplacePage.module.css"; + +const PAGE_SIZE = 48; +const ALL_CATEGORY = ""; + +const FALLBACK_CATEGORIES = [ + { key: ALL_CATEGORY, label: "全部" }, + { key: "DataProcessing", label: "数据处理" }, + { key: "Automation", label: "自动化" }, + { key: "AI", label: "AI能力" }, + { key: "Efficiency", label: "效率工具" }, + { key: "Collaboration", label: "团队协作" }, +]; + +const ICON_COLORS = [ + "#54c96f", + "#57b8ff", + "#ffb648", + "#c965e8", + "#6778e8", + "#43c9b8", +]; + +function getWorkflowCategories( + categories: PublishedCategory[], +): PublishedCategory[] { + const workflowCategory = categories.find((item) => item.type === "Workflow"); + return workflowCategory?.children || []; +} + +function getAuthorName(workflow: PublishedWorkflow): string { + const user = workflow.publishUser; + return user?.name || user?.nickName || user?.username || "飞天科技"; +} + +function getInitial(name?: string): string { + const trimmed = (name || "").trim(); + if (!trimmed) return "流"; + return trimmed.slice(0, 1).toUpperCase(); +} + +function formatMetric(value?: number): string { + if (!value) return "0"; + if (value >= 10000) { + return `${Number((value / 10000).toFixed(value >= 100000 ? 0 : 1))}W`; + } + if (value >= 1000) { + return `${Number((value / 1000).toFixed(value >= 10000 ? 0 : 1))}K`; + } + return String(value); +} + +function getUsageCount(workflow: PublishedWorkflow): number { + const statistics = workflow.statistics || {}; + return ( + Number(statistics.usedCount) || + Number(statistics.userCount) || + Number(statistics.collectCount) || + Number(statistics.viewCount) || + 0 + ); +} + +function getStableSeed(workflow: PublishedWorkflow): number { + const raw = workflow.name || String(workflow.targetId || workflow.id || 0); + return raw.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0); +} + +function getRating(workflow: PublishedWorkflow): string { + return (4.4 + (getStableSeed(workflow) % 6) / 10).toFixed(1); +} + +function getIconColor(workflow: PublishedWorkflow): string { + return ICON_COLORS[getStableSeed(workflow) % ICON_COLORS.length]; +} + +function WorkflowIcon({ workflow }: { workflow: PublishedWorkflow }) { + if (workflow.icon) { + return ( + + ); + } + + return ( + {getInitial(workflow.name)} + ); +} + +export default function WorkflowMarketplacePage() { + const [categories, setCategories] = useState([]); + const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY); + const [keywordInput, setKeywordInput] = useState(""); + const [keyword, setKeyword] = useState(""); + const [workflows, setWorkflows] = useState([]); + const [total, setTotal] = useState(0); + const [loadingCategories, setLoadingCategories] = useState(false); + const [loadingWorkflows, setLoadingWorkflows] = useState(false); + const [usingIds, setUsingIds] = useState>(new Set()); + const [error, setError] = useState(null); + + const visibleCategories = useMemo(() => { + if (categories.length === 0) return FALLBACK_CATEGORIES; + return [ + { key: ALL_CATEGORY, label: "全部" }, + ...categories.map((item) => ({ key: item.key, label: item.label })), + ]; + }, [categories]); + + const loadCategories = useCallback(async () => { + setLoadingCategories(true); + try { + const result = await fetchPublishedCategories(); + setCategories(getWorkflowCategories(result)); + } catch (e) { + console.error("[WorkflowMarketplace] Failed to load categories:", e); + } finally { + setLoadingCategories(false); + } + }, []); + + const loadWorkflows = useCallback(async () => { + setLoadingWorkflows(true); + setError(null); + try { + const result = await fetchPublishedWorkflows({ + page: 1, + pageSize: PAGE_SIZE, + category: activeCategory, + kw: keyword, + }); + setWorkflows(result.records || []); + setTotal(result.total || 0); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "工作流加载失败"; + setError(errorMessage); + setWorkflows([]); + setTotal(0); + } finally { + setLoadingWorkflows(false); + } + }, [activeCategory, keyword]); + + useEffect(() => { + loadCategories(); + }, [loadCategories]); + + useEffect(() => { + loadWorkflows(); + }, [loadWorkflows]); + + const handleSearch = useCallback(() => { + setKeyword(keywordInput.trim()); + }, [keywordInput]); + + const handleUse = useCallback(async (workflow: PublishedWorkflow) => { + if (workflow.collect) return; + + const workflowId = workflow.targetId || workflow.id; + setUsingIds((prev) => new Set(prev).add(workflowId)); + try { + await collectWorkflow(workflowId); + setWorkflows((prev) => + prev.map((item) => + (item.targetId || item.id) === workflowId + ? { ...item, collect: true } + : item, + ), + ); + message.success("已启用工作流"); + } catch (e) { + console.error("[WorkflowMarketplace] Failed to use workflow:", e); + } finally { + setUsingIds((prev) => { + const next = new Set(prev); + next.delete(workflowId); + return next; + }); + } + }, []); + + return ( +
+
+
+ 扩展市场 > 工作流 +
+
+

工作流

+ {total} 个工作流 +
+
+ +
+ } + placeholder="搜索工作流、名称或描述..." + value={keywordInput} + onChange={(event) => setKeywordInput(event.target.value)} + onPressEnter={handleSearch} + /> + +
+ {loadingCategories ? ( + 分类加载中... + ) : ( + visibleCategories.map((category) => ( + + )) + )} +
+ +
+ {loadingWorkflows ? ( +
+ + 工作流加载中... +
+ ) : error ? ( +
+ + +
+ ) : workflows.length === 0 ? ( +
+ +
+ ) : ( +
+ {workflows.map((workflow) => { + const workflowId = workflow.targetId || workflow.id; + const used = !!workflow.collect; + return ( +
+
+
+ +
+
+

{workflow.name}

+
+ {getAuthorName(workflow)} +
+
+
+ +
+ + {workflow.category || "工作流"} + +
+ +

+ {workflow.description || "暂无工作流描述"} +

+ +
+
+ {formatMetric(getUsageCount(workflow))} + 次使用 + + ★ {getRating(workflow)} + +
+ +
+
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/services/core/marketplace.ts b/qimingclaw/crates/agent-electron-client/src/renderer/services/core/marketplace.ts new file mode 100644 index 00000000..aedfdda2 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/services/core/marketplace.ts @@ -0,0 +1,455 @@ +import { message } from "antd"; +import { + AUTH_KEYS, + DEFAULT_API_TIMEOUT, + DEFAULT_SERVER_HOST, +} from "@shared/constants"; +import { getDomainTokenKey } from "@shared/utils/domain"; +import { normalizeServerHost } from "./auth"; + +const SUCCESS_CODE = "0000"; + +interface ApiResponse { + code: string; + message?: string; + data: T; + success?: boolean; +} + +export interface MarketplacePage { + records: T[]; + total: number; + size: number; + current: number; + pages: number; +} + +export interface PublishedCategory { + key: string; + label: string; + type: string; + children?: PublishedCategory[]; +} + +export interface PublishedSkillStatistics { + collectCount?: number; + usedCount?: number; + conversationCount?: number; + viewCount?: number; + userCount?: number; + [key: string]: unknown; +} + +export interface PublishedUser { + id?: number; + name?: string; + username?: string; + nickName?: string; + avatar?: string; +} + +export interface PublishedSkill { + id: number; + targetId: number; + name: string; + description?: string; + icon?: string; + category?: string; + collect?: boolean; + statistics?: PublishedSkillStatistics; + publishUser?: PublishedUser; + paymentRequired?: boolean; + subscribed?: boolean; +} + +export interface PublishedSkillFile { + name: string; + contents: string | null; + isDir: boolean; +} + +export interface PublishedSkillDetail extends PublishedSkill { + files?: PublishedSkillFile[]; + allowCopy?: number; + spaceId?: number; + remark?: string | null; +} + +export interface PublishedWorkflow { + id: number; + targetId: number; + name: string; + description?: string; + icon?: string; + category?: string; + collect?: boolean; + statistics?: PublishedSkillStatistics; + publishUser?: PublishedUser; + paymentRequired?: boolean; + subscribed?: boolean; +} + +export interface FetchPublishedSkillsParams { + page: number; + pageSize: number; + category: string; + kw?: string; +} + +export interface FetchPublishedWorkflowsParams { + page: number; + pageSize: number; + category: string; + kw?: string; +} + +export interface McpConfigItem { + id?: number; + uid?: string; + name?: string; + description?: string; + author?: string; + icon?: string; + categoryCode?: string; + categoryName?: string; + useStatus?: number; + serverConfigJson?: string; + configJson?: string; + serverConfigParamJson?: string; + configParamJson?: string; + localConfigParamJson?: string; + [key: string]: unknown; +} + +export interface SpaceInfo { + id: number; + name?: string; + spaceName?: string; +} + +export interface McpDeployedItem { + id: number; + uid?: string; + name?: string; + description?: string; + icon?: string; + creator?: PublishedUser | string; + creatorName?: string; + author?: string; + installType?: string; + deployStatus?: string; + deployed?: string; + deployedConfig?: unknown; + mcpConfig?: unknown; + serverName?: string; + spaceId?: number; + [key: string]: unknown; +} + +export interface FetchMcpConfigsParams { + page: number; + pageSize: number; + categoryCode?: string; + keyword?: string; + subTabType?: number; +} + +export interface UpdateAndEnableMcpConfigParams { + uid: string; + configParamJson: string; + configJson?: string; +} + +function normalizeAssetUrl( + baseUrl: string, + value?: string, +): string | undefined { + if (!value) return value; + if (/^(https?:|data:|blob:)/i.test(value)) return value; + if (value.startsWith("//")) return `${window.location.protocol}${value}`; + if (value.startsWith("/")) return `${baseUrl}${value}`; + return value; +} + +async function getBaseUrl(): Promise { + const step1 = (await window.electronAPI?.settings.get("step1_config")) as { + serverHost?: string; + } | null; + return normalizeServerHost(step1?.serverHost || DEFAULT_SERVER_HOST); +} + +async function getAuthToken(domain: string): Promise { + const oneShotToken = (await window.electronAPI?.settings.get( + AUTH_KEYS.AUTH_TOKEN, + )) as string | null; + if (oneShotToken) return oneShotToken; + + const domainTokenKey = getDomainTokenKey(domain); + return (await window.electronAPI?.settings.get(domainTokenKey)) as + | string + | null; +} + +async function marketplaceRequest( + path: string, + options: { + method?: "GET" | "POST"; + data?: unknown; + showError?: boolean; + } = {}, +): Promise { + const baseUrl = await getBaseUrl(); + const token = await getAuthToken(baseUrl); + const response = await fetch(`${baseUrl}${path}`, { + method: options.method || "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json, text/plain, */*", + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(DEFAULT_API_TIMEOUT), + ...(options.data ? { body: JSON.stringify(options.data) } : {}), + }); + + if (!response.ok) { + const error = new Error(`HTTP ${response.status}: ${response.statusText}`); + if (options.showError !== false) message.error(error.message); + throw error; + } + + const result = (await response.json()) as ApiResponse; + if (result.code !== SUCCESS_CODE) { + const errorMessage = result.message || "请求失败"; + if (options.showError !== false) message.error(errorMessage); + throw new Error(errorMessage); + } + + return result.data; +} + +export async function fetchPublishedCategories(): Promise { + return marketplaceRequest( + "/api/published/category/list", + { + method: "GET", + }, + ); +} + +export async function fetchPublishedSkills( + params: FetchPublishedSkillsParams, +): Promise> { + const baseUrl = await getBaseUrl(); + const page = await marketplaceRequest>( + "/api/published/skill/list", + { + method: "POST", + data: params, + }, + ); + + return { + ...page, + records: (page.records || []).map((item) => ({ + ...item, + icon: normalizeAssetUrl(baseUrl, item.icon), + })), + }; +} + +export async function collectSkill(skillId: number): Promise { + await marketplaceRequest(`/api/published/skill/collect/${skillId}`, { + method: "POST", + }); +} + +export async function fetchPublishedSkillDetail( + skillId: number, +): Promise { + const baseUrl = await getBaseUrl(); + const detail = await marketplaceRequest( + `/api/published/skill/${skillId}`, + { + method: "GET", + }, + ); + + return { + ...detail, + icon: normalizeAssetUrl(baseUrl, detail.icon), + }; +} + +export async function unCollectSkill(skillId: number): Promise { + await marketplaceRequest(`/api/published/skill/unCollect/${skillId}`, { + method: "POST", + }); +} + +export async function fetchPublishedWorkflows( + params: FetchPublishedWorkflowsParams, +): Promise> { + const baseUrl = await getBaseUrl(); + const page = await marketplaceRequest>( + "/api/published/workflow/list", + { + method: "POST", + data: params, + }, + ); + + return { + ...page, + records: (page.records || []).map((item) => ({ + ...item, + icon: normalizeAssetUrl(baseUrl, item.icon), + })), + }; +} + +export async function collectWorkflow(workflowId: number): Promise { + await marketplaceRequest( + `/api/published/workflow/collect/${workflowId}`, + { + method: "POST", + }, + ); +} + +export async function unCollectWorkflow(workflowId: number): Promise { + await marketplaceRequest( + `/api/published/workflow/unCollect/${workflowId}`, + { + method: "POST", + }, + ); +} + +export async function fetchMcpConfigs( + params: FetchMcpConfigsParams, +): Promise> { + const baseUrl = await getBaseUrl(); + const keyword = params.keyword?.trim(); + const queryFilter: Record = { + dataType: 3, + subTabType: params.subTabType ?? 1, + }; + + if (keyword) { + queryFilter.name = keyword; + } + + if (params.categoryCode && params.categoryCode !== "All") { + queryFilter.categoryCode = params.categoryCode; + } + + const page = await marketplaceRequest>( + "/api/system/eco/market/client/config/list", + { + method: "POST", + data: { + queryFilter, + current: params.page, + pageSize: params.pageSize, + }, + }, + ); + + return { + ...page, + records: (page.records || []).map((item) => ({ + ...item, + icon: normalizeAssetUrl(baseUrl, item.icon), + })), + }; +} + +export async function fetchSpaces(): Promise { + return marketplaceRequest("/api/space/list", { + method: "GET", + }); +} + +export async function fetchDeployedMcps(params: { + page: number; + pageSize: number; + spaceId?: number; + kw?: string; + justReturnSpaceData?: boolean; +}): Promise> { + return marketplaceRequest>( + "/api/mcp/deployed/list", + { + method: "POST", + data: { + page: params.page, + pageSize: params.pageSize, + ...(params.spaceId ? { spaceId: params.spaceId } : {}), + ...(params.justReturnSpaceData ? { justReturnSpaceData: true } : {}), + ...(params.kw ? { kw: params.kw, name: params.kw } : {}), + }, + }, + ); +} + +export async function exportMcpServerConfig(mcpId: number): Promise { + return marketplaceRequest(`/api/mcp/server/config/export/${mcpId}`, { + method: "POST", + }); +} + +export async function fetchMcpDetail( + uid: string, +): Promise { + const baseUrl = await getBaseUrl(); + const detail = await marketplaceRequest( + "/api/system/eco/market/client/config/detail", + { + method: "POST", + data: { uid }, + }, + ); + + if (!detail) return null; + return { + ...detail, + icon: normalizeAssetUrl(baseUrl, detail.icon), + }; +} + +export async function enableMcpConfig( + uid: string, +): Promise { + return marketplaceRequest( + `/api/system/eco/market/client/publish/config/enable?uid=${encodeURIComponent(uid)}`, + { + method: "POST", + }, + ); +} + +export async function disableMcpConfig( + uid: string, +): Promise { + return marketplaceRequest( + `/api/system/eco/market/client/publish/config/disable?uid=${encodeURIComponent(uid)}`, + { + method: "POST", + data: { uid }, + }, + ); +} + +export async function updateAndEnableMcpConfig( + params: UpdateAndEnableMcpConfigParams, +): Promise { + return marketplaceRequest( + "/api/system/eco/market/client/publish/config/updateAndEnable", + { + method: "POST", + data: params, + }, + ); +} diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/AppSidebar.module.css b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/AppSidebar.module.css index fc082f30..aaab942d 100644 --- a/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/AppSidebar.module.css +++ b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/AppSidebar.module.css @@ -10,12 +10,12 @@ } .brand { - height: 122px; + height: 104px; display: grid; grid-template-columns: 38px 1fr 34px; align-items: center; gap: 10px; - padding: 26px 12px 18px 20px; + padding: 12px 12px 14px 20px; } .brandLogo { diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/McpMarketplacePage.module.css b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/McpMarketplacePage.module.css new file mode 100644 index 00000000..a2d086cc --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/McpMarketplacePage.module.css @@ -0,0 +1,289 @@ +.page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: #f7f6f3; + color: #20211f; + overflow: hidden; +} + +.header { + height: 72px; + flex: 0 0 72px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 7px; + color: #8a8d89; + font-size: 13px; + line-height: 18px; + white-space: nowrap; +} + +.breadcrumb strong { + color: #006d68; + font-weight: 800; +} + +.title { + margin: 0; + font-size: 26px; + line-height: 32px; + font-weight: 800; + color: #20211f; +} + +.content { + flex: 1; + min-height: 0; + padding: 0 32px 32px; + overflow: auto; +} + +.topRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.search { + width: min(480px, 100%); +} + +.search :global(.ant-input-affix-wrapper), +.search:global(.ant-input-affix-wrapper) { + height: 44px; + border-radius: 8px; + border-color: #dcd9d1; + background: #ffffff; + box-shadow: none; +} + +.search :global(.ant-input-prefix) { + color: #9c9f9a; + font-size: 16px; +} + +.search :global(.ant-input) { + font-size: 14px; +} + +.count { + color: #72756f; + font-size: 14px; + line-height: 20px; + white-space: nowrap; +} + +.count span { + margin-right: 6px; + color: #007c78; + font-size: 18px; + font-weight: 800; +} + +.mcpArea { + min-height: 360px; +} + +.centerState { + min-height: 360px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: #777b75; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); + gap: 18px; +} + +.card { + min-height: 180px; + display: flex; + flex-direction: column; + padding: 20px; + background: #ffffff; + border: 1px solid #e8e4dc; + border-radius: 8px; + box-shadow: 0 2px 7px rgba(32, 33, 31, 0.05); +} + +.cardHeader { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; +} + +.mcpIcon { + width: 44px; + height: 44px; + border-radius: 10px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #007c78; + color: #ffffff; + font-size: 17px; + font-weight: 800; +} + +.mcpIconImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.mcpIconText { + line-height: 1; +} + +.mcpMeta { + min-width: 0; +} + +.mcpName { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #20211f; + font-size: 16px; + line-height: 22px; + font-weight: 800; +} + +.author { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #8b8d87; + font-size: 12px; + line-height: 16px; +} + +.badge { + max-width: 92px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 10px; + border-radius: 13px; + background: #e7f4f2; + color: #007c78; + font-size: 11px; + line-height: 14px; + font-weight: 800; +} + +.description { + flex: 1; + margin: 18px 0 18px; + color: #60645e; + font-size: 13px; + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.cardFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.metrics { + display: flex; + align-items: center; + gap: 7px; + color: #5d605b; + font-size: 12px; + white-space: nowrap; +} + +.rating { + color: #ff9d18; +} + +.connectButton:global(.ant-btn-primary) { + min-width: 58px; + height: 32px; + border: 0; + border-radius: 6px; + background: #00726d; + color: #ffffff; + font-weight: 800; +} + +.connectButton:global(.ant-btn-primary):hover { + background: #00645f !important; +} + +.connectedButton:global(.ant-btn) { + min-width: 74px; + height: 32px; + border: 0; + border-radius: 6px; + background: #e6f4f2; + color: #00726d; + font-weight: 800; +} + +.modalTitle { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.configForm { + margin-top: 18px; +} + +@media (max-width: 980px) { + .header { + height: auto; + min-height: 72px; + align-items: flex-start; + flex-direction: column-reverse; + justify-content: center; + gap: 8px; + padding: 18px 22px; + } + + .content { + padding: 0 22px 24px; + } + + .topRow { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + + .search { + width: 100%; + } + + .grid { + grid-template-columns: 1fr; + } +} diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/SkillsMarketplacePage.module.css b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/SkillsMarketplacePage.module.css new file mode 100644 index 00000000..c217bf53 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/SkillsMarketplacePage.module.css @@ -0,0 +1,480 @@ +.page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: #f5f5f2; + color: #151714; + overflow: hidden; +} + +.header { + height: 58px; + flex: 0 0 58px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; + background: #ffffff; + border-bottom: 1px solid #e8e7e2; +} + +.titleGroup { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.titleIcon { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #00726d; + font-size: 18px; +} + +.title { + margin: 0; + font-size: 18px; + line-height: 1; + font-weight: 700; + color: #151714; +} + +.breadcrumb { + font-size: 13px; + color: #8a8a84; + white-space: nowrap; +} + +.content { + flex: 1; + min-height: 0; + padding: 28px 32px; + overflow: auto; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.search { + width: min(320px, 100%); +} + +.search :global(.ant-input-affix-wrapper), +.search:global(.ant-input-affix-wrapper) { + height: 40px; + border-radius: 6px; + border-color: #deddd8; + background: #ffffff; + box-shadow: none; +} + +.search :global(.ant-input) { + font-size: 13px; +} + +.count { + font-size: 13px; + color: #8a8a84; + white-space: nowrap; +} + +.count span { + color: #00726d; + font-size: 16px; + font-weight: 700; + margin-right: 6px; +} + +.categoryBar { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.categoryLoading { + color: #8a8a84; + font-size: 13px; +} + +.categoryChip { + height: 32px; + padding: 0 16px; + border: 0; + border-radius: 6px; + background: #e9f0ed; + color: #00726d; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + transform 0.18s ease; +} + +.categoryChip:hover { + background: #dcebe7; +} + +.categoryChipActive { + background: #00726d; + color: #ffffff; +} + +.skillsArea { + min-height: 360px; +} + +.centerState { + min-height: 360px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: #7d7d78; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.card { + min-height: 142px; + display: flex; + flex-direction: column; + padding: 14px; + background: #ffffff; + border: 1px solid #e4e2dc; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(18, 20, 17, 0.05); + cursor: pointer; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.card:hover { + border-color: #cdd8d4; + box-shadow: 0 8px 18px rgba(18, 20, 17, 0.08); + transform: translateY(-1px); +} + +.cardHeader { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} + +.skillIcon { + width: 36px; + height: 36px; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 16px; + font-weight: 800; + background: linear-gradient(135deg, #00a879 0%, #00726d 100%); +} + +.skillIconImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.skillIconText { + line-height: 1; +} + +.skillMeta { + min-width: 0; +} + +.skillName { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + line-height: 20px; + font-weight: 800; + color: #151714; +} + +.author { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + line-height: 16px; + color: #85857f; +} + +.badge { + max-width: 92px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 3px 8px; + border-radius: 5px; + background: #e9f5f2; + color: #00806f; + font-size: 11px; + font-weight: 700; +} + +.description { + flex: 1; + margin: 14px 0 12px; + color: #777771; + font-size: 13px; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.cardFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.metrics { + display: flex; + align-items: center; + gap: 10px; + color: #555651; + font-size: 12px; + min-width: 0; + white-space: nowrap; +} + +.rating { + color: #151714; +} + +.installButton:global(.ant-btn-primary) { + min-width: 56px; + height: 30px; + border: 0; + border-radius: 6px; + background: #00726d; + color: #ffffff; + font-weight: 700; +} + +.installButton:global(.ant-btn-primary):hover { + background: #00645f !important; +} + +.installedButton:global(.ant-btn) { + min-width: 74px; + height: 30px; + border: 0; + border-radius: 6px; + background: #e7f7ee; + color: #00874f; + font-weight: 700; +} + +.detailModal :global(.ant-modal-content) { + border-radius: 10px; + padding: 0; + overflow: hidden; +} + +.detailModal :global(.ant-modal-close) { + top: 14px; + right: 14px; +} + +.detail { + max-height: min(760px, calc(100vh - 120px)); + display: flex; + flex-direction: column; + background: #f7f7f4; +} + +.detailHeader { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 28px 32px 20px; + background: #ffffff; + border-bottom: 1px solid #e8e7e2; +} + +.detailIcon { + width: 52px; + height: 52px; + border-radius: 10px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 20px; + font-weight: 800; + background: linear-gradient(135deg, #00a879 0%, #00726d 100%); +} + +.detailMeta { + min-width: 0; +} + +.detailTitle { + margin: 0; + color: #151714; + font-size: 22px; + line-height: 28px; + font-weight: 800; +} + +.detailAuthor { + margin-top: 4px; + color: #85857f; + font-size: 13px; +} + +.detailBadge { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 10px; + border-radius: 6px; + background: #e9f5f2; + color: #00806f; + font-size: 12px; + font-weight: 800; +} + +.detailDescription { + margin: 0; + padding: 22px 32px 0; + color: #585a55; + font-size: 14px; + line-height: 1.7; +} + +.detailStats { + display: flex; + align-items: center; + gap: 18px; + padding: 18px 32px 0; + color: #555651; + font-size: 13px; +} + +.detailState { + min-height: 280px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; +} + +.previewSection { + margin: 20px 32px 0; + padding: 16px; + border: 1px solid #e4e2dc; + border-radius: 8px; + background: #ffffff; +} + +.sectionHeading { + display: flex; + align-items: center; + gap: 8px; + color: #151714; + font-size: 14px; + font-weight: 800; +} + +.previewContent { + max-height: 180px; + margin: 14px 0 0; + padding: 14px; + overflow: auto; + border-radius: 6px; + background: #151714; + color: #f4f6f2; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} + +.detailFooter { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: auto; + padding: 18px 32px 24px; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 920px) { + .header { + padding: 0 20px; + } + + .content { + padding: 24px 20px; + } + + .toolbar { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + + .search { + width: 100%; + } + + .grid { + grid-template-columns: 1fr; + } + + .detailHeader { + grid-template-columns: 52px minmax(0, 1fr); + } + + .detailBadge { + grid-column: 1 / -1; + justify-self: start; + } +} diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/WorkflowMarketplacePage.module.css b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/WorkflowMarketplacePage.module.css new file mode 100644 index 00000000..f166b393 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/styles/components/WorkflowMarketplacePage.module.css @@ -0,0 +1,304 @@ +.page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: #f7f6f3; + color: #20211f; + overflow: hidden; +} + +.header { + flex: 0 0 auto; + padding: 28px 32px 0; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 7px; + color: #8a8d89; + font-size: 13px; + line-height: 18px; + white-space: nowrap; +} + +.breadcrumb strong { + color: #006d68; + font-weight: 800; +} + +.titleRow { + display: flex; + align-items: baseline; + gap: 0; + margin-top: 28px; +} + +.title { + margin: 0; + color: #20211f; + font-size: 24px; + line-height: 30px; + font-weight: 800; +} + +.totalText { + color: #888b85; + font-size: 14px; + line-height: 20px; +} + +.content { + flex: 1; + min-height: 0; + padding: 26px 32px 32px; + overflow: auto; +} + +.search { + width: min(480px, 100%); + margin-bottom: 20px; +} + +.search :global(.ant-input-affix-wrapper), +.search:global(.ant-input-affix-wrapper) { + height: 44px; + border-radius: 8px; + border-color: #dcd9d1; + background: #ffffff; + box-shadow: none; +} + +.search :global(.ant-input-prefix) { + color: #a0a39e; + font-size: 16px; +} + +.search :global(.ant-input) { + font-size: 14px; +} + +.categoryBar { + min-height: 34px; + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 22px; + flex-wrap: wrap; +} + +.categoryLoading { + color: #8a8d89; + font-size: 13px; +} + +.categoryChip { + height: 34px; + padding: 0 17px; + border: 1px solid #dedbd2; + border-radius: 17px; + background: #ffffff; + color: #4f524d; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease; +} + +.categoryChip:hover { + border-color: #007c78; + color: #007c78; +} + +.categoryChipActive { + border-color: #007c78; + background: #007c78; + color: #ffffff; +} + +.workflowArea { + min-height: 360px; +} + +.centerState { + min-height: 360px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: #777b75; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); + gap: 20px; +} + +.card { + min-height: 220px; + display: flex; + flex-direction: column; + padding: 20px; + background: #ffffff; + border: 1px solid #e8e4dc; + border-radius: 8px; + box-shadow: 0 2px 7px rgba(32, 33, 31, 0.05); +} + +.cardHeader { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.workflowIcon { + width: 44px; + height: 44px; + border-radius: 10px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 17px; + font-weight: 800; +} + +.workflowIconImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.workflowIconText { + line-height: 1; +} + +.workflowMeta { + min-width: 0; +} + +.workflowName { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #20211f; + font-size: 16px; + line-height: 21px; + font-weight: 800; +} + +.author { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #8b8d87; + font-size: 12px; + line-height: 16px; +} + +.badgeRow { + display: flex; + margin-top: 14px; +} + +.badge { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 10px; + border-radius: 13px; + background: #e7f4e9; + color: #25823d; + font-size: 11px; + line-height: 14px; + font-weight: 800; +} + +.description { + flex: 1; + margin: 18px 0 18px; + color: #60645e; + font-size: 13px; + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.cardFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.metrics { + display: flex; + align-items: center; + gap: 7px; + color: #8a8d87; + font-size: 12px; + white-space: nowrap; +} + +.rating { + color: #ff9d18; +} + +.useButton:global(.ant-btn-primary) { + min-width: 58px; + height: 32px; + border: 0; + border-radius: 6px; + background: #00726d; + color: #ffffff; + font-weight: 800; +} + +.useButton:global(.ant-btn-primary):hover { + background: #00645f !important; +} + +.usedButton:global(.ant-btn) { + min-width: 74px; + height: 32px; + border: 0; + border-radius: 6px; + background: #e4f4e7; + color: #268443; + font-weight: 800; +} + +@media (max-width: 980px) { + .header { + padding: 22px 22px 0; + } + + .titleRow { + margin-top: 22px; + } + + .content { + padding: 22px 22px 24px; + } + + .search { + width: 100%; + } + + .grid { + grid-template-columns: 1fr; + } +} diff --git a/qimingclaw/crates/agent-electron-client/src/shared/types/electron.d.ts b/qimingclaw/crates/agent-electron-client/src/shared/types/electron.d.ts index ebc46ec8..7f08c2ed 100644 --- a/qimingclaw/crates/agent-electron-client/src/shared/types/electron.d.ts +++ b/qimingclaw/crates/agent-electron-client/src/shared/types/electron.d.ts @@ -653,6 +653,47 @@ export interface QuickInitAPI { getConfig: () => Promise; } +export interface SkillInstallFile { + name: string; + contents: string | null; + isDir: boolean; +} + +export interface SkillInstallPayload { + workspaceDir: string; + skillId: number; + name: string; + category?: string | null; + author?: string | null; + source?: string | null; + files?: SkillInstallFile[]; +} + +export interface SkillInstallQuery { + workspaceDir: string; + skillId: number; + name?: string | null; +} + +export interface SkillInstallResult { + success: boolean; + installed?: boolean; + installDir?: string; + writtenFiles?: string[]; + skippedFiles?: string[]; + error?: string; +} + +export interface SkillsAPI { + installLocal: (payload: SkillInstallPayload) => Promise; + isInstalled: (query: SkillInstallQuery) => Promise; + openInstallDir: (query: SkillInstallQuery) => Promise<{ + success: boolean; + installDir?: string; + error?: string; + }>; +} + export interface ElectronAPI { versions: { node: string; @@ -740,6 +781,7 @@ export interface ElectronAPI { app: AppAPI; permissions: PermissionsAPI; quickInit: QuickInitAPI; + skills: SkillsAPI; perf: PerfAPI; on: (channel: string, callback: (...args: unknown[]) => void) => void; off: (channel: string, callback: (...args: unknown[]) => void) => void;