feat: 完善本地MCP与扩展市场能力

This commit is contained in:
baiyanyun
2026-07-02 17:43:34 +08:00
parent a574b2c661
commit 38adf72939
32 changed files with 3291 additions and 270 deletions

View File

@@ -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}

View File

@@ -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:
# 服务端配置

View File

@@ -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())
));
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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)

View File

@@ -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();
}
}
}

View File

@@ -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: ""

View File

@@ -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: ""

View File

@@ -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)
}

View File

@@ -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...");

View File

@@ -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'),
},
];

View File

@@ -27,10 +27,6 @@
/* 关键修复:确保内容从顶部开始排列,避免不均匀的间距分布 */
align-content: start;
margin-top: 10px;
.office-mcp-item {
cursor: default;
}
}
@media (min-width: 1200px) {

View File

@@ -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)}

View File

@@ -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,

View File

@@ -69,11 +69,3 @@ export enum McpExecuteTypeEnum {
// 提示词
PROMPT = 'PROMPT',
}
// MCP管理分段器枚举(自定义)
export enum McpManageSegmentedEnum {
// 自定义服务
Custom = 'Custom',
// 官方服务
Official = 'Official',
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -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");
}

View File

@@ -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),
};
}
},
);
}

View File

@@ -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") =>

View File

@@ -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>&gt;</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>&gt;</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>
);
}

View File

@@ -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,
},
);
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;