chore: commit workspace updates

This commit is contained in:
ddCat
2026-09-14 01:11:56 +08:00
parent 01a2d171f5
commit 9727656b2b
491 changed files with 12826 additions and 72249 deletions

View File

@@ -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) {

View File

@@ -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" />}

View File

@@ -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;
}
}

View File

@@ -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">

View File

@@ -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…";
}