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 79330a2e..8964f4de 100644 Binary files a/qimingclaw/crates/agent-electron-client/public/tray/tray.png and b/qimingclaw/crates/agent-electron-client/public/tray/tray.png differ 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 e91ce863..be8986b3 100644 Binary files a/qimingclaw/crates/agent-electron-client/public/tray/tray@2x.png and b/qimingclaw/crates/agent-electron-client/public/tray/tray@2x.png differ diff --git a/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate.png b/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate.png index 8c0629f8..14e81138 100644 Binary files a/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate.png and b/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate.png differ 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 24496cb4..c8dd44b6 100644 Binary files a/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate@2x.png and b/qimingclaw/crates/agent-electron-client/public/tray/trayTemplate@2x.png differ diff --git a/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts b/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts index 2191960c..8459c63e 100644 --- a/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts +++ b/qimingclaw/crates/agent-electron-client/src/main/ipc/index.ts @@ -14,6 +14,7 @@ import { registerSandboxHandlers } from "./sandboxHandlers"; import { registerPerfHandlers } from "./perfHandlers"; import { registerGuiServerHandlers } from "./guiServerHandlers"; import { registerI18nHandlers } from "./i18nHandlers"; +import { registerSkillHandlers } from "./skillHandlers"; import log from "electron-log"; export function registerAllHandlers(ctx: HandlerContext): void { @@ -32,6 +33,7 @@ export function registerAllHandlers(ctx: HandlerContext): void { registerPerfHandlers(); registerGuiServerHandlers(); registerI18nHandlers(); + registerSkillHandlers(); log.info("IPC handlers registered"); } diff --git a/qimingclaw/crates/agent-electron-client/src/main/ipc/skillHandlers.ts b/qimingclaw/crates/agent-electron-client/src/main/ipc/skillHandlers.ts new file mode 100644 index 00000000..ca77409d --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/main/ipc/skillHandlers.ts @@ -0,0 +1,233 @@ +import { ipcMain, shell } from "electron"; +import * as fs from "fs"; +import * as path from "path"; +import log from "electron-log"; + +interface SkillInstallFile { + name: string; + contents: string | null; + isDir: boolean; +} + +interface SkillInstallPayload { + workspaceDir: string; + skillId: number; + name: string; + category?: string | null; + author?: string | null; + source?: string | null; + files?: SkillInstallFile[]; +} + +interface SkillInstalledQuery { + workspaceDir: string; + skillId: number; + name?: string | null; +} + +const MANIFEST_FILE = "skill.json"; + +function sanitizeSegment(value: string): string { + const trimmed = value.trim(); + const sanitized = trimmed + .replace(/[<>:"/\\|?*\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;