feat: 完善本地MCP与扩展市场能力
@@ -27,7 +27,7 @@ spring:
|
||||
datasource:
|
||||
# 主数据源 (MySQL)
|
||||
master:
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DB_USERNAME:root}
|
||||
password: ${DB_PASSWORD:your_mysql_password}
|
||||
@@ -48,7 +48,7 @@ spring:
|
||||
filters: stat # 启用 stat filter 用于监控
|
||||
# Doris 数据源
|
||||
doris:
|
||||
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_custom_table}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_custom_table}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DORIS_USERNAME:root}
|
||||
password: ${DORIS_PASSWORD:your_doris_password}
|
||||
@@ -165,6 +165,8 @@ search:
|
||||
username: ${ES_USERNAME:elastic}
|
||||
password: ${ES_PASSWORD:your_elasticsearch_password}
|
||||
api_key: ${ES_API_KEY:}
|
||||
text_analyzer: ${ES_TEXT_ANALYZER:standard}
|
||||
search_analyzer: ${ES_SEARCH_ANALYZER:standard}
|
||||
# 生态市场模块配置
|
||||
eco-market:
|
||||
# 服务端配置
|
||||
@@ -230,4 +232,4 @@ model-api-proxy:
|
||||
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
|
||||
port: ${MODEL_PROXY_PORT:18086}
|
||||
#记录日志
|
||||
save-log: ${MODEL_PROXY_SAVE_LOG:true}
|
||||
save-log: ${MODEL_PROXY_SAVE_LOG:true}
|
||||
|
||||
@@ -27,7 +27,7 @@ spring:
|
||||
datasource:
|
||||
# 主数据源 (MySQL)
|
||||
master:
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DB_USERNAME:root}
|
||||
password: ${DB_PASSWORD:Byyagz@121}
|
||||
@@ -48,7 +48,7 @@ spring:
|
||||
filters: stat # 启用 stat filter 用于监控
|
||||
# Doris 数据源
|
||||
doris:
|
||||
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&allowPublicKeyRetrieval=true&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DORIS_USERNAME:root}
|
||||
password: ${DORIS_PASSWORD:Byyagz@121}
|
||||
@@ -165,6 +165,8 @@ search:
|
||||
username: ${ES_USERNAME:}
|
||||
password: ${ES_PASSWORD:}
|
||||
api_key: ${ES_API_KEY:}
|
||||
text_analyzer: ${ES_TEXT_ANALYZER:standard}
|
||||
search_analyzer: ${ES_SEARCH_ANALYZER:standard}
|
||||
# 生态市场模块配置
|
||||
eco-market:
|
||||
# 服务端配置
|
||||
|
||||
@@ -53,6 +53,12 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
||||
@Value("${search.elasticsearch.password:}")
|
||||
private String password;
|
||||
|
||||
@Value("${search.elasticsearch.text_analyzer:standard}")
|
||||
private String textAnalyzer;
|
||||
|
||||
@Value("${search.elasticsearch.search_analyzer:standard}")
|
||||
private String searchAnalyzer;
|
||||
|
||||
private ElasticsearchClient client;
|
||||
|
||||
@PostConstruct
|
||||
@@ -75,6 +81,14 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
||||
client.shutdown();
|
||||
}
|
||||
|
||||
private String getTextAnalyzer() {
|
||||
return StringUtils.defaultIfBlank(textAnalyzer, "standard");
|
||||
}
|
||||
|
||||
private String getSearchAnalyzer() {
|
||||
return StringUtils.defaultIfBlank(searchAnalyzer, getTextAnalyzer());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bulkIndex(List<SearchDocument> list) {
|
||||
Assert.noNullElements(list, "list cannot be left blank.");
|
||||
@@ -146,8 +160,8 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
||||
if (keyword.get()) {
|
||||
builder.properties(name.get(), property -> property.keyword(kw -> kw.index(index.get()).store(store.get())));
|
||||
} else {
|
||||
builder.properties(name.get(), property -> property.text(text -> text.analyzer("ik_max_word")
|
||||
.searchAnalyzer("ik_smart").store(store.get()).index(index.get())
|
||||
builder.properties(name.get(), property -> property.text(text -> text.analyzer(getTextAnalyzer())
|
||||
.searchAnalyzer(getSearchAnalyzer()).store(store.get()).index(index.get())
|
||||
));
|
||||
}
|
||||
} else if (declaredField.getType() == Integer.class) {
|
||||
@@ -163,8 +177,8 @@ public class ElasticsearchServiceImpl implements SearchService, ISearchRpcServic
|
||||
} else if (declaredField.getType() == Date.class) {
|
||||
builder.properties(name.get(), property -> property.date(date -> date.index(index.get()).store(store.get())));
|
||||
} else {
|
||||
builder.properties(name.get(), property -> property.text(text -> text.analyzer("ik_max_word")
|
||||
.searchAnalyzer("ik_smart").store(store.get()).index(index.get())
|
||||
builder.properties(name.get(), property -> property.text(text -> text.analyzer(getTextAnalyzer())
|
||||
.searchAnalyzer(getSearchAnalyzer()).store(store.get()).index(index.get())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.xspaceagi.log.sdk.request.DocumentSearchRequest;
|
||||
import com.xspaceagi.log.sdk.service.ISearchRpcService;
|
||||
import com.xspaceagi.log.sdk.vo.LogDocument;
|
||||
import com.xspaceagi.log.sdk.vo.SearchResult;
|
||||
import com.xspaceagi.log.web.controller.dto.LogQueryDto;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.common.UserContext;
|
||||
@@ -19,6 +20,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -125,13 +127,15 @@ public abstract class BaseController {
|
||||
createTimeRange.put("express", "range");
|
||||
filterFieldsAndValues.add(Map.of("createTime", createTimeRange));
|
||||
}
|
||||
builder.sortFieldsAndValues(Map.of("createTime", "Desc"));
|
||||
var result = iSearchRpcService.search(builder.build());
|
||||
|
||||
try {
|
||||
builder.sortFieldsAndValues(Map.of("createTime", "Desc"));
|
||||
var result = iSearchRpcService.search(builder.build());
|
||||
IPage<LogDocument> page = new Page<>(pageQueryVo.getCurrent().intValue(), pageQueryVo.getPageSize().intValue());
|
||||
page.setTotal(result.getTotal());
|
||||
page.setRecords(result.getItems().stream().map(item -> {
|
||||
page.setTotal(result == null || result.getTotal() == null ? 0 : result.getTotal());
|
||||
List<SearchResult.SearchResultItem> items = result == null || result.getItems() == null
|
||||
? Collections.emptyList()
|
||||
: result.getItems();
|
||||
page.setRecords(items.stream().map(item -> {
|
||||
LogDocument document = (LogDocument) item.getDocument();
|
||||
document.setProcessData(null);
|
||||
return document;
|
||||
|
||||
@@ -140,7 +140,16 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen
|
||||
deployStatus = new McpDeployStatusResponse();
|
||||
deployStatus.setStatus(McpDeployStatusEnum.Ready);
|
||||
} else {
|
||||
deployStatus = mcpDeployRpcService.deploy(String.valueOf(id), serverConfig, McpPersistentTypeEnum.OneShot);
|
||||
try {
|
||||
deployStatus = mcpDeployRpcService.deploy(String.valueOf(id), serverConfig, McpPersistentTypeEnum.OneShot);
|
||||
} catch (Exception e) {
|
||||
log.warn("MCP service [{}] deploy request failed", mcpName, e);
|
||||
if (scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) {
|
||||
markDeployFailed(userDto, id, mcpName, e.getMessage());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (deployStatus.getStatus() == McpDeployStatusEnum.Ready) {
|
||||
try {
|
||||
@@ -161,15 +170,7 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen
|
||||
}
|
||||
}
|
||||
if (deployStatus.getStatus() == McpDeployStatusEnum.Error) {
|
||||
McpConfig mcpConfig = new McpConfig();
|
||||
mcpConfig.setId(id);
|
||||
mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed);
|
||||
mcpConfigDomainService.update(mcpConfig);
|
||||
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
|
||||
.scope(NotifyMessage.MessageScope.System)
|
||||
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Failed", mcpName, deployStatus.getMessage()))
|
||||
.userIds(List.of(userId))
|
||||
.build());
|
||||
markDeployFailed(userDto, id, mcpName, deployStatus.getMessage());
|
||||
return true;
|
||||
}
|
||||
checkAndSendFailedNotifyMessage(userDto, id, mcpName, scheduleTask);
|
||||
@@ -178,10 +179,7 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen
|
||||
|
||||
private void checkAndSendFailedNotifyMessage(UserDto userDto, Long id, String mcpName, ScheduleTaskDto scheduleTask) {
|
||||
if (scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) {
|
||||
McpConfig mcpConfig = new McpConfig();
|
||||
mcpConfig.setId(id);
|
||||
mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed);
|
||||
mcpConfigDomainService.update(mcpConfig);
|
||||
updateDeployFailed(id);
|
||||
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
|
||||
.scope(NotifyMessage.MessageScope.System)
|
||||
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Timeout", mcpName))
|
||||
@@ -189,4 +187,20 @@ public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implemen
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
private void markDeployFailed(UserDto userDto, Long id, String mcpName, String message) {
|
||||
updateDeployFailed(id);
|
||||
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
|
||||
.scope(NotifyMessage.MessageScope.System)
|
||||
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Failed", mcpName, message))
|
||||
.userIds(List.of(userDto.getId()))
|
||||
.build());
|
||||
}
|
||||
|
||||
private void updateDeployFailed(Long id) {
|
||||
McpConfig mcpConfig = new McpConfig();
|
||||
mcpConfig.setId(id);
|
||||
mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed);
|
||||
mcpConfigDomainService.update(mcpConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,12 +226,7 @@ public class McpController {
|
||||
@Operation(summary = "MCP列表(官方服务)")
|
||||
@GetMapping("/official/list")
|
||||
public ReqResult<List<McpDto>> officialList() {
|
||||
List<McpDto> mcpDtos = mcpConfigApplicationService.queryMcpListBySpaceId(-1L);
|
||||
//移除未发布的
|
||||
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);
|
||||
return ReqResult.success(List.of());
|
||||
}
|
||||
|
||||
@RequireResource(MCP_DELETE)
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
package com.xspaceagi.system.web.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.TypeReference;
|
||||
import com.xspaceagi.eco.market.sdk.reponse.ClientSecretResponse;
|
||||
import com.xspaceagi.system.application.dto.*;
|
||||
import com.xspaceagi.system.application.service.AuthService;
|
||||
import com.xspaceagi.system.application.service.NotifyMessageApplicationService;
|
||||
import com.xspaceagi.system.infra.dao.entity.NotifyMessage;
|
||||
import com.xspaceagi.system.infra.dao.entity.User;
|
||||
import com.xspaceagi.system.infra.rpc.ClientSecretRpcService;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.utils.HttpClient;
|
||||
import com.xspaceagi.system.web.dto.EventRespDto;
|
||||
import com.xspaceagi.system.web.dto.PullMessageAckDto;
|
||||
import com.xspaceagi.system.web.dto.PullMessageRequestDto;
|
||||
import com.xspaceagi.system.web.dto.PullMessageResponseDto;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Tag(name = "消息通知相关接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/notify")
|
||||
@@ -45,22 +29,9 @@ public class NotifyMessageController {
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
|
||||
@Resource
|
||||
private HttpClient httpClient;
|
||||
|
||||
@Value("${installation-source}")
|
||||
private String installationSource;
|
||||
|
||||
@Value("${app.version}")
|
||||
private String version;
|
||||
|
||||
@Value("${eco-market.server.base-url}")
|
||||
private String cloudApi;
|
||||
|
||||
@Resource
|
||||
private ClientSecretRpcService clientSecretRpcService;
|
||||
|
||||
|
||||
@Operation(summary = "查询用户消息列表")
|
||||
@RequestMapping(path = "/message/list", method = RequestMethod.POST)
|
||||
public ReqResult<List<NotifyMessageDto>> listQuery(@RequestBody NotifyMessageQueryDto messageQueryDto) {
|
||||
@@ -68,43 +39,6 @@ public class NotifyMessageController {
|
||||
return ReqResult.success(notifyMessageApplicationService.queryNotifyMessageList(messageQueryDto));
|
||||
}
|
||||
|
||||
private void pullMessage() {
|
||||
UserDto userDto = (UserDto) RequestContext.get().getUser();
|
||||
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
|
||||
if (userDto.getRole() == User.Role.Admin) {
|
||||
// 管理员拉取云端消息
|
||||
ClientSecretResponse clientSecretResponse = clientSecretRpcService.queryClientSecret(tenantConfigDto.getTenantId());
|
||||
if (clientSecretResponse != null) {
|
||||
PullMessageRequestDto pullMessageRequestDto = new PullMessageRequestDto();
|
||||
pullMessageRequestDto.setTenantName(tenantConfigDto.getSiteName());
|
||||
pullMessageRequestDto.setSiteUrl(tenantConfigDto.getSiteUrl());
|
||||
pullMessageRequestDto.setClientId(clientSecretResponse.getClientId());
|
||||
pullMessageRequestDto.setClientSecret(clientSecretResponse.getClientSecret());
|
||||
pullMessageRequestDto.setInstallationSource(installationSource);
|
||||
pullMessageRequestDto.setVersion(version);
|
||||
pullMessageRequestDto.setUser(userDto.getNickName() != null ? userDto.getNickName() : userDto.getUserName());
|
||||
pullMessageRequestDto.setUid(userDto.getUid());
|
||||
String content = httpClient.post(cloudApi + "/api/message/pull", JSON.toJSONString(pullMessageRequestDto), Map.of("Authorization", "Bearer " + clientSecretResponse.getClientSecret()));
|
||||
ReqResult<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 = "查询用户未读消息数量")
|
||||
@RequestMapping(path = "/message/unread/count", method = RequestMethod.GET)
|
||||
public ReqResult<Long> unReadCount() {
|
||||
@@ -134,12 +68,6 @@ public class NotifyMessageController {
|
||||
eventRespDto.setHasEvent(!eventDtos.isEmpty());
|
||||
eventRespDto.setEventList(eventDtos);
|
||||
eventRespDto.setVersion(version);
|
||||
try {
|
||||
// 之前的逻辑
|
||||
pullMessage();
|
||||
} catch (Exception e) {
|
||||
log.error("触发消息拉取事件失败", e);
|
||||
}
|
||||
return ReqResult.success(eventRespDto);
|
||||
}
|
||||
|
||||
@@ -151,4 +79,4 @@ public class NotifyMessageController {
|
||||
return ReqResult.success();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
port: 18020
|
||||
log:
|
||||
level: info
|
||||
path: logs
|
||||
retain_days: 5
|
||||
runtime:
|
||||
warm_up_on_start: false
|
||||
mirror:
|
||||
npm_registry: ""
|
||||
pypi_index_url: ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
server:
|
||||
# The port to listen on for incoming connections
|
||||
port: 8085
|
||||
port: 18020
|
||||
# The log level to use
|
||||
log:
|
||||
level: debug
|
||||
@@ -8,6 +8,10 @@ log:
|
||||
path: logs
|
||||
# The number of log files to retain (default: 5)
|
||||
retain_days: 5
|
||||
runtime:
|
||||
# Warm up uv/deno dependencies on service startup.
|
||||
# Keep this disabled to avoid repeated dependency checks/downloads at startup.
|
||||
warm_up_on_start: false
|
||||
mirror:
|
||||
npm_registry: ""
|
||||
pypi_index_url: ""
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct AppConfig {
|
||||
pub log: LogConfig,
|
||||
#[serde(default)]
|
||||
pub mirror: MirrorYamlConfig,
|
||||
#[serde(default)]
|
||||
pub runtime: RuntimeConfig,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -43,6 +45,14 @@ pub struct LogConfig {
|
||||
pub retain_days: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
pub struct RuntimeConfig {
|
||||
/// Warm up uv/deno dependencies when the proxy service starts.
|
||||
#[serde(default)]
|
||||
pub warm_up_on_start: bool,
|
||||
}
|
||||
|
||||
/// Default log files to retain
|
||||
fn default_retain_days() -> u32 {
|
||||
5
|
||||
@@ -89,6 +99,13 @@ impl AppConfig {
|
||||
config.log.level = log_level;
|
||||
}
|
||||
|
||||
if let Ok(warm_up_on_start) = env::var("MCP_PROXY_WARM_UP_ON_START") {
|
||||
config.runtime.warm_up_on_start = matches!(
|
||||
warm_up_on_start.to_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,10 @@ async fn run_server_mode() -> Result<()> {
|
||||
tracing::info!(" - Log directory: {}", log_path);
|
||||
tracing::info!(" - Log level: {}", &app_config.log.level);
|
||||
tracing::info!(" - Log retention days: {}", retain_days);
|
||||
tracing::info!(
|
||||
" - Warm up on start: {}",
|
||||
app_config.runtime.warm_up_on_start
|
||||
);
|
||||
tracing::info!("Environment overrides:");
|
||||
if std::env::var("MCP_PROXY_PORT").is_ok() {
|
||||
tracing::info!(" - MCP_PROXY_PORT override detected: {}", server_port);
|
||||
@@ -219,6 +223,12 @@ async fn run_server_mode() -> Result<()> {
|
||||
if let Ok(level) = std::env::var("MCP_PROXY_LOG_LEVEL") {
|
||||
tracing::info!(" - MCP_PROXY_LOG_LEVEL override detected: {}", level);
|
||||
}
|
||||
if let Ok(warm_up_on_start) = std::env::var("MCP_PROXY_WARM_UP_ON_START") {
|
||||
tracing::info!(
|
||||
" - MCP_PROXY_WARM_UP_ON_START override detected: {}",
|
||||
warm_up_on_start
|
||||
);
|
||||
}
|
||||
tracing::info!("========================================");
|
||||
|
||||
// 监听地址
|
||||
@@ -286,14 +296,18 @@ async fn run_server_mode() -> Result<()> {
|
||||
}));
|
||||
});
|
||||
|
||||
// 预热 uv/deno 环境依赖
|
||||
tokio::spawn(async move {
|
||||
info!("Warming up uv/deno runtime dependencies...");
|
||||
match warm_up_all_envs(None, None, None, None).await {
|
||||
Ok(_) => info!("Runtime dependency warm-up completed"),
|
||||
Err(e) => error!("Runtime dependency warm-up failed: {e}"),
|
||||
}
|
||||
});
|
||||
// 预热 uv/deno 环境依赖。默认关闭,避免每次服务启动都触发依赖检查/下载。
|
||||
if app_config.runtime.warm_up_on_start {
|
||||
tokio::spawn(async move {
|
||||
info!("Warming up uv/deno runtime dependencies...");
|
||||
match warm_up_all_envs(None, None, None, None).await {
|
||||
Ok(_) => info!("Runtime dependency warm-up completed"),
|
||||
Err(e) => error!("Runtime dependency warm-up failed: {e}"),
|
||||
}
|
||||
});
|
||||
} else {
|
||||
info!("Runtime dependency warm-up skipped");
|
||||
}
|
||||
|
||||
// 启动服务器,监听多种信号以实现优雅关闭
|
||||
info!("Starting HTTP server...");
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
FilterDeployEnum,
|
||||
McpEditHeadMenusEnum,
|
||||
McpInstallTypeEnum,
|
||||
McpManageSegmentedEnum,
|
||||
McpMoreActionEnum,
|
||||
} from '@/types/enums/mcp';
|
||||
|
||||
@@ -93,15 +92,3 @@ export const MCP_EDIT_HEAD_MENU_LIST = [
|
||||
label: dict('PC.Constants.Mcp.menuPrompt'),
|
||||
},
|
||||
];
|
||||
|
||||
// MCP管理分段器列表
|
||||
export const MCP_MANAGE_SEGMENTED_LIST = [
|
||||
{
|
||||
value: McpManageSegmentedEnum.Custom,
|
||||
label: dict('PC.Constants.Mcp.segCustom'),
|
||||
},
|
||||
{
|
||||
value: McpManageSegmentedEnum.Official,
|
||||
label: dict('PC.Constants.Mcp.segOfficial'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -27,10 +27,6 @@
|
||||
/* 关键修复:确保内容从顶部开始排列,避免不均匀的间距分布 */
|
||||
align-content: start;
|
||||
margin-top: 10px;
|
||||
|
||||
.office-mcp-item {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import ButtonToggle from '@/components/ButtonToggle';
|
||||
import Loading from '@/components/custom/Loading';
|
||||
import SelectList from '@/components/custom/SelectList';
|
||||
import {
|
||||
FILTER_DEPLOY,
|
||||
MCP_MANAGE_SEGMENTED_LIST,
|
||||
} from '@/constants/mcp.constants';
|
||||
import { FILTER_DEPLOY } from '@/constants/mcp.constants';
|
||||
import { CREATE_LIST } from '@/constants/space.constants';
|
||||
import { dict } from '@/services/i18nRuntime';
|
||||
import {
|
||||
apiMcpDelete,
|
||||
apiMcpList,
|
||||
apiMcpOfficialList,
|
||||
apiMcpStop,
|
||||
} from '@/services/mcp';
|
||||
import { apiMcpDelete, apiMcpList, apiMcpStop } from '@/services/mcp';
|
||||
import { AgentComponentTypeEnum } from '@/types/enums/agent';
|
||||
import {
|
||||
DeployStatusEnum,
|
||||
FilterDeployEnum,
|
||||
McpManageSegmentedEnum,
|
||||
McpMoreActionEnum,
|
||||
} from '@/types/enums/mcp';
|
||||
import { CreateListEnum } from '@/types/enums/space';
|
||||
@@ -28,7 +19,7 @@ import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Input, message, Modal, Segmented, Space } from 'antd';
|
||||
import { Button, Empty, Input, message, Modal, Space } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { history, useModel, useParams, useRequest, useSearchParams } from 'umi';
|
||||
@@ -37,7 +28,7 @@ import McpComponentItem from './McpComponentItem';
|
||||
import ServerExportModal from './ServerExportModal';
|
||||
const cx = classNames.bind(styles);
|
||||
const { confirm } = Modal;
|
||||
type IQuery = 'create' | 'deployStatus' | 'segmentedValue' | 'keyword';
|
||||
type IQuery = 'create' | 'deployStatus' | 'keyword';
|
||||
|
||||
/**
|
||||
* 工作空间 - MCP管理
|
||||
@@ -79,10 +70,6 @@ const SpaceLibrary: React.FC = () => {
|
||||
// 服务导出弹窗
|
||||
const [serverExportModalVisible, setServerExportModalVisible] =
|
||||
useState<boolean>(false);
|
||||
// 分段器
|
||||
const [segmentedValue, setSegmentedValue] = useState<McpManageSegmentedEnum>(
|
||||
searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom,
|
||||
);
|
||||
// 当前Mcp信息
|
||||
const currentMcpInfoRef = useRef<McpDetailInfo | null>(null);
|
||||
// 获取用户信息
|
||||
@@ -110,37 +97,18 @@ const SpaceLibrary: React.FC = () => {
|
||||
setMcpList(_list);
|
||||
};
|
||||
|
||||
// 过滤官方服务列表数据
|
||||
const handleFilterOfficialList = (
|
||||
filterKeyword?: string,
|
||||
list = mcpListAllRef.current,
|
||||
) => {
|
||||
let _list = filterKeyword
|
||||
? list.filter((item) => item.name.includes(filterKeyword))
|
||||
: list;
|
||||
setMcpList(_list);
|
||||
};
|
||||
|
||||
// ✅ 监听 URL 改变(支持浏览器前进/后退)
|
||||
useEffect(() => {
|
||||
const create =
|
||||
Number(searchParams.get('create')) || CreateListEnum.All_Person;
|
||||
const deployStatus =
|
||||
searchParams.get('deployStatus') || FilterDeployEnum.All;
|
||||
const segmentedValue =
|
||||
searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom;
|
||||
const keyword = searchParams.get('keyword') || '';
|
||||
|
||||
setCreate(create);
|
||||
setDeployStatus(deployStatus);
|
||||
setSegmentedValue(segmentedValue);
|
||||
setKeyword(keyword);
|
||||
|
||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
||||
handleFilterList(create, deployStatus, keyword);
|
||||
} else {
|
||||
handleFilterOfficialList(keyword);
|
||||
}
|
||||
handleFilterList(create, deployStatus, keyword);
|
||||
}, [searchParams]);
|
||||
|
||||
// 列表请求成功后处理数据
|
||||
@@ -162,20 +130,6 @@ const SpaceLibrary: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// MCP列表(官方服务)
|
||||
const { run: runMcpOfficialList } = useRequest(apiMcpOfficialList, {
|
||||
manual: true,
|
||||
debounceInterval: 300,
|
||||
onSuccess: (result: McpDetailInfo[]) => {
|
||||
setLoading(false);
|
||||
mcpListAllRef.current = result;
|
||||
handleFilterOfficialList(keyword, result);
|
||||
},
|
||||
onError: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
// 修改状态为已停止
|
||||
const handleMcpList = (id: number, list: McpDetailInfo[]) => {
|
||||
return list.map((item) => {
|
||||
@@ -225,31 +179,19 @@ const SpaceLibrary: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue =
|
||||
searchParams.get('segmentedValue') || McpManageSegmentedEnum.Custom;
|
||||
setSegmentedValue(currentValue);
|
||||
|
||||
// 如果有 location.state,说明是点击菜单跳转过来的,会触发下面的 useEffect,这里就不需要请求了
|
||||
if (history.location.state) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
if (currentValue === McpManageSegmentedEnum.Custom) {
|
||||
runMcpList(spaceId);
|
||||
} else {
|
||||
runMcpOfficialList();
|
||||
}
|
||||
runMcpList(spaceId);
|
||||
}, [spaceId]);
|
||||
|
||||
// 监听菜单切换,重新加载数据
|
||||
useEffect(() => {
|
||||
if (history.location.state) {
|
||||
setLoading(true);
|
||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
||||
runMcpList(spaceId);
|
||||
} else {
|
||||
runMcpOfficialList();
|
||||
}
|
||||
runMcpList(spaceId);
|
||||
}
|
||||
}, [history.location.state]);
|
||||
|
||||
@@ -274,21 +216,13 @@ const SpaceLibrary: React.FC = () => {
|
||||
const _keyword = e.target.value;
|
||||
setKeyword(_keyword);
|
||||
handleChange('keyword', _keyword);
|
||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
||||
handleFilterList(create, deployStatus, _keyword);
|
||||
} else {
|
||||
handleFilterOfficialList(_keyword);
|
||||
}
|
||||
handleFilterList(create, deployStatus, _keyword);
|
||||
};
|
||||
|
||||
// 清除关键词
|
||||
const handleClearKeyword = () => {
|
||||
setKeyword('');
|
||||
if (segmentedValue === McpManageSegmentedEnum.Custom) {
|
||||
handleFilterList(create, deployStatus);
|
||||
} else {
|
||||
handleFilterOfficialList();
|
||||
}
|
||||
handleFilterList(create, deployStatus);
|
||||
};
|
||||
|
||||
// 点击更多操作
|
||||
@@ -345,11 +279,6 @@ const SpaceLibrary: React.FC = () => {
|
||||
// 点击单个资源组件
|
||||
const handleClickComponent = (info: McpDetailInfo) => {
|
||||
const { id, spaceId } = info;
|
||||
// 官方服务不能编辑, spaceId 为 -1时代表官方服务,后台写死的spaceId
|
||||
if (spaceId === -1) {
|
||||
return;
|
||||
}
|
||||
// 自定义服务,跳转到编辑应用
|
||||
history.push(`/space/${spaceId}/mcp/edit/${id}`);
|
||||
};
|
||||
|
||||
@@ -358,18 +287,6 @@ const SpaceLibrary: React.FC = () => {
|
||||
history.push(`/space/${spaceId}/mcp/create`);
|
||||
};
|
||||
|
||||
// 切换分段器
|
||||
const handleChangeSegmentedValue = (value: McpManageSegmentedEnum) => {
|
||||
handleChange('segmentedValue', value);
|
||||
setSegmentedValue(value);
|
||||
// setKeyword('');
|
||||
setLoading(true);
|
||||
if (value === McpManageSegmentedEnum.Custom) {
|
||||
runMcpList(spaceId);
|
||||
} else {
|
||||
runMcpOfficialList();
|
||||
}
|
||||
};
|
||||
const listLength = useMemo(() => {
|
||||
return mcpList.length;
|
||||
}, [mcpList]);
|
||||
@@ -386,32 +303,21 @@ const SpaceLibrary: React.FC = () => {
|
||||
<h3 className={cx(styles.title)}>
|
||||
{dict('PC.Pages.SpaceMcpManage.title')}
|
||||
</h3>
|
||||
{segmentedValue === McpManageSegmentedEnum.Custom && (
|
||||
<>
|
||||
<SelectList
|
||||
value={create}
|
||||
options={CREATE_LIST}
|
||||
onChange={handlerChangeCreate}
|
||||
/>
|
||||
{/* 单选模式 */}
|
||||
<ButtonToggle
|
||||
options={FILTER_DEPLOY}
|
||||
value={deployStatus}
|
||||
onChange={(value) =>
|
||||
handlerChangeDeployStatus(value as React.Key)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SelectList
|
||||
value={create}
|
||||
options={CREATE_LIST}
|
||||
onChange={handlerChangeCreate}
|
||||
/>
|
||||
{/* 单选模式 */}
|
||||
<ButtonToggle
|
||||
options={FILTER_DEPLOY}
|
||||
value={deployStatus}
|
||||
onChange={(value) =>
|
||||
handlerChangeDeployStatus(value as React.Key)
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div>
|
||||
<Segmented
|
||||
options={MCP_MANAGE_SEGMENTED_LIST}
|
||||
value={segmentedValue}
|
||||
onChange={handleChangeSegmentedValue}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex' }}>
|
||||
<Input
|
||||
rootClassName={cx(styles.input)}
|
||||
@@ -441,7 +347,6 @@ const SpaceLibrary: React.FC = () => {
|
||||
{mcpList?.map((info) => (
|
||||
<McpComponentItem
|
||||
key={info.id}
|
||||
className={cx(info?.spaceId === -1 && styles['office-mcp-item'])}
|
||||
mcpInfo={info}
|
||||
onClick={() => handleClickComponent(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管理列表
|
||||
export function apiMcpList(
|
||||
spaceId: number,
|
||||
|
||||
@@ -69,11 +69,3 @@ export enum McpExecuteTypeEnum {
|
||||
// 提示词
|
||||
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 { registerGuiServerHandlers } from "./guiServerHandlers";
|
||||
import { registerI18nHandlers } from "./i18nHandlers";
|
||||
import { registerSkillHandlers } from "./skillHandlers";
|
||||
import log from "electron-log";
|
||||
|
||||
export function registerAllHandlers(ctx: HandlerContext): void {
|
||||
@@ -32,6 +33,7 @@ export function registerAllHandlers(ctx: HandlerContext): void {
|
||||
registerPerfHandlers();
|
||||
registerGuiServerHandlers();
|
||||
registerI18nHandlers();
|
||||
registerSkillHandlers();
|
||||
|
||||
log.info("IPC handlers registered");
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
// Local skill installation
|
||||
skills: {
|
||||
installLocal: (payload: {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
author?: string | null;
|
||||
source?: string | null;
|
||||
files?: Array<{ name: string; contents: string | null; isDir: boolean }>;
|
||||
}) => ipcRenderer.invoke("skills:installLocal", payload),
|
||||
isInstalled: (query: {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name?: string | null;
|
||||
}) => ipcRenderer.invoke("skills:isInstalled", query),
|
||||
openInstallDir: (query: {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name?: string | null;
|
||||
}) => ipcRenderer.invoke("skills:openInstallDir", query),
|
||||
},
|
||||
|
||||
// Tray status sync
|
||||
tray: {
|
||||
updateStatus: (status: "running" | "stopped" | "error" | "starting") =>
|
||||
|
||||
@@ -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 {
|
||||
height: 122px;
|
||||
height: 104px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px 1fr 34px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 26px 12px 18px 20px;
|
||||
padding: 12px 12px 14px 20px;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
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 {
|
||||
versions: {
|
||||
node: string;
|
||||
@@ -740,6 +781,7 @@ export interface ElectronAPI {
|
||||
app: AppAPI;
|
||||
permissions: PermissionsAPI;
|
||||
quickInit: QuickInitAPI;
|
||||
skills: SkillsAPI;
|
||||
perf: PerfAPI;
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
off: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
|
||||