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

@@ -8,11 +8,9 @@ import { useSearchParams } from "react-router";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { PaginationBar } from "@/components/layout/workspace-page";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
import { refreshSystemChannels } from "@/lib/user-session";
import { createAdminChannel, deleteAdminChannel, listAdminChannels, updateAdminChannel } from "@/services/api/auth";
import { type ChannelHeader, type ModelChannel } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
@@ -23,7 +21,6 @@ type ChannelFormValues = {
name: string;
publicAlias?: string;
baseUrl: string;
allowLocalChannel?: boolean;
apiKey?: string;
secretKey?: string;
headers?: ChannelHeader[];
@@ -32,37 +29,11 @@ type ChannelFormValues = {
enabled?: boolean;
};
export function adminLocalChannelFormOwner(desktopLocalChannelsEnabled: boolean, hostname: string, requestedAllowLocalChannel?: boolean) {
const state = desktopLocalChannelFormState(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel);
return { ...state, payloadValue: desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel) };
}
export function AdminLocalChannelSwitch({ visible, checked, onChange }: { visible: boolean; checked: boolean; onChange: (checked: boolean) => void }) {
if (!visible) return null;
return (
<Form.Item name="allowLocalChannel" label="允许本机渠道" valuePropName="checked" extra={`仅放行精确 localhost 或 127.0.0.1;示例:${DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL}`}>
<Switch checked={checked} onChange={onChange} />
</Form.Item>
);
}
export function AdminLocalChannelFields({ visible, checked, form }: { visible: boolean; checked: boolean; form: Pick<FormInstance<ChannelFormValues>, "setFieldValue"> }) {
return (
<>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}>
<Input placeholder={checked ? DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL : "填写渠道 Base URL"} />
</Form.Item>
<AdminLocalChannelSwitch visible={visible} checked={checked} onChange={(value) => form.setFieldValue("allowLocalChannel", value)} />
</>
);
}
export function adminChannelSavePayload(values: ChannelFormValues, desktopLocalChannelsEnabled: boolean, hostname: string) {
export function adminChannelSavePayload(values: ChannelFormValues) {
return {
name: values.name.trim(),
publicAlias: values.publicAlias?.trim() || "",
baseUrl: values.baseUrl.trim(),
allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, hostname, values.allowLocalChannel).payloadValue,
apiKey: values.apiKey?.trim() || "",
secretKey: values.secretKey?.trim() || "",
headers: values.headers || [],
@@ -91,12 +62,6 @@ export default function ChannelsPage() {
const requestSequence = useRef(0);
const [form] = Form.useForm<ChannelFormValues>();
const useGlobalConcurrency = Form.useWatch("useGlobalConcurrency", form) !== false;
const requestedAllowLocalChannel = Form.useWatch("allowLocalChannel", form) === true;
const desktopLocalChannelsEnabled = useUserStore((state) => state.features.desktopLocalChannelsEnabled);
const desktopLocalChannelHostname = typeof window === "undefined" ? "" : window.location.hostname;
const desktopLocalChannelControl = adminLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, requestedAllowLocalChannel);
const allowLocalChannel = desktopLocalChannelControl.checked;
const showDesktopLocalChannelControl = desktopLocalChannelControl.visible;
const hasFilters = Boolean(keyword || status !== "all");
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
@@ -146,7 +111,6 @@ export default function ChannelsPage() {
? {
name: channel.name,
baseUrl: channel.baseUrl,
allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked,
apiKey: "",
secretKey: "",
headers: channel.headers || [],
@@ -154,7 +118,7 @@ export default function ChannelsPage() {
concurrencyLimit: channel.concurrencyLimit || undefined,
enabled: channel.enabled !== false,
}
: { name: "", baseUrl: "", allowLocalChannel: false, apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true },
: { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true },
);
setDrawerOpen(true);
form.setFieldsValue({ publicAlias: channel?.publicAlias || "" });
@@ -182,7 +146,7 @@ export default function ChannelsPage() {
}
setSaving(true);
try {
const payload = adminChannelSavePayload(values, desktopLocalChannelsEnabled, desktopLocalChannelHostname);
const payload = adminChannelSavePayload(values);
await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
await syncChannels();
setDrawerOpen(false);
@@ -382,7 +346,9 @@ export default function ChannelsPage() {
<Form.Item name="publicAlias" label="前台显示别名" extra="留空时显示渠道名称;填写后用户端只显示此别名,后台仍保留原渠道名称。" rules={[{ max: 80, message: "别名不能超过 80 个字符" }]}>
<Input maxLength={80} placeholder="可选,例如:精选图片" />
</Form.Item>
<AdminLocalChannelFields visible={showDesktopLocalChannelControl} checked={allowLocalChannel} form={form} />
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}>
<Input placeholder="填写云端渠道 Base URL" />
</Form.Item>
<Form.Item
name="apiKey"
label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"}

View File

@@ -246,7 +246,7 @@ function taskStatusTone(value?: string): AdminStatusTone {
}
function quotaUsageItems(detail: AdminUserDetail) {
const structuredBytes = detail.storageUsage.assetBytes + detail.storageUsage.canvasBytes + detail.storageUsage.sessionBytes;
const structuredBytes = detail.storageUsage.assetBytes + detail.storageUsage.canvasBytes;
const bytes = (value: number) => value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(2)} GB` : `${(value / 1024 ** 2).toFixed(1)} MB`;
const number = (value: number) => new Intl.NumberFormat("zh-CN").format(value);
return [
@@ -256,7 +256,6 @@ function quotaUsageItems(detail: AdminUserDetail) {
{ label: "任务与请求日志数据", value: detail.storageUsage.taskBytes, limit: detail.quota.taskDataGB * 1024 ** 3, display: `${bytes(detail.storageUsage.taskBytes)} / ${detail.quota.taskDataGB} GB` },
{ label: "素材数量", value: detail.storageUsage.assetCount, limit: detail.quota.assetCount, display: `${number(detail.storageUsage.assetCount)} / ${number(detail.quota.assetCount)}` },
{ label: "画布数量", value: detail.storageUsage.canvasCount, limit: detail.quota.canvasCount, display: `${number(detail.storageUsage.canvasCount)} / ${number(detail.quota.canvasCount)}` },
{ label: "Agent 会话数量", value: detail.storageUsage.sessionCount, limit: detail.quota.sessionCount, display: `${number(detail.storageUsage.sessionCount)} / ${number(detail.quota.sessionCount)}` },
{ label: "任务历史数量", value: detail.storageUsage.taskCount, limit: detail.quota.taskCount, display: `${number(detail.storageUsage.taskCount)} / ${number(detail.quota.taskCount)}` },
{ label: "上游请求日志数量", value: detail.storageUsage.apiCallCount, limit: detail.quota.apiCallLogCount, display: `${number(detail.storageUsage.apiCallCount)} / ${number(detail.quota.apiCallLogCount)}` },
];

View File

@@ -323,7 +323,7 @@ export function ChannelModelEditor({
</div>
</section>
) : null}
{modelCapability === "image" || modelCapability === "video" ? (
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
<section className="admin-model-editor-section admin-model-editor-section-stacked admin-model-editor-parameters">
<SectionHeading title="协议参数" description="配置可发送参数、支持值与默认值;仅影响当前模型。" />
<div className="admin-model-editor-section-content">

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { App, Button, Skeleton } from "antd";
import { Switch } from "@/components/ui/base/switch";
import { AlertTriangle, Clapperboard, Coins, ListChecks, LockKeyhole, MonitorCog, PlugZap, RadioTower, RefreshCw, ShieldCheck, Sparkles } from "lucide-react";
import { AlertTriangle, Clapperboard, Coins, ListChecks, MonitorCog, PlugZap, RadioTower, RefreshCw, ShieldCheck, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth";
@@ -269,7 +269,6 @@ export default function FeatureAvailabilityPanel() {
status={<AdminStatusBadge label={draftFeatures.frontendModelsEnabled ? "前台模型目录" : "系统渠道"} tone="info" />}
>
<FeatureSourceRow row={modelFeatureRows[0]} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} />
<FeatureRuntimeRow enabled={draftFeatures.desktopLocalChannelsEnabled} />
</FeatureDomainPanel>
</div>
</div>
@@ -341,18 +340,6 @@ function FeatureSourceRow({ row, saved, draft, saving, onChange }: { row: Featur
);
}
function FeatureRuntimeRow({ enabled }: { enabled: boolean }) {
return (
<aside className="admin-feature-runtime-note" aria-label="桌面本地渠道部署状态">
<LockKeyhole className="size-4" aria-hidden="true" />
<div>
<strong>桌面本地渠道由部署环境控制</strong>
</div>
<AdminStatusBadge label={enabled ? "当前可用" : "当前不可用"} tone={enabled ? "success" : "neutral"} />
</aside>
);
}
function effectiveFeatureValue(features: FeatureAvailability, key: FeatureKey) {
if (key === "systemPluginsVisibleToUsers") return features.pluginCenterEnabled && features.systemPluginsVisibleToUsers;
return features[key];
@@ -388,7 +375,6 @@ function parseFeatureAvailability(value: unknown): FeatureAvailability {
frontendModelsEnabled: record.frontendModelsEnabled as boolean,
pluginCenterEnabled: record.pluginCenterEnabled as boolean,
systemPluginsVisibleToUsers: record.systemPluginsVisibleToUsers as boolean,
desktopLocalChannelsEnabled: record.desktopLocalChannelsEnabled === true,
configured: typeof record.configured === "boolean" ? record.configured : undefined,
updatedBy: typeof record.updatedBy === "string" ? record.updatedBy : undefined,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,

View File

@@ -9,8 +9,7 @@ import { PaginationBar } from "@/components/layout/workspace-page";
import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { MEDIA_CONVERSION_PLUGIN_ID } from "@/lib/plugins/builtin/media-conversion";
import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import type { PluginManifest, PluginManifestV2 } from "@/lib/plugins/plugin-types";
import { fetchAdminPlugins, setPluginPlatformAvailability, uninstallPlugin, uploadPlugin, type AdminPluginState, type BackendPlugin, type PluginManagement } from "@/services/api/plugins";
@@ -27,7 +26,7 @@ type AdminPluginItem = {
error?: string;
};
const officialApplicationIds = new Set([RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance", MEDIA_CONVERSION_PLUGIN_ID]);
const officialApplicationIds = new Set([RUNNINGHUB_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID]);
export default function AdminPluginsPage() {
const { message, modal } = App.useApp();
@@ -371,7 +370,7 @@ function mergePlugins(remote: BackendPlugin[]): AdminPluginItem[] {
origin: "official",
kind: application ? "application" : "protocol",
activationScope: application ? "user" : "system",
configurationScope: application ? (plugin.manifest.id === EAGLE_PLUGIN_ID || plugin.manifest.id === RUNNINGHUB_PLUGIN_ID || plugin.manifest.id === COMFYUI_PLUGIN_ID ? "user" : "none") : "system",
configurationScope: application ? (plugin.manifest.id === EAGLE_PLUGIN_ID || plugin.manifest.id === RUNNINGHUB_PLUGIN_ID ? "user" : "none") : "system",
},
});
}

View File

@@ -24,7 +24,6 @@ type PolicySectionDefinition = {
const resourceFields: PolicyField[] = [
{ group: "resource", name: "resourceUploadMB", label: "普通资源单文件", extra: "素材上传和远程导入的单文件业务上限。", unit: "MB", max: 999 },
{ group: "resource", name: "sessionUploadMB", label: "Agent 会话附件", extra: "单个会话附件的大小上限。", unit: "MB", max: 999 },
{ group: "resource", name: "generatedFileMB", label: "单个生成资源", extra: "上游生成响应和落库资源的单文件上限。", unit: "MB", max: 999 },
{ group: "resource", name: "dailyUploadMB", label: "每日上传总量", extra: "按 UTC 自然日累计资源与附件上传。", unit: "MB", max: 999_999 },
{ group: "resource", name: "storedFileGB", label: "账号文件总量", extra: "资源文件与 Agent 会话附件合计。", unit: "GB", max: 999 },
@@ -32,7 +31,6 @@ const resourceFields: PolicyField[] = [
{ group: "resource", name: "taskDataGB", label: "任务数据总量", extra: "任务历史、结果和上游请求日志合计。", unit: "GB", max: 999 },
{ group: "resource", name: "assetCount", label: "素材数量", extra: "单账号可保存的素材记录数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "canvasCount", label: "画布数量", extra: "单账号可保存的画布数量。", unit: "个", max: 999_999_999 },
{ group: "resource", name: "sessionCount", label: "Agent 会话数量", extra: "单账号可保存的 Agent 会话数量。", unit: "个", max: 999_999_999 },
{ group: "resource", name: "taskCount", label: "任务历史数量", extra: "单账号保留的任务历史记录数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "apiCallLogCount", label: "请求日志数量", extra: "单账号保留的上游请求日志数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "recycleBinRetentionDays", label: "回收站自动清理时间", extra: "素材移入回收站后自动彻底删除的天数,0 表示不自动清理。", unit: "天", min: 0, max: 365 },
@@ -55,10 +53,8 @@ const timeoutFields: PolicyField[] = [
const rateFields: PolicyField[] = [
{ group: "request", name: "taskCreatePerMinute", label: "任务创建", extra: "每账号每分钟允许创建的任务数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "sessionCreatePerMinute", label: "会话创建", extra: "每账号每分钟允许创建的会话数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "resourceUploadPerMinute", label: "资源上传", extra: "每账号每分钟上传资源的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "resourceImportPerMinute", label: "资源导入", extra: "每账号每分钟导入远程资源的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "sessionFilePerMinute", label: "会话附件", extra: "每账号每分钟上传会话附件的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "assetWritePerMinute", label: "素材写入", extra: "每账号每分钟写入素材的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "canvasWritePerMinute", label: "画布写入", extra: "每账号每分钟写入画布的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "registerPerHour", label: "账号注册", extra: "每 IP 每小时允许的注册请求次数,包含失败尝试;多人共用网络时建议至少 30 次。", unit: "次/小时", max: 999_999 },

View File

@@ -1,76 +0,0 @@
import { useCallback, useRef, useState, type ReactNode } from "react";
// 根据视口宽度动态计算面板宽度约束,避免小屏幕上面板挤压画布
export function getPanelWidthBounds(): { min: number; max: number } {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
if (vw < 768) return { min: 260, max: 360 };
if (vw < 1024) return { min: 280, max: 440 };
if (vw < 1440) return { min: 320, max: 560 };
return { min: 360, max: 760 };
}
// 智能体面板列包裹器:承载面板宽度管理、resize 拖拽和顶部停靠偏移。
// 面板停靠在画布工具栏下方,避免悬浮工具栏遮挡 Agent 标题和消息;沉浸模式传 0。
// closing 期间保持宽度和渲染,让面板播放滑出动画;动画结束后由父组件卸载列。
export function AssistantPanelColumn({
width,
closing,
topInset,
onWidthChange,
children,
}: {
width: number;
closing: boolean;
topInset: string;
onWidthChange: (width: number) => void;
children: (resizing: boolean) => ReactNode;
}) {
const columnRef = useRef<HTMLDivElement>(null);
const [resizing, setResizing] = useState(false);
// 拖拽时列右边缘固定(flex 末位),左边缘随鼠标移动。
const startResize = useCallback((event: React.MouseEvent) => {
event.preventDefault();
const rightEdge = columnRef.current?.getBoundingClientRect().right ?? 0;
const { min, max } = getPanelWidthBounds();
const move = (e: MouseEvent) => {
onWidthChange(Math.min(max, Math.max(min, rightEdge - e.clientX)));
};
const stop = () => {
setResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", stop);
};
setResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", stop);
}, [onWidthChange]);
return (
<div
ref={columnRef}
className="relative z-[var(--z-toolbar)] flex shrink-0 overflow-hidden"
style={{
width,
paddingTop: topInset,
transition: resizing ? "none" : "width var(--motion-dur-base-calc) var(--motion-ease-out), padding-top var(--motion-dur-base-calc) var(--motion-ease-out)",
}}
>
<div className="h-full w-full">
{!closing ? (
<button
type="button"
className="absolute inset-y-0 left-0 z-[var(--node-z-overlay)] w-4 -translate-x-1/2 cursor-col-resize"
onMouseDown={startResize}
aria-label="调整右侧面板宽度"
/>
) : null}
{children(resizing)}
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { CheckCircle2, CloudUpload, Eye, LoaderCircle, RotateCcw, TriangleAlert,
import type { GenerationTask } from "@/services/api/task-center";
import type { MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasAgentChange } from "./use-canvas-agent-operations";
import type { CanvasOperationChange } from "./use-canvas-operation-history";
import { aceternityMotion } from "@/lib/aceternity-motion";
export type CanvasUploadStatus = {
@@ -113,7 +113,7 @@ export function CanvasMergeStatusToast({ progress, theme }: { progress: MergeVid
);
}
export function CanvasAgentChangeToast({ change, theme, onView, onUndo, onClose }: { change: CanvasAgentChange; theme: CanvasTheme; onView: () => void; onUndo: () => void; onClose: () => void }) {
export function CanvasOperationChangeToast({ change, theme, onView, onUndo, onClose }: { change: CanvasOperationChange; theme: CanvasTheme; onView: () => void; onUndo: () => void; onClose: () => void }) {
return (
<div
data-canvas-no-zoom

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
@@ -31,10 +31,6 @@ type CanvasTopBarProps = {
onUndo: () => void;
onRedo: () => void;
onShare: () => void;
agentOpen: boolean;
agentPanelWidth?: number;
compactAgentStatus?: { connected: boolean; enabled: boolean; activity: string };
onToggleAgent: () => void;
shortcutRequestNonce: number;
mediaPerformanceMode: CanvasMediaPerformanceMode;
onMediaPerformanceModeChange: (mode: CanvasMediaPerformanceMode) => void;
@@ -62,10 +58,6 @@ export function CanvasTopBar({
onUndo,
onRedo,
onShare,
agentOpen,
agentPanelWidth,
compactAgentStatus,
onToggleAgent,
shortcutRequestNonce,
mediaPerformanceMode,
onMediaPerformanceModeChange,
@@ -101,7 +93,7 @@ export function CanvasTopBar({
return (
<>
<div className="canvas-topbar pointer-events-none absolute left-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5" style={{ right: agentOpen && agentPanelWidth ? `calc(${agentPanelWidth}px + var(--space-3))` : 0, transition: "right var(--motion-dur-base-calc) var(--motion-ease-out)" }}>
<div className="canvas-topbar pointer-events-none absolute inset-x-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2" style={dockStyle}>
<CanvasTopBarTooltip label="打开画布菜单">
<Dropdown
@@ -160,7 +152,13 @@ export function CanvasTopBar({
{title}
</button>
<CanvasTopBarTooltip label="重命名画布">
<button type="button" className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
<button
type="button"
className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2"
style={{ color: theme.node.text }}
onClick={onStartTitleEditing}
aria-label="重命名画布"
>
<Pencil className="size-3.5" />
</button>
</CanvasTopBarTooltip>
@@ -185,9 +183,16 @@ export function CanvasTopBar({
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
<CanvasTopBarTooltip label="搜索画布节点">
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" />
<Button
type="text"
className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex"
style={{ color: theme.node.text }}
icon={<Search className="size-4" />}
onClick={onOpenSearch}
aria-label="搜索画布节点"
/>
</CanvasTopBarTooltip>
{!agentOpen ? <CanvasTopBarTooltip label="导入第三方画布">
<CanvasTopBarTooltip label="导入第三方画布">
<Dropdown
trigger={["click"]}
placement="bottomRight"
@@ -202,8 +207,8 @@ export function CanvasTopBar({
<span className="hidden lg:inline">导入第三方画布</span>
</Button>
</Dropdown>
</CanvasTopBarTooltip> : null}
{!agentOpen ? <CanvasTopBarTooltip label="媒体性能模式">
</CanvasTopBarTooltip>
<CanvasTopBarTooltip label="媒体性能模式">
<Dropdown
trigger={["click"]}
menu={{
@@ -219,8 +224,7 @@ export function CanvasTopBar({
>
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" />
</Dropdown>
</CanvasTopBarTooltip> : null}
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
</CanvasTopBarTooltip>
{user && creditsEnabled ? (
<CanvasTopBarTooltip label="查看积分明细">
<Link
@@ -235,14 +239,7 @@ export function CanvasTopBar({
</CanvasTopBarTooltip>
) : null}
<CanvasTopBarTooltip label="进入专注模式(Shift + Ctrl/Cmd + F)">
<Button
type="text"
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
style={{ color: theme.node.text }}
icon={<Focus className="size-4" />}
onClick={onEnterFocusMode}
aria-label="进入专注模式"
/>
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Focus className="size-4" />} onClick={onEnterFocusMode} aria-label="进入专注模式" />
</CanvasTopBarTooltip>
{shortDramaGuide ? (
<CanvasTopBarTooltip label={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
@@ -262,17 +259,6 @@ export function CanvasTopBar({
<CanvasTopBarTooltip label="分享画布">
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" />
</CanvasTopBarTooltip>
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
className="canvas-topbar-action !h-10 !rounded-xl !px-3 !font-medium"
style={{ background: agentOpen ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
icon={<Bot className="size-4" />}
onClick={onToggleAgent}
aria-pressed={agentOpen}
>
Agent
</Button>
</div>
</div>
<CanvasShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
@@ -304,10 +290,3 @@ function canvasTitleInputSize(value: string) {
const visualLength = Array.from(value || "画布名称").reduce((length, character) => length + (character.codePointAt(0)! > 0xff ? 2 : 1), 0);
return Math.min(30, Math.max(5, visualLength));
}
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex";
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return <CanvasTopBarTooltip label="打开本地 Codex 面板"><button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} aria-label="打开本地 Codex 面板"><span className="size-2 rounded-full" style={{ background: dotColor }} /><span className="max-w-[180px] truncate">{label}</span></button></CanvasTopBarTooltip>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@ import { listGenerationTasks, type GenerationTask } from "@/services/api/task-ce
export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
const query = useQuery<GenerationTask[]>({
queryKey: ["canvas-active-tasks", projectId],
queryFn: () => listGenerationTasks(5, { projectId, activeOnly: true }),
// Agent 的持久化执行仍复用任务队列/计费生命周期,但不应占据画布右上角的“生成任务”浮层。
// 多取一页再过滤,避免 Agent 排在前面时把真正的画布生成任务挤掉。
queryFn: () => listGenerationTasks(30, { projectId, activeOnly: true }).then((tasks) => tasks.filter((task) => !isInternalAgentTask(task)).slice(0, 5)),
enabled: enabled && Boolean(projectId),
refetchInterval: (current) => (current.state.data?.length ? 2_000 : 10_000),
refetchOnWindowFocus: true,
@@ -33,3 +35,6 @@ export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
};
}
function isInternalAgentTask(task: GenerationTask) {
return task.operation?.startsWith("cloud_agent") === true;
}

View File

@@ -1,48 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CANVAS_AGENT_PANEL_MOTION_MS } from "@/components/canvas/canvas-assistant-panel";
import type { CanvasAgentMode } from "@/components/canvas/canvas-agent-chat-ui";
import { useCallback, useState } from "react";
export function useCanvasAssistantVisibility() {
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
const [assistantMounted, setAssistantMounted] = useState(false);
const [assistantClosing, setAssistantClosing] = useState(false);
const [agentMode, setAgentMode] = useState<CanvasAgentMode>("online");
const [assistantOpen, setAssistantOpen] = useState(false);
const openAgent = useCallback((mode?: CanvasAgentMode) => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (mode) setAgentMode(mode);
setAssistantMounted(true);
setAssistantClosing(false);
setAssistantCollapsed(false);
const openAgent = useCallback(() => {
setAssistantOpen(true);
}, []);
const closeAgent = useCallback(() => {
if (!assistantMounted || assistantClosing) return;
setAssistantCollapsed(true);
setAssistantClosing(true);
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null;
setAssistantMounted(false);
setAssistantClosing(false);
}, CANVAS_AGENT_PANEL_MOTION_MS);
}, [assistantClosing, assistantMounted]);
useEffect(() => () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
setAssistantOpen(false);
}, []);
return {
agentMode,
assistantClosing,
assistantMounted,
assistantOpen: assistantMounted && !assistantCollapsed,
assistantClosing: false,
assistantMounted: true,
assistantOpen,
closeAgent,
openAgent,
setAgentMode,
};
}

View File

@@ -67,12 +67,6 @@ function selectRunningHubWorkflow(config: AiConfig) {
|| config.runningHub.workflows[0];
}
function selectComfyBridgeWorkflow(config: AiConfig) {
return config.comfyBridge.workflows.find((item) => item.workflowId.trim() === config.comfyBridge.workflowId.trim())
|| config.comfyBridge.workflows.find((item) => item.capability === config.comfyBridge.capability)
|| config.comfyBridge.workflows[0];
}
export function useCanvasConnectionController({
projectId,
config,
@@ -233,7 +227,7 @@ export function useCanvasConnectionController({
setContextMenu(null);
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub" | "comfyui") => {
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const nodeType = type;
if (nodeType === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
@@ -257,19 +251,16 @@ export function useCanvasConnectionController({
? batchSourceNodeIds.length ? batchScriptPrompt : sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : ""
: "";
const selectedWorkflowProvider = nodeType === CanvasNodeType.Config
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "comfyui" : undefined)
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : undefined)
: undefined;
if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) {
message.error(`${selectedWorkflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`);
message.error("RunningHub 工作流插件未启用");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const runningHubWorkflow = selectedWorkflowProvider === "runninghub" ? selectRunningHubWorkflow(config) : undefined;
const comfyBridgeWorkflow = selectedWorkflowProvider === "comfyui" ? selectComfyBridgeWorkflow(config) : undefined;
const workflowCapability = selectedWorkflowProvider === "runninghub"
? normalizeRunningHubCapability(runningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability))
: comfyBridgeWorkflow?.capability || "image";
const workflowCapability = normalizeRunningHubCapability(runningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability));
const metadata: CanvasNodeMetadata | undefined = nodeType === CanvasNodeType.Config
? {
generationMode: selectedWorkflowProvider ? workflowCapability === "video" ? "video" as const : workflowCapability === "audio" ? "audio" as const : "image" as const : "image" as const,
@@ -278,9 +269,6 @@ export function useCanvasConnectionController({
...(selectedWorkflowProvider === "runninghub" ? {
workflowTitle: "RunningHub 工作流",
...(runningHubWorkflow ? { runningHubWorkflowId: runningHubWorkflow.workflowId, runningHubWorkflowKind: runningHubWorkflow.kind === "app" ? "app" as const : "workflow" as const } : {}),
} : selectedWorkflowProvider === "comfyui" ? {
workflowTitle: "ComfyUI Bridge",
...(comfyBridgeWorkflow ? { comfyBridgeWorkflowId: comfyBridgeWorkflow.workflowId } : {}),
} : {})
}
: nodeType === CanvasNodeType.Drawing
@@ -304,7 +292,7 @@ export function useCanvasConnectionController({
? pending.position
: connectedNodeCenterFromEdgeDrop(pending.position, spec, pending.connection.handleType);
const newNode = createCanvasNode(nodeType, position, metadata);
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : "ComfyUI Bridge";
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = "RunningHub 工作流";
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.Drawing) {
message.error("批量连接暂不支持创建绘图,请先连接到普通节点");
@@ -312,12 +300,6 @@ export function useCanvasConnectionController({
setConnecting(null);
return;
}
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.MediaConversion) {
message.error("转换节点一次只能连接一个输入,请先连接到普通节点");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const batchPlan = batchSourceNodeIds.length
? planBatchConnections({ sourceNodeIds: batchSourceNodeIds, targetNodeId: newNode.id, nodes: [...nodesRef.current, newNode], connections: connectionsRef.current, config, allowCapacityOverflow: true })
: null;
@@ -337,7 +319,8 @@ export function useCanvasConnectionController({
setConnections(nextConnections);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
const skippedCount = batchPlan.skipped.length;
const duplicateCount = batchPlan.duplicates.length;
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
@@ -403,18 +386,18 @@ export function useCanvasConnectionController({
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (nodeType === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
else if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, runtimeStatuses, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub" | "comfyui") => {
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const nodeType = type;
if (nodeType === CanvasNodeType.Config) {
if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return `${workflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`;
if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return "RunningHub 工作流插件未启用";
}
if (pending.batchSourceNodeIds?.length) {
if (nodeType === CanvasNodeType.MediaConversion) return "转换节点一次只能连接一个输入";
if (nodeType === CanvasNodeType.Drawing || nodeType === CanvasNodeType.Config) return "批量连接暂不支持此节点类型";
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: getNodeSpec(nodeType).width, height: getNodeSpec(nodeType).height };
const plan = planBatchConnections({ sourceNodeIds: pending.batchSourceNodeIds, targetNodeId: pendingNode.id, nodes: [...nodesRef.current, pendingNode], connections: connectionsRef.current, config, allowCapacityOverflow: true });

View File

@@ -1,3 +1,4 @@
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { useCallback, useRef, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
@@ -101,6 +102,10 @@ export function useCanvasGenerationExecutor({
nodeId,
async () => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
if (isCanvasNodeGenerating(sourceNode)) {
message.info("该节点的生成任务仍在进行中,请等待完成后再生成");
return;
}
if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") {
message.info("合并成片节点不直接重新生成,请重新选择源视频合并");
return;
@@ -224,8 +229,8 @@ export function useCanvasGenerationExecutor({
generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model"
? {
provider: generationConfig.taskWorkflowProvider,
workflowId: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.workflowId : generationConfig.comfyBridge.workflowId,
workflowKind: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.selectedKind : undefined,
workflowId: generationConfig.runningHub.workflowId,
workflowKind: generationConfig.runningHub.selectedKind,
parameters: sourceNode?.metadata?.workflowParameters,
}
: undefined,
@@ -387,6 +392,7 @@ export function useCanvasGenerationExecutor({
);
}
function generationModelRequirements(
mode: CanvasNodeGenerationMode,
input: Pick<Awaited<ReturnType<typeof hydrateNodeGenerationContext>>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">,

View File

@@ -12,7 +12,7 @@ import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas
import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { generationFailureMetadata } from "@/lib/generation-error";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { consumeCanvasAgentGenerationContinuation } from "./use-canvas-agent-operations";
import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history";
type CanvasGenerationRequest = {
targetNodeId: string;
@@ -94,11 +94,11 @@ export async function recoverCanvasGenerationTaskNode(input: {
applyGenerationTaskResult: (nodeId: string, task: GenerationTask) => Promise<void>;
signal: AbortSignal;
isCurrentProject?: () => boolean;
consumeContinuation?: typeof consumeCanvasAgentGenerationContinuation;
consumeContinuation?: typeof consumeCanvasGenerationContinuation;
}) {
const isCurrentProject = () => !input.signal.aborted && (input.isCurrentProject?.() ?? true);
if (!isCurrentProject()) return;
const consumeContinuation = input.consumeContinuation ?? consumeCanvasAgentGenerationContinuation;
const consumeContinuation = input.consumeContinuation ?? consumeCanvasGenerationContinuation;
const recoveryBaseNodes = useCanvasStore.getState().projects.find((project) => project.id === input.projectId)?.nodes ?? input.nodesRef.current;
try {
if (input.completed.projectId && input.completed.projectId !== input.projectId) throw new Error("生成任务不属于当前画布");
@@ -107,7 +107,7 @@ export async function recoverCanvasGenerationTaskNode(input: {
throw new Error(input.completed.error || (input.completed.status === "cancelled" ? "任务已取消" : "任务失败"));
}
if (!input.continuationOnly) {
if (input.node.type === CanvasNodeType.Script && input.completed.type === "agent_storyboard_rows") {
if (input.node.type === CanvasNodeType.Script && input.completed.type === "canvas_text" && input.completed.operation === "storyboard") {
const result = storyboardRowsFromTask(input.completed);
const recoveredNodes = input.nodesRef.current.map((item) =>
item.id === input.node.id
@@ -381,7 +381,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
if (!isCurrentProject()) throw error;
return [];
})
).filter((task) => task.projectId === startedProjectId && (task.type.startsWith("canvas_") || task.type === "agent_storyboard_rows"))
).filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_"))
: [];
if (!isCurrentProject()) return;
await Promise.all(

View File

@@ -14,7 +14,6 @@ import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { useUserStore } from "@/stores/use-user-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { createDefaultPortraitClearanceState, PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts";
import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows";
import { usePluginStore } from "@/stores/use-plugin-store";
@@ -129,32 +128,31 @@ export function useCanvasNodeOperations({
setSelectedConnectionId(null);
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const createNode = useCallback((type: CanvasNodeTypeId, position?: Position, workflowProvider?: "runninghub" | "comfyui") => {
const createNode = useCallback((type: CanvasNodeTypeId, position?: Position, workflowProvider?: "runninghub") => {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
return;
}
const selectedWorkflowProvider = type === CanvasNodeType.Config
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "comfyui" : undefined)
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : undefined)
: undefined;
if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) {
message.error(`${selectedWorkflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`);
message.error("RunningHub 工作流插件未启用");
return;
}
const workflowTitle = type === CanvasNodeType.Config && selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : type === CanvasNodeType.Config && selectedWorkflowProvider === "comfyui" ? "ComfyUI Bridge" : undefined;
const workflowTitle = type === CanvasNodeType.Config && selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : undefined;
const metadata: CanvasNodeMetadata | undefined = type === CanvasNodeType.Drawing
? { drawingEngine: defaultDrawingEngine }
: type === CanvasNodeType.Config
? { generationMode: "image", workflowProvider: selectedWorkflowProvider || "model" }
: type === PORTRAIT_CLEARANCE_NODE_TYPE
? { portraitClearance: createDefaultPortraitClearanceState() }
: undefined;
: undefined;
const node = createCanvasNode(type, position || getCanvasCenter(), metadata);
if (workflowTitle) node.title = workflowTitle;
commitNodes([...nodesRef.current, node]);
selectNodes(new Set([node.id]));
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing && type !== CanvasNodeType.MediaConversion && type !== PORTRAIT_CLEARANCE_NODE_TYPE) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, effectiveConfig.comfyBridge.enabled, effectiveConfig.comfyBridge.workflows.length, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, runtimeStatuses, selectNodes, setDialogNodeId, tldrawLicenseKey]);
if (type === CanvasNodeType.Script) setDialogNodeId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, runtimeStatuses, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {
const folder = createCanvasNode(CanvasNodeType.Frame, position || getCanvasCenter(), {
@@ -413,7 +411,8 @@ export function useCanvasNodeOperations({
const sourceNode = sourceByTargetId.get(targetNode.id);
if (sourceNode) cloneDrawingForNode(sourceNode, targetNode, "绘图副本保存失败,请重新复制");
});
if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing && source.type !== CanvasNodeType.MediaConversion && source.type !== PORTRAIT_CLEARANCE_NODE_TYPE) setDialogNodeId(id);
if (source.type === CanvasNodeType.Script) setDialogNodeId(null);
else if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing) setDialogNodeId(id);
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, nodesRef, selectNodes, setDialogNodeId]);
const setPrimaryVersion = useCallback((nodeId: string) => {
@@ -527,7 +526,7 @@ export function useCanvasNodeOperations({
selectNodes(topLevelIds);
setContextMenu(null);
const primaryNode = nextNodes.find((node) => !node.parentId);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing && primaryNode.type !== PORTRAIT_CLEARANCE_NODE_TYPE ? primaryNode.id : null);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing && primaryNode.type !== CanvasNodeType.Script ? primaryNode.id : null);
return true;
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, getCanvasCenter, nodesRef, selectNodes, setContextMenu, setDialogNodeId]);

View File

@@ -1,21 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { applyCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { applyCanvasOperations, summarizeCanvasOperations, type CanvasOperation, type CanvasSnapshot } from "@/lib/canvas/canvas-operation-contract";
import { createGenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { persistCanvasAgentGenerationContinuationEffect } from "@/services/canvas-generation-consumer";
import { persistCanvasOperationContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas";
import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
export type CanvasAgentGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" };
type CanvasAgentGenerationContinuation = NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
type CanvasAgentGenerationOptions = Pick<CanvasNodeGenerationOptions, "context" | "retryContext" | "onTaskUpdate" | "skipDuplicateConfirmation">;
type CanvasAgentGenerationContinuationDependencies = {
export type CanvasGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" };
type CanvasGenerationContinuation = NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
type CanvasGenerationOptions = Pick<CanvasNodeGenerationOptions, "context" | "retryContext" | "onTaskUpdate" | "skipDuplicateConfirmation">;
type CanvasGenerationContinuationDependencies = {
consumeAgent?: typeof consumeGenerationTaskAgent;
persistContinuation?: typeof persistCanvasAgentGenerationContinuationEffect;
persistContinuation?: typeof persistCanvasOperationContinuationEffect;
projectId?: string;
nodeId?: string;
previousNodes?: CanvasNodeData[];
@@ -23,7 +23,7 @@ type CanvasAgentGenerationContinuationDependencies = {
setNodes?: Dispatch<SetStateAction<CanvasNodeData[]>>;
};
type UseCanvasAgentOperationsOptions = {
type UseCanvasOperationHistoryOptions = {
projectId: string;
domainProjectId?: string;
projectTitle: string;
@@ -35,7 +35,7 @@ type UseCanvasAgentOperationsOptions = {
connectionsRef: { current: CanvasConnection[] };
selectedNodeIdsRef: { current: Set<string> };
viewportRef: { current: ViewportTransform };
generateNodeRef: { current: ((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasAgentGenerationOptions) => Promise<void>) | null };
generateNodeRef: { current: ((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasGenerationOptions) => Promise<void>) | null };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
@@ -45,27 +45,27 @@ type UseCanvasAgentOperationsOptions = {
focusSelection: () => boolean;
};
export type CanvasAgentChange = {
export type CanvasOperationChange = {
id: string;
summary: string;
nodeIds: string[];
undoCount: number;
};
type CanvasAgentUndoBatch = { snapshot: CanvasAgentSnapshot; afterNodes: CanvasNodeData[]; afterConnections: CanvasConnection[]; change: Omit<CanvasAgentChange, "undoCount"> };
type CanvasOperationUndoBatch = { snapshot: CanvasSnapshot; afterNodes: CanvasNodeData[]; afterConnections: CanvasConnection[]; change: Omit<CanvasOperationChange, "undoCount"> };
type RunCanvasAgentGenerationOpsInput = {
generationOps: Array<Extract<CanvasAgentOp, { type: "run_generation" }>>;
type RunCanvasGenerationOpsInput = {
generationOps: Array<Extract<CanvasOperation, { type: "run_generation" }>>;
nodes: CanvasNodeData[];
context?: CanvasAgentGenerationContext;
generate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options: CanvasAgentGenerationOptions) => Promise<void>;
context?: CanvasGenerationContext;
generate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options: CanvasGenerationOptions) => Promise<void>;
subscribeTasks?: (ids: readonly string[], listener: (task: GenerationTask) => void) => () => void;
consumeTask?: typeof consumeGenerationTaskAgent;
resumeAgent?: (task: GenerationTask) => Promise<void>;
onContinuation?: (nodeId: string, continuation: CanvasAgentGenerationContinuation) => Promise<void> | void;
onContinuation?: (nodeId: string, continuation: CanvasGenerationContinuation) => Promise<void> | void;
};
export async function runCanvasAgentGenerationOps({
export async function runCanvasGenerationOps({
generationOps,
nodes,
context,
@@ -74,7 +74,7 @@ export async function runCanvasAgentGenerationOps({
consumeTask = consumeGenerationTaskAgent,
resumeAgent = async () => undefined,
onContinuation,
}: RunCanvasAgentGenerationOpsInput) {
}: RunCanvasGenerationOpsInput) {
const observations = new Map<string, Promise<void>>();
const observe = (taskId: string, nodeId: string, continuationIdPromise?: Promise<string>) => {
const existing = observations.get(taskId);
@@ -91,8 +91,8 @@ export async function runCanvasAgentGenerationOps({
conversationId: task.clientContext?.conversationId || context?.conversationId,
messageId: task.clientContext?.messageId || context?.messageId,
};
const continuationId = await (continuationIdPromise ?? canvasAgentGenerationContinuationId(task.clientContext?.nodeId || nodeId, taskContext));
const continuation: CanvasAgentGenerationContinuation = {
const continuationId = await (continuationIdPromise ?? canvasGenerationContinuationId(task.clientContext?.nodeId || nodeId, taskContext));
const continuation: CanvasGenerationContinuation = {
id: continuationId,
taskId: task.id,
...(taskContext.conversationId ? { conversationId: taskContext.conversationId } : {}),
@@ -141,7 +141,7 @@ export async function runCanvasAgentGenerationOps({
const retryOf = op.retry ? target?.metadata?.taskId : undefined;
if (op.retry && !retryOf) throw new Error("当前节点没有可重试的生成任务");
const retryContext = retryOf ? await createGenerationRetryContext(retryOf, target?.metadata?.attemptGroupId) : undefined;
const continuationIdPromise = canvasAgentGenerationContinuationId(op.nodeId, context);
const continuationIdPromise = canvasGenerationContinuationId(op.nodeId, context);
let observation: Promise<void> | undefined;
let continuationTaskId = "";
const generationPromise = generate(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt, {
@@ -170,11 +170,11 @@ export async function runCanvasAgentGenerationOps({
);
}
export async function consumeCanvasAgentGenerationContinuation(
export async function consumeCanvasGenerationContinuation(
task: GenerationTask,
continuation: CanvasAgentGenerationContinuation,
onCompleted: (continuation: CanvasAgentGenerationContinuation, signal?: AbortSignal) => Promise<void> | void,
dependencies: CanvasAgentGenerationContinuationDependencies = {},
continuation: CanvasGenerationContinuation,
onCompleted: (continuation: CanvasGenerationContinuation, signal?: AbortSignal) => Promise<void> | void,
dependencies: CanvasGenerationContinuationDependencies = {},
signal?: AbortSignal,
) {
if (continuation.status !== "pending" || continuation.taskId !== task.id || task.status !== "succeeded") return task;
@@ -185,7 +185,7 @@ export async function consumeCanvasAgentGenerationContinuation(
async ({ effectKey, signal: leaseSignal }) => {
const completed = { ...continuation, status: "completed" as const, effectKey };
if (dependencies.projectId && dependencies.nodeId && dependencies.nodesRef && dependencies.setNodes) {
await (dependencies.persistContinuation ?? persistCanvasAgentGenerationContinuationEffect)({
await (dependencies.persistContinuation ?? persistCanvasOperationContinuationEffect)({
projectId: dependencies.projectId,
nodeId: dependencies.nodeId,
continuation: completed,
@@ -202,13 +202,13 @@ export async function consumeCanvasAgentGenerationContinuation(
);
}
async function canvasAgentGenerationContinuationId(nodeId: string, context?: CanvasAgentGenerationContext) {
const seed = new TextEncoder().encode(`canvas-agent-generation\0${context?.source || ""}\0${context?.conversationId || ""}\0${context?.messageId || ""}\0${nodeId}`);
async function canvasGenerationContinuationId(nodeId: string, context?: CanvasGenerationContext) {
const seed = new TextEncoder().encode(`canvas-operation-generation\0${context?.source || ""}\0${context?.conversationId || ""}\0${context?.messageId || ""}\0${nodeId}`);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", seed));
return `agent:${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
export function useCanvasAgentOperations({
export function useCanvasOperationHistory({
projectId,
domainProjectId,
projectTitle,
@@ -228,11 +228,11 @@ export function useCanvasAgentOperations({
setViewport,
setContextMenu,
focusSelection,
}: UseCanvasAgentOperationsOptions) {
const undoStackRef = useRef<CanvasAgentUndoBatch[]>([]);
}: UseCanvasOperationHistoryOptions) {
const undoStackRef = useRef<CanvasOperationUndoBatch[]>([]);
const [undoOpsCount, setUndoOpsCount] = useState(0);
const [lastAgentChange, setLastAgentChange] = useState<CanvasAgentChange | null>(null);
const snapshot = useMemo<CanvasAgentSnapshot>(
const [lastAgentChange, setLastAgentChange] = useState<CanvasOperationChange | null>(null);
const snapshot = useMemo<CanvasSnapshot>(
() => ({ projectId, domainProjectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }),
[connections, domainProjectId, nodes, projectId, projectTitle, selectedNodeIds, viewport],
);
@@ -253,11 +253,11 @@ export function useCanvasAgentOperations({
}, [connections, nodes]);
const applyOps = useCallback(
async (ops?: CanvasAgentOp[], generationContext?: CanvasAgentGenerationContext) => {
async (ops?: CanvasOperation[], generationContext?: CanvasGenerationContext) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, domainProjectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(
const generationOps = safeOps.filter((op): op is Extract<CanvasOperation, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasOperations(
before,
safeOps.filter((op) => op.type !== "run_generation"),
);
@@ -278,7 +278,7 @@ export function useCanvasAgentOperations({
setViewport(next.viewport);
setContextMenu(null);
if (safeOps.length) {
const change = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, summary: summarizeCanvasAgentOps(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds };
const change = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, summary: summarizeCanvasOperations(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds };
undoStackRef.current = [...undoStackRef.current, { snapshot: before, afterNodes: next.nodes, afterConnections: next.connections, change }].slice(-10);
const nextUndoCount = undoStackRef.current.length;
setUndoOpsCount(nextUndoCount);
@@ -288,14 +288,14 @@ export function useCanvasAgentOperations({
if (generationOps.length) {
const generate = generateNodeRef.current;
if (generate)
await runCanvasAgentGenerationOps({
await runCanvasGenerationOps({
generationOps,
nodes: nodesRef.current,
generate,
context: generationContext,
onContinuation: async (nodeId, continuation) => {
if (continuation.status === "completed" && continuation.effectKey) {
await persistCanvasAgentGenerationContinuationEffect({
await persistCanvasOperationContinuationEffect({
projectId,
nodeId,
continuation,
@@ -368,7 +368,7 @@ export function useCanvasAgentOperations({
return { agentSnapshot: snapshot, agentUndoCount: undoOpsCount, applyAgentOps: applyOps, canUndoAgentOps: undoOpsCount > 0, dismissLastAgentChange: () => setLastAgentChange(null), lastAgentChange, undoAgentOps: undoOps, viewLastAgentChange };
}
function agentAffectedNodeIds(ops: CanvasAgentOp[], nodes: CanvasNodeData[]) {
function agentAffectedNodeIds(ops: CanvasOperation[], nodes: CanvasNodeData[]) {
const existingIds = new Set(nodes.map((node) => node.id));
const ids = new Set<string>();
ops.forEach((op) => {

View File

@@ -1,3 +1,4 @@
import { mergeAgentCanvasEditor } from "@/lib/canvas/agent-canvas-patch";
import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { App } from "antd";
import { useNavigate } from "react-router";
@@ -8,7 +9,7 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -83,6 +84,7 @@ export function useCanvasProjectLifecycle({
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
const [loadError, setLoadError] = useState("");
const [loadAttempt, setLoadAttempt] = useState(0);
const [agentCreatedNodes, setAgentCreatedNodes] = useState<{ projectId: string; nodes: CanvasNodeData[] } | null>(null);
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -172,9 +174,27 @@ export function useCanvasProjectLifecycle({
};
}, [projectLoaded]);
useEffect(() => subscribeAgentCanvasRefresh((project, previous) => {
if (!projectLoaded || project.id !== projectId) return;
// Merge only server-changed fields so dragging/editing other nodes can
// continue while Agent media tasks complete. Same-field conflicts fail.
const merged = previous ? mergeAgentCanvasEditor(previous, project, nodesRef.current, connectionsRef.current) : project;
nodesRef.current = merged.nodes;
connectionsRef.current = merged.connections;
setNodes(merged.nodes);
setConnections(merged.connections);
const previousIds = new Set(previous?.nodes.map((node) => node.id));
const created = project.nodes.filter((node) => !previousIds.has(node.id));
if (created.length) setAgentCreatedNodes({ projectId: project.id, nodes: created });
}), [projectId, projectLoaded, nodesRef, connectionsRef, setNodes, setConnections]);
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo });
const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo };
const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId);
// 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。
if (stored && Object.entries(patch).every(([key, value]) => JSON.stringify(stored[key as keyof CanvasProject]) === JSON.stringify(value))) return;
updateProject(projectId, patch);
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
useEffect(() => {
@@ -258,6 +278,7 @@ export function useCanvasProjectLifecycle({
loadError,
retryLoad: () => setLoadAttempt((attempt) => attempt + 1),
addedSkills,
agentCreatedNodes: agentCreatedNodes?.projectId === projectId ? agentCreatedNodes.nodes : null,
clearCanvasFiles,
createAndOpenProject,
currentProject,
@@ -268,6 +289,7 @@ export function useCanvasProjectLifecycle({
};
}
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
return currentSessions.map((session) => {

View File

@@ -346,21 +346,29 @@ export function useCanvasSelectionController({
if (hadPendingSelection && !wasSelection && strategy === "replace") deselectCanvas();
}, [deselectCanvas, resetSelectionBox, screenToCanvas, updateSelectionPreview]);
// 重渲染只更新回调,不能拆掉进行中的手势监听并清除 iframe 保护层 / 待执行帧。
const gestureHandlersRef = useRef({ finishNodeDrag, finishSelection, cancelSelectionBox, handleNodeDragMove, handlePointerMove });
useEffect(() => {
gestureHandlersRef.current = { finishNodeDrag, finishSelection, cancelSelectionBox, handleNodeDragMove, handlePointerMove };
});
useEffect(() => {
const handleMouseUp = (event: MouseEvent) => {
finishNodeDrag(event.clientX, event.clientY);
gestureHandlersRef.current.finishNodeDrag(event.clientX, event.clientY);
};
const handlePointerUp = (event: PointerEvent) => {
finishNodeDrag(event.clientX, event.clientY);
finishSelection(event.clientX, event.clientY);
gestureHandlersRef.current.finishNodeDrag(event.clientX, event.clientY);
gestureHandlersRef.current.finishSelection(event.clientX, event.clientY);
};
const cancel = () => {
finishNodeDrag();
cancelSelectionBox();
gestureHandlersRef.current.finishNodeDrag();
gestureHandlersRef.current.cancelSelectionBox();
};
window.addEventListener("mousemove", handleNodeDragMove);
const onMouseMove = (event: MouseEvent) => gestureHandlersRef.current.handleNodeDragMove(event);
const onPointerMove = (event: PointerEvent) => gestureHandlersRef.current.handlePointerMove(event);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", cancel);
window.addEventListener("blur", cancel);
@@ -368,14 +376,14 @@ export function useCanvasSelectionController({
if (dragFrameRef.current) cancelAnimationFrame(dragFrameRef.current);
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
applyCanvasNodeDragPreview(containerRef.current, null);
window.removeEventListener("mousemove", handleNodeDragMove);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("blur", cancel);
};
}, [cancelSelectionBox, finishNodeDrag, finishSelection, handleNodeDragMove, handlePointerMove]);
}, [containerRef]);
return {
alignmentGuides,

View File

@@ -170,8 +170,8 @@ export function useCanvasStoryboard({
assertCurrent();
const request = {
projectId,
type: "agent_storyboard_rows",
operation: "storyboard_rows",
type: "canvas_text",
operation: "storyboard",
prompt: skillExecution.prompt,
model: generationConfig.model,
...(logicalModelIDForConfig(generationConfig) ? { logicalModelId: logicalModelIDForConfig(generationConfig) } : {}),

View File

@@ -6,8 +6,10 @@ import { applyCanvasLiveViewport } from "@/lib/canvas/canvas-live-viewport";
import { getCanvasNodesBounds, viewportAtScale, viewportForBounds, type CanvasViewportSize } from "@/lib/canvas/canvas-viewport";
import { CanvasNodeType, type CanvasNodeData, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
import { useCanvasViewportTransition } from "./use-canvas-viewport-transition";
import { unobscuredCanvasArea, viewportForAgentNodes } from "@/lib/canvas/canvas-viewport";
type UseCanvasViewportControllerOptions = {
agentCreatedNodes: CanvasNodeData[] | null;
containerRef: { current: HTMLDivElement | null };
size: CanvasViewportSize;
viewportRef: { current: ViewportTransform };
@@ -22,6 +24,7 @@ type UseCanvasViewportControllerOptions = {
};
export function useCanvasViewportController({
agentCreatedNodes,
containerRef,
size,
viewportRef,
@@ -58,6 +61,23 @@ export function useCanvasViewportController({
}, [containerRef]);
const { cancelViewportTransition, transitionViewportTo } = useCanvasViewportTransition(viewportRef, previewViewport, commitViewport);
const focusedAgentBatch = useRef<CanvasNodeData[] | null>(null);
useEffect(() => {
if (!agentCreatedNodes || focusedAgentBatch.current === agentCreatedNodes || size.width <= 0 || size.height <= 0) return;
focusedAgentBatch.current = agentCreatedNodes;
const targets = agentCreatedNodes.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current));
const rect = containerRef.current?.getBoundingClientRect();
const panel = containerRef.current?.ownerDocument.querySelector(".canvas-agent-panel")?.getBoundingClientRect();
const area = unobscuredCanvasArea(size, rect && panel ? { left: panel.left - rect.left, top: panel.top - rect.top, right: panel.right - rect.left, bottom: panel.bottom - rect.top } : undefined);
const target = viewportForAgentNodes(targets, viewportRef.current, area);
if (!target) return;
setSelectedNodeIds(new Set(targets.map((node) => node.id)));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(null);
setToolbarNodeId(null);
transitionViewportTo(target);
}, [agentCreatedNodes, containerRef, nodesRef, size, viewportRef, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, setToolbarNodeId, transitionViewportTo]);
const screenToCanvas = useCallback((clientX: number, clientY: number): Position => {
const rect = containerRef.current?.getBoundingClientRect();
@@ -124,7 +144,7 @@ export function useCanvasViewportController({
const scale = Math.min(1.18, Math.max(viewportRef.current.k, 0.72));
transitionViewportTo({ x: size.width / 2 - (node.position.x + node.width / 2) * scale, y: size.height / 2 - (node.position.y + node.height / 2) * scale, k: scale });
selectFocusedNode(node.id);
setDialogNodeId(node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion ? null : node.id);
setDialogNodeId(node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Script ? null : node.id);
}, [nodesRef, selectFocusedNode, setDialogNodeId, size.height, size.width, transitionViewportTo, viewportRef]);
const setZoomScale = useCallback((scale: number) => {

View File

@@ -1,162 +0,0 @@
import { useEffect, useRef } from "react";
import { readPortraitTask, readPortraitTaskImage, readPortraitTaskResult, claimPortraitModelJob, completePortraitModelJob, failPortraitModelJob, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { PORTRAIT_CLEARANCE_NODE_TYPE, type PortraitClearanceNodeState, type PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
import { portraitVisionMessages, portraitVisionModelError, resolvePortraitVisionModel, parsePortraitVisionToolResponse, portraitVisionTool } from "@/lib/portrait-clearance/vision";
import { requestToolResponse } from "@/services/api/image";
import { resolveModelChannel, useEffectiveConfig } from "@/stores/use-config-store";
type PortraitClearanceCoordinatorOptions = {
nodes: CanvasNodeData[];
onUpdateState: (nodeId: string, state: PortraitClearanceNodeState) => void;
};
const PORTRAIT_MODEL_JOB_TIMEOUT_MS = 75_000;
export function usePortraitClearanceCoordinator({ nodes, onUpdateState }: PortraitClearanceCoordinatorOptions) {
const runningRef = useRef(false);
const abortRef = useRef<Set<AbortController>>(new Set());
const nodesRef = useRef(nodes);
nodesRef.current = nodes;
const effectiveConfig = useEffectiveConfig();
useEffect(() => {
let disposed = false;
const poll = async () => {
if (disposed || runningRef.current) return;
const active = nodesRef.current.filter((node) => {
if (node.type !== PORTRAIT_CLEARANCE_NODE_TYPE) return false;
const task = node.metadata?.portraitClearance;
return Boolean(task?.activeTaskId && task.task?.status !== "completed" && task.task?.status !== "partial" && task.task?.status !== "failed" && task.task?.status !== "cancelled");
});
if (!active.length) return;
runningRef.current = true;
try {
for (const node of active) {
if (disposed) break;
const state = node.metadata?.portraitClearance;
const taskId = state?.activeTaskId;
if (!state || !taskId) continue;
try {
const task = await readPortraitTask(taskId);
let next = mergeTaskState(state, task);
if (task.status === "waiting_model" && state.analysisMode === "local-plus-vision") {
const modelError = portraitVisionModelError(effectiveConfig, state.modelPolicy);
if (!modelError) {
await runPortraitModelJobs(taskId, state, effectiveConfig, abortRef, task.modelRef);
const refreshed = await readPortraitTask(taskId);
next = mergeTaskState(state, refreshed);
} else {
next = { ...next, task: { ...next.task!, errorCode: state.modelPolicy.mode === "pinned" ? "portrait_vision_model_removed" : "portrait_vision_model_unavailable", errorMessage: modelError } };
}
}
const effectiveTask = task.status === "waiting_model" && state.analysisMode === "local-plus-vision" ? await readPortraitTask(taskId).catch(() => task) : task;
if ((effectiveTask.status === "completed" || effectiveTask.status === "partial") && effectiveTask.detailsAvailable) {
try {
const result = await readPortraitTaskResult(taskId);
if (isResult(result)) next = { ...next, lastResult: { taskId, highestRisk: result.highestRisk, riskCounts: result.riskCounts, candidateCount: result.candidateCount, comparedCount: result.comparedCount, ...(effectiveTask.modelRef ? { modelRef: effectiveTask.modelRef } : {}), completedAt: effectiveTask.completedAt || effectiveTask.updatedAt, detailsAvailable: true } };
} catch {
// The task summary remains useful if the large result is temporarily unavailable.
}
}
if (effectiveTask.status === "completed" || effectiveTask.status === "partial") next = { ...next, activeTaskId: undefined };
onUpdateState(node.id, next);
} catch {
// Runtime reconnects are expected; the next poll resumes from the durable task id.
}
}
} finally {
runningRef.current = false;
}
};
void poll();
const timer = window.setInterval(() => void poll(), 1_500);
return () => {
disposed = true;
window.clearInterval(timer);
for (const controller of abortRef.current) controller.abort();
abortRef.current.clear();
};
}, [effectiveConfig, onUpdateState]);
}
async function runPortraitModelJobs(taskId: string, state: PortraitClearanceNodeState, config: ReturnType<typeof useEffectiveConfig>, abortRef: { current: Set<AbortController> }, frozenModelRef?: string) {
const model = frozenModelRef || resolvePortraitVisionModel(config, state.modelPolicy);
const channel = resolveModelChannel(config, model);
const channelLimit = Number.isInteger(channel.concurrencyLimit) && (channel.concurrencyLimit || 0) > 0 ? channel.concurrencyLimit! : 10;
const limit = Math.min(10, Math.max(1, state.settings.modelConcurrency), channelLimit);
const jobs = [];
for (let index = 0; index < limit; index += 1) {
const job = await claimPortraitModelJob(taskId);
if (!job?.leaseToken) break;
jobs.push(job);
}
await Promise.all(jobs.map((job) => runPortraitModelJob(taskId, state, config, abortRef, job, model)));
}
async function runPortraitModelJob(taskId: string, state: PortraitClearanceNodeState, config: ReturnType<typeof useEffectiveConfig>, abortRef: { current: Set<AbortController> }, job: Awaited<ReturnType<typeof claimPortraitModelJob>>, model: string) {
if (!job?.leaseToken) return;
const controller = new AbortController();
let timedOut = false;
let timeoutId: number | undefined;
abortRef.current.add(controller);
try {
const timeout = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
reject(new Error("portrait_model_timeout"));
}, PORTRAIT_MODEL_JOB_TIMEOUT_MS);
});
const work = (async () => {
const result = await readPortraitTaskResult(taskId, controller.signal);
const pair = isResult(result) ? result.pairs.find((candidate) => candidate.id === job.pairId) : undefined;
if (!pair) {
await failPortraitModelJob(taskId, job, "portrait_model_job_conflict", "模型作业对应的比对结果不存在", false, controller.signal);
return;
}
const [query, comparison] = await Promise.all([readPortraitTaskImage(taskId, job.queryImageId, controller.signal), readPortraitTaskImage(taskId, job.comparisonImageId, controller.signal)]);
const response = await requestToolResponse({ ...config, model }, portraitVisionMessages({ queryDataUrl: query.dataUrl, comparisonDataUrl: comparison.dataUrl, queryName: job.queryImageId, comparisonName: job.comparisonImageId, localPrecheck: pair.localPrecheck }), [portraitVisionTool], { type: "function", name: "submit_portrait_comparison" }, undefined, { signal: controller.signal });
if (timedOut) throw new Error("portrait_model_timeout");
const vision = parsePortraitVisionToolResponse(response);
await completePortraitModelJob(taskId, job, vision as unknown as Record<string, unknown>, controller.signal);
})();
await Promise.race([work, timeout]);
} catch (error) {
if (controller.signal.aborted && !timedOut) return;
const rawMessage = error instanceof Error ? error.message : "视觉模型分析失败";
const message = timedOut ? "视觉模型请求超过 75 秒,已跳过该候选" : rawMessage;
const errorCode = timedOut ? "portrait_model_timeout" : rawMessage === "portrait_vision_result_invalid" || rawMessage === "portrait_vision_tool_missing" ? "portrait_vision_result_invalid" : "portrait_model_rate_limited";
const retryable = !timedOut && rawMessage !== "portrait_vision_result_invalid" && rawMessage !== "portrait_vision_tool_missing";
try {
await failPortraitModelJob(taskId, job, errorCode, message, retryable);
} catch {
// The lease may have expired or the task may have been stopped; the next poll can reclaim it safely.
}
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
abortRef.current.delete(controller);
}
}
function mergeTaskState(state: PortraitClearanceNodeState, task: PortraitRuntimeTask): PortraitClearanceNodeState {
return {
...state,
activeTaskId: task.taskId,
task: {
status: task.status,
stage: task.stage,
progress: task.progress,
processedCandidates: task.processedCandidates,
...(task.totalCandidates === undefined ? {} : { totalCandidates: task.totalCandidates }),
...(task.errorCode ? { errorCode: task.errorCode } : {}),
...(task.errorMessage ? { errorMessage: task.errorMessage } : {}),
updatedAt: task.updatedAt,
},
};
}
function isResult(value: unknown): value is { highestRisk: PortraitRiskLevel; riskCounts: Partial<Record<PortraitRiskLevel, number>>; candidateCount: number; comparedCount: number; pairs: Array<{ id: string; queryImageId: string; comparisonImageId: string; localPrecheck: unknown }> } {
return Boolean(value && typeof value === "object" && "highestRisk" in value && typeof (value as { highestRisk?: unknown }).highestRisk === "string" && "riskCounts" in value && typeof (value as { riskCounts?: unknown }).riskCounts === "object" && "candidateCount" in value && typeof (value as { candidateCount?: unknown }).candidateCount === "number" && "comparedCount" in value && typeof (value as { comparedCount?: unknown }).comparedCount === "number" && Array.isArray((value as { pairs?: unknown }).pairs));
}

View File

@@ -117,8 +117,7 @@ export function reconcileCreationTaskMessages(runtime: CreationRuntime, conversa
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
}
if (matches.every((task) => task.status === "cancelled")) {
const localOnly = matches.find(runtime.isLocalDreaminaWaitStopped);
return { ...message, status: "cancelled" as const, content: localOnly ? runtime.localDreaminaCancellationMessage(localOnly) : "已停止", error: undefined, taskIds: nextTaskIds };
return { ...message, status: "cancelled" as const, content: "已停止", error: undefined, taskIds: nextTaskIds };
}
const failed = matches.find((task) => task.status === "failed" || task.creationError);
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds };

View File

@@ -1,7 +1,6 @@
export { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce } from "@/lib/canvas/canvas-project-generation";
export { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch } from "@/services/api/generation-task";
export { subscribeGenerationTasks } from "@/services/api/task-center";
export { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection";
export { uploadMediaFile } from "@/services/file-storage";
export { uploadImage } from "@/services/image-storage";
export { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";

View File

@@ -9,8 +9,7 @@ import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { MEDIA_CONVERSION_PLUGIN_ID } from "@/lib/plugins/builtin/media-conversion";
import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts";
import type { PluginManifest, PluginManifestV2, RegisteredPlugin } from "@/lib/plugins/plugin-types";
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
@@ -203,7 +202,7 @@ export default function PluginsPage() {
const detailsPlugin = detailsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === detailsPluginId) : undefined;
const hasPluginConfiguration = (plugin: RegisteredPlugin) => Boolean(plugin.manifest.configuration?.fields?.length);
const canConfigurePlugin = (plugin: RegisteredPlugin) => Boolean(pluginStates[plugin.manifest.id]?.canConfigure) && (hasPluginConfiguration(plugin) || plugin.manifest.id === RUNNINGHUB_PLUGIN_ID || plugin.manifest.id === COMFYUI_PLUGIN_ID);
const canConfigurePlugin = (plugin: RegisteredPlugin) => Boolean(pluginStates[plugin.manifest.id]?.canConfigure) && (hasPluginConfiguration(plugin) || plugin.manifest.id === RUNNINGHUB_PLUGIN_ID);
const isPluginEnabled = (plugin: RegisteredPlugin, installation = installations.find((item) => item.manifest.id === plugin.manifest.id)) => pluginStates[plugin.manifest.id]?.effectiveEnabled ?? Boolean(installation?.enabled);
@@ -212,7 +211,7 @@ export default function PluginsPage() {
const next = await setUserPluginEnabled(plugin.manifest.id, enabled);
setEnabled(plugin.manifest.id, enabled);
setPluginStates({ ...usePluginStore.getState().pluginStates, [next.pluginId]: next });
if (next.pluginId === RUNNINGHUB_PLUGIN_ID || next.pluginId === COMFYUI_PLUGIN_ID) {
if (next.pluginId === RUNNINGHUB_PLUGIN_ID) {
setRuntimeStatuses({ ...usePluginStore.getState().runtimeStatuses, [next.pluginId]: next.effectiveEnabled ? "enabled" : "disabled" });
}
message.success(`${plugin.manifest.name}${enabled ? "已启用" : "已停用"}`);
@@ -572,15 +571,15 @@ export default function PluginsPage() {
<p>在创作页或图片、视频节点的提示词编辑器中使用“优化”按钮,即可让当前文本模型整理提示词。</p>
<p className="mt-2 text-[var(--fs-micro)] text-foreground/50">插件不会自动覆盖原提示词,只有点击“采用”后才会回填到当前输入框。</p>
</div>
) : settingsPlugin.manifest.id === RUNNINGHUB_PLUGIN_ID || settingsPlugin.manifest.id === COMFYUI_PLUGIN_ID ? (
) : settingsPlugin.manifest.id === RUNNINGHUB_PLUGIN_ID ? (
<div className="plugin-settings-empty">
<p>{settingsPlugin.manifest.id === RUNNINGHUB_PLUGIN_ID ? "RunningHub 的 API Key、Workflow / App 和字段映射在宿主设置页维护。" : "ComfyUI Bridge 的设备、服务地址和工作流字段在宿主设置页维护。"}</p>
<p>RunningHub 的 API Key、Workflow / App 和字段映射在宿主设置页维护。</p>
<Button
type="primary"
icon={<ExternalLink className="size-4" />}
onClick={() => {
setSettingsPluginId(null);
navigate(`/settings?section=${settingsPlugin.manifest.id === RUNNINGHUB_PLUGIN_ID ? "runninghub" : "comfyui"}`);
navigate("/settings?section=runninghub");
}}
>
打开工作流设置
@@ -628,7 +627,7 @@ function toRegisteredPlugin(plugin: BackendPlugin): RegisteredPlugin {
}
function isOfficialApplicationPlugin(pluginId: string) {
return [RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance", ART_CRITIQUE_PLUGIN_ID, MEDIA_CONVERSION_PLUGIN_ID].includes(pluginId);
return [RUNNINGHUB_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, ART_CRITIQUE_PLUGIN_ID].includes(pluginId);
}
function pluginSourceLabel(plugin: RegisteredPlugin, state?: PluginState) {

View File

@@ -92,8 +92,8 @@ export async function generateChapterStoryboard(input: ChapterStoryboardGenerati
});
const task = await createGenerationTask({
projectId: input.projectId,
type: "agent_storyboard_rows",
operation: "storyboard_rows",
type: "canvas_text",
operation: "storyboard",
prompt: skillExecution.prompt,
model,
...(logicalModelIDForConfig(config) ? { logicalModelId: logicalModelIDForConfig(config) } : {}),

View File

@@ -328,7 +328,6 @@ export default function WorkflowProductionWorkbench(props: Props) {
if (!productionStep) throw new Error("当前生成阶段不可用,请刷新页面后重试");
if (productionStep.status === "failed") throw new Error("当前生成阶段失败,请刷新后重试");
if (!routedModel) throw new Error(activeStage === "video" ? "请先配置视频模型" : "请先配置图片模型");
if (routedModel.startsWith("local:dreamina-cli")) throw new Error("本机即梦任务暂不能登记到分镜产物,请选择后端模型渠道");
const compatibilityError = modelCompatibilityError(effectiveConfig, routedModel, modelRequirements);
if (compatibilityError) throw new Error(`当前模型配置不可用:${compatibilityError}`);
const saved = await saveProjectShot(projectId, {

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

View File

@@ -9,11 +9,11 @@ import { MediaPreview } from "@/components/media-preview";
import { ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, isGenerationTaskSubmissionUncertain, operationOptions, statusLabel } from "@/lib/generation-task-display";
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
import { buildVideoOperationPrompt } from "@/lib/prompts";
import { backendProviderConfig, logicalModelIDForConfig } from "@/services/api/generation-task";
import { createAgentSession, createGenerationTask, deleteGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, refreshGenerationTaskStatus, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { createGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
@@ -299,10 +299,6 @@ export default function TasksPage() {
const runAction = async (id: string) => {
const currentTask = tasksRef.current.find((task) => task.id === id);
if (currentTask && isGenerationTaskSubmissionUncertain(currentTask)) {
message.warning("提交结果尚未确认,不能自动重试;请先核对官方状态,避免重复生成。");
return;
}
setActingId(id);
try {
const next = await retryGenerationTask(id);
@@ -318,47 +314,6 @@ export default function TasksPage() {
}
};
const deleteLocalTask = (task: GenerationTask) => {
if (task.status === "queued" || task.status === "running") {
message.warning("任务正在执行,不能删除本机记录;请等待任务完成");
return;
}
modal.confirm({
title: "删除本机任务记录?",
content: "这只会删除本机任务记录,不会删除已生成的素材。",
okText: "删除本机记录",
okButtonProps: { danger: true },
cancelText: "保留",
onOk: async () => {
setActingId(task.id);
try {
await deleteGenerationTask(task.id);
setTasks((items) => items.filter((item) => item.id !== task.id));
if (detailTask?.id === task.id) setDetailTask(null);
message.success("本机任务记录已删除");
} catch (error) {
message.error(error instanceof Error ? error.message : "删除失败");
} finally {
setActingId("");
}
},
});
};
const refreshLocalTaskStatus = async (task: GenerationTask) => {
setActingId(task.id);
try {
const next = await refreshGenerationTaskStatus(task.id);
setTasks((items) => items.map((item) => (item.id === task.id ? { ...item, ...next } : item)));
setDetailTask((current) => (current?.id === task.id ? { ...current, ...next } : current));
message.success(next.officialStatus ? `官方返回状态:${next.officialStatus}` : "状态已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "更新状态失败");
} finally {
setActingId("");
}
};
const queryProviderTask = async (task: GenerationTask) => {
setActingId(task.id);
try {
@@ -387,16 +342,7 @@ export default function TasksPage() {
const values = await form.validateFields();
setCreating(true);
try {
if (values.operation === "agent_session") {
const textModel = values.model?.trim() || effectiveConfig.textModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, textModel)) {
message.error("请先在设置里配置可用的文本模型、Base URL 和 API Key");
return;
}
const requestConfig = resolveModelRequestConfig(effectiveConfig, textModel);
const detail = await createAgentSession({ projectId: values.projectId, prompt: values.prompt, config: backendProviderConfig(requestConfig), ...(logicalModelIDForConfig(requestConfig) ? { logicalModelId: logicalModelIDForConfig(requestConfig) } : {}) });
setTasks((items) => [...detail.tasks, ...items]);
} else {
{
const videoModel = values.model?.trim() || effectiveConfig.videoModel || effectiveConfig.model;
if (values.operation !== "compare_versions" && !isAiConfigReady(effectiveConfig, videoModel)) {
message.error("请先在设置里配置可用的视频模型、Base URL 和 API Key");
@@ -507,7 +453,7 @@ export default function TasksPage() {
</div>
</WorkspacePage>
<Modal className="library-modal" title="新建异步生成任务" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={submitTask} confirmLoading={creating} okText="创建任务">
<Form form={form} layout="vertical" initialValues={{ operation: "agent_session" }}>
<Form form={form} layout="vertical" initialValues={{ operation: "text_to_video" }}>
<Form.Item name="operation" label="任务类型" rules={[{ required: true, message: "请选择任务类型" }]}>
<Select options={operationOptions} />
</Form.Item>
@@ -538,18 +484,7 @@ export default function TasksPage() {
{detailTask.providerCancelStatus ? <InfoItem label="上游取消" value={providerCancelStatusLabel(detailTask)} /> : null}
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
</div>
{detailTask.provider === "dreamina-cli" ? <p className="text-xs leading-5 text-foreground/60">官方状态采用最终一致轮询;转入后台后仍会继续等待并同步官方状态。官方即梦 CLI 当前不支持可靠的官方取消。</p> : null}
<div className="flex flex-wrap justify-end gap-2">
{detailTask.provider === "dreamina-cli" && detailTask.receiptRecorded && detailTask.status === "running" ? (
<Button aria-label="更新官方状态" icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void refreshLocalTaskStatus(detailTask)}>
更新官方状态
</Button>
) : null}
{detailTask.provider === "dreamina-cli" ? (
<Button danger aria-label="删除本机记录" icon={<Trash2 className="size-4" />} loading={actingId === detailTask.id} onClick={() => deleteLocalTask(detailTask)}>
删除本机记录
</Button>
) : null}
{canQueryProviderTask(detailTask) ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button> : null}
{isTaskFailed(detailTask) ? <Button icon={<Bug className="size-4" />} onClick={() => navigate(`/settings?section=diagnostics&taskId=${encodeURIComponent(detailTask.id)}${detailTask.projectId ? `&projectId=${encodeURIComponent(detailTask.projectId)}` : ""}`)}>导出诊断包</Button> : null}
</div>

View File

@@ -82,7 +82,7 @@ export function formatModelName(config: AiConfig, task: GenerationTask) {
const model = raw.includes("::") ? raw.split("::").pop()?.trim() || raw : raw;
// 工作流名称是任务快照,不属于模型渠道,不能交给模型展示名解析器再次映射成“系统模型”。
if (task.provider === "runninghub" || task.provider === "comfyui-bridge") return raw || "工作流";
if (task.provider === "runninghub") return raw || "工作流";
if (!model) return "工作流";
if (model === "version-router") return "版本对比工作流";
if (model === "workflow-router") return "工作流路由";