feat: 完善本地MCP与扩展市场能力
@@ -27,7 +27,7 @@ spring:
|
|||||||
datasource:
|
datasource:
|
||||||
# 主数据源 (MySQL)
|
# 主数据源 (MySQL)
|
||||||
master:
|
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
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: ${DB_USERNAME:root}
|
username: ${DB_USERNAME:root}
|
||||||
password: ${DB_PASSWORD:your_mysql_password}
|
password: ${DB_PASSWORD:your_mysql_password}
|
||||||
@@ -48,7 +48,7 @@ spring:
|
|||||||
filters: stat # 启用 stat filter 用于监控
|
filters: stat # 启用 stat filter 用于监控
|
||||||
# Doris 数据源
|
# Doris 数据源
|
||||||
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
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: ${DORIS_USERNAME:root}
|
username: ${DORIS_USERNAME:root}
|
||||||
password: ${DORIS_PASSWORD:your_doris_password}
|
password: ${DORIS_PASSWORD:your_doris_password}
|
||||||
@@ -165,6 +165,8 @@ search:
|
|||||||
username: ${ES_USERNAME:elastic}
|
username: ${ES_USERNAME:elastic}
|
||||||
password: ${ES_PASSWORD:your_elasticsearch_password}
|
password: ${ES_PASSWORD:your_elasticsearch_password}
|
||||||
api_key: ${ES_API_KEY:}
|
api_key: ${ES_API_KEY:}
|
||||||
|
text_analyzer: ${ES_TEXT_ANALYZER:standard}
|
||||||
|
search_analyzer: ${ES_SEARCH_ANALYZER:standard}
|
||||||
# 生态市场模块配置
|
# 生态市场模块配置
|
||||||
eco-market:
|
eco-market:
|
||||||
# 服务端配置
|
# 服务端配置
|
||||||
@@ -230,4 +232,4 @@ model-api-proxy:
|
|||||||
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
|
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
|
||||||
port: ${MODEL_PROXY_PORT:18086}
|
port: ${MODEL_PROXY_PORT:18086}
|
||||||
#记录日志
|
#记录日志
|
||||||
save-log: ${MODEL_PROXY_SAVE_LOG:true}
|
save-log: ${MODEL_PROXY_SAVE_LOG:true}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ spring:
|
|||||||
datasource:
|
datasource:
|
||||||
# 主数据源 (MySQL)
|
# 主数据源 (MySQL)
|
||||||
master:
|
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
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: ${DB_USERNAME:root}
|
username: ${DB_USERNAME:root}
|
||||||
password: ${DB_PASSWORD:Byyagz@121}
|
password: ${DB_PASSWORD:Byyagz@121}
|
||||||
@@ -48,7 +48,7 @@ spring:
|
|||||||
filters: stat # 启用 stat filter 用于监控
|
filters: stat # 启用 stat filter 用于监控
|
||||||
# Doris 数据源
|
# Doris 数据源
|
||||||
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
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: ${DORIS_USERNAME:root}
|
username: ${DORIS_USERNAME:root}
|
||||||
password: ${DORIS_PASSWORD:Byyagz@121}
|
password: ${DORIS_PASSWORD:Byyagz@121}
|
||||||
@@ -165,6 +165,8 @@ search:
|
|||||||
username: ${ES_USERNAME:}
|
username: ${ES_USERNAME:}
|
||||||
password: ${ES_PASSWORD:}
|
password: ${ES_PASSWORD:}
|
||||||
api_key: ${ES_API_KEY:}
|
api_key: ${ES_API_KEY:}
|
||||||
|
text_analyzer: ${ES_TEXT_ANALYZER:standard}
|
||||||
|
search_analyzer: ${ES_SEARCH_ANALYZER:standard}
|
||||||
# 生态市场模块配置
|
# 生态市场模块配置
|
||||||
eco-market:
|
eco-market:
|
||||||
# 服务端配置
|
# 服务端配置
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
|||||||
@Value("${search.elasticsearch.password:}")
|
@Value("${search.elasticsearch.password:}")
|
||||||
private String 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;
|
private ElasticsearchClient client;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@@ -75,6 +81,14 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
|||||||
client.shutdown();
|
client.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getTextAnalyzer() {
|
||||||
|
return StringUtils.defaultIfBlank(textAnalyzer, "standard");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getSearchAnalyzer() {
|
||||||
|
return StringUtils.defaultIfBlank(searchAnalyzer, getTextAnalyzer());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void bulkIndex(List<SearchDocument> list) {
|
public void bulkIndex(List<SearchDocument> list) {
|
||||||
Assert.noNullElements(list, "list cannot be left blank.");
|
Assert.noNullElements(list, "list cannot be left blank.");
|
||||||
@@ -146,8 +160,8 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
|||||||
if (keyword.get()) {
|
if (keyword.get()) {
|
||||||
builder.properties(name.get(), property -> property.keyword(kw -> kw.index(index.get()).store(store.get())));
|
builder.properties(name.get(), property -> property.keyword(kw -> kw.index(index.get()).store(store.get())));
|
||||||
} else {
|
} else {
|
||||||
builder.properties(name.get(), property -> property.text(text -> text.analyzer("ik_max_word")
|
builder.properties(name.get(), property -> property.text(text -> text.analyzer(getTextAnalyzer())
|
||||||
.searchAnalyzer("ik_smart").store(store.get()).index(index.get())
|
.searchAnalyzer(getSearchAnalyzer()).store(store.get()).index(index.get())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else if (declaredField.getType() == Integer.class) {
|
} else if (declaredField.getType() == Integer.class) {
|
||||||
@@ -163,8 +177,8 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
|||||||
} else if (declaredField.getType() == Date.class) {
|
} else if (declaredField.getType() == Date.class) {
|
||||||
builder.properties(name.get(), property -> property.date(date -> date.index(index.get()).store(store.get())));
|
builder.properties(name.get(), property -> property.date(date -> date.index(index.get()).store(store.get())));
|
||||||
} else {
|
} else {
|
||||||
builder.properties(name.get(), property -> property.text(text -> text.analyzer("ik_max_word")
|
builder.properties(name.get(), property -> property.text(text -> text.analyzer(getTextAnalyzer())
|
||||||
.searchAnalyzer("ik_smart").store(store.get()).index(index.get())
|
.searchAnalyzer(getSearchAnalyzer()).store(store.get()).index(index.get())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.xspaceagi.log.sdk.request.DocumentSearchRequest;
|
import com.xspaceagi.log.sdk.request.DocumentSearchRequest;
|
||||||
import com.xspaceagi.log.sdk.service.ISearchRpcService;
|
import com.xspaceagi.log.sdk.service.ISearchRpcService;
|
||||||
import com.xspaceagi.log.sdk.vo.LogDocument;
|
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.log.web.controller.dto.LogQueryDto;
|
||||||
import com.xspaceagi.system.spec.common.RequestContext;
|
import com.xspaceagi.system.spec.common.RequestContext;
|
||||||
import com.xspaceagi.system.spec.common.UserContext;
|
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 org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -125,13 +127,15 @@ public abstract class BaseController {
|
|||||||
createTimeRange.put("express", "range");
|
createTimeRange.put("express", "range");
|
||||||
filterFieldsAndValues.add(Map.of("createTime", createTimeRange));
|
filterFieldsAndValues.add(Map.of("createTime", createTimeRange));
|
||||||
}
|
}
|
||||||
builder.sortFieldsAndValues(Map.of("createTime", "Desc"));
|
|
||||||
var result = iSearchRpcService.search(builder.build());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
builder.sortFieldsAndValues(Map.of("createTime", "Desc"));
|
||||||
|
var result = iSearchRpcService.search(builder.build());
|
||||||
IPage<LogDocument> page = new Page<>(pageQueryVo.getCurrent().intValue(), pageQueryVo.getPageSize().intValue());
|
IPage<LogDocument> page = new Page<>(pageQueryVo.getCurrent().intValue(), pageQueryVo.getPageSize().intValue());
|
||||||
page.setTotal(result.getTotal());
|
page.setTotal(result == null || result.getTotal() == null ? 0 : result.getTotal());
|
||||||
page.setRecords(result.getItems().stream().map(item -> {
|
List<SearchResult.SearchResultItem> items = result == null || result.getItems() == null
|
||||||
|
? Collections.emptyList()
|
||||||
|
: result.getItems();
|
||||||
|
page.setRecords(items.stream().map(item -> {
|
||||||
LogDocument document = (LogDocument) item.getDocument();
|
LogDocument document = (LogDocument) item.getDocument();
|
||||||
document.setProcessData(null);
|
document.setProcessData(null);
|
||||||
return document;
|
return document;
|
||||||
|
|||||||
@@ -140,7 +140,16 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen
|
|||||||
deployStatus = new McpDeployStatusResponse();
|
deployStatus = new McpDeployStatusResponse();
|
||||||
deployStatus.setStatus(McpDeployStatusEnum.Ready);
|
deployStatus.setStatus(McpDeployStatusEnum.Ready);
|
||||||
} else {
|
} 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) {
|
if (deployStatus.getStatus() == McpDeployStatusEnum.Ready) {
|
||||||
try {
|
try {
|
||||||
@@ -161,15 +170,7 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (deployStatus.getStatus() == McpDeployStatusEnum.Error) {
|
if (deployStatus.getStatus() == McpDeployStatusEnum.Error) {
|
||||||
McpConfig mcpConfig = new McpConfig();
|
markDeployFailed(userDto, id, mcpName, deployStatus.getMessage());
|
||||||
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());
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
checkAndSendFailedNotifyMessage(userDto, id, mcpName, scheduleTask);
|
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) {
|
private void checkAndSendFailedNotifyMessage(UserDto userDto, Long id, String mcpName, ScheduleTaskDto scheduleTask) {
|
||||||
if (scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) {
|
if (scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) {
|
||||||
McpConfig mcpConfig = new McpConfig();
|
updateDeployFailed(id);
|
||||||
mcpConfig.setId(id);
|
|
||||||
mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed);
|
|
||||||
mcpConfigDomainService.update(mcpConfig);
|
|
||||||
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
|
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
|
||||||
.scope(NotifyMessage.MessageScope.System)
|
.scope(NotifyMessage.MessageScope.System)
|
||||||
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Timeout", mcpName))
|
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Timeout", mcpName))
|
||||||
@@ -189,4 +187,20 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen
|
|||||||
.build());
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,12 +226,7 @@ public class McpController {
|
|||||||
@Operation(summary = "MCP列表(官方服务)")
|
@Operation(summary = "MCP列表(官方服务)")
|
||||||
@GetMapping("/official/list")
|
@GetMapping("/official/list")
|
||||||
public ReqResult<List<McpDto>> officialList() {
|
public ReqResult<List<McpDto>> officialList() {
|
||||||
List<McpDto> mcpDtos = mcpConfigApplicationService.queryMcpListBySpaceId(-1L);
|
return ReqResult.success(List.of());
|
||||||
//移除未发布的
|
|
||||||
mcpDtos.removeIf(mcpDto -> mcpDto.getDeployStatus() != DeployStatusEnum.Deployed);
|
|
||||||
List<SpaceObjectPermissionEnum> permissionList = new ArrayList<>();
|
|
||||||
mcpDtos.forEach(mcpDto -> mcpDto.setPermissions(permissionList.stream().map(SpaceObjectPermissionEnum::name).collect(Collectors.toList())));
|
|
||||||
return ReqResult.success(mcpDtos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequireResource(MCP_DELETE)
|
@RequireResource(MCP_DELETE)
|
||||||
|
|||||||
@@ -1,39 +1,23 @@
|
|||||||
package com.xspaceagi.system.web.controller;
|
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.dto.*;
|
||||||
import com.xspaceagi.system.application.service.AuthService;
|
import com.xspaceagi.system.application.service.AuthService;
|
||||||
import com.xspaceagi.system.application.service.NotifyMessageApplicationService;
|
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.common.RequestContext;
|
||||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
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.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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMethod;
|
import org.springframework.web.bind.annotation.RequestMethod;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Tag(name = "消息通知相关接口")
|
@Tag(name = "消息通知相关接口")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/notify")
|
@RequestMapping("/api/notify")
|
||||||
@@ -45,22 +29,9 @@ public class NotifyMessageController {
|
|||||||
@Resource
|
@Resource
|
||||||
private AuthService authService;
|
private AuthService authService;
|
||||||
|
|
||||||
@Resource
|
|
||||||
private HttpClient httpClient;
|
|
||||||
|
|
||||||
@Value("${installation-source}")
|
|
||||||
private String installationSource;
|
|
||||||
|
|
||||||
@Value("${app.version}")
|
@Value("${app.version}")
|
||||||
private String version;
|
private String version;
|
||||||
|
|
||||||
@Value("${eco-market.server.base-url}")
|
|
||||||
private String cloudApi;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private ClientSecretRpcService clientSecretRpcService;
|
|
||||||
|
|
||||||
|
|
||||||
@Operation(summary = "查询用户消息列表")
|
@Operation(summary = "查询用户消息列表")
|
||||||
@RequestMapping(path = "/message/list", method = RequestMethod.POST)
|
@RequestMapping(path = "/message/list", method = RequestMethod.POST)
|
||||||
public ReqResult<List<NotifyMessageDto>> listQuery(@RequestBody NotifyMessageQueryDto messageQueryDto) {
|
public ReqResult<List<NotifyMessageDto>> listQuery(@RequestBody NotifyMessageQueryDto messageQueryDto) {
|
||||||
@@ -68,43 +39,6 @@ public class NotifyMessageController {
|
|||||||
return ReqResult.success(notifyMessageApplicationService.queryNotifyMessageList(messageQueryDto));
|
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<List<PullMessageResponseDto>> pullMessageResponseDtos = JSON.parseObject(content, new TypeReference<ReqResult<List<PullMessageResponseDto>>>() {
|
|
||||||
});
|
|
||||||
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 = "查询用户未读消息数量")
|
@Operation(summary = "查询用户未读消息数量")
|
||||||
@RequestMapping(path = "/message/unread/count", method = RequestMethod.GET)
|
@RequestMapping(path = "/message/unread/count", method = RequestMethod.GET)
|
||||||
public ReqResult<Long> unReadCount() {
|
public ReqResult<Long> unReadCount() {
|
||||||
@@ -134,12 +68,6 @@ public class NotifyMessageController {
|
|||||||
eventRespDto.setHasEvent(!eventDtos.isEmpty());
|
eventRespDto.setHasEvent(!eventDtos.isEmpty());
|
||||||
eventRespDto.setEventList(eventDtos);
|
eventRespDto.setEventList(eventDtos);
|
||||||
eventRespDto.setVersion(version);
|
eventRespDto.setVersion(version);
|
||||||
try {
|
|
||||||
// 之前的逻辑
|
|
||||||
pullMessage();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("触发消息拉取事件失败", e);
|
|
||||||
}
|
|
||||||
return ReqResult.success(eventRespDto);
|
return ReqResult.success(eventRespDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,4 +79,4 @@ public class NotifyMessageController {
|
|||||||
return ReqResult.success();
|
return ReqResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
server:
|
server:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8080
|
port: 18020
|
||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
path: logs
|
path: logs
|
||||||
retain_days: 5
|
retain_days: 5
|
||||||
|
runtime:
|
||||||
|
warm_up_on_start: false
|
||||||
mirror:
|
mirror:
|
||||||
npm_registry: ""
|
npm_registry: ""
|
||||||
pypi_index_url: ""
|
pypi_index_url: ""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
server:
|
server:
|
||||||
# The port to listen on for incoming connections
|
# The port to listen on for incoming connections
|
||||||
port: 8085
|
port: 18020
|
||||||
# The log level to use
|
# The log level to use
|
||||||
log:
|
log:
|
||||||
level: debug
|
level: debug
|
||||||
@@ -8,6 +8,10 @@ log:
|
|||||||
path: logs
|
path: logs
|
||||||
# The number of log files to retain (default: 5)
|
# The number of log files to retain (default: 5)
|
||||||
retain_days: 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:
|
mirror:
|
||||||
npm_registry: ""
|
npm_registry: ""
|
||||||
pypi_index_url: ""
|
pypi_index_url: ""
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub struct AppConfig {
|
|||||||
pub log: LogConfig,
|
pub log: LogConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mirror: MirrorYamlConfig,
|
pub mirror: MirrorYamlConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime: RuntimeConfig,
|
||||||
}
|
}
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -43,6 +45,14 @@ pub struct LogConfig {
|
|||||||
pub retain_days: u32,
|
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
|
/// Default log files to retain
|
||||||
fn default_retain_days() -> u32 {
|
fn default_retain_days() -> u32 {
|
||||||
5
|
5
|
||||||
@@ -89,6 +99,13 @@ impl AppConfig {
|
|||||||
config.log.level = log_level;
|
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)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,10 @@ async fn run_server_mode() -> Result<()> {
|
|||||||
tracing::info!(" - Log directory: {}", log_path);
|
tracing::info!(" - Log directory: {}", log_path);
|
||||||
tracing::info!(" - Log level: {}", &app_config.log.level);
|
tracing::info!(" - Log level: {}", &app_config.log.level);
|
||||||
tracing::info!(" - Log retention days: {}", retain_days);
|
tracing::info!(" - Log retention days: {}", retain_days);
|
||||||
|
tracing::info!(
|
||||||
|
" - Warm up on start: {}",
|
||||||
|
app_config.runtime.warm_up_on_start
|
||||||
|
);
|
||||||
tracing::info!("Environment overrides:");
|
tracing::info!("Environment overrides:");
|
||||||
if std::env::var("MCP_PROXY_PORT").is_ok() {
|
if std::env::var("MCP_PROXY_PORT").is_ok() {
|
||||||
tracing::info!(" - MCP_PROXY_PORT override detected: {}", server_port);
|
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") {
|
if let Ok(level) = std::env::var("MCP_PROXY_LOG_LEVEL") {
|
||||||
tracing::info!(" - MCP_PROXY_LOG_LEVEL override detected: {}", 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!("========================================");
|
tracing::info!("========================================");
|
||||||
|
|
||||||
// 监听地址
|
// 监听地址
|
||||||
@@ -286,14 +296,18 @@ async fn run_server_mode() -> Result<()> {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 预热 uv/deno 环境依赖
|
// 预热 uv/deno 环境依赖。默认关闭,避免每次服务启动都触发依赖检查/下载。
|
||||||
tokio::spawn(async move {
|
if app_config.runtime.warm_up_on_start {
|
||||||
info!("Warming up uv/deno runtime dependencies...");
|
tokio::spawn(async move {
|
||||||
match warm_up_all_envs(None, None, None, None).await {
|
info!("Warming up uv/deno runtime dependencies...");
|
||||||
Ok(_) => info!("Runtime dependency warm-up completed"),
|
match warm_up_all_envs(None, None, None, None).await {
|
||||||
Err(e) => error!("Runtime dependency warm-up failed: {e}"),
|
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...");
|
info!("Starting HTTP server...");
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
FilterDeployEnum,
|
FilterDeployEnum,
|
||||||
McpEditHeadMenusEnum,
|
McpEditHeadMenusEnum,
|
||||||
McpInstallTypeEnum,
|
McpInstallTypeEnum,
|
||||||
McpManageSegmentedEnum,
|
|
||||||
McpMoreActionEnum,
|
McpMoreActionEnum,
|
||||||
} from '@/types/enums/mcp';
|
} from '@/types/enums/mcp';
|
||||||
|
|
||||||
@@ -93,15 +92,3 @@ export const MCP_EDIT_HEAD_MENU_LIST = [
|
|||||||
label: dict('PC.Constants.Mcp.menuPrompt'),
|
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'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -27,10 +27,6 @@
|
|||||||
/* 关键修复:确保内容从顶部开始排列,避免不均匀的间距分布 */
|
/* 关键修复:确保内容从顶部开始排列,避免不均匀的间距分布 */
|
||||||
align-content: start;
|
align-content: start;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
||||||
.office-mcp-item {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import ButtonToggle from '@/components/ButtonToggle';
|
import ButtonToggle from '@/components/ButtonToggle';
|
||||||
import Loading from '@/components/custom/Loading';
|
import Loading from '@/components/custom/Loading';
|
||||||
import SelectList from '@/components/custom/SelectList';
|
import SelectList from '@/components/custom/SelectList';
|
||||||
import {
|
import { FILTER_DEPLOY } from '@/constants/mcp.constants';
|
||||||
FILTER_DEPLOY,
|
|
||||||
MCP_MANAGE_SEGMENTED_LIST,
|
|
||||||
} from '@/constants/mcp.constants';
|
|
||||||
import { CREATE_LIST } from '@/constants/space.constants';
|
import { CREATE_LIST } from '@/constants/space.constants';
|
||||||
import { dict } from '@/services/i18nRuntime';
|
import { dict } from '@/services/i18nRuntime';
|
||||||
import {
|
import { apiMcpDelete, apiMcpList, apiMcpStop } from '@/services/mcp';
|
||||||
apiMcpDelete,
|
|
||||||
apiMcpList,
|
|
||||||
apiMcpOfficialList,
|
|
||||||
apiMcpStop,
|
|
||||||
} from '@/services/mcp';
|
|
||||||
import { AgentComponentTypeEnum } from '@/types/enums/agent';
|
import { AgentComponentTypeEnum } from '@/types/enums/agent';
|
||||||
import {
|
import {
|
||||||
DeployStatusEnum,
|
DeployStatusEnum,
|
||||||
FilterDeployEnum,
|
FilterDeployEnum,
|
||||||
McpManageSegmentedEnum,
|
|
||||||
McpMoreActionEnum,
|
McpMoreActionEnum,
|
||||||
} from '@/types/enums/mcp';
|
} from '@/types/enums/mcp';
|
||||||
import { CreateListEnum } from '@/types/enums/space';
|
import { CreateListEnum } from '@/types/enums/space';
|
||||||
@@ -28,7 +19,7 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
} from '@ant-design/icons';
|
} 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 classNames from 'classnames';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { history, useModel, useParams, useRequest, useSearchParams } from 'umi';
|
import { history, useModel, useParams, useRequest, useSearchParams } from 'umi';
|
||||||
@@ -37,7 +28,7 @@ import McpComponentItem from './McpComponentItem';
|
|||||||
import ServerExportModal from './ServerExportModal';
|
import ServerExportModal from './ServerExportModal';
|
||||||
const cx = classNames.bind(styles);
|
const cx = classNames.bind(styles);
|
||||||
const { confirm } = Modal;
|
const { confirm } = Modal;
|
||||||
type IQuery = 'create' | 'deployStatus' | 'segmentedValue' | 'keyword';
|
type IQuery = 'create' | 'deployStatus' | 'keyword';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作空间 - MCP管理
|
* 工作空间 - MCP管理
|
||||||
@@ -79,10 +70,6 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
// 服务导出弹窗
|
// 服务导出弹窗
|
||||||
const [serverExportModalVisible, setServerExportModalVisible] =
|
const [serverExportModalVisible, setServerExportModalVisible] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
// 分段器
|
|
||||||
const [segmentedValue, setSegmentedValue] = useState<McpManageSegmentedEnum>(
|
|
||||||
searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom,
|
|
||||||
);
|
|
||||||
// 当前Mcp信息
|
// 当前Mcp信息
|
||||||
const currentMcpInfoRef = useRef<McpDetailInfo | null>(null);
|
const currentMcpInfoRef = useRef<McpDetailInfo | null>(null);
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
@@ -110,37 +97,18 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
setMcpList(_list);
|
setMcpList(_list);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 过滤官方服务列表数据
|
|
||||||
const handleFilterOfficialList = (
|
|
||||||
filterKeyword?: string,
|
|
||||||
list = mcpListAllRef.current,
|
|
||||||
) => {
|
|
||||||
let _list = filterKeyword
|
|
||||||
? list.filter((item) => item.name.includes(filterKeyword))
|
|
||||||
: list;
|
|
||||||
setMcpList(_list);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ✅ 监听 URL 改变(支持浏览器前进/后退)
|
// ✅ 监听 URL 改变(支持浏览器前进/后退)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const create =
|
const create =
|
||||||
Number(searchParams.get('create')) || CreateListEnum.All_Person;
|
Number(searchParams.get('create')) || CreateListEnum.All_Person;
|
||||||
const deployStatus =
|
const deployStatus =
|
||||||
searchParams.get('deployStatus') || FilterDeployEnum.All;
|
searchParams.get('deployStatus') || FilterDeployEnum.All;
|
||||||
const segmentedValue =
|
|
||||||
searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom;
|
|
||||||
const keyword = searchParams.get('keyword') || '';
|
const keyword = searchParams.get('keyword') || '';
|
||||||
|
|
||||||
setCreate(create);
|
setCreate(create);
|
||||||
setDeployStatus(deployStatus);
|
setDeployStatus(deployStatus);
|
||||||
setSegmentedValue(segmentedValue);
|
|
||||||
setKeyword(keyword);
|
setKeyword(keyword);
|
||||||
|
handleFilterList(create, deployStatus, keyword);
|
||||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
|
||||||
handleFilterList(create, deployStatus, keyword);
|
|
||||||
} else {
|
|
||||||
handleFilterOfficialList(keyword);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
}, [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[]) => {
|
const handleMcpList = (id: number, list: McpDetailInfo[]) => {
|
||||||
return list.map((item) => {
|
return list.map((item) => {
|
||||||
@@ -225,31 +179,19 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentValue =
|
|
||||||
searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom;
|
|
||||||
setSegmentedValue(currentValue);
|
|
||||||
|
|
||||||
// 如果有 location.state,说明是点击菜单跳转过来的,会触发下面的 useEffect,这里就不需要请求了
|
// 如果有 location.state,说明是点击菜单跳转过来的,会触发下面的 useEffect,这里就不需要请求了
|
||||||
if (history.location.state) {
|
if (history.location.state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
if (currentValue === McpManageSegmentedEnum.Custom) {
|
runMcpList(spaceId);
|
||||||
runMcpList(spaceId);
|
|
||||||
} else {
|
|
||||||
runMcpOfficialList();
|
|
||||||
}
|
|
||||||
}, [spaceId]);
|
}, [spaceId]);
|
||||||
|
|
||||||
// 监听菜单切换,重新加载数据
|
// 监听菜单切换,重新加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (history.location.state) {
|
if (history.location.state) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
runMcpList(spaceId);
|
||||||
runMcpList(spaceId);
|
|
||||||
} else {
|
|
||||||
runMcpOfficialList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [history.location.state]);
|
}, [history.location.state]);
|
||||||
|
|
||||||
@@ -274,21 +216,13 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
const _keyword = e.target.value;
|
const _keyword = e.target.value;
|
||||||
setKeyword(_keyword);
|
setKeyword(_keyword);
|
||||||
handleChange('keyword', _keyword);
|
handleChange('keyword', _keyword);
|
||||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
handleFilterList(create, deployStatus, _keyword);
|
||||||
handleFilterList(create, deployStatus, _keyword);
|
|
||||||
} else {
|
|
||||||
handleFilterOfficialList(_keyword);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清除关键词
|
// 清除关键词
|
||||||
const handleClearKeyword = () => {
|
const handleClearKeyword = () => {
|
||||||
setKeyword('');
|
setKeyword('');
|
||||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
handleFilterList(create, deployStatus);
|
||||||
handleFilterList(create, deployStatus);
|
|
||||||
} else {
|
|
||||||
handleFilterOfficialList();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点击更多操作
|
// 点击更多操作
|
||||||
@@ -345,11 +279,6 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
// 点击单个资源组件
|
// 点击单个资源组件
|
||||||
const handleClickComponent = (info: McpDetailInfo) => {
|
const handleClickComponent = (info: McpDetailInfo) => {
|
||||||
const { id, spaceId } = info;
|
const { id, spaceId } = info;
|
||||||
// 官方服务不能编辑, spaceId 为 -1时代表官方服务,后台写死的spaceId
|
|
||||||
if (spaceId === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 自定义服务,跳转到编辑应用
|
|
||||||
history.push(`/space/${spaceId}/mcp/edit/${id}`);
|
history.push(`/space/${spaceId}/mcp/edit/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,18 +287,6 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
history.push(`/space/${spaceId}/mcp/create`);
|
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(() => {
|
const listLength = useMemo(() => {
|
||||||
return mcpList.length;
|
return mcpList.length;
|
||||||
}, [mcpList]);
|
}, [mcpList]);
|
||||||
@@ -386,32 +303,21 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
<h3 className={cx(styles.title)}>
|
<h3 className={cx(styles.title)}>
|
||||||
{dict('PC.Pages.SpaceMcpManage.title')}
|
{dict('PC.Pages.SpaceMcpManage.title')}
|
||||||
</h3>
|
</h3>
|
||||||
{segmentedValue === McpManageSegmentedEnum.Custom && (
|
<SelectList
|
||||||
<>
|
value={create}
|
||||||
<SelectList
|
options={CREATE_LIST}
|
||||||
value={create}
|
onChange={handlerChangeCreate}
|
||||||
options={CREATE_LIST}
|
/>
|
||||||
onChange={handlerChangeCreate}
|
{/* 单选模式 */}
|
||||||
/>
|
<ButtonToggle
|
||||||
{/* 单选模式 */}
|
options={FILTER_DEPLOY}
|
||||||
<ButtonToggle
|
value={deployStatus}
|
||||||
options={FILTER_DEPLOY}
|
onChange={(value) =>
|
||||||
value={deployStatus}
|
handlerChangeDeployStatus(value as React.Key)
|
||||||
onChange={(value) =>
|
}
|
||||||
handlerChangeDeployStatus(value as React.Key)
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Segmented
|
|
||||||
options={MCP_MANAGE_SEGMENTED_LIST}
|
|
||||||
value={segmentedValue}
|
|
||||||
onChange={handleChangeSegmentedValue}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, display: 'flex' }}>
|
<div style={{ flex: 1, display: 'flex' }}>
|
||||||
<Input
|
<Input
|
||||||
rootClassName={cx(styles.input)}
|
rootClassName={cx(styles.input)}
|
||||||
@@ -441,7 +347,6 @@ const SpaceLibrary: React.FC = () => {
|
|||||||
{mcpList?.map((info) => (
|
{mcpList?.map((info) => (
|
||||||
<McpComponentItem
|
<McpComponentItem
|
||||||
key={info.id}
|
key={info.id}
|
||||||
className={cx(info?.spaceId === -1 && styles['office-mcp-item'])}
|
|
||||||
mcpInfo={info}
|
mcpInfo={info}
|
||||||
onClick={() => handleClickComponent(info)}
|
onClick={() => handleClickComponent(info)}
|
||||||
onClickMore={(item) => handleClickMore(item, info)}
|
onClickMore={(item) => handleClickMore(item, info)}
|
||||||
|
|||||||
@@ -79,15 +79,6 @@ export function apiMcpDetail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP列表(官方服务)
|
|
||||||
export function apiMcpOfficialList(): Promise<
|
|
||||||
RequestResponse<McpDetailInfo[]>
|
|
||||||
> {
|
|
||||||
return request('/api/mcp/official/list', {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCP管理列表
|
// MCP管理列表
|
||||||
export function apiMcpList(
|
export function apiMcpList(
|
||||||
spaceId: number,
|
spaceId: number,
|
||||||
|
|||||||
@@ -69,11 +69,3 @@ export enum McpExecuteTypeEnum {
|
|||||||
// 提示词
|
// 提示词
|
||||||
PROMPT = 'PROMPT',
|
PROMPT = 'PROMPT',
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP管理分段器枚举(自定义)
|
|
||||||
export enum McpManageSegmentedEnum {
|
|
||||||
// 自定义服务
|
|
||||||
Custom = 'Custom',
|
|
||||||
// 官方服务
|
|
||||||
Official = 'Official',
|
|
||||||
}
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 265 B |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 490 B |
@@ -14,6 +14,7 @@ import { registerSandboxHandlers } from "./sandboxHandlers";
|
|||||||
import { registerPerfHandlers } from "./perfHandlers";
|
import { registerPerfHandlers } from "./perfHandlers";
|
||||||
import { registerGuiServerHandlers } from "./guiServerHandlers";
|
import { registerGuiServerHandlers } from "./guiServerHandlers";
|
||||||
import { registerI18nHandlers } from "./i18nHandlers";
|
import { registerI18nHandlers } from "./i18nHandlers";
|
||||||
|
import { registerSkillHandlers } from "./skillHandlers";
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
|
|
||||||
export function registerAllHandlers(ctx: HandlerContext): void {
|
export function registerAllHandlers(ctx: HandlerContext): void {
|
||||||
@@ -32,6 +33,7 @@ export function registerAllHandlers(ctx: HandlerContext): void {
|
|||||||
registerPerfHandlers();
|
registerPerfHandlers();
|
||||||
registerGuiServerHandlers();
|
registerGuiServerHandlers();
|
||||||
registerI18nHandlers();
|
registerI18nHandlers();
|
||||||
|
registerSkillHandlers();
|
||||||
|
|
||||||
log.info("IPC handlers registered");
|
log.info("IPC handlers registered");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -253,6 +253,29 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
stopAll: () => ipcRenderer.invoke("services:stopAll"),
|
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 status sync
|
||||||
tray: {
|
tray: {
|
||||||
updateStatus: (status: "running" | "stopped" | "error" | "starting") =>
|
updateStatus: (status: "running" | "stopped" | "error" | "starting") =>
|
||||||
|
|||||||
@@ -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<T>(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 <img className={styles.mcpIconImage} src={item.icon} alt="" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={styles.mcpIconText}>{getInitial(item.name)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, McpServerEntry> {
|
||||||
|
const parsed = safeParseJson<unknown>(exportedConfig, exportedConfig);
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("MCP 导出配置为空或格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configObject = parsed as Record<string, unknown>;
|
||||||
|
const maybeServers = configObject.mcpServers;
|
||||||
|
if (maybeServers && typeof maybeServers === "object") {
|
||||||
|
const servers = maybeServers as Record<string, unknown>;
|
||||||
|
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<LocalMcpItem[]>([]);
|
||||||
|
const [localConfig, setLocalConfig] = useState<McpServersConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [connectingIds, setConnectingIds] = useState<Set<number>>(new Set());
|
||||||
|
const [error, setError] = useState<string | null>(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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.breadcrumb}>
|
||||||
|
扩展市场 <span>></span> <strong>MCP 库</strong>
|
||||||
|
</div>
|
||||||
|
<h1 className={styles.title}>MCP 库</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
<div className={styles.topRow}>
|
||||||
|
<Input
|
||||||
|
className={styles.search}
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="搜索已部署 MCP、空间或描述..."
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(event) => setKeywordInput(event.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
<div className={styles.count}>
|
||||||
|
<span>{filteredItems.length}</span> 个 MCP 服务
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.mcpArea}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Spin />
|
||||||
|
<span>MCP 服务加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Empty description={error} />
|
||||||
|
<Button type="primary" onClick={loadMcpConfigs}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Empty description="暂无已部署 MCP 服务" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const connected = !!getMatchedServerId(localConfig, item);
|
||||||
|
return (
|
||||||
|
<article className={styles.card} key={item.id}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div className={styles.mcpIcon}>
|
||||||
|
<McpIcon item={item} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.mcpMeta}>
|
||||||
|
<h2 className={styles.mcpName}>
|
||||||
|
{item.name || "未命名 MCP"}
|
||||||
|
</h2>
|
||||||
|
<div className={styles.author}>
|
||||||
|
{item.spaceName} / {getCreatorName(item)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.badge}>自定义</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.description}>
|
||||||
|
{item.description || "暂无 MCP 服务描述"}
|
||||||
|
</p>
|
||||||
|
<div className={styles.cardFooter}>
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<span>{item.deployStatus || "Deployed"}</span>
|
||||||
|
{item.serverName && <span>{item.serverName}</span>}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={
|
||||||
|
connected
|
||||||
|
? styles.connectedButton
|
||||||
|
: styles.connectButton
|
||||||
|
}
|
||||||
|
type={connected ? "default" : "primary"}
|
||||||
|
size="small"
|
||||||
|
icon={connected ? <CheckOutlined /> : <ApiOutlined />}
|
||||||
|
loading={connectingIds.has(item.id)}
|
||||||
|
disabled={connected}
|
||||||
|
onClick={() => handleConnect(item)}
|
||||||
|
>
|
||||||
|
{connected ? "已连接" : "连接"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <img className={styles.skillIconImage} src={skill.icon} alt="" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={styles.skillIconText}>{getInitial(skill.name)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PublishedCategory[]>([]);
|
||||||
|
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY);
|
||||||
|
const [keywordInput, setKeywordInput] = useState("");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [skills, setSkills] = useState<PublishedSkill[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||||
|
const [loadingSkills, setLoadingSkills] = useState(false);
|
||||||
|
const [installingIds, setInstallingIds] = useState<Set<number>>(new Set());
|
||||||
|
const [installedSkillIds, setInstalledSkillIds] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedSkill, setSelectedSkill] = useState<PublishedSkill | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [skillDetail, setSkillDetail] = useState<PublishedSkillDetail | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detailError, setDetailError] = useState<string | null>(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<string, string>();
|
||||||
|
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 (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.titleGroup}>
|
||||||
|
<div className={styles.titleIcon}>
|
||||||
|
<AppstoreOutlined />
|
||||||
|
</div>
|
||||||
|
<h1 className={styles.title}>技能库</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.breadcrumb}>扩展市场 / 技能库</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Input
|
||||||
|
className={styles.search}
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="搜索技能、插件或作者..."
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(event) => setKeywordInput(event.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
<div className={styles.count}>
|
||||||
|
<span>{total}</span> 个技能
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.categoryBar}>
|
||||||
|
{loadingCategories ? (
|
||||||
|
<span className={styles.categoryLoading}>分类加载中...</span>
|
||||||
|
) : (
|
||||||
|
visibleCategories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.key || "all"}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.categoryChip} ${
|
||||||
|
activeCategory === category.key
|
||||||
|
? styles.categoryChipActive
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleCategorySelect(category.key)}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.skillsArea}>
|
||||||
|
{loadingSkills ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Spin />
|
||||||
|
<span>技能加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Empty description={error} />
|
||||||
|
<Button type="primary" onClick={loadSkills}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : skills.length === 0 ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Empty description="暂无技能" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{sortedSkills.map((skill) => {
|
||||||
|
const skillId = getSkillId(skill);
|
||||||
|
const installed = installedSkillIds.has(skillId);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={styles.card}
|
||||||
|
key={`${skill.id}-${skillId}`}
|
||||||
|
onClick={() => handleOpenDetail(skill)}
|
||||||
|
>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div className={styles.skillIcon}>
|
||||||
|
<SkillIcon skill={skill} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.skillMeta}>
|
||||||
|
<h2 className={styles.skillName}>{skill.name}</h2>
|
||||||
|
<div className={styles.author}>
|
||||||
|
@{getAuthorName(skill)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getCategoryLabel(skill.category) && (
|
||||||
|
<span className={styles.badge}>
|
||||||
|
{getCategoryLabel(skill.category)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={styles.description}>
|
||||||
|
{skill.description || "暂无技能描述"}
|
||||||
|
</p>
|
||||||
|
<div className={styles.cardFooter}>
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<span>
|
||||||
|
{formatMetric(getDownloadCount(skill))} 下载
|
||||||
|
</span>
|
||||||
|
<span className={styles.rating}>
|
||||||
|
★ {getRating(skill)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={
|
||||||
|
installed
|
||||||
|
? styles.installedButton
|
||||||
|
: styles.installButton
|
||||||
|
}
|
||||||
|
type={installed ? "default" : "primary"}
|
||||||
|
size="small"
|
||||||
|
icon={installed ? <CheckOutlined /> : undefined}
|
||||||
|
loading={installingIds.has(skillId)}
|
||||||
|
disabled={installed}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleInstall(skill);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{installed ? "已安装" : "安装"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
className={styles.detailModal}
|
||||||
|
width={760}
|
||||||
|
open={!!selectedSkill}
|
||||||
|
title={null}
|
||||||
|
footer={null}
|
||||||
|
onCancel={handleCloseDetail}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{detailSkill && (
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<div className={styles.detailIcon}>
|
||||||
|
<SkillIcon skill={detailSkill} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailMeta}>
|
||||||
|
<h2 className={styles.detailTitle}>{detailSkill.name}</h2>
|
||||||
|
<div className={styles.detailAuthor}>
|
||||||
|
@{getAuthorName(detailSkill)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getCategoryLabel(detailSkill.category) && (
|
||||||
|
<span className={styles.detailBadge}>
|
||||||
|
{getCategoryLabel(detailSkill.category)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className={styles.detailState}>
|
||||||
|
<Spin />
|
||||||
|
<span>详情加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : detailError ? (
|
||||||
|
<div className={styles.detailState}>
|
||||||
|
<Empty description={detailError} />
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => handleOpenDetail(detailSkill)}
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={styles.detailDescription}>
|
||||||
|
{detailSkill.description || "暂无技能描述"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.detailStats}>
|
||||||
|
<span>
|
||||||
|
{formatMetric(getDownloadCount(detailSkill))} 下载
|
||||||
|
</span>
|
||||||
|
<span>★ {getRating(detailSkill)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewFile?.contents && (
|
||||||
|
<section className={styles.previewSection}>
|
||||||
|
<div className={styles.sectionHeading}>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span>{previewFile.name}</span>
|
||||||
|
</div>
|
||||||
|
<pre className={styles.previewContent}>
|
||||||
|
{previewFile.contents.slice(0, 1800)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.detailFooter}>
|
||||||
|
<Button onClick={handleCloseDetail}>关闭</Button>
|
||||||
|
<Button
|
||||||
|
className={
|
||||||
|
detailSkillId && installedSkillIds.has(detailSkillId)
|
||||||
|
? styles.installedButton
|
||||||
|
: styles.installButton
|
||||||
|
}
|
||||||
|
type={
|
||||||
|
detailSkillId && installedSkillIds.has(detailSkillId)
|
||||||
|
? "default"
|
||||||
|
: "primary"
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
detailSkillId && installedSkillIds.has(detailSkillId) ? (
|
||||||
|
<CheckOutlined />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
loading={!!detailSkillId && installingIds.has(detailSkillId)}
|
||||||
|
disabled={
|
||||||
|
(!!detailSkillId && installedSkillIds.has(detailSkillId)) ||
|
||||||
|
detailLoading
|
||||||
|
}
|
||||||
|
onClick={() => handleInstall(detailSkill)}
|
||||||
|
>
|
||||||
|
{detailSkillId && installedSkillIds.has(detailSkillId)
|
||||||
|
? "已安装"
|
||||||
|
: "安装"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<img className={styles.workflowIconImage} src={workflow.icon} alt="" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.workflowIconText}>{getInitial(workflow.name)}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowMarketplacePage() {
|
||||||
|
const [categories, setCategories] = useState<PublishedCategory[]>([]);
|
||||||
|
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY);
|
||||||
|
const [keywordInput, setKeywordInput] = useState("");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [workflows, setWorkflows] = useState<PublishedWorkflow[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||||
|
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
|
||||||
|
const [usingIds, setUsingIds] = useState<Set<number>>(new Set());
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.breadcrumb}>
|
||||||
|
扩展市场 <span>></span> <strong>工作流</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
<h1 className={styles.title}>工作流</h1>
|
||||||
|
<span className={styles.totalText}>{total} 个工作流</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
<Input
|
||||||
|
className={styles.search}
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="搜索工作流、名称或描述..."
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(event) => setKeywordInput(event.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.categoryBar}>
|
||||||
|
{loadingCategories ? (
|
||||||
|
<span className={styles.categoryLoading}>分类加载中...</span>
|
||||||
|
) : (
|
||||||
|
visibleCategories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.key || "all"}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.categoryChip} ${
|
||||||
|
activeCategory === category.key
|
||||||
|
? styles.categoryChipActive
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveCategory(category.key)}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.workflowArea}>
|
||||||
|
{loadingWorkflows ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Spin />
|
||||||
|
<span>工作流加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Empty description={error} />
|
||||||
|
<Button type="primary" onClick={loadWorkflows}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : workflows.length === 0 ? (
|
||||||
|
<div className={styles.centerState}>
|
||||||
|
<Empty description="暂无工作流" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{workflows.map((workflow) => {
|
||||||
|
const workflowId = workflow.targetId || workflow.id;
|
||||||
|
const used = !!workflow.collect;
|
||||||
|
return (
|
||||||
|
<article className={styles.card} key={workflowId}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div
|
||||||
|
className={styles.workflowIcon}
|
||||||
|
style={{ backgroundColor: getIconColor(workflow) }}
|
||||||
|
>
|
||||||
|
<WorkflowIcon workflow={workflow} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.workflowMeta}>
|
||||||
|
<h2 className={styles.workflowName}>{workflow.name}</h2>
|
||||||
|
<div className={styles.author}>
|
||||||
|
{getAuthorName(workflow)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.badgeRow}>
|
||||||
|
<span className={styles.badge}>
|
||||||
|
{workflow.category || "工作流"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.description}>
|
||||||
|
{workflow.description || "暂无工作流描述"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.cardFooter}>
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<span>{formatMetric(getUsageCount(workflow))}</span>
|
||||||
|
<span>次使用</span>
|
||||||
|
<span className={styles.rating}>
|
||||||
|
★ {getRating(workflow)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={used ? styles.usedButton : styles.useButton}
|
||||||
|
type={used ? "default" : "primary"}
|
||||||
|
size="small"
|
||||||
|
icon={used ? <CheckOutlined /> : undefined}
|
||||||
|
loading={usingIds.has(workflowId)}
|
||||||
|
disabled={used}
|
||||||
|
onClick={() => handleUse(workflow)}
|
||||||
|
>
|
||||||
|
{used ? "已使用" : "使用"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
code: string;
|
||||||
|
message?: string;
|
||||||
|
data: T;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplacePage<T> {
|
||||||
|
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<string> {
|
||||||
|
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<string | null> {
|
||||||
|
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<T>(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
method?: "GET" | "POST";
|
||||||
|
data?: unknown;
|
||||||
|
showError?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
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<PublishedCategory[]> {
|
||||||
|
return marketplaceRequest<PublishedCategory[]>(
|
||||||
|
"/api/published/category/list",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublishedSkills(
|
||||||
|
params: FetchPublishedSkillsParams,
|
||||||
|
): Promise<MarketplacePage<PublishedSkill>> {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const page = await marketplaceRequest<MarketplacePage<PublishedSkill>>(
|
||||||
|
"/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<void> {
|
||||||
|
await marketplaceRequest<null>(`/api/published/skill/collect/${skillId}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublishedSkillDetail(
|
||||||
|
skillId: number,
|
||||||
|
): Promise<PublishedSkillDetail> {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const detail = await marketplaceRequest<PublishedSkillDetail>(
|
||||||
|
`/api/published/skill/${skillId}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
icon: normalizeAssetUrl(baseUrl, detail.icon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unCollectSkill(skillId: number): Promise<void> {
|
||||||
|
await marketplaceRequest<null>(`/api/published/skill/unCollect/${skillId}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublishedWorkflows(
|
||||||
|
params: FetchPublishedWorkflowsParams,
|
||||||
|
): Promise<MarketplacePage<PublishedWorkflow>> {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const page = await marketplaceRequest<MarketplacePage<PublishedWorkflow>>(
|
||||||
|
"/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<void> {
|
||||||
|
await marketplaceRequest<null>(
|
||||||
|
`/api/published/workflow/collect/${workflowId}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unCollectWorkflow(workflowId: number): Promise<void> {
|
||||||
|
await marketplaceRequest<null>(
|
||||||
|
`/api/published/workflow/unCollect/${workflowId}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMcpConfigs(
|
||||||
|
params: FetchMcpConfigsParams,
|
||||||
|
): Promise<MarketplacePage<McpConfigItem>> {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const keyword = params.keyword?.trim();
|
||||||
|
const queryFilter: Record<string, unknown> = {
|
||||||
|
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<MarketplacePage<McpConfigItem>>(
|
||||||
|
"/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<SpaceInfo[]> {
|
||||||
|
return marketplaceRequest<SpaceInfo[]>("/api/space/list", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeployedMcps(params: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
spaceId?: number;
|
||||||
|
kw?: string;
|
||||||
|
justReturnSpaceData?: boolean;
|
||||||
|
}): Promise<MarketplacePage<McpDeployedItem>> {
|
||||||
|
return marketplaceRequest<MarketplacePage<McpDeployedItem>>(
|
||||||
|
"/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<unknown> {
|
||||||
|
return marketplaceRequest<unknown>(`/api/mcp/server/config/export/${mcpId}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMcpDetail(
|
||||||
|
uid: string,
|
||||||
|
): Promise<McpConfigItem | null> {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const detail = await marketplaceRequest<McpConfigItem | null>(
|
||||||
|
"/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<McpConfigItem | null> {
|
||||||
|
return marketplaceRequest<McpConfigItem | null>(
|
||||||
|
`/api/system/eco/market/client/publish/config/enable?uid=${encodeURIComponent(uid)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableMcpConfig(
|
||||||
|
uid: string,
|
||||||
|
): Promise<McpConfigItem | null> {
|
||||||
|
return marketplaceRequest<McpConfigItem | null>(
|
||||||
|
`/api/system/eco/market/client/publish/config/disable?uid=${encodeURIComponent(uid)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
data: { uid },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAndEnableMcpConfig(
|
||||||
|
params: UpdateAndEnableMcpConfigParams,
|
||||||
|
): Promise<McpConfigItem | null> {
|
||||||
|
return marketplaceRequest<McpConfigItem | null>(
|
||||||
|
"/api/system/eco/market/client/publish/config/updateAndEnable",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
data: params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
height: 122px;
|
height: 104px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 38px 1fr 34px;
|
grid-template-columns: 38px 1fr 34px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 26px 12px 18px 20px;
|
padding: 12px 12px 14px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brandLogo {
|
.brandLogo {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -653,6 +653,47 @@ export interface QuickInitAPI {
|
|||||||
getConfig: () => Promise<QuickInitConfig | null>;
|
getConfig: () => Promise<QuickInitConfig | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<SkillInstallResult>;
|
||||||
|
isInstalled: (query: SkillInstallQuery) => Promise<SkillInstallResult>;
|
||||||
|
openInstallDir: (query: SkillInstallQuery) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
installDir?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
versions: {
|
versions: {
|
||||||
node: string;
|
node: string;
|
||||||
@@ -740,6 +781,7 @@ export interface ElectronAPI {
|
|||||||
app: AppAPI;
|
app: AppAPI;
|
||||||
permissions: PermissionsAPI;
|
permissions: PermissionsAPI;
|
||||||
quickInit: QuickInitAPI;
|
quickInit: QuickInitAPI;
|
||||||
|
skills: SkillsAPI;
|
||||||
perf: PerfAPI;
|
perf: PerfAPI;
|
||||||
on: (channel: string, callback: (...args: unknown[]) => void) => void;
|
on: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||||
off: (channel: string, callback: (...args: unknown[]) => void) => void;
|
off: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||||
|
|||||||