feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge

This commit is contained in:
yepwait
2026-08-26 15:02:24 +08:00
committed by ddCat
parent 6141bd688c
commit 86fc3fda2f
82 changed files with 11405 additions and 248 deletions

View File

@@ -1,6 +1,6 @@
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Switch, Tooltip } from "antd";
import { ChevronDown, ChevronUp, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { ChevronDown, ChevronUp, MonitorUp, Plus, RefreshCw, Trash2, Workflow } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { WorkspaceState } from "@/components/layout/workspace-state";
@@ -23,7 +23,13 @@ import { ChannelModelSettings } from "./channel-video-pricing";
import { useUserStore } from "@/stores/use-user-store";
type UserChannelConnection = "openai" | "gemini";
export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) {
type ChannelSettingsPaneProps = {
onOpenModels: () => void;
onOpenRunningHub?: () => void;
onOpenComfyUI?: () => void;
};
export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComfyUI }: ChannelSettingsPaneProps) {
const { message } = App.useApp();
const config = useConfigStore((state) => state.config);
const replaceConfig = useConfigStore((state) => state.replaceConfig);
@@ -33,6 +39,8 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
const desktopLocalChannelsEnabled = useUserStore((state) => state.features.desktopLocalChannelsEnabled);
const desktopLocalChannelHostname = typeof window === "undefined" ? "" : window.location.hostname;
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
const runningHubReady = Boolean(config.runningHub.enabled && config.runningHub.baseUrl.trim() && config.runningHub.apiKey.trim() && config.runningHub.workflowId.trim());
const comfyBridgeReady = Boolean(config.comfyBridge.enabled && config.comfyBridge.bridgeId.trim() && config.comfyBridge.workflowId.trim());
useEffect(() => {
void fetchPluginProviderCatalog("user.custom-channel").then(setProviderCatalog).catch(() => setProviderCatalog([]));
@@ -156,7 +164,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
const skipped = userChannels.filter((channel) => channelConnectionError(channel));
if (!runnable.length) {
const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";");
message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key");
message.error(detail || "没有可拉取的个人模型渠道,请先填写有效 Base URL 和 API Key");
return;
}
setChannelLoading("all", true);
@@ -210,14 +218,38 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
<Form layout="vertical" requiredMark={false}>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>自定义渠道</h2>
<p>渠道只保存连接类型;拉取模型后,请在“模型与能力”中配置协议。<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={onOpenModels}>打开模型选择</Button></p>
<h2>个人渠道</h2>
<p>管理个人模型服务和工作流渠道。普通渠道只保存连接类型;模型能力在“模型与能力”中配置。<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={onOpenModels}>打开模型选择</Button></p>
</div>
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" icon={<RefreshCw className="size-4" />} loading={loadingChannelIds.includes("all")} disabled={loadingChannelIds.some((id) => id !== "all")} onClick={() => void refreshAllModels()}>拉取全部</Button>
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>新增渠道</Button>
</div>
</div>
<section className="settings-section mb-3">
<div className="mb-3">
<h3 className="text-sm font-semibold">个人工作流渠道</h3>
<p className="mt-1 text-xs text-foreground/55">RunningHub 和 ComfyUI 使用各自的工作流参数与执行通道,配置入口统一放在个人渠道中。</p>
</div>
<div className="grid gap-2 lg:grid-cols-2">
<WorkflowChannelEntry
icon={<Workflow className="size-4" />}
title="RunningHub"
description="云端工作流和 RunningHub App"
status={runningHubReady ? `${config.runningHub.workflows.length} 个工作流已配置` : config.runningHub.enabled ? "待完成连接和工作流配置" : "未启用"}
ready={runningHubReady}
onOpen={onOpenRunningHub}
/>
<WorkflowChannelEntry
icon={<MonitorUp className="size-4" />}
title="ComfyUI"
description="通过 Bridge 连接本机或远程 ComfyUI"
status={comfyBridgeReady ? `${config.comfyBridge.workflows.length} 个工作流已配置` : config.comfyBridge.enabled ? "待选择 Bridge 和工作流" : "未启用"}
ready={comfyBridgeReady}
onOpen={onOpenComfyUI}
/>
</div>
</section>
{userChannels.length ? (
<div className="settings-channel-list space-y-2">
{userChannels.map((channel) => {
@@ -237,7 +269,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
<Tooltip title={collapsed ? "展开渠道配置" : "收起渠道配置"}>
<Button className="size-10 p-0 sm:size-8" size="small" type="text" aria-label={`${collapsed ? "展开" : "收起"}渠道配置 ${channel.name || "未命名渠道"}`} aria-expanded={!collapsed} aria-controls={`channel-${channel.id}-details`} icon={collapsed ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />} onClick={() => toggleChannelCollapsed(channel.id)} />
</Tooltip>
<Popconfirm title="删除自定义渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
<Popconfirm title="删除个人模型渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
<Tooltip title="删除渠道"><Button className="size-10 p-0 sm:size-8" aria-label={`删除渠道 ${channel.name || "未命名渠道"}`} size="small" type="text" danger disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")} icon={<Trash2 className="size-3.5" />} /></Tooltip>
</Popconfirm>
</div>
@@ -270,11 +302,27 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
);
})}
</div>
) : <WorkspaceState icon="settings" compact title="当前没有自定义渠道" description="管理员配置的系统渠道会出现在模型选择中;也可以添加自己的模型服务。" action={<Button icon={<Plus className="size-4" />} onClick={addChannel}>新增自定义渠道</Button>} />}
) : <WorkspaceState icon="settings" compact title="当前没有个人模型渠道" description="管理员配置的系统渠道会出现在模型选择中;也可以添加自己的模型服务。" action={<Button icon={<Plus className="size-4" />} onClick={addChannel}>新增个人模型渠道</Button>} />}
</Form>
);
}
function WorkflowChannelEntry({ icon, title, description, status, ready, onOpen }: { icon: ReactNode; title: string; description: string; status: string; ready: boolean; onOpen?: () => void }) {
return (
<div className="settings-channel flex min-w-0 items-center justify-between gap-3 p-3">
<div className="flex min-w-0 items-start gap-2.5">
<span className="mt-0.5 shrink-0 text-[var(--workspace-accent)]" aria-hidden="true">{icon}</span>
<div className="min-w-0">
<h4 className="text-sm font-semibold">{title}</h4>
<p className="mt-0.5 truncate text-xs text-foreground/55">{description}</p>
<span className={`settings-channel-status mt-1.5 ${ready ? "is-ready" : "is-warning"}`}><i aria-hidden="true" />{status}</span>
</div>
</div>
<Button size="small" onClick={onOpen} disabled={!onOpen}>配置</Button>
</div>
);
}
export function userLocalChannelFormOwner(desktopLocalChannelsEnabled: boolean, hostname: string, requestedAllowLocalChannel?: boolean) {
const state = desktopLocalChannelFormState(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel);
return { ...state, payloadValue: desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel) };

View File

@@ -0,0 +1,501 @@
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Switch } from "antd";
import { Copy, Download, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { WorkflowGraphEditor } from "@/components/workflow-graph-editor";
import { WorkflowTestWorkbench } from "@/components/workflow-test-workbench";
import { createComfyBridge, listComfyBridges, revokeComfyBridge, type ComfyBridgeSummary } from "@/services/api/comfy-bridge";
import { normalizeWorkflowFieldMappings, useConfigStore, type ComfyBridgeConfig, type ComfyBridgeWorkflow, type WorkflowFieldMapping, type WorkflowGraphPreview } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
type DiscoveredWorkflow = { workflowId: string; title?: string; fields?: WorkflowFieldMapping[]; workflowJson?: Record<string, unknown>; workflowGraph?: WorkflowGraphPreview; format?: "api" | "ui" };
type BridgePlatform = "windows" | "linux";
type LinuxArchitecture = "amd64" | "arm64";
export function ComfyUIBridgeSettingsPane() {
const { message } = App.useApp();
const userId = useUserStore((state) => state.user?.id);
const config = useConfigStore((state) => state.config.comfyBridge);
const updateConfig = useConfigStore((state) => state.updateConfig);
const [bridges, setBridges] = useState<ComfyBridgeSummary[]>([]);
const [bridgeName, setBridgeName] = useState("我的 ComfyUI");
const [bridgeToken, setBridgeToken] = useState("");
const [loading, setLoading] = useState(false);
const [registering, setRegistering] = useState(false);
const [revoking, setRevoking] = useState(false);
const [bridgePlatform, setBridgePlatform] = useState<BridgePlatform>(detectBridgePlatform);
const [linuxArchitecture, setLinuxArchitecture] = useState<LinuxArchitecture>(detectLinuxArchitecture);
const [workflowJsonText, setWorkflowJsonText] = useState("");
const [workspaceMode, setWorkspaceMode] = useState<"fields" | "test">("fields");
const selectedBridge = bridges.find((item) => item.id === config.bridgeId);
const selectedWorkflow = config.workflows.find((item) => item.workflowId === config.workflowId);
const selectedCapability = selectedWorkflow?.capability || config.capability;
const discovered = useMemo(() => discoveredWorkflows(selectedBridge, config.capability), [config.capability, selectedBridge]);
const draftWorkflowJson = useMemo(() => parseWorkflowJson(workflowJsonText) || selectedWorkflow?.workflowJson, [selectedWorkflow?.workflowJson, workflowJsonText]);
const update = (patch: Partial<ComfyBridgeConfig>) => updateConfig("comfyBridge", { ...config, ...patch });
const bridgeServer = typeof window === "undefined" ? "" : window.location.origin;
const hasFreshBridgeToken = Boolean(bridgeToken);
const commandToken = bridgeToken || "注册 Bridge 后生成一次性 Token";
const bridgeDownloadURL = `${bridgeServer}/OpenAICanvas-ComfyBridge.exe`;
const linuxBridgeDownloadURL = `${bridgeServer}/OpenAICanvas-ComfyBridge-linux-${linuxArchitecture}`;
const agentStartCommand =
bridgePlatform === "linux"
? linuxStartCommand(linuxBridgeDownloadURL, bridgeServer, commandToken, config.comfyUrl, config.workflowDir, linuxArchitecture)
: windowsStartCommand(bridgeDownloadURL, bridgeServer, commandToken, config.comfyUrl, config.workflowDir);
const comfyUrlError = comfyURLValidationError(config.comfyUrl);
const reload = async () => {
if (!userId) return;
setLoading(true);
try {
const items = await listComfyBridges();
setBridges(items);
const currentBridgeExists = Boolean(config.bridgeId && items.some((item) => item.id === config.bridgeId));
// Bridge 重连或页面刷新后,只有一个可用设备时自动恢复选择;工作流配置继续保留。
if (!currentBridgeExists && !config.bridgeId && items.length === 1) update({ bridgeId: items[0].id });
} catch (error) {
message.error(error instanceof Error ? `读取 Bridge 失败:${error.message}` : "读取 Bridge 失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!userId) {
setBridges([]);
return;
}
void reload();
// 账号切换时重新读取设备;配置变更由控件直接维护。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
useEffect(() => {
setWorkflowJsonText(selectedWorkflow?.workflowJson ? JSON.stringify(selectedWorkflow.workflowJson, null, 2) : "");
}, [selectedWorkflow?.workflowId, selectedWorkflow?.workflowJson]);
useEffect(() => {
if (!selectedWorkflow) return;
const found = discovered.find((item) => item.workflowId === selectedWorkflow.workflowId);
const needsFields = !selectedWorkflow.fields?.length && Boolean(found?.fields?.length);
const needsJson = !selectedWorkflow.workflowJson && Boolean(found?.workflowJson);
const needsGraph = !selectedWorkflow.workflowGraph && Boolean(found?.workflowGraph);
if (!needsFields && !needsJson && !needsGraph) return;
updateConfig("comfyBridge", {
...config,
workflows: config.workflows.map((item) =>
item.workflowId === selectedWorkflow.workflowId
? { ...item, ...(found?.fields?.length ? { fields: found.fields } : {}), ...(found?.workflowJson ? { workflowJson: found.workflowJson } : {}), ...(found?.workflowGraph ? { workflowGraph: found.workflowGraph } : {}) }
: item,
),
});
}, [config, discovered, selectedWorkflow, updateConfig]);
const register = async () => {
if (!bridgeName.trim()) return message.warning("请填写 Bridge 名称");
setRegistering(true);
try {
const result = await createComfyBridge(bridgeName.trim());
setBridges((items) => [result.bridge, ...items]);
setBridgeToken(result.token);
update({ bridgeId: result.bridge.id });
message.success("Bridge 已注册,请立即复制启动命令并运行 Bridge");
} catch (error) {
message.error(error instanceof Error ? `注册 Bridge 失败:${error.message}` : "注册 Bridge 失败");
} finally {
setRegistering(false);
}
};
const selectWorkflow = (workflowId: string) => {
const normalizedWorkflowId = workflowId.trim();
if (!normalizedWorkflowId) {
update({ workflowId: "" });
return;
}
const found = discovered.find((item) => item.workflowId === normalizedWorkflowId);
const saved = config.workflows.find((item) => item.workflowId === normalizedWorkflowId);
const workflows = saved
? config.workflows.map((item) =>
item.workflowId === normalizedWorkflowId
? {
...item,
...(found?.fields?.length && !item.fields?.length ? { fields: found.fields } : {}),
...(found?.workflowJson && !item.workflowJson ? { workflowJson: found.workflowJson } : {}),
...(found?.workflowGraph && !item.workflowGraph ? { workflowGraph: found.workflowGraph } : {}),
}
: item,
)
: [
...config.workflows,
{
workflowId: normalizedWorkflowId,
title: found?.title || normalizedWorkflowId,
capability: config.capability,
fields: found?.fields || [],
...(found?.workflowJson ? { workflowJson: found.workflowJson } : {}),
...(found?.workflowGraph ? { workflowGraph: found.workflowGraph } : {}),
},
];
update({ workflowId: normalizedWorkflowId, capability: saved?.capability || config.capability, workflows });
};
const revokeSelectedBridge = async () => {
if (!selectedBridge) return;
setRevoking(true);
try {
await revokeComfyBridge(selectedBridge.id);
setBridges((items) => items.filter((item) => item.id !== selectedBridge.id));
update({ bridgeId: "", workflowId: "" });
setBridgeToken("");
message.success("Bridge 已撤销,该进程将无法继续领取任务");
} catch (error) {
message.error(error instanceof Error ? `撤销 Bridge 失败:${error.message}` : "撤销 Bridge 失败");
} finally {
setRevoking(false);
}
};
const updateWorkflowCapability = (capability: ComfyBridgeConfig["capability"]) => {
const workflowId = config.workflowId.trim();
const workflows = workflowId ? config.workflows.map((item) => (item.workflowId === workflowId ? { ...item, capability, fields: normalizeWorkflowFieldMappings(item.fields, capability) } : item)) : config.workflows;
update({ capability, workflows });
};
const saveWorkflow = () => {
const workflowId = config.workflowId.trim();
if (!workflowId) return message.warning("请先选择或填写工作流文件名");
try {
const workflowJson = workflowJsonText.trim() ? (JSON.parse(workflowJsonText) as Record<string, unknown>) : undefined;
if (workflowJson && (typeof workflowJson !== "object" || Array.isArray(workflowJson))) throw new Error("工作流 JSON 必须是对象");
const current = config.workflows.find((item) => item.workflowId === workflowId);
const found = discovered.find((item) => item.workflowId === workflowId);
const next: ComfyBridgeWorkflow = {
workflowId,
title: current?.title || found?.title || workflowId,
capability: selectedCapability,
fields: normalizeWorkflowFieldMappings(current?.fields || found?.fields || [], selectedCapability),
...(workflowJson ? { workflowJson } : {}),
...(current?.workflowGraph || found?.workflowGraph ? { workflowGraph: current?.workflowGraph || found?.workflowGraph } : {}),
};
update({ workflows: [...config.workflows.filter((item) => item.workflowId !== workflowId), next] });
message.success("ComfyUI 工作流配置已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "工作流配置格式无效");
}
};
const updateWorkflowFields = (fields: WorkflowFieldMapping[]) => {
if (!selectedWorkflow) return;
update({ workflows: config.workflows.map((item) => (item.workflowId === selectedWorkflow.workflowId ? { ...item, fields } : item)) });
};
return (
<Form layout="vertical" requiredMark={false}>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>ComfyUI Bridge</h2>
<p>Bridge 主动连接云端,并访问运行 Bridge 的机器能够连接到的任意 ComfyUI 服务;每个账号注册并运行自己的 Bridge,其他账号不能调用你的设备。</p>
</div>
<Switch checked={config.enabled} checkedChildren="启用" unCheckedChildren="停用" onChange={(enabled) => update({ enabled })} />
</div>
{!userId ? (
<p className="text-sm text-foreground/60">登录后可注册和管理 Bridge。</p>
) : (
<>
<section className="settings-section">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Bridge 设备</h3>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void reload()}>
刷新
</Button>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<Input value={bridgeName} maxLength={80} placeholder="例如:办公室 ComfyUI" onChange={(event) => setBridgeName(event.target.value)} />
<Button type="primary" icon={<Plus className="size-4" />} loading={registering} onClick={() => void register()}>
注册 Bridge
</Button>
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-2">
<Form.Item label="ComfyUI 服务地址" className="mb-0" validateStatus={comfyUrlError ? "error" : undefined} help={comfyUrlError || "可填写本机、局域网或公网地址;必须能从运行 Bridge 的电脑访问。"}>
<Input value={config.comfyUrl} placeholder="http://127.0.0.1:8188" onChange={(event) => update({ comfyUrl: event.target.value })} onBlur={(event) => update({ comfyUrl: normalizeComfyURL(event.target.value) })} />
</Form.Item>
<Form.Item label="工作流目录" className="mb-0" extra="Bridge 从此目录发现 ComfyUI API JSON;也可在下方直接粘贴 JSON。">
<Input
value={config.workflowDir}
placeholder={bridgePlatform === "linux" ? "/opt/ComfyUI/user/default/workflows" : "D:\ComfyUI\workflows"}
onChange={(event) => update({ workflowDir: event.target.value })}
onBlur={(event) => update({ workflowDir: event.target.value.trim() })}
/>
</Form.Item>
</div>
<div className="mt-4 rounded-lg border border-border/60 bg-foreground/[0.03] p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h4 className="text-sm font-semibold">安装 Bridge</h4>
<p className="mt-1 text-xs text-foreground/60">在能访问 ComfyUI 的机器上运行;Windows 使用独立程序,Linux 使用对应架构的原生程序,不需要安装 Node.js、npm 或项目源码。</p>
</div>
<Segmented
size="small"
value={bridgePlatform}
options={[
{ label: "Windows", value: "windows" },
{ label: "Linux", value: "linux" },
]}
onChange={(value) => setBridgePlatform(value as BridgePlatform)}
/>
</div>
{bridgePlatform === "linux" ? (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-foreground/65">
<span>Linux 架构:</span>
<Segmented
size="small"
value={linuxArchitecture}
options={[
{ label: "x64", value: "amd64" },
{ label: "ARM64", value: "arm64" },
]}
onChange={(value) => setLinuxArchitecture(value as LinuxArchitecture)}
/>
<span>云服务器通常选择 x64。</span>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="small"
icon={<Download className="size-3.5" />}
href={bridgePlatform === "linux" ? linuxBridgeDownloadURL : bridgeDownloadURL}
download={bridgePlatform === "linux" ? `OpenAICanvas-ComfyBridge-linux-${linuxArchitecture}` : "OpenAICanvas-ComfyBridge.exe"}
>
下载 {bridgePlatform === "linux" ? "Linux Bridge" : "Windows Bridge"}
</Button>
</div>
<div className="mt-3 grid gap-2 text-xs">
<div className="rounded-md bg-black/20 px-3 py-2">
<span className="mr-2 text-foreground/55">安装并启动({bridgePlatform === "linux" ? "Linux Shell" : "PowerShell"}):</span>
<pre className="mt-2 whitespace-pre-wrap break-all font-mono text-xs leading-5">{hasFreshBridgeToken ? agentStartCommand : "请先点击“注册 Bridge”。一次性 Token 只在注册成功后显示;旧 Token 无法从页面找回。"}</pre>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="small"
disabled={Boolean(comfyUrlError) || !hasFreshBridgeToken}
icon={<Copy className="size-3.5" />}
onClick={() =>
void navigator.clipboard
.writeText(agentStartCommand)
.then(() => message.success("安装并启动命令已复制"))
.catch(() => message.warning("复制失败,请手动复制"))
}
>
复制安装并启动命令
</Button>
</div>
</div>
<p className="mt-2 text-xs text-foreground/55">每位用户都要登录自己的账号、注册自己的 Bridge,再在能访问 ComfyUI 的机器执行命令。不要把 Bridge Token 发给其他人;修改地址或工作流目录后需复制新命令并重启。</p>
</div>
{bridgeToken ? (
<div className="mt-3 space-y-2">
<p className="text-xs text-foreground/60">令牌只显示一次,建议复制启动命令后关闭页面。令牌不会写入下载链接。</p>
<Input.TextArea value={bridgeToken} readOnly autoSize={{ minRows: 2, maxRows: 4 }} />
<Button
size="small"
icon={<Copy className="size-3.5" />}
onClick={() =>
void navigator.clipboard
.writeText(bridgeToken)
.then(() => message.success("令牌已复制"))
.catch(() => message.warning("复制失败,请手动复制"))
}
>
复制令牌
</Button>
</div>
) : null}
<Form.Item label="当前 Bridge" className="mb-0 mt-3">
<Select
className="w-full"
value={config.bridgeId || undefined}
placeholder="选择已注册 Bridge"
options={bridges.map((item) => ({ label: `${item.name} · ${item.online ? "在线" : "离线"}`, value: item.id }))}
onChange={(bridgeId) => update({ bridgeId })}
/>
</Form.Item>
{selectedBridge ? (
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-foreground/60">
<span>
Bridge ID:{selectedBridge.id} · {selectedBridge.online ? "在线" : "离线"} · 已发现 {discovered.length} 个工作流
</span>
<Popconfirm
title="撤销这个 Bridge?"
description="撤销后令牌立即失效,本机 Bridge 将无法继续领取任务。"
okText="撤销"
cancelText="取消"
okButtonProps={{ danger: true, loading: revoking }}
onConfirm={() => revokeSelectedBridge()}
>
<Button size="small" type="text" danger loading={revoking} icon={<Trash2 className="size-3.5" />}>
撤销
</Button>
</Popconfirm>
</div>
) : null}
</section>
<section className="settings-section mt-4">
<h3 className="mb-3 text-sm font-semibold">工作流配置</h3>
<div className="grid gap-3 lg:grid-cols-12">
<Form.Item label="Bridge 发现的工作流" className="mb-0 lg:col-span-6">
<Select
showSearch
className="w-full"
value={config.workflowId || undefined}
placeholder={selectedBridge?.online ? "选择 workflows 目录中的 JSON" : "Bridge 在线后自动显示"}
options={discovered.map((item) => ({ label: item.title || item.workflowId, value: item.workflowId }))}
onChange={selectWorkflow}
/>
</Form.Item>
<Form.Item label="工作流用途" className="mb-0 lg:col-span-3">
<Select
value={selectedCapability}
options={[
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
]}
onChange={(capability) => updateWorkflowCapability(capability as ComfyBridgeConfig["capability"])}
/>
</Form.Item>
<Form.Item label="工作流文件名" className="mb-0 lg:col-span-3">
<Input value={config.workflowId} placeholder="workflow.json" onChange={(event) => update({ workflowId: event.target.value })} onBlur={(event) => selectWorkflow(event.target.value.trim())} />
</Form.Item>
<div className="workflow-workspace-tabs lg:col-span-12">
<Segmented
block
value={workspaceMode}
options={[
{ label: "字段配置", value: "fields" },
{ label: "测试画布", value: "test" },
]}
onChange={(value) => setWorkspaceMode(value as "fields" | "test")}
/>
</div>
{workspaceMode === "fields" ? (
<>
<div className="lg:col-span-12">
<WorkflowGraphEditor
workflowJson={draftWorkflowJson}
workflowGraph={selectedWorkflow?.workflowGraph}
fields={selectedWorkflow?.fields || []}
disabled={!selectedWorkflow}
onChange={updateWorkflowFields}
emptyDescription="尚未读取到工作流拓扑;请刷新 Bridge 后重新选择工作流"
/>
</div>
<details className="workflow-json-details lg:col-span-12" open={!draftWorkflowJson}>
<summary>查看或粘贴 ComfyUI API JSON</summary>
<Form.Item className="mb-0 mt-3" extra="留空时 Bridge 从其 workflows 目录按文件名读取;粘贴后使用同一套工作流拓扑和字段编辑器。">
<Input.TextArea rows={10} spellCheck={false} value={workflowJsonText} onChange={(event) => setWorkflowJsonText(event.target.value)} />
</Form.Item>
</details>
<div className="flex justify-end gap-2 lg:col-span-12">
<Popconfirm title="删除当前工作流配置?" okText="删除" cancelText="取消" onConfirm={() => update({ workflowId: "", workflows: config.workflows.filter((item) => item.workflowId !== config.workflowId) })}>
<Button danger disabled={!selectedWorkflow} icon={<Trash2 className="size-4" />}>
删除
</Button>
</Popconfirm>
<Button type="primary" onClick={saveWorkflow}>
保存工作流
</Button>
</div>
</>
) : (
<div className="lg:col-span-12">
<WorkflowTestWorkbench
key={`comfyui:${selectedWorkflow?.workflowId || "empty"}`}
provider="comfyui"
workflowId={selectedWorkflow?.workflowId || ""}
title={selectedWorkflow?.title || config.workflowId}
capability={selectedCapability}
fields={selectedWorkflow?.fields || []}
disabled={!selectedWorkflow || !config.bridgeId || !selectedBridge?.online}
disabledReason={!selectedWorkflow ? "请先选择并保存一个 ComfyUI 工作流" : !config.bridgeId ? "请先选择 Bridge 设备" : "当前 Bridge 离线,请在可访问 ComfyUI 的电脑上启动 Bridge"}
/>
</div>
)}
</div>
</section>
</>
)}
</Form>
);
}
function discoveredWorkflows(bridge: ComfyBridgeSummary | undefined, capability: ComfyBridgeConfig["capability"]): DiscoveredWorkflow[] {
const value = bridge?.capabilities ? bridge.capabilities["workflows"] : undefined;
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (typeof item === "string" && item.trim()) return [{ workflowId: item.trim(), title: item.trim() }];
if (!item || typeof item !== "object") return [];
const raw = item as Record<string, unknown>;
const workflowId = String(raw.workflowId || raw.id || raw.fileName || "").trim();
const fields = normalizeWorkflowFieldMappings(raw.fields, capability);
const workflowJson = raw.workflowJson && typeof raw.workflowJson === "object" && !Array.isArray(raw.workflowJson) ? (raw.workflowJson as Record<string, unknown>) : undefined;
const workflowGraph = raw.workflowGraph && typeof raw.workflowGraph === "object" && !Array.isArray(raw.workflowGraph) ? (raw.workflowGraph as WorkflowGraphPreview) : undefined;
return workflowId ? [{ workflowId, title: String(raw.title || raw.name || workflowId), fields, workflowJson, workflowGraph, format: raw.format === "api" ? "api" : "ui" }] : [];
});
}
function normalizeComfyURL(value: string) {
const normalized = value.trim().replace(/\/+$/, "");
return normalized || "http://127.0.0.1:8188";
}
function comfyURLValidationError(value: string) {
try {
const parsed = new URL(value.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "ComfyUI 地址只支持 http:// 或 https://";
if (!parsed.hostname || parsed.username || parsed.password) return "ComfyUI 地址格式无效,不能在 URL 中携带账号密码";
return "";
} catch {
return "请输入完整的 ComfyUI 地址,例如 http://192.168.1.20:8188";
}
}
function powerShellArgument(value: string) {
return `'${value.replace(/'/g, "''")}'`;
}
function shellArgument(value: string) {
return `'${value.replace(/'/g, "'\\''")}'`;
}
function windowsStartCommand(downloadURL: string, server: string, token: string, comfyURL: string, workflowDir: string) {
return `$bridgeDir = Join-Path $env:LOCALAPPDATA 'OpenAICanvas'\nNew-Item -ItemType Directory -Force -Path $bridgeDir | Out-Null\n$bridgeFile = Join-Path $bridgeDir 'OpenAICanvas-ComfyBridge.exe'\nInvoke-WebRequest ${powerShellArgument(downloadURL)} -OutFile $bridgeFile\n$bridgeStream = [System.IO.File]::OpenRead($bridgeFile)\ntry { $bridgeHeader0 = $bridgeStream.ReadByte(); $bridgeHeader1 = $bridgeStream.ReadByte() } finally { $bridgeStream.Dispose() }\nif ($bridgeHeader0 -ne 0x4D -or $bridgeHeader1 -ne 0x5A) { throw 'Bridge 下载失败:服务器未返回 Windows 可执行程序,请联系管理员重新部署 Bridge' }\n& $bridgeFile --server ${powerShellArgument(server)} --token ${powerShellArgument(token)} --comfy ${powerShellArgument(comfyURL)} --workflow-dir ${powerShellArgument(workflowDir)}`;
}
function linuxStartCommand(downloadURL: string, server: string, token: string, comfyURL: string, workflowDir: string, architecture: LinuxArchitecture) {
const bridgeDir = "./openai-canvas-bridge";
const bridgeFile = `${bridgeDir}/OpenAICanvas-ComfyBridge-linux-${architecture}`;
return `bridge_dir=${shellArgument(bridgeDir)}\nmkdir -p "$bridge_dir"\ncurl --fail --location ${shellArgument(downloadURL)} --output ${shellArgument(bridgeFile)}\nchmod +x ${shellArgument(bridgeFile)}\n${shellArgument(bridgeFile)} --server ${shellArgument(server)} --token ${shellArgument(token)} --comfy ${shellArgument(comfyURL)} --workflow-dir ${shellArgument(workflowDir)}`;
}
function detectBridgePlatform(): BridgePlatform {
if (typeof navigator !== "undefined" && /linux/i.test(`${navigator.platform} ${navigator.userAgent}`)) return "linux";
return "windows";
}
function detectLinuxArchitecture(): LinuxArchitecture {
if (typeof navigator !== "undefined" && /arm64|aarch64/i.test(`${navigator.platform} ${navigator.userAgent}`)) return "arm64";
return "amd64";
}
function parseWorkflowJson(value: string) {
if (!value.trim()) return undefined;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}

View File

@@ -1,5 +1,5 @@
import { App, Button, Form, Input, InputNumber, Select } from "antd";
import { ArrowLeft, Boxes, Bug, Cloud, MessageSquareText, RadioTower, SlidersHorizontal, SquareTerminal } from "lucide-react";
import { ArrowLeft, Boxes, Bug, Cloud, MessageSquareText, MonitorUp, RadioTower, SlidersHorizontal, SquareTerminal, Workflow } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
@@ -10,16 +10,20 @@ import { defaultConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-
import { useUserStore } from "@/stores/use-user-store";
import { ChannelSettingsPane, channelValidationError, focusInvalidChannelField, isChannelReady } from "./channel-settings-pane";
export { UserLocalChannelFields, UserLocalChannelSwitch, userLocalChannelChangePatch, userLocalChannelFormOwner } from "./channel-settings-pane";
import { ComfyUIBridgeSettingsPane } from "./comfyui-bridge-settings-pane";
import { ModelDefaultGrid } from "./model-default-grid";
import { LocalCliSettings } from "./local-cli-settings";
import { PromptPreferencesPane } from "./prompt-preferences-pane";
import DiagnosticsPanel from "./diagnostics-panel";
import { RunningHubSettingsPane } from "./runninghub-settings-pane";
type ConfigSectionKey = "local-cli" | "channels" | "models" | "preferences" | "prompts" | "storage" | "diagnostics";
type ConfigSectionKey = "local-cli" | "channels" | "models" | "runninghub" | "comfyui" | "preferences" | "prompts" | "storage" | "diagnostics";
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
{ key: "local-cli", label: "本机工具", description: "连接 Runtime 与官方 CLI", icon: <SquareTerminal className="size-4" /> },
{ key: "channels", label: "自定义渠道", description: "连接你自己的模型服务", icon: <RadioTower className="size-4" /> },
{ key: "channels", label: "个人渠道", description: "模型服务与个人工作流", icon: <RadioTower className="size-4" /> },
{ key: "runninghub", label: "RunningHub 工作流", description: "个人渠道的云端工作流配置", icon: <Workflow className="size-4" /> },
{ key: "comfyui", label: "ComfyUI Bridge", description: "个人渠道的 Bridge 工作流配置", icon: <MonitorUp className="size-4" /> },
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
{ key: "preferences", label: "生成偏好", description: "画布、视频与音频默认值", icon: <SlidersHorizontal className="size-4" /> },
{ key: "prompts", label: "提示词偏好", description: "按任务定制平台模板", icon: <MessageSquareText className="size-4" /> },
@@ -82,7 +86,11 @@ export default function SettingsPage() {
return;
}
const hasReadyLocalRuntime = effectiveConfig.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false && Boolean(channel.localModels?.length));
if (!effectiveConfig.channels.some(isChannelReady) && !hasReadyLocalRuntime) {
const workflowReady = Boolean(
(config.runningHub.enabled && config.runningHub.workflowId.trim() && config.runningHub.baseUrl.trim() && config.runningHub.apiKey.trim())
|| (config.comfyBridge.enabled && config.comfyBridge.bridgeId.trim() && config.comfyBridge.workflowId.trim()),
);
if (!effectiveConfig.channels.some(isChannelReady) && !hasReadyLocalRuntime && !workflowReady) {
selectSection(customChannelsEnabled ? "channels" : "models");
message.error(customChannelsEnabled ? (shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置") : "当前没有可用的系统模型,请联系管理员配置系统渠道");
return;
@@ -93,7 +101,7 @@ export default function SettingsPage() {
const panes: Record<ConfigSectionKey, ReactNode> = {
"local-cli": <SettingsPane><LocalCliSettings /></SettingsPane>,
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} /></SettingsPane>,
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} onOpenRunningHub={() => selectSection("runninghub")} onOpenComfyUI={() => selectSection("comfyui")} /></SettingsPane>,
models: (
<SettingsPane>
<div className="settings-pane-header">
@@ -107,6 +115,8 @@ export default function SettingsPage() {
</div>
</SettingsPane>
),
runninghub: <SettingsPane><RunningHubSettingsPane /></SettingsPane>,
comfyui: <SettingsPane><ComfyUIBridgeSettingsPane /></SettingsPane>,
preferences: (
<SettingsPane>
<div className="settings-pane-header">

View File

@@ -0,0 +1,354 @@
import { App, AutoComplete, Button, Form, Input, Popconfirm, Select, Segmented, Switch } from "antd";
import { RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { WorkflowFieldMappingEditor } from "@/components/workflow-field-mapping-editor";
import { WorkflowGraphEditor } from "@/components/workflow-graph-editor";
import { WorkflowTestWorkbench } from "@/components/workflow-test-workbench";
import { fetchRunningHubApp, fetchRunningHubWorkflow } from "@/services/api/runninghub";
import {
normalizeRunningHubCapability,
normalizeRunningHubWorkflowKind,
mergeWorkflowFieldMappings,
normalizeWorkflowFieldMappings,
useConfigStore,
type RunningHubCapability,
type RunningHubConfig,
type RunningHubWorkflow,
type RunningHubWorkflowKind,
type WorkflowFieldMapping,
} from "@/stores/use-config-store";
const capabilityOptions = [
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
];
function workflowCapability(workflow: RunningHubWorkflow | undefined, fallback: RunningHubCapability): RunningHubCapability {
return normalizeRunningHubCapability(workflow?.capability, normalizeRunningHubCapability(fallback));
}
function workflowKind(workflow: RunningHubWorkflow | undefined): RunningHubWorkflowKind {
return normalizeRunningHubWorkflowKind(workflow?.kind);
}
function workflowEntryKey(workflow: RunningHubWorkflow): string {
return `${workflowKind(workflow)}:${workflow.workflowId.trim()}`;
}
function capabilityLabel(capability: RunningHubCapability) {
return capability === "video" ? "视频" : capability === "audio" ? "音频" : "图片";
}
function workflowNeedsMediaUpload(fields: WorkflowFieldMapping[]) {
return fields.some((field) => {
const source = String(field.source || "")
.trim()
.toLowerCase();
const fieldType = String(field.fieldType || "")
.trim()
.toUpperCase();
return ["referenceimage", "referencevideo", "referenceaudio", "mask"].includes(source) || ["IMAGE", "VIDEO", "AUDIO"].includes(fieldType);
});
}
export function RunningHubSettingsPane() {
const { message } = App.useApp();
const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const runningHub = config.runningHub;
const selected = runningHub.workflows.find((item) => item.workflowId.trim() === runningHub.workflowId.trim() && workflowKind(item) === runningHub.selectedKind);
const selectedKey = selected ? workflowEntryKey(selected) : undefined;
const [draftId, setDraftId] = useState(selected?.workflowId || "");
const [workflowText, setWorkflowText] = useState(selected?.workflowJson ? JSON.stringify(selected.workflowJson, null, 2) : "");
const [kind, setKind] = useState<RunningHubWorkflowKind>(selected ? workflowKind(selected) : runningHub.selectedKind);
const [title, setTitle] = useState(selected?.title || "");
const [capability, setCapability] = useState<RunningHubCapability>(workflowCapability(selected, runningHub.capability));
const [fetching, setFetching] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<"fields" | "test">("fields");
useEffect(() => {
if (!selected) return;
setDraftId(selected.workflowId);
setWorkflowText(selected.workflowJson ? JSON.stringify(selected.workflowJson, null, 2) : "");
setKind(workflowKind(selected));
setTitle(selected.title || "");
setCapability(workflowCapability(selected, runningHub.capability));
}, [selectedKey, selected?.workflowJson, selected?.title, selected?.capability, runningHub.capability]);
const update = (patch: Partial<RunningHubConfig>) => updateConfig("runningHub", { ...runningHub, ...patch });
const draftMatchesSelected = Boolean(selected && workflowEntryKey(selected) === `${kind}:${draftId.trim()}`);
const persistWorkflow = (workflow: RunningHubWorkflow, nextCapability = capability) => {
const workflowId = workflow.workflowId.trim();
if (!workflowId) return;
const nextKind = workflowKind(workflow);
const next: RunningHubWorkflow = {
...workflow,
kind: nextKind,
workflowId,
capability: normalizeRunningHubCapability(nextCapability, capability),
fields: normalizeWorkflowFieldMappings(workflow.fields, normalizeRunningHubCapability(nextCapability, capability)),
};
if (nextKind === "app") {
// AI 应用只保存其公开参数,绝不能继续携带之前 Workflow 的 JSON。
delete next.workflowJson;
next.webappId = (next.webappId || workflowId).trim();
} else {
delete next.webappId;
}
const nextKey = workflowEntryKey(next);
update({
workflowId,
selectedKind: nextKind,
capability: next.capability || capability,
workflows: [...runningHub.workflows.filter((item) => workflowEntryKey(item) !== nextKey), next],
});
};
const fetchWorkflow = async () => {
const id = draftId.trim();
if (!id) return message.warning(kind === "app" ? "请先填写 webappId" : "请先填写 workflowId");
if (!runningHub.apiKey.trim()) return message.warning("请先填写积分 API Key");
setFetching(true);
try {
// 管理接口只接受拉取参数所需的小请求,不能把已保存工作流及大段 JSON 一并提交。
const fetchConfig = {
baseUrl: runningHub.baseUrl,
apiKey: runningHub.apiKey,
walletApiKey: "",
useWallet: false,
title: title.trim(),
capability,
};
const result = kind === "app" ? await fetchRunningHubApp({ ...fetchConfig, webappId: id }) : await fetchRunningHubWorkflow({ ...fetchConfig, workflowId: id });
const existing = runningHub.workflows.find((workflow) => workflowEntryKey(workflow) === `${kind}:${id}`);
const item: RunningHubWorkflow = {
...result,
kind,
workflowId: id,
webappId: kind === "app" ? result.webappId || id : undefined,
title: result.title || title.trim() || id,
fields: existing ? mergeWorkflowFieldMappings(existing.fields, result.fields, capability) : normalizeWorkflowFieldMappings(result.fields, capability),
workflowJson: kind === "workflow" ? result.workflowJson || {} : undefined,
};
persistWorkflow(item, capability);
setWorkflowText(item.workflowJson ? JSON.stringify(item.workflowJson, null, 2) : "");
setTitle(item.title || "");
message.success(
workflowNeedsMediaUpload(item.fields || []) ? `${kind === "app" ? "RunningHub App" : "RunningHub 工作流"}参数已保存;生成时会调用 RunningHub 素材上传接口` : `${kind === "app" ? "RunningHub App" : "RunningHub 工作流"}参数已重新拉取并保存`,
);
} catch (error) {
message.error(error instanceof Error ? `拉取失败:${error.message}` : "拉取 RunningHub 参数失败");
} finally {
setFetching(false);
}
};
const removeWorkflow = (workflow: RunningHubWorkflow) => {
const targetKey = workflowEntryKey(workflow);
const remaining = runningHub.workflows.filter((item) => workflowEntryKey(item) !== targetKey);
update({ workflowId: "", selectedKind: workflowKind(workflow), workflows: remaining });
setDraftId("");
setWorkflowText("");
setTitle("");
message.success(`已删除${workflowKind(workflow) === "app" ? " App" : " Workflow"}:${workflow.title || workflow.workflowId}`);
};
const updateCapability = (value: RunningHubCapability) => {
const nextCapability = normalizeRunningHubCapability(value, capability);
setCapability(nextCapability);
if (selected && draftMatchesSelected) persistWorkflow({ ...selected, capability: nextCapability }, nextCapability);
};
const selectWorkflow = (entryKey: string | undefined) => {
if (!entryKey) {
update({ workflowId: "" });
setDraftId("");
setWorkflowText("");
setTitle("");
return;
}
const workflow = runningHub.workflows.find((item) => workflowEntryKey(item) === entryKey);
if (!workflow) return message.error("已保存条目不存在,请刷新页面后重试");
const nextCapability = workflowCapability(workflow, runningHub.capability);
update({ workflowId: workflow.workflowId.trim(), selectedKind: workflowKind(workflow), capability: nextCapability });
setDraftId(workflow.workflowId.trim());
setWorkflowText(workflow.workflowJson ? JSON.stringify(workflow.workflowJson, null, 2) : "");
setKind(workflowKind(workflow));
setTitle(workflow.title || "");
setCapability(nextCapability);
};
const changeKind = (nextKind: RunningHubWorkflowKind) => {
setKind(nextKind);
setDraftId("");
setWorkflowText("");
setTitle("");
update({ workflowId: "", selectedKind: nextKind });
};
const updateWorkflowJson = (value: string) => {
if (kind === "app") return;
if (!selected || !draftMatchesSelected) return message.warning("请先拉取或选择要编辑的 Workflow");
try {
const parsed = value.trim() ? JSON.parse(value) : {};
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("工作流 JSON 必须是对象");
persistWorkflow({ ...selected, title: title.trim() || selected.title, workflowJson: parsed });
} catch (error) {
message.error(error instanceof Error ? error.message : "工作流 JSON 格式无效");
}
};
const updateWorkflowFields = (fields: WorkflowFieldMapping[]) => {
if (!selected || !draftMatchesSelected) return;
persistWorkflow({ ...selected, title: title.trim() || selected.title, fields });
};
const updateTitle = () => {
if (!selected || !draftMatchesSelected) return;
persistWorkflow({ ...selected, title: title.trim() || selected.workflowId });
};
return (
<Form layout="vertical" requiredMark={false}>
<div className="settings-pane-header">
<div className="min-w-0">
<h2>RunningHub 工作流</h2>
<p>独立于系统渠道配置。工作流提交使用积分 API Key,参考素材上传使用企业级 API Key。</p>
</div>
<Switch checked={runningHub.enabled} checkedChildren="启用" unCheckedChildren="停用" onChange={(enabled) => update({ enabled })} />
</div>
<div className="settings-section grid gap-3 lg:grid-cols-12">
<Form.Item label="Base URL" className="mb-0 lg:col-span-6">
<AutoComplete
className="w-full"
value={runningHub.baseUrl}
options={[
{ label: "中国站 · https://www.runninghub.cn", value: "https://www.runninghub.cn" },
{ label: "国际站 · https://www.runninghub.ai", value: "https://www.runninghub.ai" },
]}
onChange={(baseUrl) => update({ baseUrl })}
onBlur={() => update({ baseUrl: runningHub.baseUrl.trim().replace(/\/+$/, "") })}
>
<Input placeholder="选择官方站点或填写兼容网关地址" />
</AutoComplete>
</Form.Item>
<Form.Item label="积分 API Key(工作流提交)" className="mb-0 lg:col-span-6" extra="用于拉取工作流参数、创建任务和查询结果;最终提交固定使用这把 Key。若提示企业版余额不足,请检查这里没有填企业级上传 Key。">
<Input.Password autoComplete="new-password" value={runningHub.apiKey} onChange={(event) => update({ apiKey: event.target.value })} />
</Form.Item>
<Form.Item label="素材上传 API Key(企业级)" className="mb-0 lg:col-span-6" extra="仅用于上传参考图片、视频、音频和蒙版;没有参考素材时可以留空。">
<Input.Password autoComplete="new-password" value={runningHub.uploadApiKey || ""} onChange={(event) => update({ uploadApiKey: event.target.value })} />
</Form.Item>
<Form.Item label="工作流用途" className="mb-0 lg:col-span-6">
<Select className="w-full" value={capability} options={capabilityOptions} onChange={(value) => updateCapability(value as RunningHubCapability)} />
</Form.Item>
<Form.Item label="已保存条目" className="mb-0 lg:col-span-6">
<Select
className="w-full"
allowClear
value={selectedKey}
options={runningHub.workflows.map((item) => ({
label: `${workflowKind(item) === "app" ? "App" : "Workflow"} · ${item.title || item.workflowId} · ${capabilityLabel(workflowCapability(item, runningHub.capability))}`,
value: workflowEntryKey(item),
}))}
placeholder="选择已保存 Workflow / App"
onChange={selectWorkflow}
/>
</Form.Item>
<Form.Item label="类型" className="mb-0 lg:col-span-3">
<Segmented
block
value={kind}
options={[
{ label: "Workflow", value: "workflow" },
{ label: "App", value: "app" },
]}
onChange={(value) => changeKind(value as RunningHubWorkflowKind)}
/>
</Form.Item>
<Form.Item label={kind === "app" ? "webappId" : "workflowId"} className="mb-0 lg:col-span-3">
<Input value={draftId} placeholder={kind === "app" ? "RunningHub webappId" : "RunningHub workflowId"} onChange={(event) => setDraftId(event.target.value)} />
</Form.Item>
<Form.Item label="显示名称" className="mb-0 lg:col-span-6">
<Input value={title} placeholder="可选" onChange={(event) => setTitle(event.target.value)} onBlur={updateTitle} />
</Form.Item>
<Form.Item label="操作" className="workflow-entry-actions-field mb-0 lg:col-span-6">
<div className="workflow-entry-actions">
<Button type="primary" icon={<RefreshCw className="size-4" />} loading={fetching} onClick={() => void fetchWorkflow()}>
拉取参数
</Button>
<Popconfirm
title="删除当前条目?"
description={selected ? `${workflowKind(selected) === "app" ? "App" : "Workflow"} · ${selected.title || selected.workflowId}` : undefined}
okText="删除"
cancelText="取消"
onConfirm={() => selected && removeWorkflow(selected)}
>
<Button danger icon={<Trash2 className="size-4" />} disabled={!selected}>
删除
</Button>
</Popconfirm>
</div>
</Form.Item>
<div className="workflow-workspace-tabs lg:col-span-12">
<Segmented
block
value={workspaceMode}
options={[
{ label: "字段配置", value: "fields" },
{ label: "测试画布", value: "test" },
]}
onChange={(value) => setWorkspaceMode(value as "fields" | "test")}
/>
</div>
{workspaceMode === "fields" && kind === "workflow" ? (
<>
<div className="lg:col-span-12">
<WorkflowGraphEditor workflowJson={selected?.workflowJson} fields={selected?.fields || []} disabled={!selected || !draftMatchesSelected} onChange={updateWorkflowFields} emptyDescription="请先拉取 RunningHub Workflow" />
</div>
<details className="workflow-json-details lg:col-span-12">
<summary>查看或编辑 ComfyUI API JSON</summary>
<Form.Item className="mb-0 mt-3" extra="RunningHub Workflow 与 ComfyUI 共用同一种拓扑解析合同;修改 JSON 后移出输入框即保存。">
<Input.TextArea
rows={12}
value={workflowText}
spellCheck={false}
placeholder={'{"3":{"class_type":"...","inputs":{}}}'}
onChange={(event) => setWorkflowText(event.target.value)}
onBlur={() => updateWorkflowJson(workflowText)}
/>
</Form.Item>
</details>
</>
) : null}
{workspaceMode === "fields" && kind === "app" ? (
<Form.Item label="App 公开参数映射" className="mb-0 lg:col-span-12" extra="AI 应用不返回完整 ComfyUI 拓扑,只能编辑当前发布版本的公开参数;应用更新后请再次点击“拉取参数”。">
<WorkflowFieldMappingEditor fields={selected?.fields || []} disabled={!selected || !draftMatchesSelected} onChange={updateWorkflowFields} />
</Form.Item>
) : null}
{workspaceMode === "test" ? (
<div className="lg:col-span-12">
<WorkflowTestWorkbench
key={`runninghub:${selectedKey || "empty"}`}
provider="runninghub"
workflowId={selected?.workflowId || ""}
workflowKind={selected ? workflowKind(selected) : kind}
title={selected?.title || title || draftId}
capability={selected ? workflowCapability(selected, capability) : capability}
fields={selected?.fields || []}
disabled={!selected || !draftMatchesSelected || !runningHub.baseUrl.trim() || !runningHub.apiKey.trim()}
disabledReason={!selected || !draftMatchesSelected ? "请先拉取或选择一个已保存的 RunningHub 条目" : !runningHub.baseUrl.trim() ? "请先填写 RunningHub Base URL" : "请先填写积分 API Key"}
/>
</div>
) : null}
</div>
</Form>
);
}
export function runningHubWorkflow(config: RunningHubConfig): RunningHubWorkflow | undefined {
const workflowId = config.workflowId.trim();
return config.workflows.find((item) => item.workflowId.trim() === workflowId && workflowKind(item) === config.selectedKind);
}