feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge
This commit is contained in:
@@ -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) };
|
||||
|
||||
501
web/src/pages/settings/comfyui-bridge-settings-pane.tsx
Normal file
501
web/src/pages/settings/comfyui-bridge-settings-pane.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
354
web/src/pages/settings/runninghub-settings-pane.tsx
Normal file
354
web/src/pages/settings/runninghub-settings-pane.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user