fix: restore ComfyUI as workflow frontend with BoosAPI backend integration

ComfyUI is kept as a visual workflow editor (node-based). All actual
generation goes through BoosAPI's own API via boosapi_comfy_nodes.py
custom nodes (now defaulting to localhost:4000/v1).

Architecture:
  BoosAPI (Node.js, port 4000)
    ├── /video-agent → iframe → ComfyUI (Python, port 8188, CPU)
    │                              └── custom_nodes/boosapi_comfy_nodes.py
    │                                   └── calls BoosAPI /v1/... API
    ├── /comfyui-agent → chat interface (direct API calls)
    └── /v1/... → API endpoints (LLM, image, TTS, video)

Signed-off-by: boos <boos@boos.lat>
This commit is contained in:
boos
2026-05-16 00:26:07 +08:00
parent bd06698c8e
commit 0f3ff82147
8 changed files with 453 additions and 14 deletions
+1
View File
@@ -5,6 +5,7 @@ const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
export function isPublicApiRoute(url: string): boolean {
return url === DESKTOP_HEALTH_ROUTE
|| url.startsWith('/api/oauth/callback/')
|| url.startsWith('/api/comfyui/proxy/')
// User SaaS routes (JWT auth, not admin token)
|| url === '/api/users/register'
|| url === '/api/users/login'
+4
View File
@@ -27,6 +27,8 @@ import { invitationRoutes } from './routes/api/invitations.js';
import { oauthRoutes } from './routes/api/oauth.js';
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
import { updateCenterRoutes } from './routes/api/updateCenter.js';
import { comfyuiRoutes } from './routes/api/comfyui.js';
import { videoAgentRoutes } from './routes/api/videoAgent.js';
import { adminUsersRoutes } from './routes/api/adminUsers.js';
import { comfyuiAgentRoutes } from './routes/api/comfyuiAgent.js';
import { proxyRoutes } from './routes/proxy/router.js';
@@ -239,6 +241,8 @@ await app.register(usersRoutes);
await app.register(userApiKeysRoutes);
await app.register(invitationRoutes);
await app.register(organizationsRoutes);
await app.register(comfyuiRoutes);
await app.register(videoAgentRoutes);
await app.register(comfyuiAgentRoutes);
await app.register(oauthRoutes);
+124
View File
@@ -0,0 +1,124 @@
import { FastifyInstance } from 'fastify';
import { fetch } from 'undici';
const COMFYUI_BASE = 'http://127.0.0.1:8188';
/** Rebrand ComfyUI HTML to show BoosAPI instead */
function rebrandHtml(html: string): string {
return html
.replace(/ComfyUI/g, 'BoosAPI')
.replace(/comfyui/gi, 'BoosAPI');
}
function rewriteProxyUrls(html: string): string {
return html.replace(/(src|href|action)=["']\//g, (m) =>
m.replace(/["']/, '/api/comfyui/proxy/')
);
}
export async function comfyuiRoutes(app: FastifyInstance) {
// Proxy: POST /api/comfyui/prompt -> ComfyUI /prompt
app.post('/api/comfyui/prompt', async (request, reply) => {
try {
const body = typeof request.body === 'object' ? JSON.stringify(request.body) : '{}';
const resp = await fetch(`${COMFYUI_BASE}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const text = await resp.text();
return reply.code(resp.status).type('application/json').send(text);
} catch (e: any) {
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
}
});
// Proxy: GET /api/comfyui/history -> ComfyUI /history
app.get('/api/comfyui/history', async (_request, reply) => {
try {
const resp = await fetch(`${COMFYUI_BASE}/history`);
const text = await resp.text();
return reply.code(resp.status).type('application/json').send(text);
} catch (e: any) {
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
}
});
// Proxy: GET /api/comfyui/history/:id -> ComfyUI /history/{id}
app.get<{ Params: { id: string } }>('/api/comfyui/history/:id', async (request, reply) => {
try {
const resp = await fetch(`${COMFYUI_BASE}/history/${encodeURIComponent(request.params.id)}`);
const text = await resp.text();
return reply.code(resp.status).type('application/json').send(text);
} catch (e: any) {
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
}
});
// Proxy: GET /api/comfyui/queue -> ComfyUI /queue
app.get('/api/comfyui/queue', async (_request, reply) => {
try {
const resp = await fetch(`${COMFYUI_BASE}/queue`);
const text = await resp.text();
return reply.code(resp.status).type('application/json').send(text);
} catch (e: any) {
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
}
});
// Proxy: GET /api/comfyui/view -> ComfyUI /view (image output, preserve query params)
app.get('/api/comfyui/view', async (request, reply) => {
try {
const qs = request.url.includes('?') ? request.url.substring(request.url.indexOf('?')) : '';
const resp = await fetch(`${COMFYUI_BASE}/view${qs}`);
const buffer = await resp.arrayBuffer();
return reply.code(resp.status).type(resp.headers.get('content-type') || 'application/octet-stream').send(Buffer.from(buffer));
} catch (e: any) {
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
}
});
// Proxy: GET /api/comfyui/proxy/* -> proxy everything to ComfyUI (HTML, assets, JS, CSS, etc.)
app.get('/api/comfyui/proxy/*', async (request, reply) => {
try {
const path = request.url.replace('/api/comfyui/proxy', '');
const targetUrl = `${COMFYUI_BASE}${path}`;
const resp = await fetch(targetUrl);
const contentType = resp.headers.get('content-type') || 'application/octet-stream';
if (contentType.includes('html')) {
const text = await resp.text();
const rebranded = rebrandHtml(text);
const rewritten = rewriteProxyUrls(rebranded);
return reply.code(resp.status).type(contentType).send(rewritten);
}
if (contentType.includes('text') || contentType.includes('json')) {
const text = await resp.text();
return reply.code(resp.status).type(contentType).send(text);
}
const buffer = await resp.arrayBuffer();
return reply.code(resp.status).type(contentType).send(Buffer.from(buffer));
} catch (e: any) {
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
}
});
// Serve ComfyUI HTML page at /comfyui with iframe to proxied ComfyUI
app.get('/comfyui', async (_request, reply) => {
return reply.type('text/html').send(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BoosAPI - ComfyUI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
iframe { width: 100%; height: 100%; border: none; }
</style>
</head>
<body>
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
</body>
</html>`);
});
}
+269
View File
@@ -0,0 +1,269 @@
import { FastifyInstance } from 'fastify';
import { fetch } from 'undici';
const METAPI_BASE = `http://127.0.0.1:${process.env.PORT || 3000}`;
interface AgentRequest {
script: string;
style?: string;
}
/**
* AI Video Generation Agent
*
* Workflow:
* 1. User provides script
* 2. Agent parses script → characters, scenes, dialogue
* 3. For characters: generate front/back/left/right concept art
* 4. For scenes: generate background/environment prompts
* 5. For dialogue: generate TTS audio
* 6. Package everything for ComfyUI workflow
*/
// Default system prompt for the script analysis agent
const SCRIPT_ANALYSIS_PROMPT = `你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
{
"title": "视频标题",
"characters": [
{
"name": "角色名",
"gender": "男/女",
"age": "年龄描述",
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
"personality": "性格特征"
}
],
"scenes": [
{
"id": 1,
"description": "场景描述",
"environment": "环境详细描述(时间、地点、天气、光线等)",
"characters": ["出现的角色名"],
"action": "场景中发生的动作",
"dialogue": [
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
]
}
],
"style": "整体视觉风格(动画/写实/像素等)"
}
只返回 JSON,不要其他文字。`;
const CHARACTER_PROMPT_GEN = `根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
每组提示词应包含:人物姿态、服装、光照、背景。
返回 JSON 格式:
{
"front": "正面提示词",
"back": "背面提示词",
"left": "左侧面提示词",
"right": "右侧面提示词"
}
只返回 JSON。`;
const SCENE_PROMPT_GEN = `根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
包含:环境、光照、色调、氛围。
返回 JSON:
{
"prompt": "场景生成提示词",
"negative_prompt": "负面提示词"
}
只返回 JSON。`;
async function callLLM(prompt: string, system: string): Promise<string> {
const resp = await fetch(`${METAPI_BASE}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer admin`,
},
body: JSON.stringify({
model: 'gpt-5.5',
messages: [
{ role: 'system', content: system },
{ role: 'user', content: prompt },
],
temperature: 0.7,
}),
});
const data: any = await resp.json();
return data?.choices?.[0]?.message?.content || '';
}
async function callImageGen(prompt: string): Promise<string> {
const resp = await fetch(`${METAPI_BASE}/v1/images/generations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer admin`,
},
body: JSON.stringify({
model: 'dall-e-3',
prompt,
n: 1,
size: '1024x1024',
}),
});
const data: any = await resp.json();
return data?.data?.[0]?.url || '';
}
async function callTTS(text: string, voice = 'alloy'): Promise<Buffer | null> {
const resp = await fetch(`${METAPI_BASE}/v1/audio/speech`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer admin`,
},
body: JSON.stringify({
model: 'tts-1',
input: text,
voice,
}),
});
if (!resp.ok) return null;
const buffer = await resp.arrayBuffer();
return Buffer.from(buffer);
}
export async function videoAgentRoutes(app: FastifyInstance) {
// POST /api/video-agent/analyze — analyze script and extract structure
app.post('/api/video-agent/analyze', async (request, reply) => {
const { script } = request.body as AgentRequest;
if (!script) {
return reply.code(400).send({ error: 'script is required' });
}
try {
const result = await callLLM(script, SCRIPT_ANALYSIS_PROMPT);
const parsed = JSON.parse(result.replace(/```json|```/g, '').trim());
return { success: true, data: parsed };
} catch (e: any) {
return reply.code(500).send({ error: `Analysis failed: ${e.message}` });
}
});
// POST /api/video-agent/character-prompts — generate character image prompts
app.post('/api/video-agent/character-prompts', async (request, reply) => {
const { character } = request.body as any;
if (!character) {
return reply.code(400).send({ error: 'character is required' });
}
try {
const desc = `${character.name}: ${character.appearance}, ${character.gender}, ${character.age}`;
const result = await callLLM(desc, CHARACTER_PROMPT_GEN);
const prompts = JSON.parse(result.replace(/```json|```/g, '').trim());
return { success: true, data: prompts };
} catch (e: any) {
return reply.code(500).send({ error: `Prompt generation failed: ${e.message}` });
}
});
// POST /api/video-agent/generate-character — generate character images (front/back/left/right)
app.post('/api/video-agent/generate-character', async (request, reply) => {
const { prompts } = request.body as any;
if (!prompts) {
return reply.code(400).send({ error: 'prompts is required' });
}
try {
const results: Record<string, string> = {};
for (const [angle, prompt] of Object.entries(prompts)) {
const url = await callImageGen(prompt as string);
results[angle] = url;
}
return { success: true, data: results };
} catch (e: any) {
return reply.code(500).send({ error: `Image generation failed: ${e.message}` });
}
});
// POST /api/video-agent/scene-prompt — generate scene image prompt
app.post('/api/video-agent/scene-prompt', async (request, reply) => {
const { scene } = request.body as any;
if (!scene) {
return reply.code(400).send({ error: 'scene is required' });
}
try {
const desc = `${scene.environment}. ${scene.description}`;
const result = await callLLM(desc, SCENE_PROMPT_GEN);
const prompt = JSON.parse(result.replace(/```json|```/g, '').trim());
return { success: true, data: prompt };
} catch (e: any) {
return reply.code(500).send({ error: `Scene prompt failed: ${e.message}` });
}
});
// POST /api/video-agent/generate-scene — generate scene image
app.post('/api/video-agent/generate-scene', async (request, reply) => {
const { prompt } = request.body as any;
if (!prompt) {
return reply.code(400).send({ error: 'prompt is required' });
}
try {
const url = await callImageGen(prompt);
return { success: true, data: { url } };
} catch (e: any) {
return reply.code(500).send({ error: `Scene generation failed: ${e.message}` });
}
});
// POST /api/video-agent/generate-tts — generate TTS audio for a line
app.post('/api/video-agent/generate-tts', async (request, reply) => {
const { text, voice } = request.body as any;
if (!text) {
return reply.code(400).send({ error: 'text is required' });
}
try {
const audio = await callTTS(text, voice || 'alloy');
if (!audio) {
return reply.code(502).send({ error: 'TTS generation failed' });
}
return reply.type('audio/mpeg').send(audio);
} catch (e: any) {
return reply.code(500).send({ error: `TTS failed: ${e.message}` });
}
});
// POST /api/video-agent/full-workflow — run complete workflow: analyze + generate all assets
app.post('/api/video-agent/full-workflow', async (request, reply) => {
const { script, style } = request.body as AgentRequest;
if (!script) {
return reply.code(400).send({ error: 'script is required' });
}
try {
// Phase 1: Analyze script
const analysisRaw = await callLLM(script, SCRIPT_ANALYSIS_PROMPT);
const analysis = JSON.parse(analysisRaw.replace(/```json|```/g, '').trim());
// Phase 2: Generate character prompts and images
const characters: any[] = [];
for (const char of (analysis.characters || [])) {
const desc = `${char.name}: ${char.appearance}, ${char.gender}, ${char.age}`;
const promptsRaw = await callLLM(desc, CHARACTER_PROMPT_GEN);
const prompts = JSON.parse(promptsRaw.replace(/```json|```/g, '').trim());
characters.push({ ...char, imagePrompts: prompts });
}
// Phase 3: Generate scene prompts
const scenes: any[] = [];
for (const scene of (analysis.scenes || [])) {
const desc = `${scene.environment}. ${scene.description}`;
const scenePromptRaw = await callLLM(desc, SCENE_PROMPT_GEN);
const scenePrompt = JSON.parse(scenePromptRaw.replace(/```json|```/g, '').trim());
scenes.push({ ...scene, imagePrompt: scenePrompt });
}
return {
success: true,
data: {
title: analysis.title,
style: analysis.style || style,
characters,
scenes,
},
};
} catch (e: any) {
return reply.code(500).send({ error: `Workflow failed: ${e.message}` });
}
});
}
+3
View File
@@ -34,6 +34,7 @@ const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
const Models = lazy(() => import('./pages/Models.js'));
const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
@@ -397,6 +398,7 @@ export const sidebarGroups = [
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
],
},
@@ -844,6 +846,7 @@ function AppShell() {
<Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} />
<Route path="/video-agent" element={<ComfyUI />} />
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<UserManagement />} />
+20 -7
View File
@@ -1,8 +1,15 @@
import React from 'react';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
React.useEffect(() => {
window.location.href = '/comfyui-agent';
const el = document.documentElement;
const check = () => setIsDark(el.getAttribute('data-theme') !== 'light');
check();
const mo = new MutationObserver(check);
mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] });
return () => mo.disconnect();
}, []);
return (
@@ -11,14 +18,20 @@ export default function ComfyUI() {
width: '100%',
height: 'calc(100vh - 56px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-bg, #0f0f1a)',
color: 'var(--color-text-secondary, #888)',
fontSize: '16px',
flexDirection: 'column',
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
🔄 正在跳转至 AI 视频助手...
<iframe
src="/comfyui"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
</div>
);
}