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

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;