feat: add workflow studio and postgres support

This commit is contained in:
boos
2026-05-25 16:03:07 +08:00
parent 7cdb02ef1d
commit cb35d5fc6e
96 changed files with 12414 additions and 15548 deletions
+93 -135
View File
@@ -1,16 +1,20 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
const [iframeSrc, setIframeSrc] = React.useState('');
const [starting, setStarting] = React.useState(true);
const [message, setMessage] = React.useState('正在启动 ComfyUI...');
const [error, setError] = React.useState('');
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const [available, setAvailable] = React.useState(false);
const [message, setMessage] = React.useState('正在检查外部 ComfyUI...');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const openFullscreen = () => {
window.open('/api/comfyui/proxy/', '_blank', 'noopener,noreferrer');
};
const reloadFrame = () => {
if (iframeRef.current) {
iframeRef.current.src = '/api/comfyui/proxy/';
}
};
React.useEffect(() => {
const el = document.documentElement;
@@ -23,121 +27,25 @@ export default function ComfyUI() {
React.useEffect(() => {
let cancelled = false;
const encodedToken = token ? encodeURIComponent(token) : '';
const requestTimeoutMs = 35_000;
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), requestTimeoutMs);
try {
return await fetch(input, { ...init, signal: controller.signal });
} finally {
window.clearTimeout(timer);
}
};
const resolveIframeFromPayload = (payload: any) => {
const comfyui = payload?.comfyui || payload || {};
const mode = String(comfyui?.mode || '').toLowerCase();
const status = String(comfyui?.status || '').toLowerCase();
const lastError = String(comfyui?.lastError || comfyui?.last_error || '').trim();
if (mode === 'container') {
if (status === 'running') return `/comfy/?token=${encodedToken}`;
if (status === 'unhealthy' || status === 'failed') {
throw new Error(lastError || 'ComfyUI 容器启动失败');
}
return '';
}
if (status && status !== 'disabled') {
return '/api/comfyui/proxy/';
}
if (lastError) {
throw new Error(lastError);
}
return '';
};
if (!token) {
setIframeSrc('');
setStarting(false);
setMessage('登录状态已过期,请重新登录。');
setError('');
return () => {
cancelled = true;
};
}
setStarting(true);
setIframeSrc('');
setError('');
setMessage('正在启动 ComfyUI...');
const start = async () => {
try {
const response = await fetchWithTimeout('/api/video-agent/start', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!cancelled) {
if (!response.ok) {
throw new Error(`启动接口返回 ${response.status}`);
}
const payload = await response.json().catch(() => null);
const nextSrc = resolveIframeFromPayload(payload);
if (nextSrc) {
setIframeSrc(nextSrc);
return;
}
}
} catch (e: any) {
if (!cancelled) {
setMessage('ComfyUI 启动较慢,正在检查状态...');
if (e?.name !== 'AbortError') setError(e?.message || '启动请求失败');
}
}
const deadline = Date.now() + 120_000;
while (!cancelled && Date.now() < deadline) {
try {
const response = await fetchWithTimeout('/api/video-agent/status', {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const payload = await response.json().catch(() => null);
const nextSrc = resolveIframeFromPayload(payload);
if (nextSrc) {
setIframeSrc(nextSrc);
return;
}
setMessage('ComfyUI 仍在启动,等待容器就绪...');
} else {
setError(`状态接口返回 ${response.status}`);
}
} catch (e: any) {
setError(e?.name === 'AbortError' ? '状态检查超时' : (e?.message || '状态检查失败'));
}
await new Promise((resolve) => window.setTimeout(resolve, 2000));
}
if (!cancelled) {
setStarting(false);
setMessage('ComfyUI 启动超时');
setError((current) => current || '请检查 /api/video-agent/status、Docker 容器日志和 4000 后端进程。');
}
};
void start().finally(() => {
if (!cancelled) setStarting(false);
});
const controller = new AbortController();
void fetch('/api/comfyui/proxy/', { method: 'GET', signal: controller.signal })
.then((response) => {
if (cancelled) return;
setAvailable(response.ok);
setMessage(response.ok
? ''
: '外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
})
.catch((err) => {
if (cancelled || err?.name === 'AbortError') return;
setAvailable(false);
setMessage('外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
});
return () => {
cancelled = true;
controller.abort();
};
}, [token]);
}, []);
return (
<div
@@ -149,15 +57,23 @@ export default function ComfyUI() {
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
{iframeSrc ? (
<div style={toolbarStyle}>
<div style={toolbarTitleStyle}>外部 ComfyUI</div>
<div style={toolbarActionsStyle}>
<button type="button" onClick={reloadFrame} disabled={!available} style={toolbarButtonStyle}>
刷新
</button>
<button type="button" onClick={openFullscreen} disabled={!available} style={primaryToolbarButtonStyle}>
新窗口打开
</button>
</div>
</div>
{available ? (
<iframe
src={iframeSrc}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
ref={iframeRef}
src="/api/comfyui/proxy/"
style={{ width: '100%', height: '100%', border: 'none' }}
title="External ComfyUI"
allow="clipboard-read; clipboard-write"
/>
) : (
@@ -168,16 +84,58 @@ export default function ComfyUI() {
color: 'var(--color-text-muted)',
fontSize: 14,
textAlign: 'center',
gap: 8,
padding: 24,
}}>
<div>{starting ? message : message}</div>
{error && (
<div style={{ color: '#ef4444', fontSize: 12, maxWidth: 520 }}>
{error}
<div>
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 8 }}>
内置 Docker ComfyUI 工作台已移除
</div>
)}
<div style={{ maxWidth: 560, lineHeight: 1.7 }}>{message}</div>
</div>
</div>
)}
</div>
);
}
const toolbarStyle: React.CSSProperties = {
minHeight: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 12px',
borderBottom: '1px solid var(--color-border-light)',
background: 'var(--color-surface)',
};
const toolbarTitleStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 800,
color: 'var(--color-text-primary)',
};
const toolbarActionsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
};
const toolbarButtonStyle: React.CSSProperties = {
minHeight: 30,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '5px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
fontSize: 12,
};
const primaryToolbarButtonStyle: React.CSSProperties = {
...toolbarButtonStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
};