chore: commit workspace updates
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Switch, Tooltip } from "antd";
|
||||
import { Pencil, MonitorUp, Plus, RefreshCw, Trash2, Workflow } from "lucide-react";
|
||||
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Tooltip } from "antd";
|
||||
import { Pencil, Plus, RefreshCw, Trash2, Workflow } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { ModelEditorModal } from "@/components/model-editor-modal";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog";
|
||||
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import {
|
||||
createModelChannel,
|
||||
@@ -18,27 +17,22 @@ import {
|
||||
type ModelChannel,
|
||||
} from "@/stores/use-config-store";
|
||||
import { ChannelModelSettings } from "./channel-video-pricing";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type UserChannelConnection = "openai" | "gemini";
|
||||
type ChannelSettingsPaneProps = {
|
||||
onOpenModels: () => void;
|
||||
onOpenRunningHub?: () => void;
|
||||
onOpenComfyUI?: () => void;
|
||||
};
|
||||
|
||||
export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComfyUI }: ChannelSettingsPaneProps) {
|
||||
export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub }: ChannelSettingsPaneProps) {
|
||||
const { message } = App.useApp();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const replaceConfig = useConfigStore((state) => state.replaceConfig);
|
||||
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
|
||||
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
|
||||
const [newChannelId, setNewChannelId] = useState<string | null>(null);
|
||||
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());
|
||||
|
||||
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
|
||||
replaceConfig(withChannels(baseConfig, channels));
|
||||
@@ -98,8 +92,7 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
|
||||
}
|
||||
setChannelLoading(channel.id, true);
|
||||
try {
|
||||
const projectedChannel = { ...channel, allowLocalChannel: userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).payloadValue };
|
||||
const result = await fetchChannelModels(projectedChannel, true);
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
if (!result.models.length) {
|
||||
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
|
||||
return;
|
||||
@@ -136,8 +129,7 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
|
||||
const results = await Promise.all(
|
||||
runnable.map(async (channel) => {
|
||||
try {
|
||||
const projectedChannel = { ...channel, allowLocalChannel: userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).payloadValue };
|
||||
const result = await fetchChannelModels(projectedChannel, true);
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
return { channel, result, error: "" };
|
||||
} catch (error) {
|
||||
return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" };
|
||||
@@ -190,10 +182,10 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
|
||||
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>新增渠道</Button>
|
||||
</div>
|
||||
</div>
|
||||
{onOpenRunningHub || onOpenComfyUI ? <section className="settings-section mb-3">
|
||||
{onOpenRunningHub ? <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>
|
||||
<p className="mt-1 text-xs text-foreground/55">RunningHub 使用独立的云端工作流参数与执行通道。</p>
|
||||
</div>
|
||||
<div className="grid gap-2 lg:grid-cols-2">
|
||||
{onOpenRunningHub ? (
|
||||
@@ -206,16 +198,6 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
|
||||
onOpen={onOpenRunningHub}
|
||||
/>
|
||||
) : null}
|
||||
{onOpenComfyUI ? (
|
||||
<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}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section> : null}
|
||||
{userChannels.length ? (
|
||||
@@ -247,7 +229,7 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
|
||||
subtitle={channel.name}
|
||||
onClose={closeChannelEditor}
|
||||
footer={<div className="model-editor-footer">
|
||||
<span className="text-xs text-foreground/50">更改实时保存到本地渠道配置</span>
|
||||
<span className="text-xs text-foreground/50">更改实时保存到云端渠道配置</span>
|
||||
<div className="model-editor-footer-actions">
|
||||
<Button loading={loadingChannelIds.includes(channel.id)} onClick={() => void refreshChannelModels(channel)}>拉取模型</Button>
|
||||
<Button type="primary" onClick={closeChannelEditor}>完成</Button>
|
||||
@@ -263,14 +245,7 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
|
||||
<div className="model-editor-connection-fields grid gap-3 sm:grid-cols-2">
|
||||
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 sm:col-span-1"><Input id={`channel-${channel.id}-name`} value={channel.name} placeholder="例如:我的 NewAPI" onChange={(event) => updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /></Form.Item>
|
||||
<Form.Item label="目录连接类型" className="mb-0 sm:col-span-1" extra="仅影响模型目录拉取。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI", value: "openai" }, { label: "Gemini", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
|
||||
<UserLocalChannelFields
|
||||
channel={channel}
|
||||
visible={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).visible}
|
||||
checked={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked}
|
||||
desktopLocalChannelsEnabled={desktopLocalChannelsEnabled}
|
||||
hostname={desktopLocalChannelHostname}
|
||||
updateChannel={updateChannel}
|
||||
/>
|
||||
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 sm:col-span-1"><Input id={`channel-${channel.id}-base-url`} inputMode="url" value={channel.baseUrl} placeholder="填写云端渠道 Base URL" onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/u, "") })} /></Form.Item>
|
||||
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 sm:col-span-1"><Input.Password id={`channel-${channel.id}-api-key`} autoComplete="new-password" value={channel.apiKey} placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })} /></Form.Item>
|
||||
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 sm:col-span-1" extra="即梦等 AK/SK 协议需要;其他协议留空。"><Input.Password id={`channel-${channel.id}-secret-key`} autoComplete="new-password" value={channel.secretKey || ""} placeholder="填写 Secret Key" onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })} /></Form.Item>
|
||||
<div className="sm:col-span-2"><ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} /></div>
|
||||
@@ -312,35 +287,6 @@ function WorkflowChannelEntry({ icon, title, description, status, ready, onOpen
|
||||
);
|
||||
}
|
||||
|
||||
export function userLocalChannelFormOwner(desktopLocalChannelsEnabled: boolean, hostname: string, requestedAllowLocalChannel?: boolean) {
|
||||
const state = desktopLocalChannelFormState(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel);
|
||||
return { ...state, payloadValue: desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel) };
|
||||
}
|
||||
|
||||
export function UserLocalChannelSwitch({ visible, checked, onChange }: { visible: boolean; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<Form.Item label="允许本机渠道" className="mb-0 lg:col-span-12" extra={`仅放行精确 localhost 或 127.0.0.1;示例:${DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL}`}>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserLocalChannelFields({ channel, visible, checked, desktopLocalChannelsEnabled, hostname, updateChannel }: { channel: ModelChannel; visible: boolean; checked: boolean; desktopLocalChannelsEnabled: boolean; hostname: string; updateChannel: (id: string, patch: Partial<ModelChannel>) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6">
|
||||
<Input id={`channel-${channel.id}-base-url`} inputMode="url" value={channel.baseUrl} placeholder={checked ? DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL : "填写渠道 Base URL"} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })} />
|
||||
</Form.Item>
|
||||
<UserLocalChannelSwitch visible={visible} checked={checked} onChange={(value) => updateChannel(channel.id, userLocalChannelChangePatch(desktopLocalChannelsEnabled, hostname, value))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function userLocalChannelChangePatch(desktopLocalChannelsEnabled: boolean, hostname: string, checked: boolean) {
|
||||
return { allowLocalChannel: userLocalChannelFormOwner(desktopLocalChannelsEnabled, hostname, checked).payloadValue };
|
||||
}
|
||||
|
||||
export function channelValidationError(channel: ModelChannel) {
|
||||
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
|
||||
}
|
||||
@@ -411,7 +357,7 @@ function channelConnectionError(channel: ModelChannel) {
|
||||
}
|
||||
|
||||
function channelConnectionSignature(channel: ModelChannel) {
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.allowLocalChannel === true ? "local" : "remote", JSON.stringify(channel.headers || [])].join("\n");
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, JSON.stringify(channel.headers || [])].join("\n");
|
||||
}
|
||||
|
||||
function channelProtocolLabel(channel: ModelChannel) {
|
||||
|
||||
@@ -116,7 +116,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
onClose={() => setActiveModel(null)}
|
||||
footer={
|
||||
<div className="model-editor-footer">
|
||||
<span className="text-xs text-foreground/50">更改实时保存到本地渠道配置</span>
|
||||
<span className="text-xs text-foreground/50">更改实时保存到云端渠道配置</span>
|
||||
<div className="model-editor-footer-actions">
|
||||
<Button
|
||||
icon={<FlaskConical className="size-4" />}
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
import { App, Button, Form, Input, Popconfirm, Select } from "antd";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import { SegmentedControl } from "@/components/ui/base/segmented-control";
|
||||
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>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
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>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
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">
|
||||
<SegmentedControl
|
||||
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,7 +1,7 @@
|
||||
import { App, Button, Input, InputNumber } from "antd";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
import { SettingsRow } from "@/components/ui/product/settings-row";
|
||||
import { ArrowLeft, Boxes, Bug, Cloud, MessageSquareText, MonitorUp, RadioTower, SlidersHorizontal, SquareTerminal, Workflow } from "lucide-react";
|
||||
import { ArrowLeft, Boxes, Bug, Cloud, MessageSquareText, RadioTower, SlidersHorizontal, Workflow } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
@@ -11,23 +11,18 @@ import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { defaultConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
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";
|
||||
import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
|
||||
import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
|
||||
type ConfigSectionKey = "local-cli" | "channels" | "models" | "runninghub" | "comfyui" | "preferences" | "prompts" | "storage" | "diagnostics";
|
||||
type ConfigSectionKey = "channels" | "models" | "runninghub" | "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: "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" /> },
|
||||
@@ -47,10 +42,7 @@ export default function SettingsPage() {
|
||||
const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled);
|
||||
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
|
||||
const runningHubPluginEnabled = runtimeStatuses[RUNNINGHUB_PLUGIN_ID] === "enabled";
|
||||
const comfyUIPluginEnabled = runtimeStatuses[COMFYUI_PLUGIN_ID] === "enabled";
|
||||
const requestedSectionEnabled = requestedSection !== "runninghub" && requestedSection !== "comfyui"
|
||||
|| requestedSection === "runninghub" && runningHubPluginEnabled
|
||||
|| requestedSection === "comfyui" && comfyUIPluginEnabled;
|
||||
const requestedSectionEnabled = requestedSection !== "runninghub" || runningHubPluginEnabled;
|
||||
const initialSection = isConfigSection(requestedSection) && requestedSectionEnabled ? requestedSection : customChannelsEnabled ? "channels" : "models";
|
||||
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(initialSection === "channels" && !customChannelsEnabled ? "models" : initialSection);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -60,8 +52,7 @@ export default function SettingsPage() {
|
||||
const userId = useUserStore((state) => state.user?.id);
|
||||
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
|
||||
const visibleConfigSections = useMemo(() => (customChannelsEnabled ? configSections : configSections.filter((section) => section.key !== "channels"))
|
||||
.filter((section) => section.key !== "runninghub" || runningHubPluginEnabled)
|
||||
.filter((section) => section.key !== "comfyui" || comfyUIPluginEnabled), [comfyUIPluginEnabled, customChannelsEnabled, runningHubPluginEnabled]);
|
||||
.filter((section) => section.key !== "runninghub" || runningHubPluginEnabled), [customChannelsEnabled, runningHubPluginEnabled]);
|
||||
|
||||
const isVisibleConfigSection = (value: string | null): value is ConfigSectionKey => isConfigSection(value) && visibleConfigSections.some((section) => section.key === value);
|
||||
|
||||
@@ -90,7 +81,7 @@ export default function SettingsPage() {
|
||||
}, [message, userId]);
|
||||
|
||||
const selectSection = (section: ConfigSectionKey) => {
|
||||
if ((section === "runninghub" && !runningHubPluginEnabled) || (section === "comfyui" && !comfyUIPluginEnabled)) return;
|
||||
if (section === "runninghub" && !runningHubPluginEnabled) return;
|
||||
setActiveTab(section);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", section);
|
||||
@@ -105,12 +96,8 @@ export default function SettingsPage() {
|
||||
focusInvalidChannelField(invalidChannel);
|
||||
return;
|
||||
}
|
||||
const hasReadyLocalRuntime = effectiveConfig.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false && Boolean(channel.localModels?.length));
|
||||
const workflowReady = Boolean(
|
||||
(runningHubPluginEnabled && config.runningHub.enabled && config.runningHub.workflowId.trim() && config.runningHub.baseUrl.trim() && config.runningHub.apiKey.trim())
|
||||
|| (comfyUIPluginEnabled && config.comfyBridge.enabled && config.comfyBridge.bridgeId.trim() && config.comfyBridge.workflowId.trim()),
|
||||
);
|
||||
if (!effectiveConfig.channels.some(isChannelReady) && !hasReadyLocalRuntime && !workflowReady) {
|
||||
const workflowReady = Boolean(runningHubPluginEnabled && config.runningHub.enabled && config.runningHub.workflowId.trim() && config.runningHub.baseUrl.trim() && config.runningHub.apiKey.trim());
|
||||
if (!effectiveConfig.channels.some(isChannelReady) && !workflowReady) {
|
||||
selectSection(customChannelsEnabled ? "channels" : "models");
|
||||
message.error(customChannelsEnabled ? (shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置") : "当前没有可用的系统模型,请联系管理员配置系统渠道");
|
||||
return;
|
||||
@@ -120,8 +107,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const panes: Record<ConfigSectionKey, ReactNode> = {
|
||||
"local-cli": <SettingsPane><LocalCliSettings /></SettingsPane>,
|
||||
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} onOpenRunningHub={runningHubPluginEnabled ? () => selectSection("runninghub") : undefined} onOpenComfyUI={comfyUIPluginEnabled ? () => selectSection("comfyui") : undefined} /></SettingsPane>,
|
||||
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} onOpenRunningHub={runningHubPluginEnabled ? () => selectSection("runninghub") : undefined} /></SettingsPane>,
|
||||
models: (
|
||||
<SettingsPane>
|
||||
<div className="settings-pane-header">
|
||||
@@ -136,7 +122,6 @@ export default function SettingsPage() {
|
||||
</SettingsPane>
|
||||
),
|
||||
runninghub: <SettingsPane><RunningHubSettingsPane /></SettingsPane>,
|
||||
comfyui: <SettingsPane><ComfyUIBridgeSettingsPane /></SettingsPane>,
|
||||
preferences: (
|
||||
<SettingsPane>
|
||||
<div className="settings-pane-header">
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { App, Button, Popconfirm, Tag, Typography } from "antd";
|
||||
import { StatusBadge } from "@/components/ui/base/badges";
|
||||
import { CheckCircle2, Copy, ExternalLink, LogIn, LogOut, RefreshCw, Server, SquareTerminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { DreaminaAgentError, getDreaminaStatus, loginDreamina, logoutDreamina, type DreaminaCliStatus } from "@/services/local-dreamina-cli";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore, type LocalRuntimeConnectionState } from "@/stores/use-local-runtime-store";
|
||||
|
||||
type PendingAction = "refresh" | "login" | "logout" | "";
|
||||
type PresentationAction = "refresh" | "login" | "open_verification" | "logout" | null;
|
||||
type Presentation = {
|
||||
label: string;
|
||||
tone: "success" | "processing" | "warning" | "error" | "default";
|
||||
action: PresentationAction;
|
||||
actionLabel?: string;
|
||||
creditLabel?: string;
|
||||
creditObservedAtLabel?: string;
|
||||
};
|
||||
|
||||
export const LOCAL_CLI_SETTINGS_COPY = {
|
||||
runtimeTitle: "本机连接",
|
||||
runtimeConnected: "本机服务已连接,CLI 状态会自动同步。",
|
||||
runtimeDetecting: "正在检测本机服务;请确认已启动当前版本。",
|
||||
runtimeReconnect: "重新连接",
|
||||
runtimeSafety: "官方 CLI 登录资料保存在本机;本页面不读取或上传 Cookie、浏览器 Profile 或登录令牌。",
|
||||
runtimeRefresh: "刷新状态",
|
||||
dreaminaDescription: "直接读取当前 Windows 用户的官方即梦 CLI 登录状态。",
|
||||
dreaminaDisconnected: "连接本机服务后自动检测",
|
||||
dreaminaDisconnectedMessage: "重新连接本机服务后,将自动读取官方 CLI 状态。",
|
||||
dreaminaMembership: "账号生成权限:未知。当前页面只确认本机适配器支持与登录状态;具体账号是否可生成,以官方最终结果为准。",
|
||||
dreaminaConsistency: "任务状态通过后台轮询最终同步,不是实时推送;关闭页面不会停止已经提交的官方任务。",
|
||||
dreaminaCancel: "官方 Dreamina CLI 当前不提供取消命令;官方已接受的任务只能转入后台继续同步,不能伪装成已取消。",
|
||||
dreaminaAccountSwitch: "本机任务运行期间,请不要在其他程序中切换 Dreamina CLI 账号;外部换号无法被本页面实时感知。",
|
||||
dreaminaRefresh: "刷新状态",
|
||||
} as const;
|
||||
|
||||
export function localCliSettingsPresentation(input: { connection: string; moduleAvailable: boolean; dreamina?: DreaminaCliStatus; timeZone?: string }): { runtime: Presentation; dreamina: Presentation } {
|
||||
const runtime = runtimePresentation(input.connection as LocalRuntimeConnectionState);
|
||||
if (input.connection !== "connected") {
|
||||
return { runtime, dreamina: { label: LOCAL_CLI_SETTINGS_COPY.dreaminaDisconnected, tone: "default", action: null } };
|
||||
}
|
||||
if (!input.moduleAvailable) {
|
||||
return { runtime, dreamina: { label: "模块未加载", tone: "error", action: "refresh" } };
|
||||
}
|
||||
const status = input.dreamina;
|
||||
if (!status) return { runtime, dreamina: { label: "正在检测", tone: "processing", action: "refresh" } };
|
||||
const creditObservedAt = formatCreditObservedAt(status.creditObservedAt, input.timeZone);
|
||||
const hasScopedCredit = status.totalCredit !== undefined && Boolean(status.accountBinding) && status.sessionEpoch !== undefined && Boolean(creditObservedAt);
|
||||
if (status.state === "missing") return { runtime, dreamina: { label: "未安装", tone: "error", action: "refresh" } };
|
||||
if (status.state === "login_pending") return { runtime, dreamina: { label: "等待官方授权", tone: "processing", action: "open_verification" } };
|
||||
if (status.authenticated)
|
||||
return {
|
||||
runtime,
|
||||
dreamina: {
|
||||
label: "已登录",
|
||||
tone: "success",
|
||||
action: "logout",
|
||||
...(!hasScopedCredit
|
||||
? {}
|
||||
: {
|
||||
creditLabel: `即梦积分 ${new Intl.NumberFormat("zh-CN").format(status.totalCredit!)}`,
|
||||
}),
|
||||
...(creditObservedAt ? { creditObservedAtLabel: `上次刷新积分 ${creditObservedAt}` } : {}),
|
||||
},
|
||||
};
|
||||
if (status.state === "installed") return { runtime, dreamina: { label: "未登录", tone: "warning", action: "login" } };
|
||||
return { runtime, dreamina: { label: "检测失败", tone: "error", action: "refresh" } };
|
||||
}
|
||||
|
||||
export function formatCreditObservedAt(value: unknown, timeZone?: string) {
|
||||
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) return undefined;
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}).formatToParts(new Date(value));
|
||||
const hour = parts.find((part) => part.type === "hour")?.value;
|
||||
const minute = parts.find((part) => part.type === "minute")?.value;
|
||||
return hour && minute ? `${hour}:${minute}` : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function LocalCliSettings() {
|
||||
const { message } = App.useApp();
|
||||
const connection = useLocalRuntimeStore((state) => state.connection);
|
||||
const connecting = useLocalRuntimeStore((state) => state.connecting);
|
||||
const modules = useLocalRuntimeStore((state) => state.modules);
|
||||
const runtimeError = useLocalRuntimeStore((state) => state.error);
|
||||
const connect = useLocalRuntimeStore((state) => state.connect);
|
||||
const moduleAvailable = modules.some((module) => module.id === "dreamina");
|
||||
const [status, setStatus] = useState<DreaminaCliStatus>();
|
||||
const [pending, setPending] = useState<PendingAction>("");
|
||||
const lifecycle = useRef<{ revision: number; controller: AbortController | null }>({
|
||||
revision: 0,
|
||||
controller: null,
|
||||
});
|
||||
const presentation = localCliSettingsPresentation({ connection, moduleAvailable, dreamina: status });
|
||||
|
||||
const refreshRuntime = useCallback(() => {
|
||||
const controller = new AbortController();
|
||||
void connect(controller.signal);
|
||||
}, [connect]);
|
||||
|
||||
const runDreamina = useCallback(
|
||||
async (action: Exclude<PendingAction, "">) => {
|
||||
if (connection !== "connected" || !moduleAvailable) {
|
||||
message.warning("请先连接已加载 Dreamina 的本机运行时");
|
||||
return;
|
||||
}
|
||||
lifecycle.current.controller?.abort();
|
||||
const revision = ++lifecycle.current.revision;
|
||||
const controller = new AbortController();
|
||||
lifecycle.current.controller = controller;
|
||||
setPending(action);
|
||||
try {
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
const options = { signal: controller.signal };
|
||||
const next = action === "login" ? await loginDreamina(client, options) : action === "logout" ? await logoutDreamina(client, options) : await getDreaminaStatus(client, options);
|
||||
if (revision !== lifecycle.current.revision || controller.signal.aborted) return;
|
||||
setStatus(next);
|
||||
if (action === "logout") message.success("Dreamina CLI 已退出登录");
|
||||
if (action === "login" && next.state === "login_pending") {
|
||||
message.info("请打开官方验证页完成授权,再刷新状态");
|
||||
}
|
||||
} catch (error) {
|
||||
if (revision !== lifecycle.current.revision || controller.signal.aborted) return;
|
||||
setStatus(undefined);
|
||||
message.error(error instanceof DreaminaAgentError ? error.message : "Dreamina CLI 操作失败");
|
||||
} finally {
|
||||
if (revision === lifecycle.current.revision) {
|
||||
lifecycle.current.controller = null;
|
||||
setPending("");
|
||||
}
|
||||
}
|
||||
},
|
||||
[connection, message, moduleAvailable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection !== "connected" || !moduleAvailable) {
|
||||
lifecycle.current.revision++;
|
||||
lifecycle.current.controller?.abort();
|
||||
lifecycle.current.controller = null;
|
||||
setStatus(undefined);
|
||||
setPending("");
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void runDreamina("refresh");
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
lifecycle.current.revision++;
|
||||
lifecycle.current.controller?.abort();
|
||||
lifecycle.current.controller = null;
|
||||
};
|
||||
}, [connection, moduleAvailable, runDreamina]);
|
||||
|
||||
const openVerification = () => {
|
||||
if (status?.verificationUri) window.open(status.verificationUri, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section aria-labelledby="local-runtime-title" className="rounded-md border border-border bg-background px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Server className="size-4 shrink-0 text-foreground/60" />
|
||||
<h2 id="local-runtime-title" className="text-base font-semibold">
|
||||
{LOCAL_CLI_SETTINGS_COPY.runtimeTitle}
|
||||
</h2>
|
||||
<StatusBadge variant="filled" className="m-0" tone={presentation.runtime.tone === "processing" ? "loading" : presentation.runtime.tone === "default" ? "neutral" : presentation.runtime.tone} label={presentation.runtime.label} />
|
||||
<p className="min-w-0 text-sm text-foreground/60">{connection === "connected" ? LOCAL_CLI_SETTINGS_COPY.runtimeConnected : runtimeError || LOCAL_CLI_SETTINGS_COPY.runtimeDetecting}</p>
|
||||
<p className="basis-full text-xs leading-5 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.runtimeSafety}</p>
|
||||
</div>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={connecting} onClick={refreshRuntime}>
|
||||
{presentation.runtime.actionLabel || LOCAL_CLI_SETTINGS_COPY.runtimeRefresh}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="dreamina-cli-title" className="rounded-md border border-border bg-background p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-[var(--r-lg)] bg-foreground/5">
|
||||
<SquareTerminal className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 id="dreamina-cli-title" className="text-base font-semibold">
|
||||
Dreamina CLI
|
||||
</h2>
|
||||
<StatusBadge
|
||||
variant="filled"
|
||||
className="m-0"
|
||||
tone={presentation.dreamina.tone === "processing" ? "loading" : presentation.dreamina.tone === "default" ? "neutral" : presentation.dreamina.tone}
|
||||
label={presentation.dreamina.label}
|
||||
/>
|
||||
{status?.version ? <Tag className="m-0">v{status.version}</Tag> : null}
|
||||
{presentation.dreamina.creditLabel ? (
|
||||
<Tag color="blue" className="m-0">
|
||||
{presentation.dreamina.creditLabel}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-foreground/60">{LOCAL_CLI_SETTINGS_COPY.dreaminaDescription}</p>
|
||||
{presentation.dreamina.creditObservedAtLabel ? <p className="mt-1 text-xs text-foreground/50">{presentation.dreamina.creditObservedAtLabel}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={pending === "refresh"} disabled={connection !== "connected" || !moduleAvailable || Boolean(pending && pending !== "refresh")} onClick={() => void runDreamina("refresh")}>
|
||||
{LOCAL_CLI_SETTINGS_COPY.dreaminaRefresh}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 pt-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-foreground/75">{status?.message || dreaminaEmptyMessage(connection, moduleAvailable)}</p>
|
||||
<p className="text-xs leading-6 text-foreground/60">{LOCAL_CLI_SETTINGS_COPY.dreaminaMembership}</p>
|
||||
<p className="text-xs leading-6 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.dreaminaConsistency}</p>
|
||||
<p className="text-xs leading-6 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.dreaminaCancel}</p>
|
||||
<p className="text-xs leading-6 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.dreaminaAccountSwitch}</p>
|
||||
{status?.state === "missing" ? (
|
||||
<p className="rounded-md bg-foreground/[0.035] p-3 text-xs leading-6 text-foreground/65">未检测到官方 Dreamina CLI。请按官方说明安装并确保命令在 PATH 中可用,安装后点击“刷新状态”。当前版本不内置未经核实的安装源。</p>
|
||||
) : null}
|
||||
{status?.state === "login_pending" ? (
|
||||
<div className="rounded-md border border-border/70 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-foreground/60">官方验证用户码</span>
|
||||
<Typography.Text
|
||||
strong
|
||||
copyable={{
|
||||
text: status.userCode,
|
||||
icon: [<Copy className="size-3.5" key="copy" />, <CheckCircle2 className="size-3.5" key="done" />],
|
||||
}}
|
||||
>
|
||||
{status.userCode}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{status.expiresAt ? <p className="mt-1 text-xs text-foreground/50">有效期至 {new Date(status.expiresAt).toLocaleTimeString()}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
{status?.state === "installed" ? (
|
||||
<Button type="primary" icon={<LogIn className="size-4" />} loading={pending === "login"} disabled={Boolean(pending && pending !== "login")} onClick={() => void runDreamina("login")}>
|
||||
登录
|
||||
</Button>
|
||||
) : null}
|
||||
{status?.state === "login_pending" ? (
|
||||
<Button type="primary" icon={<ExternalLink className="size-4" />} onClick={openVerification}>
|
||||
打开官方验证页
|
||||
</Button>
|
||||
) : null}
|
||||
{status?.authenticated ? (
|
||||
<Popconfirm title="退出 Dreamina CLI?" description="只清除当前 OS 用户的官方 CLI 登录状态。" okText="退出" cancelText="取消" onConfirm={() => void runDreamina("logout")}>
|
||||
<Button danger icon={<LogOut className="size-4" />} loading={pending === "logout"}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function runtimePresentation(connection: LocalRuntimeConnectionState): Presentation {
|
||||
if (connection === "connected") return { label: "已连接", tone: "success", action: "refresh" };
|
||||
if (connection === "connecting") return { label: "正在检测", tone: "processing", action: null };
|
||||
if (connection === "origin_not_trusted") return { label: "需要重新连接", tone: "error", action: "refresh", actionLabel: LOCAL_CLI_SETTINGS_COPY.runtimeReconnect };
|
||||
if (connection === "unreachable") return { label: "未发现", tone: "error", action: "refresh" };
|
||||
if (connection === "incompatible") return { label: "版本不兼容", tone: "error", action: "refresh" };
|
||||
if (connection === "runtime_error") return { label: "运行时错误", tone: "error", action: "refresh" };
|
||||
return { label: "尚未检测", tone: "default", action: "refresh" };
|
||||
}
|
||||
|
||||
function dreaminaEmptyMessage(connection: LocalRuntimeConnectionState, moduleAvailable: boolean) {
|
||||
if (connection !== "connected") return LOCAL_CLI_SETTINGS_COPY.dreaminaDisconnectedMessage;
|
||||
if (!moduleAvailable) return "当前 Runtime 未加载 Dreamina 模块,请更新并重启 Runtime。";
|
||||
return "正在检测 Dreamina CLI…";
|
||||
}
|
||||
Reference in New Issue
Block a user