完善客户端市场与对话能力

This commit is contained in:
baiyanyun
2026-07-13 09:11:24 +08:00
parent a69bb4b8a8
commit d08d30a7ab
16 changed files with 7541 additions and 147 deletions

View File

@@ -31,6 +31,7 @@ import {
} from "./services/core/auth";
import { AUTH_KEYS, normalizeAgentEngine } from "@shared/constants";
import type { QuickInitConfig } from "@shared/types/quickInit";
import type { UpdateState } from "@shared/types/updateTypes";
import { t, getCurrentLang } from "./services/core/i18n";
import SetupWizard from "./components/setup/SetupWizard";
import LoginPage from "./components/setup/LoginPage";
@@ -43,7 +44,10 @@ import LogViewer from "./components/pages/LogViewer";
import PermissionsPage from "./components/pages/PermissionsPage";
import SessionsPage from "./components/pages/SessionsPage";
import AiChatPage from "./components/pages/AiChatPage";
import AutomationPage from "./components/pages/AutomationPage";
import KnowledgePage from "./components/pages/KnowledgePage";
import SkillsMarketplacePage from "./components/pages/SkillsMarketplacePage";
import AgentMarketplacePage from "./components/pages/AgentMarketplacePage";
import McpMarketplacePage from "./components/pages/McpMarketplacePage";
import WorkflowMarketplacePage from "./components/pages/WorkflowMarketplacePage";
import MCPSettings from "./components/settings/MCPSettings";
@@ -52,6 +56,7 @@ import AppSidebar, {
LegacyTabKey,
} from "./components/layout/AppSidebar";
import type { WebviewHeaderActions } from "./components/pages/SessionsPage";
import type { AiAgentLaunchContext } from "./services/core/aiChat";
import { createLogger } from "./services/utils/rendererLog";
import styles from "./styles/components/App.module.css";
import { lightTheme, darkTheme } from "./styles/theme";
@@ -99,7 +104,10 @@ type TabKey =
| "client"
| "sessions"
| "legacySessions"
| "automation"
| "knowledge"
| "skills"
| "agents"
| "mcpLibrary"
| "workflow"
| "mcp"
@@ -109,6 +117,11 @@ type TabKey =
| "logs"
| "about";
/** macOS/Linux 无 download-progress 时,用本地模拟进度避免长期显示 0%。 */
const UPDATE_SIMULATED_PROGRESS_CAP = 90;
const UPDATE_SIMULATED_PROGRESS_INTERVAL_MS = 500;
const UPDATE_SIMULATED_DURATION_MS = 45_000;
// 服务状态接口(与 ClientPage 共享)
export interface ServiceItem {
key: string;
@@ -322,6 +335,8 @@ function App() {
const [activeTab, setActiveTab] = useState<TabKey>("client");
const [activeNavKey, setActiveNavKey] = useState<NavKey>("overview");
const [sessionsAutoOpen, setSessionsAutoOpen] = useState(false);
const [aiAgentLaunchContext, setAiAgentLaunchContext] =
useState<AiAgentLaunchContext | null>(null);
const [webviewActions, setWebviewActions] =
useState<WebviewHeaderActions | null>(null);
const [username, setUsername] = useState<string>("");
@@ -335,6 +350,13 @@ function App() {
const servicesPollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
/** 递增后通知 ClientPage 刷新账号状态(用户名等),与 reg 返回保持一致 */
const [authRefreshTrigger, setAuthRefreshTrigger] = useState(0);
const [updateState, setUpdateState] = useState<UpdateState>({
status: "idle",
});
const [sidebarSimulatedPercent, setSidebarSimulatedPercent] = useState(0);
const sidebarSimulatedIntervalRef = useRef<ReturnType<
typeof setInterval
> | null>(null);
const getStartupServiceKeys = useCallback(async (): Promise<string[]> => {
const keys = ["mcpProxy", "agent", "fileServer", "lanproxy"];
if (!FEATURES.ENABLE_GUI_AGENT_SERVER) return keys;
@@ -815,6 +837,55 @@ function App() {
};
}, [isSetupComplete]);
// ============================================
// 监听更新状态(侧栏品牌区展示)
// ============================================
useEffect(() => {
const handler = (state: UpdateState) => {
if (state) setUpdateState(state);
};
window.electronAPI?.on("update:status", handler as any);
window.electronAPI?.app?.getUpdateState?.()?.then((state) => {
if (state) setUpdateState(state);
});
return () => {
window.electronAPI?.off("update:status", handler as any);
};
}, []);
useEffect(() => {
const isDownloading = updateState.status === "downloading";
const hasRealProgress = updateState.progress != null;
if (isDownloading && !hasRealProgress) {
setSidebarSimulatedPercent(0);
const increment =
(UPDATE_SIMULATED_PROGRESS_CAP / UPDATE_SIMULATED_DURATION_MS) *
UPDATE_SIMULATED_PROGRESS_INTERVAL_MS;
const id = setInterval(() => {
setSidebarSimulatedPercent((prev) => {
const next = prev + increment;
return next >= UPDATE_SIMULATED_PROGRESS_CAP
? UPDATE_SIMULATED_PROGRESS_CAP
: next;
});
}, UPDATE_SIMULATED_PROGRESS_INTERVAL_MS);
sidebarSimulatedIntervalRef.current = id;
return () => {
clearInterval(id);
sidebarSimulatedIntervalRef.current = null;
};
}
if (!isDownloading || hasRealProgress) {
if (sidebarSimulatedIntervalRef.current) {
clearInterval(sidebarSimulatedIntervalRef.current);
sidebarSimulatedIntervalRef.current = null;
}
setSidebarSimulatedPercent(0);
}
}, [updateState.status, updateState.progress]);
// ============================================
// 监听托盘/菜单事件
// ============================================
@@ -987,28 +1058,25 @@ function App() {
}
if (key === "newTask") {
setAiAgentLaunchContext(null);
setSessionsAutoOpen(true);
setActiveTab("sessions");
return;
}
if (key === "aiChat") {
setAiAgentLaunchContext(null);
setActiveTab("sessions");
return;
}
if (key === "skills") {
setActiveTab("skills");
if (key === "automation") {
setActiveTab("automation");
return;
}
if (key === "mcpLibrary") {
setActiveTab("mcpLibrary");
return;
}
if (key === "workflow") {
setActiveTab("workflow");
if (key === "knowledge") {
setActiveTab("knowledge");
return;
}
@@ -1031,12 +1099,114 @@ function App() {
const navByLegacyTab: Partial<Record<LegacyTabKey, NavKey>> = {
client: "overview",
sessions: "aiChat",
mcp: "mcpLibrary",
automation: "automation",
skills: "overview",
agents: "overview",
mcpLibrary: "overview",
workflow: "overview",
mcp: "systemSettings",
settings: "systemSettings",
dependencies: "systemSettings",
permissions: "systemSettings",
logs: "systemSettings",
about: "systemSettings",
};
setActiveNavKey(navByLegacyTab[key] || "systemSettings");
}, []);
const handleDownloadUpdate = useCallback(async () => {
if (updateState.canAutoUpdate === false) {
await window.electronAPI?.app?.openReleasesPage?.();
return;
}
try {
setUpdateState((prev) => ({
...prev,
status: "downloading",
progress: undefined,
}));
const res = await window.electronAPI?.app?.downloadUpdate?.();
if (!res || !res.success) {
message.error(res?.error || t("Claw.About.downloadFailed"));
setUpdateState((prev) => ({
...prev,
status: "available",
}));
}
} catch {
message.error(t("Claw.About.downloadFailed"));
setUpdateState((prev) => ({
...prev,
status: "available",
}));
}
}, [updateState.canAutoUpdate]);
const handleInstallUpdate = useCallback(async () => {
try {
const res = await window.electronAPI?.app?.installUpdate?.();
if (res && !res.success) {
message.error(res.error || t("Claw.About.installFailed"));
}
} catch {
message.error(t("Claw.About.installFailed"));
}
}, []);
const sidebarUpdateNotice = useMemo(() => {
if (updateState.status === "available") {
return {
tone: "available" as const,
label:
updateState.canAutoUpdate === false
? t("Claw.App.UpdateTag.newVersion", {
version: updateState.version,
})
: t("Claw.App.UpdateTag.download", {
version: updateState.version,
}),
onClick: handleDownloadUpdate,
};
}
if (updateState.status === "downloading") {
return {
tone: "downloading" as const,
label: t("Claw.App.UpdateTag.downloading", {
percent: Math.round(
updateState.progress?.percent ?? sidebarSimulatedPercent,
),
}),
};
}
if (updateState.status === "downloaded") {
return {
tone: "install" as const,
label: t("Claw.About.installUpdate"),
onClick: handleInstallUpdate,
};
}
return undefined;
}, [
handleDownloadUpdate,
handleInstallUpdate,
sidebarSimulatedPercent,
updateState.canAutoUpdate,
updateState.progress?.percent,
updateState.status,
updateState.version,
]);
const handleStartAgentChat = useCallback((context: AiAgentLaunchContext) => {
setAiAgentLaunchContext(context);
setSessionsAutoOpen(false);
setActiveNavKey("aiChat");
setActiveTab("sessions");
}, []);
// ============================================
// 平台检测
// ============================================
@@ -1157,6 +1327,7 @@ function App() {
email={userEmail}
onlineStatus={onlineStatus}
isMacOS={isMacOS}
updateNotice={sidebarUpdateNotice}
onNavSelect={handleNavSelect}
onLegacySelect={handleLegacySelect}
onLogout={handleSidebarLogout}
@@ -1202,6 +1373,7 @@ function App() {
{activeTab === "sessions" && (
<AiChatPage
autoNew={sessionsAutoOpen}
launchContext={aiAgentLaunchContext}
onAutoNewConsumed={() => setSessionsAutoOpen(false)}
/>
)}
@@ -1211,7 +1383,12 @@ function App() {
onWebviewChange={setWebviewActions}
/>
)}
{activeTab === "automation" && <AutomationPage />}
{activeTab === "knowledge" && <KnowledgePage />}
{activeTab === "skills" && <SkillsMarketplacePage />}
{activeTab === "agents" && (
<AgentMarketplacePage onStartChat={handleStartAgentChat} />
)}
{activeTab === "mcpLibrary" && <McpMarketplacePage />}
{activeTab === "workflow" && <WorkflowMarketplacePage />}
{activeTab === "mcp" && <MCPSettings />}

View File

@@ -7,7 +7,6 @@ import {
BuildOutlined,
CommentOutlined,
DatabaseOutlined,
DeploymentUnitOutlined,
FileTextOutlined,
FolderOutlined,
InfoCircleOutlined,
@@ -15,6 +14,7 @@ import {
MenuFoldOutlined,
PlusCircleOutlined,
ProjectOutlined,
RobotOutlined,
SafetyOutlined,
SettingOutlined,
TeamOutlined,
@@ -32,16 +32,17 @@ export type NavKey =
| "knowledge"
| "document"
| "dataFetch"
| "skills"
| "mcpLibrary"
| "workflow"
| "plugins"
| "toolIntegration"
| "systemSettings";
export type LegacyTabKey =
| "client"
| "sessions"
| "automation"
| "skills"
| "agents"
| "mcpLibrary"
| "workflow"
| "mcp"
| "settings"
| "dependencies"
@@ -62,12 +63,19 @@ interface LegacyItem {
icon: React.ReactNode;
}
interface UpdateNotice {
tone: "available" | "downloading" | "install";
label: string;
onClick?: () => void;
}
interface AppSidebarProps {
activeNavKey: NavKey;
username: string;
email?: string;
onlineStatus: boolean | null;
isMacOS: boolean;
updateNotice?: UpdateNotice;
onNavSelect: (key: NavKey) => void;
onLegacySelect: (key: LegacyTabKey) => void;
onLogout: () => void;
@@ -81,35 +89,13 @@ const workspaceItems: NavItem[] = [
icon: <PlusCircleOutlined />,
badge: "3",
},
{
key: "automation",
label: "自动化",
icon: <ThunderboltOutlined />,
badge: "运行中",
},
{ key: "automation", label: "自动化", icon: <ThunderboltOutlined /> },
{ key: "aiChat", label: "AI 对话", icon: <CommentOutlined /> },
{ key: "knowledge", label: "知识库", icon: <BookOutlined /> },
{ key: "document", label: "文档编辑", icon: <FileTextOutlined /> },
{ key: "dataFetch", label: "智能取数", icon: <DatabaseOutlined /> },
];
const marketItems: NavItem[] = [
{ key: "skills", label: "技能库", icon: <BuildOutlined />, badge: "获取" },
{ key: "mcpLibrary", label: "MCP 库", icon: <ApiOutlined />, badge: "获取" },
{
key: "workflow",
label: "工作流",
icon: <ProjectOutlined />,
badge: "安装",
},
{
key: "plugins",
label: "插件",
icon: <DeploymentUnitOutlined />,
badge: "安装",
},
];
const settingItems: NavItem[] = [
{ key: "toolIntegration", label: "工具集成", icon: <ToolOutlined /> },
{ key: "systemSettings", label: "系统设置", icon: <SettingOutlined /> },
@@ -119,6 +105,10 @@ function getLegacyItems(isMacOS: boolean): LegacyItem[] {
const items: LegacyItem[] = [
{ key: "client", label: "客户端", icon: <AppstoreOutlined /> },
{ key: "sessions", label: "会话", icon: <TeamOutlined /> },
{ key: "skills", label: "技能库", icon: <BuildOutlined /> },
{ key: "agents", label: "智能体", icon: <RobotOutlined /> },
{ key: "mcpLibrary", label: "MCP 库", icon: <ApiOutlined /> },
{ key: "workflow", label: "工作流", icon: <ProjectOutlined /> },
{ key: "mcp", label: "MCP", icon: <ApiOutlined /> },
{ key: "settings", label: "设置", icon: <SettingOutlined /> },
{ key: "dependencies", label: "依赖", icon: <FolderOutlined /> },
@@ -176,6 +166,7 @@ export default function AppSidebar({
email,
onlineStatus,
isMacOS,
updateNotice,
onNavSelect,
onLegacySelect,
onLogout,
@@ -184,6 +175,11 @@ export default function AppSidebar({
const displayEmail = email || "xingyu@feitian.com";
const legacyItems = getLegacyItems(isMacOS);
const online = onlineStatus !== false;
const updateNoticeToneClass = updateNotice
? styles[
`updateNotice${updateNotice.tone[0].toUpperCase()}${updateNotice.tone.slice(1)}`
]
: "";
const historyMenu = (
<div className={styles.historyMenu}>
@@ -209,6 +205,16 @@ export default function AppSidebar({
<div className={styles.brandText}>
<div className={styles.brandName}>{APP_DISPLAY_NAME}</div>
<div className={styles.brandSubTitle}>DIGITAL EMPLOYEE</div>
{updateNotice && (
<button
type="button"
className={`${styles.updateNotice} ${updateNoticeToneClass}`}
onClick={updateNotice.onClick}
disabled={!updateNotice.onClick}
>
{updateNotice.label}
</button>
)}
</div>
<Popover
content={historyMenu}
@@ -234,13 +240,6 @@ export default function AppSidebar({
)}
</section>
<section className={styles.navSection}>
<div className={styles.sectionTitle}></div>
{marketItems.map((item) =>
renderNavItem(item, activeNavKey, onNavSelect),
)}
</section>
<section className={styles.navSection}>
<div className={styles.sectionTitle}></div>
{settingItems.map((item) =>

View File

@@ -0,0 +1,748 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Empty, Input, Modal, Spin, message } from "antd";
import {
CheckOutlined,
CommentOutlined,
RobotOutlined,
SearchOutlined,
} from "@ant-design/icons";
import {
collectAgent,
fetchPublishedAgentDetail,
fetchPublishedAgents,
fetchPublishedCategories,
PublishedAgent,
PublishedAgentDetail,
PublishedCategory,
} from "../../services/core/marketplace";
import {
addAiAgentPublishedAgentComponent,
AiAgentContext,
AiAgentLaunchContext,
AiManualComponent,
fetchAiAgentComponents,
resolveAiAgentContext,
} from "../../services/core/aiChat";
import styles from "../../styles/components/WorkflowMarketplacePage.module.css";
const PAGE_SIZE = 48;
const ALL_CATEGORY = "";
const FALLBACK_CATEGORIES = [
{ key: ALL_CATEGORY, label: "全部" },
{ key: "Agent", label: "智能体" },
{ key: "ChatBot", label: "对话助手" },
{ key: "Efficiency", label: "效率办公" },
{ key: "DataAnalysis", label: "数据分析" },
{ key: "Knowledge", label: "知识问答" },
];
const ICON_COLORS = [
"#006d68",
"#3b7cff",
"#8f62ff",
"#ff9f43",
"#19b87a",
"#d85d8f",
];
interface AgentMarketplacePageProps {
onStartChat?: (context: AiAgentLaunchContext) => void;
}
function getAgentCategories(categories: PublishedCategory[]) {
const agentCategory = categories.find((item) => item.type === "Agent");
return agentCategory?.children || [];
}
function getAgentId(agent: PublishedAgent): number {
return agent.targetId || agent.id;
}
function getAuthorName(agent: PublishedAgent): string {
const user = agent.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 getStableSeed(agent: PublishedAgent): number {
const raw = agent.name || String(getAgentId(agent));
return raw.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0);
}
function getIconColor(agent: PublishedAgent): string {
return ICON_COLORS[getStableSeed(agent) % ICON_COLORS.length];
}
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(agent: PublishedAgent): number {
const statistics = agent.statistics || {};
return (
Number(statistics.conversationCount) ||
Number(statistics.userCount) ||
Number(statistics.usedCount) ||
Number(statistics.collectCount) ||
Number(statistics.viewCount) ||
0
);
}
function getRating(agent: PublishedAgent): string {
return (4.5 + (getStableSeed(agent) % 5) / 10).toFixed(1);
}
function formatDateTime(value?: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function AgentIcon({ agent }: { agent: PublishedAgent }) {
if (agent.icon) {
return <img className={styles.workflowIconImage} src={agent.icon} alt="" />;
}
return (
<span className={styles.workflowIconText}>{getInitial(agent.name)}</span>
);
}
function getGuideText(item?: {
content?: string;
question?: string;
info?: string;
}): string {
return item?.content || item?.question || item?.info || "";
}
export default function AgentMarketplacePage({
onStartChat,
}: AgentMarketplacePageProps) {
const [categories, setCategories] = useState<PublishedCategory[]>([]);
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY);
const [keywordInput, setKeywordInput] = useState("");
const [keyword, setKeyword] = useState("");
const [agents, setAgents] = useState<PublishedAgent[]>([]);
const [total, setTotal] = useState(0);
const [loadingCategories, setLoadingCategories] = useState(false);
const [loadingAgents, setLoadingAgents] = useState(false);
const [joiningIds, setJoiningIds] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailItem, setDetailItem] = useState<PublishedAgent | null>(null);
const [detailData, setDetailData] = useState<PublishedAgentDetail | null>(
null,
);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [agentContext, setAgentContext] = useState<AiAgentContext | null>(null);
const [joinedAgentMap, setJoinedAgentMap] = useState<
Map<number, AiManualComponent>
>(new Map());
const [loadingComponents, setLoadingComponents] = useState(false);
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(getAgentCategories(result));
} catch (e) {
console.error("[AgentMarketplace] Failed to load categories:", e);
} finally {
setLoadingCategories(false);
}
}, []);
const loadAgents = useCallback(async () => {
setLoadingAgents(true);
setError(null);
try {
const result = await fetchPublishedAgents({
page: 1,
pageSize: PAGE_SIZE,
category: activeCategory,
kw: keyword,
});
setAgents(result.records || []);
setTotal(result.total || 0);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : "智能体加载失败";
setError(errorMessage);
setAgents([]);
setTotal(0);
} finally {
setLoadingAgents(false);
}
}, [activeCategory, keyword]);
const loadAgentComponents = useCallback(async () => {
setLoadingComponents(true);
try {
const context = await resolveAiAgentContext();
setAgentContext(context);
const components = await fetchAiAgentComponents(context.agentId);
const agentMap = new Map<number, AiManualComponent>();
components
.filter((item) => String(item.type || "").toLowerCase() === "agent")
.forEach((item) => {
const targetId = Number(item.targetId || 0);
if (targetId) agentMap.set(targetId, item);
});
setJoinedAgentMap(agentMap);
} catch (e) {
console.error("[AgentMarketplace] Failed to load agent components:", e);
setAgentContext(null);
setJoinedAgentMap(new Map());
} finally {
setLoadingComponents(false);
}
}, []);
useEffect(() => {
loadCategories();
}, [loadCategories]);
useEffect(() => {
loadAgentComponents();
}, [loadAgentComponents]);
useEffect(() => {
loadAgents();
}, [loadAgents]);
const handleSearch = useCallback(() => {
setKeyword(keywordInput.trim());
}, [keywordInput]);
const markAgentJoined = useCallback((agentId: number) => {
setAgents((prev) =>
prev.map((item) =>
getAgentId(item) === agentId ? { ...item, collect: true } : item,
),
);
setDetailData((prev) =>
prev && getAgentId(prev) === agentId ? { ...prev, collect: true } : prev,
);
setDetailItem((prev) =>
prev && getAgentId(prev) === agentId ? { ...prev, collect: true } : prev,
);
}, []);
const loadAgentDetail = useCallback(async (agent: PublishedAgent) => {
const agentId = getAgentId(agent);
const detail = await fetchPublishedAgentDetail(agentId);
return {
...agent,
...detail,
icon: detail.icon || agent.icon,
targetId: agentId,
collect: detail.collect || agent.collect,
};
}, []);
const openDetail = useCallback(
async (agent: PublishedAgent) => {
setDetailOpen(true);
setDetailItem(agent);
setDetailData(null);
setDetailError(null);
setDetailLoading(true);
try {
const detail = await loadAgentDetail(agent);
setDetailData(detail);
} catch (e) {
console.error("[AgentMarketplace] Failed to load detail:", e);
setDetailError(e instanceof Error ? e.message : "智能体详情加载失败");
} finally {
setDetailLoading(false);
}
},
[loadAgentDetail],
);
const closeDetail = useCallback(() => {
setDetailOpen(false);
setDetailItem(null);
setDetailData(null);
setDetailError(null);
}, []);
const handleJoin = useCallback(
async (agent: PublishedAgent) => {
const publishedAgentId = getAgentId(agent);
if (joinedAgentMap.has(publishedAgentId)) return;
setJoiningIds((prev) => new Set(prev).add(publishedAgentId));
try {
const context = agentContext || (await resolveAiAgentContext());
if (!agentContext) setAgentContext(context);
if (context.agentId === publishedAgentId) {
throw new Error("不能将当前智能体加入自身对话组件");
}
const detail =
detailData && getAgentId(detailData) === publishedAgentId
? detailData
: await loadAgentDetail(agent);
const componentId = await addAiAgentPublishedAgentComponent({
agentId: context.agentId,
targetAgentId: publishedAgentId,
});
const component: AiManualComponent = {
id: componentId,
name: detail.name || "未命名智能体",
description: detail.description,
icon: detail.icon,
type: "Agent",
targetId: publishedAgentId,
defaultSelected: 0,
};
setJoinedAgentMap((prev) => {
const next = new Map(prev);
next.set(publishedAgentId, component);
return next;
});
markAgentJoined(publishedAgentId);
try {
await collectAgent(publishedAgentId);
} catch (collectError) {
console.warn(
"[AgentMarketplace] Agent joined but collect sync failed:",
collectError,
);
}
message.success("智能体已加入 AI 对话可选组件");
} catch (e) {
console.error("[AgentMarketplace] Failed to join agent:", e);
message.error(e instanceof Error ? e.message : "智能体加入失败");
} finally {
setJoiningIds((prev) => {
const next = new Set(prev);
next.delete(publishedAgentId);
return next;
});
}
},
[
agentContext,
detailData,
joinedAgentMap,
loadAgentDetail,
markAgentJoined,
],
);
const handleStartChat = useCallback(
(agent: PublishedAgent) => {
const agentId = getAgentId(agent);
if (!agentId) {
message.error("智能体信息不完整,无法开始对话");
return;
}
onStartChat?.({
agentId,
name: agent.name,
icon: agent.icon,
description: agent.description,
source: "marketplace",
launchKey: Date.now(),
});
},
[onStartChat],
);
const detailDisplayItem = detailData || detailItem;
const detailAgentId = detailDisplayItem ? getAgentId(detailDisplayItem) : 0;
const detailJoined = detailAgentId
? joinedAgentMap.has(detailAgentId)
: false;
const guideQuestions =
detailData?.guidQuestionDtos ||
detailData?.openingGuidQuestions?.map((item) => ({ content: item })) ||
[];
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}>
{loadingAgents ? (
<div className={styles.centerState}>
<Spin />
<span>...</span>
</div>
) : error ? (
<div className={styles.centerState}>
<Empty description={error} />
<Button type="primary" onClick={loadAgents}>
</Button>
</div>
) : agents.length === 0 ? (
<div className={styles.centerState}>
<Empty description="暂无智能体" />
</div>
) : (
<div className={styles.grid}>
{agents.map((agent) => {
const agentId = getAgentId(agent);
const joined = joinedAgentMap.has(agentId);
const isSelf = agentContext?.agentId === agentId;
return (
<article
className={styles.card}
key={agentId}
role="button"
tabIndex={0}
onClick={() => openDetail(agent)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail(agent);
}
}}
>
<div className={styles.cardHeader}>
<div
className={styles.workflowIcon}
style={{ backgroundColor: getIconColor(agent) }}
>
<AgentIcon agent={agent} />
</div>
<div className={styles.workflowMeta}>
<h2 className={styles.workflowName}>{agent.name}</h2>
<div className={styles.author}>
{getAuthorName(agent)}
</div>
</div>
</div>
<div className={styles.badgeRow}>
<span className={styles.badge}>
{agent.category || "智能体"}
</span>
{joined && (
<span className={styles.usedBadge}></span>
)}
</div>
<p className={styles.description}>
{agent.description || "暂无智能体描述"}
</p>
<div className={styles.cardFooter}>
<div className={styles.metrics}>
<span>{formatMetric(getUsageCount(agent))}</span>
<span></span>
<span className={styles.rating}>
{getRating(agent)}
</span>
</div>
<div className={styles.cardActions}>
<Button
className={styles.detailButton}
type="text"
size="small"
onClick={(event) => {
event.stopPropagation();
openDetail(agent);
}}
>
</Button>
<Button
className={styles.detailButton}
type="text"
size="small"
icon={<CommentOutlined />}
onClick={(event) => {
event.stopPropagation();
handleStartChat(agent);
}}
>
</Button>
<Button
className={
joined ? styles.usedButton : styles.useButton
}
type={joined ? "default" : "primary"}
size="small"
icon={joined ? <CheckOutlined /> : <RobotOutlined />}
loading={joiningIds.has(agentId) || loadingComponents}
disabled={joined || isSelf}
onClick={(event) => {
event.stopPropagation();
handleJoin(agent);
}}
>
{joined ? "已加入" : isSelf ? "当前" : "加入"}
</Button>
</div>
</div>
</article>
);
})}
</div>
)}
</section>
</main>
<Modal
className={styles.detailModal}
width={900}
open={detailOpen}
title={null}
footer={null}
onCancel={closeDetail}
destroyOnClose
>
{detailLoading && !detailDisplayItem ? (
<div className={styles.detailLoading}>
<Spin />
<span>...</span>
</div>
) : !detailDisplayItem ? (
<Empty description="暂无智能体详情" />
) : (
<div className={styles.detailContent}>
<div className={styles.detailHero}>
<div
className={styles.detailIcon}
style={{ backgroundColor: getIconColor(detailDisplayItem) }}
>
<AgentIcon agent={detailDisplayItem} />
</div>
<div className={styles.detailTitleBlock}>
<div className={styles.detailTitleRow}>
<h2>{detailDisplayItem.name || "未命名智能体"}</h2>
<span className={styles.badge}>
{detailDisplayItem.category || "智能体"}
</span>
{detailJoined && (
<span className={styles.usedBadge}></span>
)}
</div>
<div className={styles.detailMeta}>
<span>{getAuthorName(detailDisplayItem)}</span>
{(detailDisplayItem.modified ||
detailDisplayItem.created) && (
<span>
{formatDateTime(
detailDisplayItem.modified || detailDisplayItem.created,
)}
</span>
)}
<span>
{formatMetric(getUsageCount(detailDisplayItem))}
</span>
</div>
</div>
<div className={styles.cardActions}>
<Button
className={styles.detailButton}
type="text"
icon={<CommentOutlined />}
onClick={() => handleStartChat(detailDisplayItem)}
>
</Button>
<Button
className={
detailJoined ? styles.usedButton : styles.useButton
}
type={detailJoined ? "default" : "primary"}
icon={detailJoined ? <CheckOutlined /> : <RobotOutlined />}
loading={joiningIds.has(detailAgentId)}
disabled={
detailJoined || agentContext?.agentId === detailAgentId
}
onClick={() => handleJoin(detailDisplayItem)}
>
{detailJoined
? "已加入"
: agentContext?.agentId === detailAgentId
? "当前智能体"
: "加入对话"}
</Button>
</div>
</div>
{detailError ? (
<div className={styles.detailError}>
<Empty description={detailError} />
<Button
type="primary"
onClick={() => detailItem && openDetail(detailItem)}
>
</Button>
</div>
) : (
<>
{detailLoading && (
<div className={styles.detailInlineLoading}>
<Spin size="small" />
<span>...</span>
</div>
)}
<section className={styles.detailSection}>
<div className={styles.detailSectionHeader}>
<h3></h3>
<span></span>
</div>
<p className={styles.detailDescription}>
{detailDisplayItem.description || "暂无智能体描述"}
</p>
{detailDisplayItem.remark && (
<p className={styles.detailRemark}>
{detailDisplayItem.remark}
</p>
)}
</section>
<div className={styles.detailStats}>
<div>
<strong>
{formatMetric(getUsageCount(detailDisplayItem))}
</strong>
<span></span>
</div>
<div>
<strong>
{formatMetric(
Number(detailDisplayItem.statistics?.collectCount || 0),
)}
</strong>
<span></span>
</div>
<div>
<strong>{guideQuestions.length}</strong>
<span></span>
</div>
<div>
<strong>{getRating(detailDisplayItem)}</strong>
<span></span>
</div>
</div>
<section className={styles.detailSection}>
<div className={styles.detailSectionHeader}>
<h3></h3>
<span>使</span>
</div>
{detailData?.openingChatMsg && (
<p className={styles.detailRemark}>
{detailData.openingChatMsg}
</p>
)}
{guideQuestions.length === 0 ? (
<div className={styles.emptyLine}></div>
) : (
<div className={styles.argTable}>
{guideQuestions.slice(0, 6).map((item, index) => (
<div
className={styles.argRow}
key={`${getGuideText(item)}-${index}`}
>
<div className={styles.argName}>
<strong> {index + 1}</strong>
</div>
<span></span>
<p>{getGuideText(item) || "暂无内容"}</p>
<code>-</code>
</div>
))}
</div>
)}
</section>
<section className={styles.usageGuide}>
<div>
<h3>使</h3>
<p>
使
AI
</p>
</div>
{detailJoined && <span></span>}
</section>
</>
)}
</div>
)}
</Modal>
</div>
);
}

View File

@@ -1,12 +1,18 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Empty, Input, Spin, message } from "antd";
import { Button, Empty, Input, Modal, Spin, message } from "antd";
import { ApiOutlined, CheckOutlined, SearchOutlined } from "@ant-design/icons";
import type { McpServerEntry, McpServersConfig } from "@shared/types/electron";
import {
exportMcpServerConfig,
fetchDeployedMcpDetail,
fetchDeployedMcps,
fetchSpaces,
McpComponentDetail,
McpDeployedItem,
McpDeployedDetail,
McpParamDetail,
McpResourceDetail,
McpRuntimeConfig,
SpaceInfo,
} from "../../services/core/marketplace";
import styles from "../../styles/components/McpMarketplacePage.module.css";
@@ -17,6 +23,31 @@ interface LocalMcpItem extends McpDeployedItem {
spaceName?: string;
}
interface LocalMcpDetail extends McpDeployedDetail {
spaceName?: string;
}
interface CapabilityDisplayItem {
name?: string;
uri?: string;
description?: string;
mimeType?: string;
inputArgs?: McpParamDetail[];
outputArgs?: McpParamDetail[];
inputSchema?: unknown;
type?: string;
targetId?: number;
toolName?: string;
}
const INSTALL_TYPE_LABELS: Record<string, string> = {
NPX: "NPX",
UVX: "UVX",
SSE: "SSE",
STREAMABLE_HTTP: "Streamable HTTP",
COMPONENT: "组件",
};
function safeParseJson<T>(value: unknown, fallback: T): T {
if (!value) return fallback;
if (typeof value !== "string") return value as T;
@@ -150,6 +181,166 @@ function getMatchedServerId(
return servers[itemId] ? itemId : null;
}
function getInstallTypeLabel(value: unknown): string {
if (typeof value !== "string" || !value) return "自定义";
return INSTALL_TYPE_LABELS[value] || value;
}
function getDeployStatusLabel(value: unknown): string {
if (typeof value !== "string" || !value) return "已部署";
const labels: Record<string, string> = {
Initialization: "初始化",
Deploying: "部署中",
Deployed: "已部署",
DeployFailed: "部署失败",
Stopped: "已停用",
};
return labels[value] || value;
}
function formatDateTime(value: unknown): string {
if (typeof value !== "string" || !value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function getRuntimeConfig(
item?: Pick<LocalMcpItem, "deployedConfig" | "mcpConfig"> | null,
): McpRuntimeConfig {
const deployedConfig = safeParseJson<McpRuntimeConfig | null>(
item?.deployedConfig,
null,
);
const mcpConfig = safeParseJson<McpRuntimeConfig | null>(
item?.mcpConfig,
null,
);
return deployedConfig || mcpConfig || {};
}
function asCapabilityArray<T extends CapabilityDisplayItem>(
value: unknown,
): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
function getCapabilityCounts(item?: LocalMcpItem | null) {
const config = getRuntimeConfig(item);
return {
tools: asCapabilityArray(config.tools).length,
resources: asCapabilityArray(config.resources).length,
prompts: asCapabilityArray(config.prompts).length,
components: asCapabilityArray(config.components).length,
};
}
function getSchemaParams(schema: unknown): McpParamDetail[] {
if (!schema || typeof schema !== "object") return [];
const schemaObject = schema as {
properties?: Record<string, Record<string, unknown>>;
required?: string[];
};
const properties = schemaObject.properties;
if (!properties || typeof properties !== "object") return [];
return Object.entries(properties).map(([name, config]) => ({
key: name,
name,
displayName: String(config?.title || name),
description:
typeof config?.description === "string" ? config.description : "",
dataType: typeof config?.type === "string" ? config.type : undefined,
require: schemaObject.required?.includes(name),
}));
}
function getInputParams(item: CapabilityDisplayItem): McpParamDetail[] {
if (Array.isArray(item.inputArgs) && item.inputArgs.length > 0) {
return item.inputArgs;
}
return getSchemaParams(item.inputSchema);
}
function getParamName(param: McpParamDetail): string {
return (
param.displayName ||
param.name ||
(param.key !== undefined ? String(param.key) : "") ||
"未命名参数"
);
}
function renderParamList(params: McpParamDetail[]) {
if (params.length === 0) return null;
return (
<div className={styles.paramList}>
{params.slice(0, 8).map((param, index) => (
<span
className={styles.param}
key={`${getParamName(param)}-${index}`}
title={param.description}
>
{getParamName(param)}
{param.dataType && <em>{param.dataType}</em>}
{(param.require || param.required) && <strong></strong>}
</span>
))}
{params.length > 8 && (
<span className={styles.param}>+{params.length - 8}</span>
)}
</div>
);
}
function DetailCapabilitySection({
title,
description,
items,
emptyText,
}: {
title: string;
description: string;
items: CapabilityDisplayItem[];
emptyText: string;
}) {
return (
<section className={styles.detailSection}>
<div className={styles.detailSectionHeader}>
<h3>{title}</h3>
<span>{description}</span>
</div>
{items.length === 0 ? (
<div className={styles.emptyLine}>{emptyText}</div>
) : (
<div className={styles.capabilityList}>
{items.map((item, index) => {
const params = getInputParams(item);
const name = item.name || item.uri || item.toolName || "未命名能力";
return (
<div className={styles.capabilityItem} key={`${name}-${index}`}>
<div className={styles.capabilityTop}>
<strong>{name}</strong>
{item.mimeType && <span>{item.mimeType}</span>}
{item.type && <span>{item.type}</span>}
</div>
{item.description && <p>{item.description}</p>}
{renderParamList(params)}
</div>
);
})}
</div>
)}
</section>
);
}
export default function McpMarketplacePage() {
const [keywordInput, setKeywordInput] = useState("");
const [keyword, setKeyword] = useState("");
@@ -158,6 +349,11 @@ export default function McpMarketplacePage() {
const [loading, setLoading] = useState(false);
const [connectingIds, setConnectingIds] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailItem, setDetailItem] = useState<LocalMcpItem | null>(null);
const [detailData, setDetailData] = useState<LocalMcpDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const loadMcpConfigs = useCallback(async () => {
setLoading(true);
@@ -311,6 +507,45 @@ export default function McpMarketplacePage() {
setKeyword(keywordInput.trim());
}, [keywordInput]);
const openDetail = useCallback(async (item: LocalMcpItem) => {
setDetailOpen(true);
setDetailItem(item);
setDetailData(null);
setDetailError(null);
setDetailLoading(true);
try {
const detail = await fetchDeployedMcpDetail(item.id);
if (!detail) {
throw new Error("MCP 详情不存在");
}
setDetailData({
...item,
...detail,
icon: detail.icon || item.icon,
spaceName: item.spaceName,
});
} catch (e) {
console.error("[McpMarketplace] Failed to load MCP detail:", e);
setDetailError(e instanceof Error ? e.message : "MCP 详情加载失败");
} finally {
setDetailLoading(false);
}
}, []);
const closeDetail = useCallback(() => {
setDetailOpen(false);
setDetailItem(null);
setDetailData(null);
setDetailError(null);
}, []);
const detailDisplayItem = detailData || detailItem;
const detailConfig = getRuntimeConfig(detailDisplayItem);
const detailConnected = detailDisplayItem
? !!getMatchedServerId(localConfig, detailDisplayItem)
: false;
return (
<div className={styles.page}>
<header className={styles.header}>
@@ -358,8 +593,21 @@ export default function McpMarketplacePage() {
<div className={styles.grid}>
{filteredItems.map((item) => {
const connected = !!getMatchedServerId(localConfig, item);
const counts = getCapabilityCounts(item);
return (
<article className={styles.card} key={item.id}>
<article
className={styles.card}
key={item.id}
role="button"
tabIndex={0}
onClick={() => openDetail(item)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail(item);
}
}}
>
<div className={styles.cardHeader}>
<div className={styles.mcpIcon}>
<McpIcon item={item} />
@@ -379,24 +627,41 @@ export default function McpMarketplacePage() {
</p>
<div className={styles.cardFooter}>
<div className={styles.metrics}>
<span>{item.deployStatus || "Deployed"}</span>
<span>{getDeployStatusLabel(item.deployStatus)}</span>
{item.serverName && <span>{item.serverName}</span>}
{counts.tools > 0 && <span>{counts.tools} </span>}
</div>
<div className={styles.cardActions}>
<Button
className={styles.detailButton}
type="text"
size="small"
onClick={(event) => {
event.stopPropagation();
openDetail(item);
}}
>
</Button>
<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={(event) => {
event.stopPropagation();
handleConnect(item);
}}
>
{connected ? "已连接" : "连接"}
</Button>
</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>
);
@@ -405,6 +670,166 @@ export default function McpMarketplacePage() {
)}
</section>
</main>
<Modal
className={styles.detailModal}
width={860}
open={detailOpen}
title={null}
footer={null}
onCancel={closeDetail}
destroyOnClose
>
{detailLoading && !detailDisplayItem ? (
<div className={styles.detailLoading}>
<Spin />
<span>MCP ...</span>
</div>
) : !detailDisplayItem ? (
<Empty description="暂无 MCP 详情" />
) : (
<div className={styles.detailContent}>
<div className={styles.detailHero}>
<div className={styles.detailIcon}>
<McpIcon item={detailDisplayItem} />
</div>
<div className={styles.detailTitleBlock}>
<div className={styles.detailTitleRow}>
<h2>{detailDisplayItem.name || "未命名 MCP"}</h2>
<span className={styles.badge}></span>
</div>
<div className={styles.detailMeta}>
<span>{detailDisplayItem.spaceName || "未知空间"}</span>
<span>{getCreatorName(detailDisplayItem)}</span>
<span>
{getInstallTypeLabel(detailDisplayItem.installType)}
</span>
<span>
{getDeployStatusLabel(detailDisplayItem.deployStatus)}
</span>
</div>
</div>
<Button
className={
detailConnected
? styles.connectedButton
: styles.connectButton
}
type={detailConnected ? "default" : "primary"}
icon={detailConnected ? <CheckOutlined /> : <ApiOutlined />}
loading={connectingIds.has(detailDisplayItem.id)}
disabled={detailConnected}
onClick={() => handleConnect(detailDisplayItem)}
>
{detailConnected ? "已连接" : "连接"}
</Button>
</div>
{detailError ? (
<div className={styles.detailError}>
<Empty description={detailError} />
<Button
type="primary"
onClick={() => detailItem && openDetail(detailItem)}
>
</Button>
</div>
) : (
<>
{detailLoading && (
<div className={styles.detailInlineLoading}>
<Spin size="small" />
<span>...</span>
</div>
)}
<p className={styles.detailDescription}>
{detailDisplayItem.description || "暂无 MCP 服务描述"}
</p>
<div className={styles.detailStats}>
<div>
<strong>
{asCapabilityArray(detailConfig.tools).length}
</strong>
<span></span>
</div>
<div>
<strong>
{asCapabilityArray(detailConfig.resources).length}
</strong>
<span></span>
</div>
<div>
<strong>
{asCapabilityArray(detailConfig.prompts).length}
</strong>
<span></span>
</div>
<div>
<strong>
{asCapabilityArray(detailConfig.components).length}
</strong>
<span></span>
</div>
</div>
<div className={styles.detailInfoGrid}>
{detailDisplayItem.serverName && (
<div>
<span></span>
<strong>{detailDisplayItem.serverName}</strong>
</div>
)}
{detailDisplayItem.deployed && (
<div>
<span></span>
<strong>
{formatDateTime(detailDisplayItem.deployed)}
</strong>
</div>
)}
{detailDisplayItem.modified && (
<div>
<span></span>
<strong>
{formatDateTime(detailDisplayItem.modified)}
</strong>
</div>
)}
</div>
<DetailCapabilitySection
title="工具"
description="MCP 可被模型调用的工具能力"
items={asCapabilityArray(detailConfig.tools)}
emptyText="暂无工具信息"
/>
<DetailCapabilitySection
title="资源"
description="MCP 暴露的资源读取能力"
items={asCapabilityArray<McpResourceDetail>(
detailConfig.resources,
)}
emptyText="暂无资源信息"
/>
<DetailCapabilitySection
title="提示词"
description="MCP 暴露的提示词模板"
items={asCapabilityArray(detailConfig.prompts)}
emptyText="暂无提示词信息"
/>
<DetailCapabilitySection
title="组件"
description="组件型 MCP 绑定的本地能力"
items={asCapabilityArray<McpComponentDetail>(
detailConfig.components,
)}
emptyText="暂无组件信息"
/>
</>
)}
</div>
)}
</Modal>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Empty, Input, Spin, message } from "antd";
import { Button, Empty, Input, Modal, Spin, message } from "antd";
import {
CheckOutlined,
DeploymentUnitOutlined,
@@ -7,11 +7,23 @@ import {
} from "@ant-design/icons";
import {
collectWorkflow,
fetchPublishedWorkflowDetail,
fetchPublishedCategories,
fetchPublishedWorkflows,
PublishedCategory,
PublishedWorkflowDetail,
PublishedWorkflow,
WorkflowArgDetail,
} from "../../services/core/marketplace";
import {
addAiAgentWorkflowComponent,
AiAgentContext,
AiManualComponent,
enableAiAgentWorkflowForChat,
fetchAiAgentComponents,
isAiWorkflowChatSelectable,
resolveAiAgentContext,
} from "../../services/core/aiChat";
import styles from "../../styles/components/WorkflowMarketplacePage.module.css";
const PAGE_SIZE = 48;
@@ -100,6 +112,92 @@ function WorkflowIcon({ workflow }: { workflow: PublishedWorkflow }) {
);
}
function getWorkflowId(workflow: PublishedWorkflow): number {
return workflow.targetId || workflow.id;
}
function getWorkflowJoinState(
component?: AiManualComponent,
): "none" | "pending" | "ready" {
if (!component) return "none";
return isAiWorkflowChatSelectable(component) ? "ready" : "pending";
}
function formatDateTime(value?: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function getArgName(arg: WorkflowArgDetail): string {
return (
arg.displayName ||
arg.name ||
(arg.key !== undefined ? String(arg.key) : "") ||
"未命名参数"
);
}
function getArgType(arg: WorkflowArgDetail): string {
return arg.dataType || arg.inputType || "未指定";
}
function ArgList({
title,
description,
items,
emptyText,
}: {
title: string;
description: string;
items?: WorkflowArgDetail[];
emptyText: string;
}) {
const list = items || [];
return (
<section className={styles.detailSection}>
<div className={styles.detailSectionHeader}>
<h3>{title}</h3>
<span>{description}</span>
</div>
{list.length === 0 ? (
<div className={styles.emptyLine}>{emptyText}</div>
) : (
<div className={styles.argTable}>
<div className={styles.argTableHead}>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{list.map((arg, index) => (
<div className={styles.argRow} key={`${getArgName(arg)}-${index}`}>
<div className={styles.argName}>
<strong>{getArgName(arg)}</strong>
{(arg.require || arg.required) && <em></em>}
</div>
<span>{getArgType(arg)}</span>
<p>{arg.description || "暂无说明"}</p>
<code>
{arg.bindValue !== undefined && arg.bindValue !== null
? String(arg.bindValue)
: "-"}
</code>
</div>
))}
</div>
)}
</section>
);
}
export default function WorkflowMarketplacePage() {
const [categories, setCategories] = useState<PublishedCategory[]>([]);
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY);
@@ -111,6 +209,18 @@ export default function WorkflowMarketplacePage() {
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [usingIds, setUsingIds] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailItem, setDetailItem] = useState<PublishedWorkflow | null>(null);
const [detailData, setDetailData] = useState<PublishedWorkflowDetail | null>(
null,
);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [agentContext, setAgentContext] = useState<AiAgentContext | null>(null);
const [joinedWorkflowMap, setJoinedWorkflowMap] = useState<
Map<number, AiManualComponent>
>(new Map());
const [loadingComponents, setLoadingComponents] = useState(false);
const visibleCategories = useMemo(() => {
if (categories.length === 0) return FALLBACK_CATEGORIES;
@@ -158,6 +268,36 @@ export default function WorkflowMarketplacePage() {
loadCategories();
}, [loadCategories]);
const loadAgentComponents = useCallback(async () => {
setLoadingComponents(true);
try {
const context = await resolveAiAgentContext();
setAgentContext(context);
const components = await fetchAiAgentComponents(context.agentId);
const workflowMap = new Map<number, AiManualComponent>();
components
.filter((item) => String(item.type || "").toLowerCase() === "workflow")
.forEach((item) => {
const targetId = Number(item.targetId || 0);
if (targetId) workflowMap.set(targetId, item);
});
setJoinedWorkflowMap(workflowMap);
} catch (e) {
console.error(
"[WorkflowMarketplace] Failed to load agent components:",
e,
);
setAgentContext(null);
setJoinedWorkflowMap(new Map());
} finally {
setLoadingComponents(false);
}
}, []);
useEffect(() => {
loadAgentComponents();
}, [loadAgentComponents]);
useEffect(() => {
loadWorkflows();
}, [loadWorkflows]);
@@ -166,32 +306,155 @@ export default function WorkflowMarketplacePage() {
setKeyword(keywordInput.trim());
}, [keywordInput]);
const handleUse = useCallback(async (workflow: PublishedWorkflow) => {
if (workflow.collect) return;
const markWorkflowJoined = useCallback((workflowId: number) => {
setWorkflows((prev) =>
prev.map((item) =>
getWorkflowId(item) === workflowId ? { ...item, collect: true } : item,
),
);
setDetailData((prev) =>
prev && getWorkflowId(prev) === workflowId
? { ...prev, collect: true }
: prev,
);
setDetailItem((prev) =>
prev && getWorkflowId(prev) === workflowId
? { ...prev, collect: true }
: prev,
);
}, []);
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);
const addJoinedWorkflow = useCallback(
(workflow: PublishedWorkflow, component: AiManualComponent) => {
const workflowId = getWorkflowId(workflow);
setJoinedWorkflowMap((prev) => {
const next = new Map(prev);
next.set(workflowId, component);
return next;
});
}
markWorkflowJoined(workflowId);
},
[markWorkflowJoined],
);
const loadWorkflowDetail = useCallback(
async (workflow: PublishedWorkflow) => {
const workflowId = getWorkflowId(workflow);
const detail = await fetchPublishedWorkflowDetail(workflowId);
return {
...workflow,
...detail,
icon: detail.icon || workflow.icon,
targetId: workflowId,
collect: detail.collect || workflow.collect,
};
},
[],
);
const openDetail = useCallback(
async (workflow: PublishedWorkflow) => {
setDetailOpen(true);
setDetailItem(workflow);
setDetailData(null);
setDetailError(null);
setDetailLoading(true);
try {
const detail = await loadWorkflowDetail(workflow);
setDetailData(detail);
} catch (e) {
console.error("[WorkflowMarketplace] Failed to load detail:", e);
setDetailError(e instanceof Error ? e.message : "工作流详情加载失败");
} finally {
setDetailLoading(false);
}
},
[loadWorkflowDetail],
);
const closeDetail = useCallback(() => {
setDetailOpen(false);
setDetailItem(null);
setDetailData(null);
setDetailError(null);
}, []);
const handleUse = useCallback(
async (workflow: PublishedWorkflow) => {
const workflowId = getWorkflowId(workflow);
const joinedComponent = joinedWorkflowMap.get(workflowId);
if (getWorkflowJoinState(joinedComponent) === "ready") return;
setUsingIds((prev) => new Set(prev).add(workflowId));
try {
const context = agentContext || (await resolveAiAgentContext());
if (!agentContext) setAgentContext(context);
const detail =
detailData && getWorkflowId(detailData) === workflowId
? detailData
: await loadWorkflowDetail(workflow);
let component = joinedComponent;
if (!component) {
const componentId = await addAiAgentWorkflowComponent({
agentId: context.agentId,
workflowId,
});
const components = await fetchAiAgentComponents(context.agentId);
component =
components.find((item) => item.id === componentId) ||
({
id: componentId,
name: detail.name || "未命名工作流",
description: detail.description,
icon: detail.icon,
type: "Workflow",
targetId: workflowId,
defaultSelected: 0,
} satisfies AiManualComponent);
}
const selectableComponent = isAiWorkflowChatSelectable(component)
? component
: await enableAiAgentWorkflowForChat(component);
addJoinedWorkflow(detail, selectableComponent);
try {
await collectWorkflow(workflowId);
} catch (collectError) {
console.warn(
"[WorkflowMarketplace] Workflow joined but collect sync failed:",
collectError,
);
}
message.success("工作流已加入 AI 对话可选组件");
} catch (e) {
console.error("[WorkflowMarketplace] Failed to join workflow:", e);
message.error(e instanceof Error ? e.message : "工作流加入失败");
} finally {
setUsingIds((prev) => {
const next = new Set(prev);
next.delete(workflowId);
return next;
});
}
},
[
addJoinedWorkflow,
agentContext,
detailData,
joinedWorkflowMap,
loadWorkflowDetail,
],
);
const detailDisplayItem = detailData || detailItem;
const detailJoinState = detailDisplayItem
? getWorkflowJoinState(
joinedWorkflowMap.get(getWorkflowId(detailDisplayItem)),
)
: "none";
return (
<div className={styles.page}>
<header className={styles.header}>
@@ -257,10 +520,25 @@ export default function WorkflowMarketplacePage() {
) : (
<div className={styles.grid}>
{workflows.map((workflow) => {
const workflowId = workflow.targetId || workflow.id;
const used = !!workflow.collect;
const workflowId = getWorkflowId(workflow);
const joinedComponent = joinedWorkflowMap.get(workflowId);
const joinState = getWorkflowJoinState(joinedComponent);
const joined = joinState === "ready";
const pendingJoin = joinState === "pending";
return (
<article className={styles.card} key={workflowId}>
<article
className={styles.card}
key={workflowId}
role="button"
tabIndex={0}
onClick={() => openDetail(workflow)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail(workflow);
}
}}
>
<div className={styles.cardHeader}>
<div
className={styles.workflowIcon}
@@ -280,6 +558,11 @@ export default function WorkflowMarketplacePage() {
<span className={styles.badge}>
{workflow.category || "工作流"}
</span>
{joinState !== "none" && (
<span className={styles.usedBadge}>
{joined ? "已加入" : "待启用"}
</span>
)}
</div>
<p className={styles.description}>
@@ -294,17 +577,47 @@ export default function WorkflowMarketplacePage() {
{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 className={styles.cardActions}>
<Button
className={styles.detailButton}
type="text"
size="small"
onClick={(event) => {
event.stopPropagation();
openDetail(workflow);
}}
>
</Button>
<Button
className={
joined ? styles.usedButton : styles.useButton
}
type={joined ? "default" : "primary"}
size="small"
icon={
joined ? (
<CheckOutlined />
) : (
<DeploymentUnitOutlined />
)
}
loading={
usingIds.has(workflowId) || loadingComponents
}
disabled={joined}
onClick={(event) => {
event.stopPropagation();
handleUse(workflow);
}}
>
{joined
? "已加入"
: pendingJoin
? "启用对话"
: "使用"}
</Button>
</div>
</div>
</article>
);
@@ -313,6 +626,169 @@ export default function WorkflowMarketplacePage() {
)}
</section>
</main>
<Modal
className={styles.detailModal}
width={900}
open={detailOpen}
title={null}
footer={null}
onCancel={closeDetail}
destroyOnClose
>
{detailLoading && !detailDisplayItem ? (
<div className={styles.detailLoading}>
<Spin />
<span>...</span>
</div>
) : !detailDisplayItem ? (
<Empty description="暂无工作流详情" />
) : (
<div className={styles.detailContent}>
<div className={styles.detailHero}>
<div
className={styles.detailIcon}
style={{ backgroundColor: getIconColor(detailDisplayItem) }}
>
<WorkflowIcon workflow={detailDisplayItem} />
</div>
<div className={styles.detailTitleBlock}>
<div className={styles.detailTitleRow}>
<h2>{detailDisplayItem.name || "未命名工作流"}</h2>
<span className={styles.badge}>
{detailDisplayItem.category || "工作流"}
</span>
{detailJoinState !== "none" && (
<span className={styles.usedBadge}>
{detailJoinState === "ready" ? "已加入" : "待启用"}
</span>
)}
</div>
<div className={styles.detailMeta}>
<span>{getAuthorName(detailDisplayItem)}</span>
{detailDisplayItem.created && (
<span>{formatDateTime(detailDisplayItem.created)}</span>
)}
<span>
{formatMetric(getUsageCount(detailDisplayItem))} 使
</span>
</div>
</div>
<Button
className={
detailJoinState === "ready"
? styles.usedButton
: styles.useButton
}
type={detailJoinState === "ready" ? "default" : "primary"}
icon={
detailJoinState === "ready" ? (
<CheckOutlined />
) : (
<DeploymentUnitOutlined />
)
}
loading={usingIds.has(getWorkflowId(detailDisplayItem))}
disabled={detailJoinState === "ready"}
onClick={() => handleUse(detailDisplayItem)}
>
{detailJoinState === "ready"
? "已加入"
: detailJoinState === "pending"
? "启用对话"
: "使用"}
</Button>
</div>
{detailError ? (
<div className={styles.detailError}>
<Empty description={detailError} />
<Button
type="primary"
onClick={() => detailItem && openDetail(detailItem)}
>
</Button>
</div>
) : (
<>
{detailLoading && (
<div className={styles.detailInlineLoading}>
<Spin size="small" />
<span>...</span>
</div>
)}
<section className={styles.detailSection}>
<div className={styles.detailSectionHeader}>
<h3></h3>
<span></span>
</div>
<p className={styles.detailDescription}>
{detailDisplayItem.description || "暂无工作流描述"}
</p>
{detailDisplayItem.remark && (
<p className={styles.detailRemark}>
{detailDisplayItem.remark}
</p>
)}
</section>
<div className={styles.detailStats}>
<div>
<strong>{(detailData?.inputArgs || []).length}</strong>
<span></span>
</div>
<div>
<strong>{(detailData?.outputArgs || []).length}</strong>
<span></span>
</div>
<div>
<strong>
{formatMetric(getUsageCount(detailDisplayItem))}
</strong>
<span>使</span>
</div>
<div>
<strong>{getRating(detailDisplayItem)}</strong>
<span></span>
</div>
</div>
<ArgList
title="入参配置"
description="在 AI 对话中调用工作流时需要填写或绑定的参数"
items={detailData?.inputArgs}
emptyText="暂无入参配置"
/>
<ArgList
title="出参配置"
description="工作流执行完成后会返回的结果字段"
items={detailData?.outputArgs}
emptyText="暂无出参配置"
/>
<section className={styles.usageGuide}>
<div>
<h3>使</h3>
<p>
使 AI
AI
</p>
</div>
{detailJoinState !== "none" && (
<span>
{detailJoinState === "ready"
? "已加入对话组件"
: "需要启用后才会出现在 AI 对话工作流列表"}
</span>
)}
</section>
</>
)}
</div>
)}
</Modal>
</div>
);
}

View File

@@ -188,6 +188,7 @@ body {
.app-body {
flex: 1;
display: flex;
min-width: 0;
overflow: hidden;
}
@@ -211,8 +212,10 @@ body {
.app-content {
flex: 1;
min-width: 0;
padding: 20px 24px;
background: var(--color-bg-layout);
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;

View File

@@ -72,13 +72,29 @@ export interface AiSelectedComponent {
type: string;
}
export interface AiComponentBindConfig {
invokeType?: string;
defaultSelected?: number;
inputArgBindConfigs?: unknown[];
argBindConfigs?: unknown[];
outputArgBindConfigs?: unknown[];
async?: number;
asyncReplyContent?: string;
directOutput?: number;
[key: string]: unknown;
}
export interface AiManualComponent {
id: number;
name: string;
icon?: string;
description?: string;
type: string;
targetId?: number;
toolName?: string;
bindConfig?: string | AiComponentBindConfig;
defaultSelected?: number;
[key: string]: unknown;
}
export interface AiSkillMention {
@@ -215,6 +231,15 @@ export interface AiAgentContext {
domain: string;
}
export interface AiAgentLaunchContext {
agentId: number;
name?: string;
icon?: string;
description?: string;
source?: "marketplace" | "default";
launchKey?: number;
}
export interface AiStreamController {
abort: () => void;
done: Promise<void>;
@@ -270,6 +295,18 @@ function appendParams(url: string, params?: Record<string, unknown>): string {
return query ? `${url}?${query}` : url;
}
function parseBindConfig(
bindConfig?: AiManualComponent["bindConfig"],
): AiComponentBindConfig {
if (!bindConfig) return {};
if (typeof bindConfig !== "string") return { ...bindConfig };
try {
return JSON.parse(bindConfig) as AiComponentBindConfig;
} catch {
return {};
}
}
async function aiRequest<T>(
path: string,
options: AiRequestOptions = {},
@@ -422,6 +459,172 @@ export async function fetchAiModelOptions(
);
}
export async function addAiAgentMcpComponent(params: {
agentId: number;
mcpId: number;
toolName: string;
}): Promise<number> {
return addAiAgentComponent({
agentId: params.agentId,
type: "Mcp",
targetId: params.mcpId,
toolName: params.toolName,
});
}
export async function addAiAgentWorkflowComponent(params: {
agentId: number;
workflowId: number;
}): Promise<number> {
return addAiAgentComponent({
agentId: params.agentId,
type: "Workflow",
targetId: params.workflowId,
});
}
export async function addAiAgentKnowledgeComponent(params: {
agentId: number;
knowledgeId: number;
}): Promise<number> {
return addAiAgentComponent({
agentId: params.agentId,
type: "Knowledge",
targetId: params.knowledgeId,
});
}
export function isAiKnowledgeChatSelectable(
component: AiManualComponent,
): boolean {
if (String(component.type || "").toLowerCase() !== "knowledge") return false;
const bindConfig = parseBindConfig(component.bindConfig);
const invokeType = String(bindConfig.invokeType || "");
const defaultSelected =
component.defaultSelected ?? bindConfig.defaultSelected;
return invokeType === "MANUAL" && Number(defaultSelected) === 1;
}
export async function enableAiAgentKnowledgeForChat(
component: AiManualComponent,
): Promise<AiManualComponent> {
const bindConfig = parseBindConfig(component.bindConfig);
const nextBindConfig: AiComponentBindConfig = {
...bindConfig,
invokeType: "MANUAL",
defaultSelected: 1,
matchingDegree: bindConfig.matchingDegree ?? 0.5,
maxRecallCount: bindConfig.maxRecallCount ?? 5,
noneRecallReplyType: bindConfig.noneRecallReplyType || "DEFAULT",
searchStrategy: bindConfig.searchStrategy || "MIXED",
};
await aiRequest<null>("/api/agent/component/knowledge/update", {
method: "POST",
data: {
id: component.id,
targetId: component.targetId ?? null,
name: component.name,
icon: component.icon,
description: component.description,
bindConfig: nextBindConfig,
},
});
return {
...component,
bindConfig: nextBindConfig,
defaultSelected: nextBindConfig.defaultSelected,
};
}
export async function addAiAgentPublishedAgentComponent(params: {
agentId: number;
targetAgentId: number;
}): Promise<number> {
if (params.agentId === params.targetAgentId) {
throw new Error("不能将当前智能体加入自身对话组件");
}
return addAiAgentComponent({
agentId: params.agentId,
type: "Agent",
targetId: params.targetAgentId,
});
}
export function isAiWorkflowChatSelectable(
component: AiManualComponent,
): boolean {
if (String(component.type || "").toLowerCase() !== "workflow") return false;
const invokeType = String(
parseBindConfig(component.bindConfig).invokeType || "",
);
return invokeType === "MANUAL" || invokeType === "MANUAL_ON_DEMAND";
}
export async function enableAiAgentWorkflowForChat(
component: AiManualComponent,
): Promise<AiManualComponent> {
const bindConfig = parseBindConfig(component.bindConfig);
const inputArgBindConfigs =
bindConfig.inputArgBindConfigs || bindConfig.argBindConfigs || [];
const nextBindConfig: AiComponentBindConfig = {
...bindConfig,
inputArgBindConfigs,
argBindConfigs: inputArgBindConfigs,
outputArgBindConfigs: bindConfig.outputArgBindConfigs || [],
invokeType: "MANUAL_ON_DEMAND",
defaultSelected: bindConfig.defaultSelected ?? 0,
async: bindConfig.async ?? 0,
asyncReplyContent: bindConfig.asyncReplyContent || "",
directOutput: bindConfig.directOutput ?? 0,
};
await aiRequest<null>("/api/agent/component/workflow/update", {
method: "POST",
data: {
id: component.id,
targetId: component.targetId ?? null,
name: component.name,
icon: component.icon,
description: component.description,
bindConfig: nextBindConfig,
},
});
return {
...component,
bindConfig: nextBindConfig,
defaultSelected: nextBindConfig.defaultSelected,
};
}
export async function addAiAgentComponent(params: {
agentId: number;
type: string;
targetId: number | string;
toolName?: string;
}): Promise<number> {
return aiRequest<number>("/api/agent/component/add", {
method: "POST",
data: {
agentId: params.agentId,
type: params.type,
targetId: params.targetId,
...(params.toolName ? { toolName: params.toolName } : {}),
},
});
}
export async function fetchAiAgentComponents(
agentId: number,
): Promise<AiManualComponent[]> {
return aiRequest<AiManualComponent[]>(
`/api/agent/component/list/${agentId}`,
{ method: "GET", showError: false },
);
}
export async function fetchAtSkills(params: {
type: "all" | "recent" | "favorite";
kw?: string;

View File

@@ -0,0 +1,708 @@
import { message } from "antd";
import {
AUTH_KEYS,
DEFAULT_API_TIMEOUT,
DEFAULT_SERVER_HOST,
} from "@shared/constants";
import { getDomainTokenKey } from "@shared/utils/domain";
import { getCurrentAuth, normalizeServerHost } from "./auth";
const SUCCESS_CODE = "0000";
const MAX_KNOWLEDGE_FILE_SIZE = 100 * 1024 * 1024;
export const KNOWLEDGE_UPLOAD_SUFFIX = [
"doc",
"docx",
"pdf",
"md",
"json",
"txt",
"jpg",
"png",
"gif",
"webp",
"svg",
"heic",
"mp4",
"mkv",
"mov",
"webm",
"mp3",
"aac",
"wav",
"flac",
"ogg",
"opus",
];
interface ApiResponse<T> {
code: string;
message?: string;
data: T;
success?: boolean;
}
interface KnowledgeRequestOptions {
method?: "GET" | "POST";
data?: unknown;
params?: Record<string, unknown>;
headers?: Record<string, string>;
showError?: boolean;
timeout?: number;
formData?: FormData;
responseType?: "json" | "blob";
}
export interface KnowledgePageResult<T> {
records: T[];
total: number;
size?: number;
current?: number;
pages?: number;
}
export interface KnowledgeComponent {
id: number;
targetId?: number;
type?: string;
name: string;
description?: string;
icon?: string;
fileSize?: number;
docCount?: number;
spaceId?: number;
created?: string;
modified?: string;
creatorName?: string;
[key: string]: unknown;
}
export interface KnowledgeInfo {
id: number;
spaceId: number;
name: string;
description?: string;
dataType?: number;
icon?: string;
embeddingModelId?: number;
chatModelId?: number;
fileSize?: number;
pubStatus?: string;
created?: string;
modified?: string;
creatorName?: string;
workflowId?: string | number;
workflowName?: string;
workflowDescription?: string;
workflowIcon?: string;
[key: string]: unknown;
}
export interface KnowledgeModelOption {
id: number;
name?: string;
model?: string;
description?: string;
enabled?: number;
[key: string]: unknown;
}
export interface KnowledgeDocumentStatus {
docStatus?: string;
docStatusCode?: number;
docStatusDesc?: string;
docStatusReason?: string;
}
export interface KnowledgeDocumentInfo extends KnowledgeDocumentStatus {
id: number;
kbId: number;
name: string;
docUrl?: string;
pubStatus?: string;
hasQa?: boolean;
hasEmbedding?: boolean;
fileSize?: number;
spaceId?: number;
created?: string;
modified?: string;
creatorName?: string;
[key: string]: unknown;
}
export interface KnowledgeUploadFileInfo {
key?: string;
url: string;
name: string;
type?: string;
size: number;
}
export interface KnowledgeRawSegmentInfo {
id: number;
docId: number;
kbId?: number;
rawTxt: string;
sortIndex?: number;
spaceId?: number;
created?: string;
modified?: string;
[key: string]: unknown;
}
export interface KnowledgeQaInfo {
id: number;
kbId?: number;
docId?: number;
rawId?: number;
question: string;
answer: string;
hasEmbedding?: boolean;
spaceId?: number;
created?: string;
modified?: string;
[key: string]: unknown;
}
export interface KnowledgeConfigSaveParams {
id?: number;
spaceId: number;
name: string;
description?: string;
embeddingModelId: number;
icon?: string;
dataType?: number;
workflowId?: number | string | null;
}
function appendParams(url: string, params?: Record<string, unknown>): string {
if (!params) return url;
const target = new URL(url);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
target.searchParams.set(key, String(value));
}
});
return target.toString();
}
async function getBaseUrl(): Promise<string> {
const auth = await getCurrentAuth();
if (auth.userInfo?.currentDomain) {
return normalizeServerHost(auth.userInfo.currentDomain);
}
const config = (await window.electronAPI?.settings.get("step1_config")) as {
serverHost?: string;
} | null;
return normalizeServerHost(config?.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 getAuthHeaders(domain: string): Promise<Record<string, string>> {
const token = await getAuthToken(domain);
return token ? { Authorization: `Bearer ${token}` } : {};
}
function normalizeAssetUrl(
baseUrl: string,
value?: string,
): string | undefined {
if (!value) return value;
if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) {
return value;
}
try {
return new URL(value, baseUrl).toString();
} catch {
return value;
}
}
async function knowledgeRequest<T>(
path: string,
options: KnowledgeRequestOptions = {},
): Promise<T> {
const baseUrl = await getBaseUrl();
const authHeaders = await getAuthHeaders(baseUrl);
const finalUrl = appendParams(`${baseUrl}${path}`, options.params);
const isFormData = !!options.formData;
const response = await fetch(finalUrl, {
method: options.method || "GET",
credentials: "include",
cache: "no-store",
headers: {
Accept:
options.responseType === "blob"
? "*/*"
: "application/json, text/plain, */*",
...(isFormData ? {} : { "Content-Type": "application/json" }),
...authHeaders,
...options.headers,
},
signal: AbortSignal.timeout(options.timeout || DEFAULT_API_TIMEOUT),
...(isFormData
? { body: options.formData }
: options.data !== undefined
? { 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;
}
if (options.responseType === "blob") {
return (await response.blob()) as T;
}
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;
}
function normalizeKnowledgeComponent(
baseUrl: string,
item: KnowledgeComponent,
): KnowledgeComponent {
return {
...item,
icon: normalizeAssetUrl(baseUrl, item.icon),
};
}
function normalizeKnowledgeInfo(
baseUrl: string,
item: KnowledgeInfo,
): KnowledgeInfo {
return {
...item,
icon: normalizeAssetUrl(baseUrl, item.icon),
};
}
export function getKnowledgeId(item: KnowledgeComponent | KnowledgeInfo) {
const targetId = (item as { targetId?: number | string }).targetId;
return Number(targetId || item.id);
}
export async function fetchKnowledgeComponents(
spaceId: number,
): Promise<KnowledgeComponent[]> {
const baseUrl = await getBaseUrl();
const list = await knowledgeRequest<KnowledgeComponent[]>(
`/api/component/list/${spaceId}`,
{ method: "GET" },
);
return (list || [])
.filter((item) => String(item.type || "").toLowerCase() === "knowledge")
.map((item) => normalizeKnowledgeComponent(baseUrl, item));
}
export async function fetchEmbeddingModels(
spaceId?: number,
): Promise<KnowledgeModelOption[]> {
return knowledgeRequest<KnowledgeModelOption[]>("/api/model/list", {
method: "POST",
data: {
...(spaceId ? { spaceId } : {}),
modelType: "Embeddings",
},
});
}
export async function createKnowledgeConfig(
params: KnowledgeConfigSaveParams,
): Promise<number> {
return knowledgeRequest<number>("/api/knowledge/config/add", {
method: "POST",
data: {
spaceId: params.spaceId,
name: params.name,
description: params.description || "",
dataType: params.dataType || 1,
icon: params.icon || "",
embeddingModelId: params.embeddingModelId,
workflowId: params.workflowId ?? null,
},
});
}
export async function updateKnowledgeConfig(
params: KnowledgeConfigSaveParams,
): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/config/update", {
method: "POST",
data: {
id: params.id,
spaceId: params.spaceId,
name: params.name,
description: params.description || "",
dataType: params.dataType || 1,
icon: params.icon || "",
embeddingModelId: params.embeddingModelId,
workflowId: params.workflowId ?? null,
},
});
}
export async function fetchKnowledgeDetail(
knowledgeId: number,
): Promise<KnowledgeInfo> {
const baseUrl = await getBaseUrl();
const detail = await knowledgeRequest<KnowledgeInfo>(
"/api/knowledge/config/detailById",
{
method: "GET",
params: { id: knowledgeId },
},
);
return normalizeKnowledgeInfo(baseUrl, detail);
}
export async function deleteKnowledgeConfig(
knowledgeId: number,
): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/config/deleteById", {
method: "GET",
params: { id: knowledgeId },
});
}
export async function fetchKnowledgeDocuments(params: {
kbId: number;
name?: string;
current?: number;
pageSize?: number;
}): Promise<KnowledgePageResult<KnowledgeDocumentInfo>> {
return knowledgeRequest<KnowledgePageResult<KnowledgeDocumentInfo>>(
"/api/knowledge/document/list",
{
method: "POST",
data: {
queryFilter: {
kbId: params.kbId,
name: params.name || undefined,
},
current: params.current || 1,
pageSize: params.pageSize || 48,
},
},
);
}
export function validateKnowledgeFile(file: File): string | null {
if (!file.name.includes(".")) return "请上传正确的文件类型";
const suffix = file.name.split(".").pop()?.toLowerCase() || "";
if (!KNOWLEDGE_UPLOAD_SUFFIX.includes(suffix)) {
return "暂不支持该文件类型";
}
if (file.size > MAX_KNOWLEDGE_FILE_SIZE) {
return "单个文件不能超过 100MB";
}
return null;
}
export async function uploadKnowledgeFile(
file: File,
): Promise<KnowledgeUploadFileInfo> {
const validationError = validateKnowledgeFile(file);
if (validationError) throw new Error(validationError);
const formData = new FormData();
formData.append("file", file);
const result = await knowledgeRequest<{
key?: string;
url?: string;
fileName?: string;
mimeType?: string;
size?: number;
}>("/api/file/upload", {
method: "POST",
formData,
timeout: DEFAULT_API_TIMEOUT * 2,
});
if (!result?.url) {
throw new Error("上传成功但未返回文件地址");
}
return {
key: result.key || "",
url: result.url,
name: result.fileName || file.name,
type: result.mimeType || file.type,
size: result.size || file.size,
};
}
export async function uploadKnowledgeAsset(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const result = await knowledgeRequest<{
url?: string;
}>("/api/file/upload", {
method: "POST",
formData,
timeout: DEFAULT_API_TIMEOUT * 2,
});
if (!result?.url) {
throw new Error("上传成功但未返回文件地址");
}
return result.url;
}
export async function addKnowledgeDocuments(params: {
kbId: number;
files: KnowledgeUploadFileInfo[];
}): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/document/add", {
method: "POST",
data: {
kbId: params.kbId,
fileList: params.files.map((file) => ({
name: file.name,
docUrl: file.url,
fileSize: file.size,
})),
autoSegmentConfigFlag: true,
},
});
}
export async function addCustomKnowledgeDocument(params: {
kbId: number;
name: string;
fileContent: string;
}): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/document/customAdd", {
method: "POST",
data: {
kbId: params.kbId,
name: params.name,
fileContent: params.fileContent,
autoSegmentConfigFlag: true,
},
});
}
export async function updateKnowledgeDocumentName(params: {
docId: number;
name: string;
}): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/document/updateDocName", {
method: "POST",
data: params,
});
}
export async function deleteKnowledgeDocument(docId: number): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/document/deleteById", {
method: "GET",
params: { id: docId },
});
}
export async function queryKnowledgeDocumentStatus(
docIds: number[],
): Promise<KnowledgeDocumentInfo[]> {
return knowledgeRequest<KnowledgeDocumentInfo[]>(
"/api/knowledge/document/queryDocStatus",
{
method: "POST",
data: { docIds },
showError: false,
},
);
}
export async function generateKnowledgeDocumentQa(
docId: number,
): Promise<null> {
return knowledgeRequest<null>(`/api/knowledge/document/generateQA/${docId}`, {
method: "POST",
});
}
export async function generateKnowledgeDocumentEmbeddings(
docId: number,
): Promise<null> {
return knowledgeRequest<null>(
`/api/knowledge/document/doc/generateEmbeddings/${docId}`,
{
method: "POST",
},
);
}
export async function retryKnowledgeDocumentTask(docId: number): Promise<null> {
return knowledgeRequest<null>(
`/api/knowledge/document/doc/autoRetryTaskByDocId/${docId}`,
{
method: "GET",
},
);
}
export async function fetchKnowledgeRawSegments(params: {
docId: number;
current?: number;
pageSize?: number;
}): Promise<KnowledgePageResult<KnowledgeRawSegmentInfo>> {
return knowledgeRequest<KnowledgePageResult<KnowledgeRawSegmentInfo>>(
"/api/knowledge/rawSegment/list",
{
method: "POST",
data: {
queryFilter: { docId: params.docId },
current: params.current || 1,
pageSize: params.pageSize || 30,
},
},
);
}
export async function addKnowledgeRawSegment(params: {
spaceId: number;
docId: number;
rawTxt: string;
sortIndex?: number;
}): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/rawSegment/add", {
method: "POST",
data: {
spaceId: params.spaceId,
docId: params.docId,
rawTxt: params.rawTxt,
sortIndex: params.sortIndex || 0,
},
});
}
export async function updateKnowledgeRawSegment(params: {
spaceId: number;
rawSegmentId: number;
docId: number;
rawTxt: string;
sortIndex?: number;
}): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/rawSegment/update", {
method: "POST",
data: {
spaceId: params.spaceId,
rawSegmentId: params.rawSegmentId,
docId: params.docId,
rawTxt: params.rawTxt,
sortIndex: params.sortIndex || 0,
},
});
}
export async function deleteKnowledgeRawSegment(
rawSegmentId: number,
): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/rawSegment/deleteById", {
method: "GET",
params: { id: rawSegmentId },
});
}
export async function fetchKnowledgeQaList(params: {
kbId: number;
spaceId?: number;
question?: string;
current?: number;
pageSize?: number;
}): Promise<KnowledgePageResult<KnowledgeQaInfo>> {
return knowledgeRequest<KnowledgePageResult<KnowledgeQaInfo>>(
"/api/knowledge/qa/list",
{
method: "POST",
data: {
queryFilter: {
kbId: params.kbId,
spaceId: params.spaceId,
question: params.question || undefined,
},
current: params.current || 1,
pageSize: params.pageSize || 20,
},
},
);
}
export async function addKnowledgeQa(params: {
kbId: number;
spaceId?: number;
question: string;
answer: string;
}): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/qa/add", {
method: "POST",
data: params,
});
}
export async function updateKnowledgeQa(params: {
id: number;
question: string;
answer: string;
}): Promise<null> {
return knowledgeRequest<null>("/api/knowledge/qa/update", {
method: "POST",
data: params,
});
}
export async function deleteKnowledgeQa(id: number): Promise<null> {
const formData = new FormData();
formData.append("id", String(id));
return knowledgeRequest<null>("/api/knowledge/qa/deleteById", {
method: "POST",
formData,
});
}
export async function importKnowledgeQaExcel(params: {
kbId: number;
file: File;
}): Promise<null> {
const formData = new FormData();
formData.append("file", params.file);
formData.append("kbId", String(params.kbId));
return knowledgeRequest<null>("/api/knowledge/qa/importExcel", {
method: "POST",
formData,
timeout: DEFAULT_API_TIMEOUT * 2,
});
}
export async function downloadKnowledgeQaTemplate(): Promise<Blob> {
return knowledgeRequest<Blob>("/api/knowledge/qa/downloadExcelTemplate", {
method: "GET",
responseType: "blob",
});
}

View File

@@ -87,6 +87,66 @@ export interface PublishedWorkflow {
publishUser?: PublishedUser;
paymentRequired?: boolean;
subscribed?: boolean;
allowCopy?: number;
spaceId?: number;
created?: string;
remark?: string | null;
}
export interface WorkflowArgDetail {
key?: string | number;
name?: string;
displayName?: string;
description?: string;
dataType?: string;
inputType?: string;
require?: boolean;
required?: boolean;
enable?: boolean;
bindValue?: string | number | boolean | null;
children?: WorkflowArgDetail[];
subArgs?: WorkflowArgDetail[];
[key: string]: unknown;
}
export interface PublishedWorkflowDetail extends PublishedWorkflow {
inputArgs?: WorkflowArgDetail[];
outputArgs?: WorkflowArgDetail[];
}
export interface PublishedAgent {
id: number;
targetId: number;
name: string;
description?: string;
icon?: string;
category?: string;
collect?: boolean;
statistics?: PublishedSkillStatistics;
publishUser?: PublishedUser;
paymentRequired?: boolean;
subscribed?: boolean;
allowCopy?: number;
spaceId?: number;
created?: string;
modified?: string;
remark?: string | null;
targetSubType?: "ChatBot" | "PageApp" | string;
}
export interface PublishedAgentDetail extends PublishedAgent {
agentId?: number;
type?: string;
openingChatMsg?: string;
openingGuidQuestions?: string[];
guidQuestionDtos?: Array<{
content?: string;
question?: string;
info?: string;
[key: string]: unknown;
}>;
manualComponents?: unknown[];
variables?: unknown[];
}
export interface FetchPublishedSkillsParams {
@@ -101,6 +161,15 @@ export interface FetchPublishedWorkflowsParams {
pageSize: number;
category: string;
kw?: string;
spaceId?: number;
justReturnSpaceData?: boolean;
}
export interface FetchPublishedAgentsParams {
page: number;
pageSize: number;
category: string;
kw?: string;
}
export interface McpConfigItem {
@@ -146,6 +215,60 @@ export interface McpDeployedItem {
[key: string]: unknown;
}
export interface McpParamDetail {
key?: string | number;
name?: string;
displayName?: string;
description?: string;
dataType?: string;
require?: boolean;
required?: boolean;
subArgs?: McpParamDetail[];
children?: McpParamDetail[];
[key: string]: unknown;
}
export interface McpToolDetail {
name?: string;
description?: string;
inputArgs?: McpParamDetail[];
outputArgs?: McpParamDetail[];
inputSchema?: unknown;
[key: string]: unknown;
}
export interface McpResourceDetail extends McpToolDetail {
uri?: string;
mimeType?: string;
}
export interface McpComponentDetail {
name?: string;
icon?: string;
description?: string;
type?: string;
targetId?: number;
toolName?: string;
[key: string]: unknown;
}
export interface McpRuntimeConfig {
serverConfig?: string;
components?: McpComponentDetail[];
tools?: McpToolDetail[];
resources?: McpResourceDetail[];
prompts?: McpToolDetail[];
[key: string]: unknown;
}
export interface McpDeployedDetail extends McpDeployedItem {
mcpConfig?: McpRuntimeConfig;
deployedConfig?: McpRuntimeConfig;
permissions?: string[];
created?: string;
modified?: string;
}
export interface FetchMcpConfigsParams {
page: number;
pageSize: number;
@@ -309,6 +432,23 @@ export async function fetchPublishedWorkflows(
};
}
export async function fetchPublishedWorkflowDetail(
workflowId: number,
): Promise<PublishedWorkflowDetail> {
const baseUrl = await getBaseUrl();
const detail = await marketplaceRequest<PublishedWorkflowDetail>(
`/api/published/workflow/${workflowId}`,
{
method: "GET",
},
);
return {
...detail,
icon: normalizeAssetUrl(baseUrl, detail.icon),
};
}
export async function collectWorkflow(workflowId: number): Promise<void> {
await marketplaceRequest<null>(
`/api/published/workflow/collect/${workflowId}`,
@@ -327,6 +467,61 @@ export async function unCollectWorkflow(workflowId: number): Promise<void> {
);
}
export async function fetchPublishedAgents(
params: FetchPublishedAgentsParams,
): Promise<MarketplacePage<PublishedAgent>> {
const baseUrl = await getBaseUrl();
const page = await marketplaceRequest<MarketplacePage<PublishedAgent>>(
"/api/published/agent/list",
{
method: "POST",
data: {
...params,
targetType: "Agent",
targetSubType: "ChatBot",
},
},
);
return {
...page,
records: (page.records || []).map((item) => ({
...item,
icon: normalizeAssetUrl(baseUrl, item.icon),
})),
};
}
export async function fetchPublishedAgentDetail(
agentId: number,
): Promise<PublishedAgentDetail> {
const baseUrl = await getBaseUrl();
const detail = await marketplaceRequest<PublishedAgentDetail>(
`/api/published/agent/${agentId}`,
{
method: "GET",
},
);
return {
...detail,
icon: normalizeAssetUrl(baseUrl, detail.icon),
targetId: detail.targetId || detail.agentId || agentId,
};
}
export async function collectAgent(agentId: number): Promise<void> {
await marketplaceRequest<null>(`/api/user/agent/collect/${agentId}`, {
method: "POST",
});
}
export async function unCollectAgent(agentId: number): Promise<void> {
await marketplaceRequest<null>(`/api/user/agent/unCollect/${agentId}`, {
method: "POST",
});
}
export async function fetchMcpConfigs(
params: FetchMcpConfigsParams,
): Promise<MarketplacePage<McpConfigItem>> {
@@ -400,6 +595,24 @@ export async function exportMcpServerConfig(mcpId: number): Promise<unknown> {
});
}
export async function fetchDeployedMcpDetail(
mcpId: number,
): Promise<McpDeployedDetail | null> {
const baseUrl = await getBaseUrl();
const detail = await marketplaceRequest<McpDeployedDetail | null>(
`/api/mcp/${mcpId}`,
{
method: "GET",
},
);
if (!detail) return null;
return {
...detail,
icon: normalizeAssetUrl(baseUrl, detail.icon),
};
}
export async function fetchMcpDetail(
uid: string,
): Promise<McpConfigItem | null> {

View File

@@ -1,9 +1,12 @@
.page {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
grid-template-columns: clamp(220px, 22vw, 280px) minmax(0, 1fr);
width: 100%;
max-width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
background: #f5f6fb;
color: #15171a;
}
@@ -22,7 +25,9 @@
flex-direction: column;
min-width: 0;
min-height: 0;
max-width: 100%;
padding: 16px;
overflow: hidden;
background: #ffffff;
border-right: 1px solid #dfe3e8;
}
@@ -111,6 +116,8 @@
grid-template-rows: 56px minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
max-width: 100%;
overflow: hidden;
background: #f5f6fb;
}
@@ -118,13 +125,16 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 0 24px;
overflow: hidden;
background: #ffffff;
border-bottom: 1px solid #dfe3e8;
}
.breadcrumb {
flex: 1;
min-width: 0;
overflow: hidden;
color: #68717b;
@@ -147,7 +157,8 @@
flex-shrink: 0;
align-items: center;
gap: 8px;
max-width: 240px;
min-width: 0;
max-width: min(240px, 36%);
height: 28px;
padding: 0 12px;
overflow: hidden;
@@ -169,6 +180,7 @@
.chatBody {
min-height: 0;
padding: 28px 32px;
overflow-x: hidden;
overflow-y: auto;
}
@@ -235,6 +247,7 @@
display: flex;
gap: 10px;
width: 100%;
min-width: 0;
margin-bottom: 18px;
}
@@ -262,7 +275,8 @@
}
.messageBubble {
max-width: min(720px, 76%);
min-width: 0;
max-width: min(720px, calc(100% - 48px));
padding: 12px 14px;
color: #1a2026;
background: #ffffff;
@@ -271,13 +285,16 @@
}
.userRow .messageBubble {
max-width: min(720px, 82%);
color: #ffffff;
background: #00746f;
border-radius: 12px 12px 2px;
}
.messageText {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
@@ -287,8 +304,10 @@
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
margin-bottom: 10px;
padding: 10px 12px;
overflow-wrap: anywhere;
color: #5d6873;
background: #f1f4f6;
border-left: 3px solid #b6c4c7;
@@ -311,7 +330,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
max-width: min(240px, 100%);
padding: 5px 8px;
overflow: hidden;
color: inherit;
@@ -345,6 +364,7 @@
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 28px;
padding: 5px 8px;
color: #56616c;
@@ -370,7 +390,9 @@
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
margin-top: 8px;
overflow-wrap: anywhere;
color: #76818c;
font-size: 12px;
}
@@ -392,7 +414,9 @@
}
.composer {
min-width: 0;
padding: 14px 32px 12px;
overflow: hidden;
background: #ffffff;
border-top: 1px solid #dfe3e8;
}
@@ -408,7 +432,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
max-width: min(240px, 100%);
height: 28px;
padding: 0 10px;
overflow: hidden;
@@ -424,16 +448,20 @@
.inputRow {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
grid-template-columns: minmax(0, 1fr) minmax(64px, 72px);
gap: 12px;
align-items: end;
min-width: 0;
}
.textarea {
min-width: 0;
border-radius: 10px;
}
.sendButton {
width: 100%;
min-width: 0;
height: 40px;
border-radius: 9px;
background: #00746f;
@@ -444,7 +472,9 @@
.composerToolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
min-height: 30px;
margin-top: 8px;
color: #8c959f;
@@ -452,15 +482,19 @@
}
.toolbarSpacer {
flex: 1;
flex: 1 1 80px;
min-width: 0;
}
.shortcut {
flex: 0 1 auto;
white-space: nowrap;
}
.modelSelect {
width: 150px;
flex: 0 1 150px;
width: clamp(118px, 16vw, 150px);
min-width: 0;
}
.hiddenInput {
@@ -469,12 +503,17 @@
.skillPanel,
.mcpPanel {
width: 320px;
width: min(320px, calc(100vw - 48px));
max-height: 360px;
}
.skillList,
.mcpPanel {
width: min(380px, calc(100vw - 48px));
max-height: 420px;
}
.skillList,
.mcpList {
display: flex;
flex-direction: column;
gap: 6px;
@@ -482,6 +521,47 @@
overflow-y: auto;
}
.mcpList {
max-height: 330px;
}
.mcpPanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.mcpPanelHeader div {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.mcpPanelHeader strong {
color: #202932;
font-size: 14px;
line-height: 1.3;
}
.mcpPanelHeader small {
color: #7a858f;
font-size: 12px;
line-height: 1.3;
}
.mcpPanelState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 160px;
color: #7a858f;
}
.skillItem,
.mcpItem {
display: flex;
@@ -498,6 +578,11 @@
cursor: pointer;
}
.mcpItem {
align-items: flex-start;
min-height: 58px;
}
.skillItem:hover,
.mcpItem:hover,
.skillItem.selected,
@@ -525,8 +610,21 @@
object-fit: cover;
}
.mcpItemIcon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-top: 2px;
color: #00746f;
background: #e5f4f2;
border-radius: 8px;
}
.skillMeta,
.mcpItem span {
.mcpMeta {
display: flex;
flex: 1;
flex-direction: column;
@@ -534,7 +632,7 @@
}
.skillMeta strong,
.mcpItem strong {
.mcpMeta strong {
overflow: hidden;
font-size: 13px;
line-height: 1.3;
@@ -543,7 +641,8 @@
}
.skillMeta small,
.mcpItem small {
.mcpMeta small,
.mcpMeta em {
overflow: hidden;
color: #7a858f;
font-size: 12px;
@@ -552,9 +651,23 @@
white-space: nowrap;
}
.mcpMeta em {
margin-top: 3px;
color: #98a1a9;
font-style: normal;
}
.mcpItemAction {
flex: 0 0 auto;
margin-top: 5px;
color: #00746f;
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 980px) {
.page {
grid-template-columns: 236px minmax(0, 1fr);
grid-template-columns: clamp(200px, 26vw, 236px) minmax(0, 1fr);
}
.threadRail {
@@ -572,6 +685,10 @@
max-width: 86%;
}
.assistantRow .messageBubble {
max-width: calc(100% - 44px);
}
.shortcut {
display: none;
}
@@ -580,13 +697,85 @@
@media (max-width: 760px) {
.page {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.threadRail {
display: none;
display: grid;
grid-template-columns: minmax(130px, 0.8fr) minmax(0, 1.2fr);
gap: 8px;
padding: 10px 12px;
border-right: 0;
border-bottom: 1px solid #dfe3e8;
}
.suggestionList {
grid-template-columns: 1fr;
}
.newButton {
height: 34px;
}
.search {
height: 34px;
margin-top: 0;
}
.recentLabel {
grid-column: 1 / -1;
margin: 2px 0 0;
}
.conversationList {
grid-column: 1 / -1;
flex-direction: row;
min-height: 54px;
overflow-x: auto;
overflow-y: hidden;
}
.conversationItem {
flex: 0 0 min(180px, 52vw);
min-height: 52px;
}
.chatShell {
grid-template-rows: 48px minmax(0, 1fr) auto;
}
.chatHeader,
.chatBody,
.composer {
padding-left: 12px;
padding-right: 12px;
}
.chatHeader {
gap: 10px;
}
.modelBadge {
max-width: 40%;
padding: 0 8px;
}
.messageBubble,
.userRow .messageBubble,
.assistantRow .messageBubble {
max-width: calc(100% - 42px);
}
.inputRow {
grid-template-columns: minmax(0, 1fr) 64px;
gap: 8px;
}
.composerToolbar :global(.ant-btn) {
min-width: 0;
}
.modelSelect {
flex-basis: 128px;
}
}

View File

@@ -50,6 +50,56 @@
white-space: nowrap;
}
.updateNotice {
max-width: 128px;
height: 20px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 6px;
padding: 0 8px;
border: 1px solid transparent;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.updateNotice:disabled {
cursor: default;
}
.updateNotice:not(:disabled):hover {
opacity: 0.84;
transform: translateY(-1px);
}
.updateNoticeAvailable {
background: rgba(82, 196, 26, 0.12);
border-color: rgba(82, 196, 26, 0.25);
color: #3f9f12;
}
.updateNoticeDownloading {
background: rgba(22, 119, 255, 0.1);
border-color: rgba(22, 119, 255, 0.2);
color: #1677ff;
}
.updateNoticeInstall {
background: rgba(250, 173, 20, 0.12);
border-color: rgba(250, 173, 20, 0.25);
color: #d48806;
}
.historyTrigger {
width: 30px;
height: 30px;

View File

@@ -0,0 +1,536 @@
.page {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f7f6f3;
color: #20211f;
}
.header,
.detailHeader {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 28px 32px 0;
min-width: 0;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: #8a8d89;
font-size: 13px;
line-height: 18px;
white-space: nowrap;
}
.breadcrumb span {
color: #b7bab4;
}
.breadcrumb strong {
min-width: 0;
overflow: hidden;
color: #006d68;
font-weight: 800;
text-overflow: ellipsis;
}
.titleRow {
margin-top: 20px;
}
.titleRow h1,
.detailTitleBlock h1 {
margin: 0;
color: #20211f;
font-size: 24px;
line-height: 30px;
font-weight: 800;
}
.titleRow p,
.detailTitleBlock p {
margin: 8px 0 0;
color: #888b85;
font-size: 14px;
line-height: 20px;
}
.content,
.detailContent {
flex: 1;
min-height: 0;
padding: 24px 32px 32px;
overflow: auto;
}
.toolbar,
.tabToolbar {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
margin-bottom: 18px;
flex-wrap: wrap;
}
.search,
.innerSearch {
width: min(420px, 100%);
}
.toolbarCreateButton {
flex: 0 0 auto;
}
.search :global(.ant-input-affix-wrapper),
.search:global(.ant-input-affix-wrapper),
.innerSearch :global(.ant-input-affix-wrapper),
.innerSearch:global(.ant-input-affix-wrapper) {
height: 42px;
border-radius: 8px;
border-color: #dcd9d1;
background: #ffffff;
box-shadow: none;
}
.count {
margin-left: auto;
color: #888b85;
font-size: 14px;
white-space: nowrap;
}
.count span {
margin-right: 6px;
color: #007c78;
font-size: 18px;
font-weight: 800;
}
.listArea {
min-height: 360px;
}
.centerState {
min-height: 320px;
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(280px, 1fr));
gap: 16px;
}
.card {
min-height: 202px;
display: flex;
flex-direction: column;
padding: 18px;
background: #ffffff;
border: 1px solid #e8e4dc;
border-radius: 8px;
box-shadow: 0 2px 7px rgba(32, 33, 31, 0.05);
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.card:hover {
border-color: #bddbd7;
box-shadow: 0 8px 22px rgba(32, 33, 31, 0.08);
transform: translateY(-1px);
}
.cardHeader {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.knowledgeIcon,
.detailIcon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 10px;
background: #e2f0ec;
color: #007c78;
font-weight: 800;
}
.detailIcon {
width: 52px;
height: 52px;
flex: 0 0 52px;
}
.knowledgeIconImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.knowledgeIconText {
font-size: 18px;
}
.cardTitle {
min-width: 0;
}
.cardTitle h3 {
margin: 0;
overflow: hidden;
color: #20211f;
font-size: 16px;
line-height: 22px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardTitle span {
display: block;
margin-top: 4px;
color: #8a8d89;
font-size: 12px;
}
.cardDescription {
display: -webkit-box;
min-height: 44px;
margin: 14px 0 0;
overflow: hidden;
color: #5e625c;
font-size: 13px;
line-height: 22px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.cardMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: auto;
padding-top: 14px;
color: #8a8d89;
font-size: 12px;
}
.cardMeta span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardActions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
.detailHeader {
align-items: flex-start;
}
.detailTitleBlock {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 14px;
}
.detailTitleBlock h1 {
margin-top: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailActions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.statRow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.statCard {
min-width: 0;
padding: 16px;
background: #ffffff;
border: 1px solid #e8e4dc;
border-radius: 8px;
}
.statCard span {
display: block;
color: #888b85;
font-size: 12px;
line-height: 18px;
}
.statCard strong {
display: block;
min-width: 0;
margin-top: 8px;
overflow: hidden;
color: #20211f;
font-size: 18px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tabPane {
min-width: 0;
padding: 4px 0 0;
}
.documentSelect {
width: min(340px, 100%);
}
.hiddenInput {
display: none;
}
.iconUploadRow {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.iconUploadCard {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 20px;
align-items: center;
gap: 12px;
padding: 10px 12px;
color: #3f423d;
text-align: left;
background: #fbfaf8;
border: 1px solid #dedbd2;
border-radius: 8px;
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease;
}
.iconUploadCard:hover {
background: #ffffff;
border-color: #9bc9c2;
}
.iconUploadCard:disabled {
cursor: wait;
opacity: 0.72;
}
.iconPreview {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #007c78;
background: #e2f0ec;
border-radius: 10px;
}
.iconPreview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.iconPreview :global(.anticon) {
font-size: 21px;
}
.iconUploadText {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.iconUploadText strong {
overflow: hidden;
color: #20211f;
font-size: 14px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.iconUploadText span {
overflow: hidden;
color: #888b85;
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mutedText {
color: #888b85;
font-size: 13px;
}
.tableActions {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
}
.linkButton {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
padding: 0;
color: #007c78;
font: inherit;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
}
.linkButton span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.segmentText,
.qaText {
margin: 0;
color: #3f423d;
font-size: 13px;
line-height: 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.segmentText {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.qaText {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.graphPlaceholder {
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
color: #747872;
text-align: center;
background: #ffffff;
border: 1px dashed #d8d4ca;
border-radius: 8px;
}
.graphPlaceholder :global(.anticon) {
color: #007c78;
font-size: 34px;
}
.graphPlaceholder h3 {
margin: 0;
color: #20211f;
font-size: 18px;
}
.graphPlaceholder p {
max-width: 520px;
margin: 0;
font-size: 14px;
line-height: 22px;
}
@media (max-width: 1180px) {
.statRow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 860px) {
.header,
.detailHeader {
padding: 20px 20px 0;
flex-direction: column;
align-items: stretch;
}
.content,
.detailContent {
padding: 20px;
}
.count {
margin-left: 0;
}
.detailActions {
justify-content: flex-start;
}
.statRow {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -120,6 +120,22 @@
border: 1px solid #e8e4dc;
border-radius: 8px;
box-shadow: 0 2px 7px rgba(32, 33, 31, 0.05);
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.card:hover {
border-color: #bddbd7;
box-shadow: 0 8px 22px rgba(32, 33, 31, 0.08);
transform: translateY(-1px);
}
.card:focus-visible {
outline: 2px solid rgba(0, 114, 109, 0.28);
outline-offset: 2px;
}
.cardHeader {
@@ -214,16 +230,36 @@
.metrics {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
color: #5d605b;
font-size: 12px;
white-space: nowrap;
min-width: 0;
}
.rating {
color: #ff9d18;
}
.cardActions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.detailButton:global(.ant-btn) {
height: 32px;
padding: 0 6px;
color: #00726d;
font-weight: 800;
}
.detailButton:global(.ant-btn):hover {
color: #00645f !important;
background: #e6f4f2 !important;
}
.connectButton:global(.ant-btn-primary) {
min-width: 58px;
height: 32px;
@@ -258,6 +294,307 @@
margin-top: 18px;
}
.detailModal :global(.ant-modal-content) {
padding: 0;
border-radius: 10px;
overflow: hidden;
}
.detailModal :global(.ant-modal-close) {
top: 18px;
right: 18px;
}
.detailLoading {
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #777b75;
}
.detailContent {
max-height: min(760px, calc(100vh - 96px));
overflow: auto;
background: #fbfaf8;
}
.detailHero {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 28px 30px 22px;
background: #ffffff;
border-bottom: 1px solid #ebe7df;
}
.detailIcon {
width: 56px;
height: 56px;
}
.detailIcon .mcpIcon {
width: 56px;
height: 56px;
border-radius: 12px;
font-size: 20px;
}
.detailTitleBlock {
min-width: 0;
}
.detailTitleRow {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.detailTitleRow h2 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #20211f;
font-size: 22px;
line-height: 30px;
font-weight: 850;
}
.detailMeta {
margin-top: 6px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
color: #777b75;
font-size: 12px;
line-height: 18px;
}
.detailMeta span {
padding-right: 8px;
border-right: 1px solid #ddd8cf;
}
.detailMeta span:last-child {
border-right: 0;
padding-right: 0;
}
.detailError {
min-height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 28px;
}
.detailInlineLoading {
margin: 18px 30px 0;
display: inline-flex;
align-items: center;
gap: 8px;
color: #777b75;
font-size: 12px;
}
.detailDescription {
margin: 22px 30px 0;
color: #4f534d;
font-size: 14px;
line-height: 1.65;
}
.detailStats {
margin: 20px 30px 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.detailStats div,
.detailInfoGrid div {
min-width: 0;
border: 1px solid #ebe7df;
border-radius: 8px;
background: #ffffff;
}
.detailStats div {
padding: 14px 16px;
}
.detailStats strong {
display: block;
color: #00726d;
font-size: 22px;
line-height: 28px;
font-weight: 850;
}
.detailStats span,
.detailInfoGrid span {
color: #83877f;
font-size: 12px;
line-height: 18px;
}
.detailInfoGrid {
margin: 12px 30px 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.detailInfoGrid div {
padding: 12px 14px;
}
.detailInfoGrid strong {
display: block;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #2c2f2a;
font-size: 13px;
line-height: 18px;
font-weight: 750;
}
.detailSection {
margin: 18px 30px 0;
}
.detailSection:last-child {
padding-bottom: 30px;
}
.detailSectionHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.detailSectionHeader h3 {
margin: 0;
color: #20211f;
font-size: 15px;
line-height: 22px;
font-weight: 850;
}
.detailSectionHeader span {
color: #8a8d89;
font-size: 12px;
line-height: 18px;
}
.emptyLine {
padding: 16px;
border: 1px dashed #ded9d0;
border-radius: 8px;
color: #8a8d89;
background: #ffffff;
font-size: 13px;
}
.capabilityList {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.capabilityItem {
min-width: 0;
padding: 14px;
border: 1px solid #ebe7df;
border-radius: 8px;
background: #ffffff;
}
.capabilityTop {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.capabilityTop strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #20211f;
font-size: 14px;
line-height: 20px;
font-weight: 800;
}
.capabilityTop span {
flex: 0 0 auto;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 7px;
border-radius: 10px;
background: #f0eee8;
color: #6d706a;
font-size: 11px;
line-height: 15px;
}
.capabilityItem p {
margin: 8px 0 0;
color: #62665f;
font-size: 12px;
line-height: 1.55;
}
.paramList {
margin-top: 10px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.param {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 7px;
border-radius: 6px;
background: #eef7f5;
color: #006d68;
font-size: 11px;
line-height: 16px;
font-style: normal;
}
.param em {
color: #748079;
font-size: 10px;
font-style: normal;
}
.param strong {
color: #c06a19;
font-size: 10px;
font-weight: 800;
}
@media (max-width: 980px) {
.header {
height: auto;
@@ -286,4 +623,35 @@
.grid {
grid-template-columns: 1fr;
}
.detailHero {
grid-template-columns: 48px minmax(0, 1fr);
padding: 24px 22px 18px;
}
.detailHero > :global(.ant-btn) {
grid-column: 1 / -1;
justify-self: start;
}
.detailIcon,
.detailIcon .mcpIcon {
width: 48px;
height: 48px;
}
.detailStats,
.detailInfoGrid,
.capabilityList {
grid-template-columns: 1fr;
}
.detailDescription,
.detailStats,
.detailInfoGrid,
.detailSection,
.detailInlineLoading {
margin-left: 22px;
margin-right: 22px;
}
}

View File

@@ -149,6 +149,22 @@
border: 1px solid #e8e4dc;
border-radius: 8px;
box-shadow: 0 2px 7px rgba(32, 33, 31, 0.05);
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.card:hover {
border-color: #bddbd7;
box-shadow: 0 8px 22px rgba(32, 33, 31, 0.08);
transform: translateY(-1px);
}
.card:focus-visible {
outline: 2px solid rgba(0, 114, 109, 0.28);
outline-offset: 2px;
}
.cardHeader {
@@ -208,6 +224,8 @@
.badgeRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
@@ -225,6 +243,20 @@
font-weight: 800;
}
.usedBadge {
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 4px 10px;
border-radius: 13px;
background: #edf4ff;
color: #2968bf;
font-size: 11px;
line-height: 14px;
font-weight: 800;
}
.description {
flex: 1;
margin: 18px 0 18px;
@@ -247,16 +279,36 @@
.metrics {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
color: #8a8d87;
font-size: 12px;
white-space: nowrap;
min-width: 0;
}
.rating {
color: #ff9d18;
}
.cardActions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.detailButton:global(.ant-btn) {
height: 32px;
padding: 0 6px;
color: #00726d;
font-weight: 800;
}
.detailButton:global(.ant-btn):hover {
color: #00645f !important;
background: #e6f4f2 !important;
}
.useButton:global(.ant-btn-primary) {
min-width: 58px;
height: 32px;
@@ -272,7 +324,7 @@
}
.usedButton:global(.ant-btn) {
min-width: 74px;
min-width: 86px;
height: 32px;
border: 0;
border-radius: 6px;
@@ -281,6 +333,325 @@
font-weight: 800;
}
.detailModal :global(.ant-modal-content) {
border-radius: 10px;
overflow: hidden;
}
.detailModal :global(.ant-modal-content) {
padding: 0;
}
.detailModal :global(.ant-modal-close) {
top: 18px;
right: 18px;
}
.detailLoading {
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #777b75;
}
.detailContent {
max-height: min(780px, calc(100vh - 96px));
overflow: auto;
background: #fbfaf8;
}
.detailHero {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 28px 30px 22px;
background: #ffffff;
border-bottom: 1px solid #ebe7df;
}
.detailIcon {
width: 56px;
height: 56px;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
font-weight: 800;
}
.detailIcon .workflowIconImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.detailTitleBlock {
min-width: 0;
}
.detailTitleRow {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.detailTitleRow h2 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #20211f;
font-size: 22px;
line-height: 30px;
font-weight: 850;
}
.detailMeta {
margin-top: 6px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
color: #777b75;
font-size: 12px;
line-height: 18px;
}
.detailMeta span {
padding-right: 8px;
border-right: 1px solid #ddd8cf;
}
.detailMeta span:last-child {
border-right: 0;
padding-right: 0;
}
.detailError {
min-height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 28px;
}
.detailInlineLoading {
margin: 18px 30px 0;
display: inline-flex;
align-items: center;
gap: 8px;
color: #777b75;
font-size: 12px;
}
.detailSection {
margin: 20px 30px 0;
}
.detailSection:last-child {
padding-bottom: 30px;
}
.detailSectionHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.detailSectionHeader h3,
.usageGuide h3 {
margin: 0;
color: #20211f;
font-size: 15px;
line-height: 22px;
font-weight: 850;
}
.detailSectionHeader span {
color: #8a8d89;
font-size: 12px;
line-height: 18px;
}
.detailDescription,
.detailRemark {
margin: 0;
color: #4f534d;
font-size: 14px;
line-height: 1.65;
}
.detailRemark {
margin-top: 10px;
padding: 12px 14px;
border-radius: 8px;
background: #ffffff;
border: 1px solid #ebe7df;
color: #62665f;
}
.detailStats {
margin: 20px 30px 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.detailStats div {
min-width: 0;
padding: 14px 16px;
border: 1px solid #ebe7df;
border-radius: 8px;
background: #ffffff;
}
.detailStats strong {
display: block;
color: #00726d;
font-size: 22px;
line-height: 28px;
font-weight: 850;
}
.detailStats span {
color: #83877f;
font-size: 12px;
line-height: 18px;
}
.emptyLine {
padding: 16px;
border: 1px dashed #ded9d0;
border-radius: 8px;
color: #8a8d89;
background: #ffffff;
font-size: 13px;
}
.argTable {
overflow: auto;
border: 1px solid #ebe7df;
border-radius: 8px;
background: #ffffff;
}
.argTableHead,
.argRow {
display: grid;
grid-template-columns: minmax(140px, 1fr) 110px minmax(220px, 1.6fr) 130px;
gap: 12px;
align-items: center;
min-width: 720px;
}
.argTableHead {
padding: 11px 14px;
border-bottom: 1px solid #ebe7df;
background: #f4f2ed;
color: #777b75;
font-size: 12px;
font-weight: 800;
}
.argRow {
padding: 12px 14px;
border-bottom: 1px solid #f0ece4;
color: #4f534d;
font-size: 12px;
}
.argRow:last-child {
border-bottom: 0;
}
.argName {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.argName strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #20211f;
font-size: 13px;
font-weight: 800;
}
.argName em {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 8px;
background: #fff4e5;
color: #c06a19;
font-size: 10px;
line-height: 14px;
font-style: normal;
font-weight: 800;
}
.argRow p,
.argRow code {
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.argRow code {
color: #6f756d;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
font-size: 12px;
}
.usageGuide {
margin: 20px 30px 30px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
border-radius: 8px;
background: #eef7f5;
border: 1px solid #d7ebe7;
}
.usageGuide p {
margin: 6px 0 0;
color: #4f5e59;
font-size: 13px;
line-height: 1.6;
}
.usageGuide span {
flex: 0 0 auto;
padding: 4px 9px;
border-radius: 11px;
background: #fff4e5;
color: #a85f18;
font-size: 11px;
line-height: 15px;
font-weight: 800;
}
@media (max-width: 980px) {
.header {
padding: 22px 22px 0;
@@ -301,4 +672,35 @@
.grid {
grid-template-columns: 1fr;
}
.detailHero {
grid-template-columns: 48px minmax(0, 1fr);
padding: 24px 22px 18px;
}
.detailHero > :global(.ant-btn) {
grid-column: 1 / -1;
justify-self: start;
}
.detailIcon {
width: 48px;
height: 48px;
}
.detailStats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detailSection,
.detailStats,
.detailInlineLoading,
.usageGuide {
margin-left: 22px;
margin-right: 22px;
}
.usageGuide {
flex-direction: column;
}
}