Files
BossAI/src/web/pages/ComfyUIAgent.tsx
T

2398 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useLocation } from 'react-router-dom';
import { marked } from 'marked';
import { getSessionToken } from '../authSession.js';
import { useToast } from '../components/Toast.js';
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
type AgentRole = 'user' | 'assistant' | 'system';
type AgentImage = {
url: string;
character?: string;
angle?: string;
};
type AgentScene = {
id: number;
description: string;
prompt: string;
url?: string;
};
type AgentTts = {
character: string;
text: string;
url?: string;
};
type AgentAttachment = {
id: string;
name: string;
mimeType: string;
size: number;
mediaKind?: 'image' | 'video' | 'audio' | 'document' | 'text' | 'file';
referenceLabel?: string;
text?: string;
dataUrl?: string;
};
type AgentMessage = {
id: string;
role: AgentRole;
content: string;
reasoningContent?: string | null;
attachments?: AgentAttachment[];
images?: AgentImage[];
scenes?: AgentScene[];
tts?: AgentTts[];
workflowJson?: unknown;
};
type AgentStatus = {
model?: string;
mode?: string;
keyName?: string;
comfyui?: {
status?: string;
mode?: string;
base_url?: string;
baseUrl?: string;
model_backend?: string;
modelBackend?: string;
};
};
type StreamAccumulator = {
content: string;
reasoningContent: string;
images: AgentImage[];
scenes: AgentScene[];
tts: AgentTts[];
workflowJson: unknown;
};
type StoredAgentSession = {
activeConversationId?: string;
conversations?: StoredAgentConversation[];
messages?: AgentMessage[];
selectedModel?: string;
selectedImageModel?: string;
};
type StoredAgentConversation = {
id: string;
title: string;
updatedAt: number;
messages: AgentMessage[];
selectedModel: string;
selectedImageModel: string;
};
type RunningConversation = {
draft: StreamAccumulator;
controller: AbortController;
};
const WELCOME_MESSAGE: AgentMessage = {
id: 'welcome',
role: 'system',
content:
'你好,我是视频助手。把脚本、分镜或角色想法发给我;确认后我会分步生成角色四视图、场景图、TTS 和 ComfyUI 工作流。',
};
const MAX_ATTACHMENT_COUNT = 8;
const MAX_INLINE_IMAGE_BYTES = 12 * 1024 * 1024;
const MAX_INLINE_TOTAL_BYTES = 36 * 1024 * 1024;
const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
const QUICK_PROMPTS = [
'帮我把这个创意拆成角色、场景和镜头节奏',
'开始制作,先生成角色和场景,不要一次输出工作流',
'根据已有角色和场景,继续生成 ComfyUI 工作流 JSON',
];
const STREAMING_REASONING_PLACEHOLDER = '正在等待模型返回真实思考过程,收到后会实时替换这里。';
const STREAMING_BODY_PLACEHOLDER = '模型正在组织正文和素材步骤...';
const SEEDANCE_PROMPT_TEMPLATES = [
{
title: '文生视频',
description: '从创意扩写成可直接投喂视频模型的完整提示词。',
prompt: [
'请参考 Seedance 2.0 的视频提示词结构,把下面创意改写成可直接用于视频生成模型的提示词。',
'',
'创意主题:',
'主体/人物:',
'场景环境:',
'核心动作:',
'运镜语言:',
'时间轴:',
'0-3秒:',
'3-6秒:',
'6-10秒:',
'音效/音乐:',
'风格氛围:电影级质感,浅景深,光影自然,动作连贯。',
'',
'输出要求:只返回最终视频提示词;消除冲突指令;如果时长超过8秒,请保留分时段描述。',
].join('\n'),
},
{
title: '图生视频',
description: '保持角色或首帧一致,补充动作、镜头和声音。',
prompt: [
'基于 @图片1 生成 Seedance 2.0 图生视频提示词:',
'',
'@图片1 作为首帧/角色参考,保持主体身份、服装、五官、背景风格一致。',
'动作变化:',
'情绪变化:',
'运镜:慢推 / 跟随 / 环绕 / 固定镜头(选择一种并写清楚)',
'时长:6-10秒',
'音效/音乐:',
'风格:',
'',
'请输出一段自然语言提示词,并检查不要出现“固定镜头”和“环绕镜头”等冲突要求。',
].join('\n'),
},
{
title: '多素材引用',
description: '给图片、视频、音频分配明确用途,避免引用模糊。',
prompt: [
'请把以下素材组织成 Seedance 2.0 多模态视频提示词:',
'',
'素材引用:',
'@图片1:作为人物/产品外观参考',
'@图片2:作为场景/背景参考',
'@视频1:参考运镜、动作节奏或转场效果',
'@音频1:参考背景音乐、旁白音色或节拍',
'',
'视频目标:',
'画面主体:',
'动作编排:',
'镜头运动:',
'音画同步:',
'生成时长:',
'',
'输出要求:每个 @ 引用都必须说明用途;不要只写“参考 @视频1”。',
].join('\n'),
},
{
title: '短剧分镜',
description: '适合剧情、口播、短剧和带台词的视频。',
prompt: [
'请写一段 15 秒短剧视频提示词,要求情绪递进清晰、台词自然、镜头可执行。',
'',
'角色设定:',
'场景:',
'冲突点:',
'',
'0-5秒:开场画面、角色表情、第一句台词',
'6-10秒:冲突升级、动作、第二句台词',
'11-15秒:反转/揭示/收尾,镜头定格',
'',
'音效:环境声、呼吸声、脚步声、音乐情绪',
'风格:短剧质感,镜头紧凑,表情真实,节奏明确。',
].join('\n'),
},
{
title: '产品广告',
description: '产品展示、材质特写、卖点节奏和品牌定格。',
prompt: [
'请生成一段 15 秒产品广告视频提示词:',
'',
'@图片1 作为产品外观参考,保持 logo、颜色、材质和比例一致。',
'0-3秒:产品动感入场,特写材质和 logo 细节。',
'4-8秒:多角度展示产品结构、功能或核心卖点,加入扫光效果。',
'9-12秒:产品进入使用场景,展示实际价值。',
'13-15秒:主视觉定格,品牌文案出现,音乐推向高潮。',
'',
'运镜:微距特写、环绕、慢推。',
'音效:产品交互声、轻微机械声或清脆提示音。',
'风格:高级商业广告,干净背景,光影精致。',
].join('\n'),
},
{
title: '视频续写/编辑',
description: '延长已有视频或定向修改局部内容。',
prompt: [
'请基于 @视频1 写 Seedance 2.0 视频续写/编辑提示词:',
'',
'@视频1 作为原始视频参考,保留主体、场景、光影、节奏和镜头连续性。',
'任务类型:向后延长 / 向前延长 / 局部编辑 / 角色替换 / 元素添加',
'新增或修改内容:',
'生成时长:',
'',
'时间轴:',
'0-3秒:',
'3-6秒:',
'6-10秒:',
'',
'音效:延续原视频声音氛围,关键动作增加同步音效。',
'输出要求:明确哪些内容保持不变,哪些内容需要改变。',
].join('\n'),
},
];
function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sessionStorageKey(audience: 'admin' | 'user') {
return `boosapi.comfyuiAgent.${audience}.session`;
}
function buildConversationTitle(messages: AgentMessage[]) {
const firstUser = messages.find((message) => message.role === 'user')?.content.trim();
if (!firstUser) return '新对话';
return firstUser.length > 24 ? `${firstUser.slice(0, 24)}...` : firstUser;
}
function createConversation(
messages: AgentMessage[] = [WELCOME_MESSAGE],
selectedModel = '',
selectedImageModel = '',
): StoredAgentConversation {
return {
id: createId('conversation'),
title: buildConversationTitle(messages),
updatedAt: Date.now(),
messages,
selectedModel,
selectedImageModel,
};
}
function isValidMessage(value: unknown): value is AgentMessage {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentMessage>;
return (
typeof item.id === 'string' &&
(item.role === 'user' || item.role === 'assistant' || item.role === 'system') &&
typeof item.content === 'string'
);
}
function isValidAttachment(value: unknown): value is AgentAttachment {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentAttachment>;
return (
typeof item.id === 'string' &&
typeof item.name === 'string' &&
typeof item.mimeType === 'string' &&
typeof item.size === 'number'
);
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('读取附件失败'));
reader.readAsDataURL(file);
});
}
const TEXT_ATTACHMENT_EXTENSIONS = new Set([
'txt',
'md',
'markdown',
'json',
'csv',
'srt',
'vtt',
]);
const DOCUMENT_ATTACHMENT_EXTENSIONS = new Set([
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
]);
function fileExtension(name: string): string {
return name.split('.').pop()?.trim().toLowerCase() || '';
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function isTextAttachment(file: File, mimeType: string): boolean {
if (mimeType.startsWith('text/')) return true;
if (['application/json', 'application/x-ndjson', 'application/xml'].includes(mimeType)) {
return true;
}
return TEXT_ATTACHMENT_EXTENSIONS.has(fileExtension(file.name));
}
function isDocumentAttachment(file: File, mimeType: string): boolean {
if (DOCUMENT_ATTACHMENT_EXTENSIONS.has(fileExtension(file.name))) return true;
return [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
].includes(mimeType);
}
function mediaKindForAttachment(name: string, mimeType: string): NonNullable<AgentAttachment['mediaKind']> {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
const extension = fileExtension(name);
if (
DOCUMENT_ATTACHMENT_EXTENSIONS.has(extension) ||
[
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
].includes(mimeType)
) {
return 'document';
}
if (
mimeType.startsWith('text/') ||
['application/json', 'application/x-ndjson', 'application/xml'].includes(mimeType) ||
TEXT_ATTACHMENT_EXTENSIONS.has(fileExtension(name))
) {
return 'text';
}
return 'file';
}
function referencePrefixForKind(kind: NonNullable<AgentAttachment['mediaKind']>): string {
switch (kind) {
case 'image':
return '图片';
case 'video':
return '视频';
case 'audio':
return '音频';
case 'document':
return '文档';
case 'text':
return '文本';
default:
return '素材';
}
}
function assignAttachmentReferences(items: AgentAttachment[]): AgentAttachment[] {
const counters: Record<string, number> = {};
return items.map((item) => {
const mediaKind = item.mediaKind || mediaKindForAttachment(item.name, item.mimeType);
const prefix = referencePrefixForKind(mediaKind);
counters[prefix] = (counters[prefix] || 0) + 1;
return {
...item,
mediaKind,
referenceLabel: `@${prefix}${counters[prefix]}`,
};
});
}
async function fileToAttachment(file: File): Promise<AgentAttachment> {
const mimeType = file.type || 'application/octet-stream';
const mediaKind = mediaKindForAttachment(file.name || 'untitled', mimeType);
const base = {
id: createId('attachment'),
name: file.name || 'untitled',
mimeType,
size: file.size,
mediaKind,
};
if (mimeType.startsWith('image/')) {
if (file.size > MAX_INLINE_IMAGE_BYTES) {
return {
...base,
text: [
`[图片素材: ${file.name}]`,
`类型: ${mimeType}`,
`大小: ${formatBytes(file.size)}`,
`图片超过 ${formatBytes(MAX_INLINE_IMAGE_BYTES)},已作为引用记录,未内联发送原图数据。`,
'如需精确参考,请压缩图片或上传较小版本。',
].join('\n'),
};
}
return { ...base, dataUrl: await fileToDataUrl(file) };
}
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
return {
...base,
text: [
`[${mimeType.startsWith('video/') ? '视频' : '音频'}素材: ${file.name}]`,
`类型: ${mimeType}`,
`大小: ${formatBytes(file.size)}`,
'当前版本会记录该素材引用和用途说明,不会把完整二进制内容塞进对话请求。',
'请在输入框说明需要参考的镜头、节奏、音色或时间段;后续接入服务端解析后会自动提取媒体信息。',
].join('\n'),
};
}
if (isDocumentAttachment(file, mimeType) && !isTextAttachment(file, mimeType)) {
return {
...base,
text: [
`[文档素材: ${file.name}]`,
`类型: ${mimeType}`,
'当前版本已记录该文件,但还没有启用服务端 PDF/Office 正文和内嵌图片解析。',
'请先把剧本文字复制到输入框,或上传 txt/md/csv/json 版本;后续解析器接入后会自动提取正文、表格和图片。',
].join('\n'),
};
}
if (!isTextAttachment(file, mimeType)) {
return {
...base,
text: `[附件: ${file.name}]\n类型: ${mimeType}\n当前版本暂不支持读取该二进制文件内容,请改用图片或文本类素材。`,
};
}
const text = await file.text();
return {
...base,
text: text.slice(0, 120_000),
};
}
function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
try {
const raw = localStorage.getItem(sessionStorageKey(audience));
if (!raw) return {};
const parsed = JSON.parse(raw) as StoredAgentSession;
const conversations: StoredAgentConversation[] = Array.isArray(parsed.conversations)
? parsed.conversations
.map((conversation): StoredAgentConversation | null => {
const messages = Array.isArray(conversation?.messages)
? conversation.messages.filter(isValidMessage).map((message) => ({
...message,
reasoningContent:
typeof message.reasoningContent === 'string'
? message.reasoningContent
: undefined,
attachments: Array.isArray(message.attachments)
? message.attachments.filter(isValidAttachment)
: undefined,
}))
: [];
if (!conversation?.id || !messages.length) return null;
return {
id: String(conversation.id),
title:
typeof conversation.title === 'string' && conversation.title.trim()
? conversation.title.trim()
: buildConversationTitle(messages),
updatedAt:
typeof conversation.updatedAt === 'number'
? conversation.updatedAt
: Date.now(),
messages,
selectedModel:
typeof conversation.selectedModel === 'string'
? conversation.selectedModel
: '',
selectedImageModel:
typeof conversation.selectedImageModel === 'string'
? conversation.selectedImageModel
: '',
};
})
.filter((conversation): conversation is StoredAgentConversation => !!conversation)
: [];
if (conversations.length) {
const activeConversationId = conversations.some(
(conversation) => conversation.id === parsed.activeConversationId,
)
? parsed.activeConversationId
: conversations[0].id;
return { conversations, activeConversationId };
}
const messages = Array.isArray(parsed.messages)
? parsed.messages.filter(isValidMessage).map((message) => ({
...message,
reasoningContent:
typeof message.reasoningContent === 'string'
? message.reasoningContent
: undefined,
attachments: Array.isArray(message.attachments)
? message.attachments.filter(isValidAttachment)
: undefined,
}))
: undefined;
return {
messages: messages?.length ? messages : undefined,
selectedModel:
typeof parsed.selectedModel === 'string' ? parsed.selectedModel : undefined,
selectedImageModel:
typeof parsed.selectedImageModel === 'string' ? parsed.selectedImageModel : undefined,
};
} catch {
return {};
}
}
function persistStoredSession(
audience: 'admin' | 'user',
conversations: StoredAgentConversation[],
activeConversationId: string,
) {
try {
localStorage.setItem(
sessionStorageKey(audience),
JSON.stringify({ conversations, activeConversationId }),
);
} catch {
// Ignore quota/private-mode failures; chat still works for the current page.
}
}
function angleLabel(angle?: string) {
if (angle === 'front') return '正面';
if (angle === 'back') return '背面';
if (angle === 'left') return '左侧';
if (angle === 'right') return '右侧';
return angle || '图像';
}
function appendLine(current: string, line: string) {
if (!line) return current;
return current ? `${current}\n${line}` : line;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
const value = String(raw || '').trim();
if (!value) return null;
if (value.startsWith('#') || value.startsWith('/')) return value;
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
if (/^https?:\/\//i.test(value)) return value;
if (/^mailto:/i.test(value) || /^tel:/i.test(value)) return value;
return null;
}
function sanitizeMarkdownHtml(html: string): string {
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
return `<p>${escapeHtml(html)}</p>`;
}
const allowedTags = new Set([
'a', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'hr', 'img', 'li', 'ol', 'p', 'pre', 'strong', 'table',
'tbody', 'td', 'th', 'thead', 'tr', 'ul',
]);
const droppedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
const voidTags = new Set(['br', 'hr', 'img']);
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
const cleanNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return escapeHtml(node.textContent || '');
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (droppedTags.has(tag)) return '';
const children = Array.from(element.childNodes).map(cleanNode).join('');
if (!allowedTags.has(tag)) return children;
const attrs: string[] = [];
if (tag === 'a') {
const href = sanitizeUrl(element.getAttribute('href'));
if (href) {
attrs.push(` href="${escapeHtml(href)}"`);
attrs.push(' target="_blank" rel="noopener noreferrer"');
}
} else if (tag === 'img') {
const src = sanitizeUrl(element.getAttribute('src'), true);
if (!src) return '';
attrs.push(` src="${escapeHtml(src)}"`);
const alt = element.getAttribute('alt');
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
} else if (tag === 'code') {
const className = String(element.getAttribute('class') || '').trim();
if (/^language-[a-z0-9_-]+$/i.test(className)) {
attrs.push(` class="${escapeHtml(className)}"`);
}
}
return voidTags.has(tag)
? `<${tag}${attrs.join('')}>`
: `<${tag}${attrs.join('')}>${children}</${tag}>`;
};
return Array.from(doc.body.childNodes).map(cleanNode).join('');
}
function renderMarkdown(content: string): string {
const raw = String(content || '').trim();
if (!raw) return '';
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
return sanitizeMarkdownHtml(rendered || `<p>${escapeHtml(raw)}</p>`);
}
function processThinkTags(content: string, reasoningContent = '') {
if (!content || !/<think>/i.test(content)) {
return { content, reasoningContent };
}
const thoughts: string[] = [];
const replyParts: string[] = [];
let lastIndex = 0;
const regex = /<think>([\s\S]*?)<\/think>/gi;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
replyParts.push(content.substring(lastIndex, match.index));
thoughts.push(match[1].trim());
lastIndex = match.index + match[0].length;
}
replyParts.push(content.substring(lastIndex));
let cleanContent = replyParts.join('');
const lastOpenThinkIndex = cleanContent.toLowerCase().lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = cleanContent.substring(lastOpenThinkIndex + '<think>'.length);
if (!/<\/think>/i.test(fragmentAfterLastOpen)) {
thoughts.push(fragmentAfterLastOpen.trim());
cleanContent = cleanContent.substring(0, lastOpenThinkIndex);
}
}
cleanContent = cleanContent.replace(/<\/?think>/gi, '').trim();
const thoughtsText = thoughts.filter(Boolean).join('\n\n---\n\n');
return {
content: cleanContent,
reasoningContent: reasoningContent && thoughtsText
? `${reasoningContent}\n\n---\n\n${thoughtsText}`
: (reasoningContent || thoughtsText),
};
}
async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
function parseWorkflow(content: string) {
const trimmed = content.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return content;
}
}
async function readSseStream(
response: Response,
onEvent: (event: any) => void,
) {
const reader = response.body?.getReader();
if (!reader) throw new Error('浏览器无法读取流式响应');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
onEvent(JSON.parse(data));
}
}
}
export default function ComfyUIAgent() {
const toast = useToast();
const location = useLocation();
const sessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const initialStoredSession = useMemo(
() => readStoredSession(sessionAudience),
[sessionAudience],
);
const initialConversations = useMemo(() => {
if (initialStoredSession.conversations?.length) return initialStoredSession.conversations;
return [
createConversation(
initialStoredSession.messages || [WELCOME_MESSAGE],
initialStoredSession.selectedModel || '',
initialStoredSession.selectedImageModel || '',
),
];
}, [initialStoredSession]);
const initialActiveConversation =
initialConversations.find(
(conversation) => conversation.id === initialStoredSession.activeConversationId,
) || initialConversations[0];
const [conversations, setConversations] = useState<StoredAgentConversation[]>(
() => initialConversations,
);
const [activeConversationId, setActiveConversationId] = useState(
() => initialActiveConversation.id,
);
const [messages, setMessages] = useState<AgentMessage[]>(
() => initialActiveConversation.messages,
);
const [input, setInput] = useState('');
const [runningByConversation, setRunningByConversation] = useState<
Record<string, RunningConversation>
>({});
const [status, setStatus] = useState<AgentStatus | null>(null);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(
() => initialActiveConversation.selectedModel || '',
);
const [imageModelOptions, setImageModelOptions] = useState<string[]>([]);
const [selectedImageModel, setSelectedImageModel] = useState(
() => initialActiveConversation.selectedImageModel || '',
);
const [modelSearch, setModelSearch] = useState('');
const [imageModelSearch, setImageModelSearch] = useState('');
const [conversationSidebarCollapsed, setConversationSidebarCollapsed] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);
const [attachments, setAttachments] = useState<AgentAttachment[]>([]);
const [readingAttachments, setReadingAttachments] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const activeConversationIdRef = useRef(activeConversationId);
const messagesRef = useRef(messages);
const conversationsRef = useRef(conversations);
const streaming = runningByConversation[activeConversationId]?.draft || null;
const loading = !!runningByConversation[activeConversationId];
const canSend =
(input.trim().length > 0 || attachments.length > 0) && !loading && !readingAttachments;
const latestWorkflow = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].workflowJson) return messages[index].workflowJson;
}
return streaming?.workflowJson || null;
}, [messages, streaming]);
const filteredModelOptions = useMemo(
() => {
const filtered = filterModelTesterModelNames(modelOptions, modelSearch).slice(0, 80);
if (selectedModel && !filtered.includes(selectedModel)) {
return [selectedModel, ...filtered];
}
return filtered;
},
[modelOptions, modelSearch, selectedModel],
);
const filteredImageModelOptions = useMemo(
() => {
const search = imageModelSearch.trim().toLowerCase();
const filtered = imageModelOptions
.filter((model) => !search || model.toLowerCase().includes(search))
.slice(0, 80);
if (selectedImageModel && !filtered.includes(selectedImageModel)) {
return [selectedImageModel, ...filtered];
}
return filtered;
},
[imageModelOptions, imageModelSearch, selectedImageModel],
);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, streaming?.content, streaming?.reasoningContent, streaming?.images.length, streaming?.scenes.length, streaming?.tts.length]);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
}, [activeConversationId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
conversationsRef.current = conversations;
}, [conversations]);
useEffect(() => {
const stored = readStoredSession(sessionAudience);
const nextConversations = stored.conversations?.length
? stored.conversations
: [
createConversation(
stored.messages || [WELCOME_MESSAGE],
stored.selectedModel || '',
stored.selectedImageModel || '',
),
];
const nextActive =
nextConversations.find(
(conversation) => conversation.id === stored.activeConversationId,
) || nextConversations[0];
setConversations(nextConversations);
setActiveConversationId(nextActive.id);
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setSelectedImageModel(nextActive.selectedImageModel || '');
setModelSearch('');
setImageModelSearch('');
Object.values(runningByConversation).forEach((running) => running.controller.abort());
setRunningByConversation({});
}, [sessionAudience]);
useEffect(() => {
setConversations((current) => {
const now = Date.now();
const next = current.length
? current.map((conversation) =>
conversation.id === activeConversationId
? {
...conversation,
title: buildConversationTitle(messages),
updatedAt: now,
messages,
selectedModel,
selectedImageModel,
}
: conversation,
)
: [createConversation(messages, selectedModel, selectedImageModel)];
const ordered = [...next].sort((a, b) => {
if (a.id === activeConversationId) return -1;
if (b.id === activeConversationId) return 1;
return b.updatedAt - a.updatedAt;
});
persistStoredSession(sessionAudience, ordered, activeConversationId);
return ordered;
});
}, [activeConversationId, messages, selectedModel, selectedImageModel, sessionAudience]);
useEffect(() => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
void fetch('/api/comfyui-agent/status', {
headers: { Authorization: `Bearer ${token}` },
})
.then((response) => (response.ok ? response.json() : null))
.then((payload) => setStatus(payload || null))
.catch(() => setStatus(null));
}, [sessionAudience]);
useEffect(() => {
const loadRouteModels = async () => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
setLoadingModels(true);
try {
const response = await fetch('/api/comfyui-agent/models', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const names = Array.isArray(payload?.models)
? payload.models
.map((model: unknown) => (typeof model === 'string' ? model.trim() : ''))
.filter(Boolean)
: [];
setModelOptions(names);
setSelectedModel((current) => {
if (current && names.includes(current)) return current;
const configuredModel = String(payload?.defaultModel || status?.model || '').trim();
if (configuredModel && names.includes(configuredModel)) return configuredModel;
return names[0] || configuredModel;
});
const imageResponse = await fetch('/api/comfyui-agent/image-models', {
headers: { Authorization: `Bearer ${token}` },
});
if (!imageResponse.ok) throw new Error(`HTTP ${imageResponse.status}`);
const imagePayload = await imageResponse.json();
const imageNames = Array.isArray(imagePayload?.models)
? imagePayload.models
.map((model: unknown) => (typeof model === 'string' ? model.trim() : ''))
.filter(Boolean)
: [];
setImageModelOptions(imageNames);
setSelectedImageModel((current) => {
if (current && imageNames.includes(current)) return current;
const configuredModel = String(imagePayload?.defaultModel || '').trim();
if (configuredModel && imageNames.includes(configuredModel)) return configuredModel;
return imageNames[0] || configuredModel || 'gpt-image-2';
});
} catch {
toast.error('加载模型路由失败');
} finally {
setLoadingModels(false);
}
};
void loadRouteModels();
}, [sessionAudience, status?.model, toast]);
const exportWorkflow = () => {
const workflow = latestWorkflow || {
name: 'BoosAPI Video Agent Project',
version: 1,
messages: messages
.filter((message) => message.role !== 'system')
.map((message) => ({
role: message.role,
content: message.content,
attachments: message.attachments || [],
images: message.images || [],
scenes: message.scenes || [],
tts: message.tts || [],
})),
};
const blob = new Blob([JSON.stringify(workflow, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'comfyui-video-agent-workflow.json';
link.click();
URL.revokeObjectURL(url);
};
const applySeedanceTemplate = (template: string) => {
setInput((current) => {
const trimmed = current.trim();
if (!trimmed) return template;
return `${trimmed}\n\n---\n${template}`;
});
};
const handleStreamEvent = (
event: any,
draft: StreamAccumulator,
apply: (next: StreamAccumulator) => void,
) => {
const next: StreamAccumulator = {
content: draft.content,
reasoningContent: draft.reasoningContent,
images: [...draft.images],
scenes: [...draft.scenes],
tts: [...draft.tts],
workflowJson: draft.workflowJson,
};
if (event.type === 'text') {
next.content += String(event.content || '');
} else if (event.type === 'reasoning') {
next.reasoningContent += String(event.content || '');
} else if (event.type === 'character_prompts') {
next.content = appendLine(
next.content,
`${event.character || '角色'} 的四视图提示词已生成。`,
);
} else if (event.type === 'image') {
next.images.push({
url: String(event.url || ''),
character: event.character,
angle: event.angle,
});
next.content = appendLine(
next.content,
`已生成 ${event.character || '角色'} ${angleLabel(event.angle)}。`,
);
} else if (event.type === 'scene') {
next.scenes.push({
id: Number(event.id || 0),
description: String(event.description || ''),
prompt: String(event.prompt || ''),
url: event.url ? String(event.url) : undefined,
});
next.content = appendLine(
next.content,
`场景 ${event.id || ''} 已准备:${event.description || event.prompt || ''}`,
);
} else if (event.type === 'tts') {
next.tts.push({
character: String(event.character || ''),
text: String(event.text || ''),
url: event.url ? String(event.url) : undefined,
});
next.content = appendLine(
next.content,
`已生成 ${event.character || '角色'} 的语音。`,
);
} else if (event.type === 'comfyui_workflow') {
next.workflowJson = parseWorkflow(String(event.content || ''));
next.content = appendLine(next.content, 'ComfyUI 工作流 JSON 已就绪。');
} else if (event.type === 'error') {
next.content = appendLine(next.content, `错误:${event.message || '生成失败'}`);
}
draft.content = next.content;
draft.reasoningContent = next.reasoningContent;
draft.images = next.images;
draft.scenes = next.scenes;
draft.tts = next.tts;
draft.workflowJson = next.workflowJson;
apply(next);
};
const appendAssistantMessageToConversation = (
conversationId: string,
assistantMessage: AgentMessage,
) => {
setConversations((current) => {
const now = Date.now();
const next = current.map((conversation) => (
conversation.id === conversationId
? {
...conversation,
updatedAt: now,
messages: [...conversation.messages, assistantMessage],
}
: conversation
));
persistStoredSession(sessionAudience, next, activeConversationIdRef.current);
return next;
});
if (activeConversationIdRef.current === conversationId) {
setMessages((current) => [...current, assistantMessage]);
}
};
const sendMessage = async () => {
const content = input.trim();
if ((!content && attachments.length === 0) || loading || readingAttachments) return;
const token = getSessionToken(sessionAudience, localStorage);
if (!token) {
toast.error('登录状态已过期,请重新登录');
return;
}
const userMessage: AgentMessage = {
id: createId('user'),
role: 'user',
content,
attachments: attachments.length ? attachments : undefined,
};
const nextMessages = [...messages, userMessage];
const controller = new AbortController();
const conversationId = activeConversationId;
const modelForRequest = selectedModel || status?.model;
const imageModelForRequest = selectedImageModel || imageModelOptions[0] || 'gpt-image-2';
const draft: StreamAccumulator = {
content: '',
reasoningContent: '',
images: [],
scenes: [],
tts: [],
workflowJson: null,
};
setMessages(nextMessages);
setInput('');
setAttachments([]);
setRunningByConversation((current) => ({
...current,
[conversationId]: { draft, controller },
}));
try {
const payloadMessages = nextMessages
.filter((message) => message.role !== 'system')
.map((message) => ({
role: message.role,
content: message.content,
reasoningContent: message.reasoningContent || '',
attachments: message.attachments || [],
}));
const response = await fetch('/api/comfyui-agent/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
messages: payloadMessages,
model: modelForRequest,
imageModel: imageModelForRequest,
}),
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
await readSseStream(response, (event) => {
handleStreamEvent(event, draft, (nextDraft) => {
setRunningByConversation((current) => {
const running = current[conversationId];
if (!running) return current;
return {
...current,
[conversationId]: {
...running,
draft: nextDraft,
},
};
});
});
});
const processed = processThinkTags(draft.content, draft.reasoningContent);
appendAssistantMessageToConversation(conversationId, {
id: createId('assistant'),
role: 'assistant',
content: processed.content || '生成完成,但没有返回文本。',
reasoningContent: processed.reasoningContent || undefined,
images: draft.images.length ? draft.images : undefined,
scenes: draft.scenes.length ? draft.scenes : undefined,
tts: draft.tts.length ? draft.tts : undefined,
workflowJson: draft.workflowJson || undefined,
});
} catch (error: any) {
if (error?.name === 'AbortError') {
if (draft.content) {
const processed = processThinkTags(draft.content, draft.reasoningContent);
appendAssistantMessageToConversation(conversationId, {
id: createId('assistant'),
role: 'assistant',
content: appendLine(processed.content, '已停止生成。'),
reasoningContent: processed.reasoningContent || undefined,
images: draft.images.length ? draft.images : undefined,
scenes: draft.scenes.length ? draft.scenes : undefined,
tts: draft.tts.length ? draft.tts : undefined,
workflowJson: draft.workflowJson || undefined,
});
}
} else {
toast.error(error?.message || '视频助手请求失败');
const processed = processThinkTags(draft.content, draft.reasoningContent);
appendAssistantMessageToConversation(conversationId, {
id: createId('assistant'),
role: 'assistant',
content: appendLine(processed.content, `请求失败:${error?.message || '未知错误'}`),
reasoningContent: processed.reasoningContent || undefined,
images: draft.images.length ? draft.images : undefined,
scenes: draft.scenes.length ? draft.scenes : undefined,
tts: draft.tts.length ? draft.tts : undefined,
workflowJson: draft.workflowJson || undefined,
});
}
} finally {
setRunningByConversation((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
}
};
const stopGeneration = () => {
runningByConversation[activeConversationId]?.controller.abort();
};
const startNewConversation = () => {
const conversation = createConversation(
[WELCOME_MESSAGE],
selectedModel || status?.model || '',
selectedImageModel || imageModelOptions[0] || '',
);
setConversations((current) => {
const next = [conversation, ...current];
persistStoredSession(sessionAudience, next, conversation.id);
return next;
});
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setSelectedImageModel(conversation.selectedImageModel || selectedImageModel || '');
setInput('');
setAttachments([]);
setModelSearch('');
setImageModelSearch('');
};
const selectConversation = (conversation: StoredAgentConversation) => {
if (conversation.id === activeConversationId) return;
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setSelectedImageModel(conversation.selectedImageModel || '');
setInput('');
setAttachments([]);
setModelSearch('');
setImageModelSearch('');
};
const deleteConversation = (conversationId: string) => {
runningByConversation[conversationId]?.controller.abort();
setConversations((current) => {
const remaining = current.filter((conversation) => conversation.id !== conversationId);
const next = remaining.length
? remaining
: [createConversation([WELCOME_MESSAGE], selectedModel, selectedImageModel)];
const nextActive =
conversationId === activeConversationId
? next[0]
: next.find((conversation) => conversation.id === activeConversationId) || next[0];
setActiveConversationId(nextActive.id);
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setSelectedImageModel(nextActive.selectedImageModel || '');
setInput('');
setAttachments([]);
persistStoredSession(sessionAudience, next, nextActive.id);
return next;
});
setRunningByConversation((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
};
const handleFilesSelected = async (files: FileList | null) => {
if (!files?.length) return;
setReadingAttachments(true);
try {
const selectedFiles = Array.from(files).slice(0, MAX_ATTACHMENT_COUNT);
const oversized = selectedFiles.filter((file) => file.size > MAX_ATTACHMENT_BYTES);
if (oversized.length > 0) {
toast.error(`附件过大:${oversized[0].name} 超过 ${formatBytes(MAX_ATTACHMENT_BYTES)}`);
return;
}
const existingInlineBytes = attachments.reduce((sum, attachment) => (
sum + (attachment.dataUrl ? attachment.size : 0)
), 0);
const nextInlineBytes = selectedFiles.reduce((sum, file) => (
sum + (file.type.startsWith('image/') && file.size <= MAX_INLINE_IMAGE_BYTES ? file.size : 0)
), 0);
if (existingInlineBytes + nextInlineBytes > MAX_INLINE_TOTAL_BYTES) {
toast.error(`图片附件总量超过 ${formatBytes(MAX_INLINE_TOTAL_BYTES)},请减少数量或压缩后再上传`);
return;
}
if (files.length > MAX_ATTACHMENT_COUNT || attachments.length + selectedFiles.length > MAX_ATTACHMENT_COUNT) {
toast.info(`最多保留 ${MAX_ATTACHMENT_COUNT} 个附件,超出的部分已忽略。`);
}
const next = await Promise.all(selectedFiles.map(fileToAttachment));
const withReferences = assignAttachmentReferences([...attachments, ...next].slice(0, MAX_ATTACHMENT_COUNT));
setAttachments(withReferences);
if (next.some((attachment) => isDocumentAttachment(
new File([], attachment.name, { type: attachment.mimeType }),
attachment.mimeType,
))) {
toast.info('PDF/Office 已作为素材记录;完整正文和内嵌图片解析会在后端解析器接入后启用。');
}
} catch (error: any) {
toast.error(error?.message || '读取附件失败');
} finally {
setReadingAttachments(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const removeAttachment = (id: string) => {
setAttachments((current) => assignAttachmentReferences(current.filter((attachment) => attachment.id !== id)));
};
const insertAttachmentReference = (label?: string) => {
if (!label) return;
setInput((current) => {
const trimmed = current.trimEnd();
return trimmed ? `${trimmed} ${label} ` : `${label} `;
});
};
const copyMessage = async (message: AgentMessage | StreamAccumulator) => {
const processed = processThinkTags(message.content || '', message.reasoningContent || '');
const parts = [
processed.reasoningContent ? `## 思考过程\n\n${processed.reasoningContent}` : '',
processed.content,
].filter(Boolean);
if (!parts.length) {
toast.error('没有可复制的文本内容。');
return;
}
try {
await copyTextToClipboard(parts.join('\n\n'));
toast.success('已复制对话内容');
} catch {
toast.error('复制失败,请手动复制。');
}
};
const renderReasoning = (message: AgentMessage | StreamAccumulator, isStreaming = false) => {
const processed = processThinkTags(message.content || '', message.reasoningContent || '');
const hasReasoning = processed.reasoningContent.trim().length > 0;
if (!hasReasoning && !isStreaming) return null;
return (
<details
open={isStreaming}
style={{
...reasoningBoxStyle,
...(!hasReasoning && isStreaming ? reasoningPendingBoxStyle : {}),
}}
>
<summary style={reasoningSummaryStyle}>
<span>思考过程</span>
{isStreaming ? <em style={reasoningLiveBadgeStyle}>实时</em> : null}
</summary>
{hasReasoning ? (
<div
style={reasoningContentStyle}
dangerouslySetInnerHTML={{ __html: renderMarkdown(processed.reasoningContent) }}
/>
) : (
<div style={reasoningPendingStyle}>
<span style={reasoningPulseDotStyle} />
<span>{STREAMING_REASONING_PLACEHOLDER}</span>
</div>
)}
</details>
);
};
const renderMessageContent = (message: AgentMessage | StreamAccumulator) => {
const processed = processThinkTags(message.content || '', message.reasoningContent || '');
return (
<div
style={messageContentStyle}
dangerouslySetInnerHTML={{ __html: renderMarkdown(processed.content) }}
/>
);
};
const renderAttachments = (items?: AgentAttachment[], compact = false) => {
if (!items?.length) return null;
return (
<div style={attachmentListStyle}>
{items.map((attachment) => (
<div key={attachment.id} style={attachmentPillStyle}>
{attachment.dataUrl ? (
attachment.mimeType.startsWith('video/') ? (
<video src={attachment.dataUrl} muted style={attachmentThumbStyle} />
) : attachment.mimeType.startsWith('audio/') ? (
<span style={attachmentMediaBadgeStyle}>音</span>
) : (
<img src={attachment.dataUrl} alt={attachment.name} style={attachmentThumbStyle} />
)
) : null}
{attachment.referenceLabel ? (
<span style={attachmentReferenceStyle}>{attachment.referenceLabel}</span>
) : null}
<span style={attachmentNameStyle}>
{attachment.name}
{!compact ? ` · ${Math.ceil(attachment.size / 1024)} KB` : ''}
</span>
</div>
))}
</div>
);
};
const renderAssets = (message: AgentMessage | StreamAccumulator) => (
<>
{!!message.images?.length && (
<div style={assetGridStyle}>
{message.images.map((image, index) => (
<figure key={`${image.url}-${index}`} style={assetFigureStyle}>
<img src={image.url} alt={`${image.character || ''} ${angleLabel(image.angle)}`} style={assetImageStyle} />
<figcaption style={captionStyle}>
{image.character || '角色'} / {angleLabel(image.angle)}
</figcaption>
</figure>
))}
</div>
)}
{!!message.scenes?.length && (
<div style={assetListStyle}>
{message.scenes.map((scene) => (
<div key={`${scene.id}-${scene.prompt}`} style={assetRowStyle}>
<strong>场景 {scene.id || '-'}</strong>
<span>{scene.description || scene.prompt}</span>
{scene.url ? <a href={scene.url} target="_blank" rel="noreferrer">查看图像</a> : null}
</div>
))}
</div>
)}
{!!message.tts?.length && (
<div style={assetListStyle}>
{message.tts.map((item, index) => (
<div key={`${item.character}-${index}`} style={assetRowStyle}>
<strong>{item.character || '语音'}</strong>
<span>{item.text}</span>
{item.url ? <audio controls src={item.url} style={{ width: '100%' }} /> : null}
</div>
))}
</div>
)}
</>
);
return (
<div className="comfyui-agent-page" style={pageStyle}>
<aside
style={{
...conversationSidebarStyle,
...(conversationSidebarCollapsed ? conversationSidebarCollapsedStyle : {}),
}}
>
<div style={conversationSidebarHeaderStyle}>
{conversationSidebarCollapsed ? (
<button
type="button"
onClick={() => setConversationSidebarCollapsed(false)}
style={sidebarToggleButtonStyle}
aria-label="展开对话列表"
title="展开对话列表"
>
›
</button>
) : (
<>
<strong>对话</strong>
<div style={conversationHeaderActionsStyle}>
<button
type="button"
onClick={() => setConversationSidebarCollapsed(true)}
style={sidebarToggleButtonStyle}
aria-label="折叠对话列表"
title="折叠对话列表"
>
‹
</button>
<button
type="button"
onClick={startNewConversation}
style={{
...secondaryButtonStyle,
padding: '6px 10px',
}}
>
新建
</button>
</div>
</>
)}
</div>
{!conversationSidebarCollapsed && <div style={conversationListStyle}>
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
onClick={() => selectConversation(conversation)}
style={{
...conversationItemStyle,
borderColor:
conversation.id === activeConversationId
? 'var(--color-primary)'
: 'var(--color-border-light)',
background:
conversation.id === activeConversationId
? 'var(--color-primary-soft, rgba(79, 70, 229, 0.12))'
: 'var(--color-surface)',
}}
>
<span style={conversationTitleStyle}>{conversation.title}</span>
<span style={conversationMetaStyle}>
{runningByConversation[conversation.id]
? '生成中'
: `${Math.max(0, conversation.messages.filter((message) => message.role !== 'system').length)} 条消息`}
</span>
<span
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
deleteConversation(conversation.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
deleteConversation(conversation.id);
}
}}
style={conversationDeleteStyle}
aria-label="删除对话"
>
删除
</span>
</button>
))}
</div>}
</aside>
<section style={chatPanelStyle}>
<header style={headerStyle}>
<div>
<h1 style={titleStyle}>视频助手</h1>
<div style={statusStyle}>
对话 {selectedModel || status?.model || '加载中'} · 生图 {selectedImageModel || 'gpt-image-2'} · 模式 {status?.mode || 'local'} · Key {status?.keyName || '当前用户'}
</div>
</div>
<div style={headerActionsStyle}>
<label style={modelPickerStyle}>
<span style={modelPickerLabelStyle}>对话模型</span>
<input
value={modelSearch}
onChange={(event) => setModelSearch(event.target.value)}
placeholder={loadingModels ? '加载模型...' : '搜索模型路由'}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSearchInputStyle}
/>
<select
value={selectedModel}
onChange={(event) => {
setSelectedModel(event.target.value);
setModelSearch('');
}}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSelectStyle}
>
{modelOptions.length === 0 ? (
<option value={status?.model || ''}>
{loadingModels ? '模型加载中' : (status?.model || '暂无模型路由')}
</option>
) : filteredModelOptions.length === 0 ? (
<option value={selectedModel}>没有匹配模型</option>
) : (
filteredModelOptions.map((model) => (
<option key={model} value={model}>{model}</option>
))
)}
</select>
</label>
<label style={modelPickerStyle}>
<span style={modelPickerLabelStyle}>生图模型</span>
<input
value={imageModelSearch}
onChange={(event) => setImageModelSearch(event.target.value)}
placeholder={loadingModels ? '加载生图模型...' : '搜索生图路由'}
disabled={loading || loadingModels || imageModelOptions.length === 0}
style={modelSearchInputStyle}
/>
<select
value={selectedImageModel}
onChange={(event) => {
setSelectedImageModel(event.target.value);
setImageModelSearch('');
}}
disabled={loading || loadingModels || imageModelOptions.length === 0}
style={modelSelectStyle}
>
{imageModelOptions.length === 0 ? (
<option value={selectedImageModel || 'gpt-image-2'}>
{loadingModels ? '生图模型加载中' : (selectedImageModel || 'gpt-image-2')}
</option>
) : filteredImageModelOptions.length === 0 ? (
<option value={selectedImageModel}>没有匹配模型</option>
) : (
filteredImageModelOptions.map((model) => (
<option key={model} value={model}>{model}</option>
))
)}
</select>
</label>
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
导出工作流
</button>
</div>
</header>
<section style={quickPromptStyle}>
{QUICK_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => setInput(prompt)}
disabled={loading}
style={chipButtonStyle}
>
{prompt}
</button>
))}
</section>
<section style={seedancePanelStyle}>
<div style={seedanceHeaderStyle}>
<div>
<div style={seedanceTitleStyle}>Seedance 2.0 提示词</div>
<div style={seedanceSubtitleStyle}>
支持 @图片 / @视频 / @音频 引用、分时段镜头和音画同步说明
</div>
</div>
<button
type="button"
onClick={() => applySeedanceTemplate(SEEDANCE_PROMPT_TEMPLATES[0].prompt)}
disabled={loading}
style={secondaryButtonStyle}
>
填入基础模板
</button>
</div>
<div style={seedanceTemplateGridStyle}>
{SEEDANCE_PROMPT_TEMPLATES.map((template) => (
<button
key={template.title}
type="button"
onClick={() => applySeedanceTemplate(template.prompt)}
disabled={loading}
style={seedanceTemplateCardStyle}
>
<span style={seedanceTemplateTitleStyle}>{template.title}</span>
<span style={seedanceTemplateDescStyle}>{template.description}</span>
</button>
))}
</div>
</section>
<main style={messagesStyle}>
{messages.map((message) => (
<article
key={message.id}
style={{
...messageStyle,
alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
background:
message.role === 'user'
? 'var(--color-primary)'
: message.role === 'system'
? 'transparent'
: 'var(--color-surface)',
color: message.role === 'user' ? '#fff' : 'var(--color-text-primary)',
borderColor:
message.role === 'system'
? 'transparent'
: 'var(--color-border-light)',
}}
>
<div style={messageHeaderStyle}>
<span style={messageRoleStyle}>
{message.role === 'user' ? '你' : message.role === 'system' ? '系统' : '助手'}
</span>
</div>
{renderReasoning(message)}
{renderMessageContent(message)}
{renderAttachments(message.attachments, message.role !== 'user')}
{renderAssets(message)}
<div style={messageActionsStyle}>
<button
type="button"
onClick={() => void copyMessage(message)}
style={{
...messageCopyButtonStyle,
color: message.role === 'user' ? 'rgba(255,255,255,0.88)' : 'var(--color-text-muted)',
borderColor: message.role === 'user' ? 'rgba(255,255,255,0.28)' : 'var(--color-border-light)',
}}
>
复制
</button>
</div>
</article>
))}
{streaming && (
<article style={{ ...messageStyle, alignSelf: 'flex-start' }}>
<div style={messageHeaderStyle}>
<span style={messageRoleStyle}>助手</span>
</div>
{renderReasoning(streaming, true)}
{streaming.content ? renderMessageContent(streaming) : null}
{streaming.content ? <span style={cursorStyle}>|</span> : null}
{!streaming.content && !streaming.images.length && !streaming.scenes.length && !streaming.tts.length ? (
<div style={streamingBodyStatusStyle}>{STREAMING_BODY_PLACEHOLDER}</div>
) : null}
{renderAssets(streaming)}
<div style={messageActionsStyle}>
<button
type="button"
onClick={() => void copyMessage(streaming)}
style={messageCopyButtonStyle}
>
复制
</button>
</div>
</article>
)}
<div ref={chatEndRef} />
</main>
<footer style={inputBarStyle}>
<div style={composerMainStyle}>
{attachments.length > 0 && (
<div style={attachmentListStyle}>
{attachments.map((attachment) => (
<div key={attachment.id} style={attachmentPillStyle}>
{attachment.dataUrl ? (
attachment.mimeType.startsWith('video/') ? (
<video src={attachment.dataUrl} muted style={attachmentThumbStyle} />
) : attachment.mimeType.startsWith('audio/') ? (
<span style={attachmentMediaBadgeStyle}>音</span>
) : (
<img src={attachment.dataUrl} alt={attachment.name} style={attachmentThumbStyle} />
)
) : null}
<button
type="button"
onClick={() => insertAttachmentReference(attachment.referenceLabel)}
disabled={loading}
style={attachmentReferenceButtonStyle}
title="插入素材引用"
>
{attachment.referenceLabel || '@素材'}
</button>
<span style={attachmentNameStyle}>
{attachment.name} · {Math.ceil(attachment.size / 1024)} KB
</span>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
disabled={loading}
style={attachmentRemoveStyle}
aria-label={`移除附件 ${attachment.name}`}
>
×
</button>
</div>
))}
</div>
)}
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void sendMessage();
}
}}
placeholder="输入脚本、分镜、角色设定或制作指令..."
rows={3}
disabled={loading}
style={textareaStyle}
/>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,audio/*,.txt,.md,.markdown,.json,.csv,.srt,.vtt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
style={{ display: 'none' }}
onChange={(event) => {
void handleFilesSelected(event.target.files);
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={loading || readingAttachments}
style={secondaryButtonStyle}
>
{readingAttachments ? '读取中' : '附件'}
</button>
{loading ? (
<button type="button" onClick={stopGeneration} style={dangerButtonStyle}>
停止
</button>
) : (
<button type="button" onClick={() => void sendMessage()} disabled={!canSend} style={{
...primaryButtonStyle,
opacity: canSend ? 1 : 0.55,
cursor: canSend ? 'pointer' : 'not-allowed',
}}>
发送
</button>
)}
</footer>
</section>
</div>
);
}
const pageStyle: CSSProperties = {
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'row',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
};
const chatPanelStyle: CSSProperties = {
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
};
const conversationSidebarStyle: CSSProperties = {
width: 260,
minWidth: 220,
borderRight: '1px solid var(--color-border)',
background: 'var(--color-surface)',
display: 'flex',
flexDirection: 'column',
transition: 'width 0.18s ease, min-width 0.18s ease',
};
const conversationSidebarCollapsedStyle: CSSProperties = {
width: 52,
minWidth: 52,
};
const conversationSidebarHeaderStyle: CSSProperties = {
minHeight: 72,
padding: '14px 14px',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
};
const conversationHeaderActionsStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
};
const sidebarToggleButtonStyle: CSSProperties = {
width: 32,
height: 32,
border: '1px solid var(--color-border-light)',
borderRadius: 8,
background: 'var(--color-bg)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
fontSize: 20,
lineHeight: 1,
};
const conversationListStyle: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: 'auto',
display: 'grid',
alignContent: 'start',
gap: 8,
padding: 12,
};
const conversationItemStyle: CSSProperties = {
width: '100%',
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: 10,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: '4px 8px',
textAlign: 'left',
color: 'var(--color-text-primary)',
cursor: 'pointer',
};
const conversationTitleStyle: CSSProperties = {
gridColumn: '1 / -1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 13,
fontWeight: 700,
};
const conversationMetaStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
};
const conversationDeleteStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-danger, #dc2626)',
};
const headerStyle: CSSProperties = {
minHeight: 72,
padding: '14px 20px',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
justifyContent: 'space-between',
gap: 16,
alignItems: 'center',
background: 'var(--color-surface)',
};
const headerActionsStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 10,
flexWrap: 'wrap',
};
const titleStyle: CSSProperties = {
margin: 0,
fontSize: 18,
fontWeight: 700,
};
const statusStyle: CSSProperties = {
marginTop: 4,
fontSize: 12,
color: 'var(--color-text-muted)',
};
const modelPickerStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(130px, 180px) minmax(180px, 260px)',
gap: 8,
alignItems: 'end',
};
const modelPickerLabelStyle: CSSProperties = {
gridColumn: '1 / -1',
fontSize: 12,
fontWeight: 700,
color: 'var(--color-text-muted)',
};
const modelSearchInputStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const modelSelectStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const quickPromptStyle: CSSProperties = {
display: 'flex',
gap: 8,
padding: '10px 20px',
borderBottom: '1px solid var(--color-border-light)',
overflowX: 'auto',
};
const seedancePanelStyle: CSSProperties = {
display: 'grid',
gap: 10,
padding: '12px 20px',
borderBottom: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-primary) 3%, var(--color-surface))',
};
const seedanceHeaderStyle: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
};
const seedanceTitleStyle: CSSProperties = {
fontSize: 13,
fontWeight: 800,
color: 'var(--color-text-primary)',
};
const seedanceSubtitleStyle: CSSProperties = {
marginTop: 3,
fontSize: 12,
color: 'var(--color-text-muted)',
};
const seedanceTemplateGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: 8,
};
const seedanceTemplateCardStyle: CSSProperties = {
minHeight: 72,
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: '9px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
textAlign: 'left',
display: 'grid',
alignContent: 'start',
gap: 4,
};
const seedanceTemplateTitleStyle: CSSProperties = {
fontSize: 13,
fontWeight: 800,
};
const seedanceTemplateDescStyle: CSSProperties = {
fontSize: 12,
lineHeight: 1.45,
color: 'var(--color-text-muted)',
};
const messagesStyle: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 20,
};
const messageStyle: CSSProperties = {
width: 'min(860px, 88%)',
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: '12px 14px',
background: 'var(--color-surface)',
};
const messageHeaderStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
marginBottom: 8,
};
const messageRoleStyle: CSSProperties = {
fontSize: 12,
fontWeight: 700,
opacity: 0.78,
};
const messageActionsStyle: CSSProperties = {
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 10,
};
const messageCopyButtonStyle: CSSProperties = {
border: '1px solid var(--color-border-light)',
borderRadius: 999,
padding: '3px 8px',
background: 'transparent',
color: 'var(--color-text-muted)',
fontSize: 12,
cursor: 'pointer',
};
const messageContentStyle: CSSProperties = {
whiteSpace: 'normal',
wordBreak: 'break-word',
lineHeight: 1.65,
fontSize: 14,
};
const reasoningBoxStyle: CSSProperties = {
marginBottom: 10,
border: '1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border-light))',
borderRadius: 8,
background: 'color-mix(in srgb, var(--color-primary) 6%, var(--color-bg))',
overflow: 'hidden',
};
const reasoningSummaryStyle: CSSProperties = {
padding: '8px 10px',
cursor: 'pointer',
fontSize: 12,
fontWeight: 700,
color: 'var(--color-primary)',
display: 'flex',
alignItems: 'center',
gap: 6,
};
const reasoningContentStyle: CSSProperties = {
padding: '0 10px 10px',
fontSize: 12,
lineHeight: 1.65,
color: 'var(--color-text-secondary)',
};
const reasoningPendingBoxStyle: CSSProperties = {
borderStyle: 'dashed',
background:
'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 7%, var(--color-bg)), color-mix(in srgb, var(--color-info) 5%, var(--color-bg)))',
};
const reasoningLiveBadgeStyle: CSSProperties = {
marginLeft: 8,
padding: '1px 6px',
border: '1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border-light))',
borderRadius: 999,
color: 'var(--color-primary)',
background: 'color-mix(in srgb, var(--color-primary) 8%, transparent)',
fontSize: 11,
fontStyle: 'normal',
fontWeight: 700,
};
const reasoningPendingStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 10px 10px',
color: 'var(--color-text-muted)',
fontSize: 12,
lineHeight: 1.6,
};
const reasoningPulseDotStyle: CSSProperties = {
width: 7,
height: 7,
flexShrink: 0,
borderRadius: 999,
background: 'var(--color-primary)',
boxShadow: '0 0 0 5px color-mix(in srgb, var(--color-primary) 12%, transparent)',
};
const streamingBodyStatusStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
width: 'fit-content',
maxWidth: '100%',
marginTop: 4,
padding: '7px 10px',
border: '1px solid var(--color-border-light)',
borderRadius: 999,
background: 'color-mix(in srgb, var(--color-bg) 74%, var(--color-surface))',
color: 'var(--color-text-muted)',
fontSize: 12,
};
const inputBarStyle: CSSProperties = {
padding: 16,
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
gap: 10,
background: 'var(--color-surface)',
};
const composerMainStyle: CSSProperties = {
display: 'grid',
gap: 8,
minWidth: 0,
};
const textareaStyle: CSSProperties = {
width: '100%',
minHeight: 72,
resize: 'vertical',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '10px 12px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 14,
outline: 'none',
};
const primaryButtonStyle: CSSProperties = {
minWidth: 88,
border: 0,
borderRadius: 8,
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
};
const secondaryButtonStyle: CSSProperties = {
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
};
const dangerButtonStyle: CSSProperties = {
...primaryButtonStyle,
background: 'var(--color-danger, #dc2626)',
cursor: 'pointer',
};
const chipButtonStyle: CSSProperties = {
whiteSpace: 'nowrap',
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '7px 11px',
background: 'var(--color-surface)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
fontSize: 12,
};
const assetGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 10,
marginTop: 12,
};
const assetFigureStyle: CSSProperties = {
margin: 0,
};
const assetImageStyle: CSSProperties = {
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: 8,
border: '1px solid var(--color-border-light)',
};
const captionStyle: CSSProperties = {
marginTop: 4,
fontSize: 12,
color: 'var(--color-text-muted)',
};
const assetListStyle: CSSProperties = {
display: 'grid',
gap: 8,
marginTop: 12,
};
const assetRowStyle: CSSProperties = {
display: 'grid',
gap: 4,
padding: 10,
border: '1px solid var(--color-border-light)',
borderRadius: 8,
background: 'var(--color-bg)',
fontSize: 13,
};
const attachmentListStyle: CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
};
const attachmentPillStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 7,
maxWidth: 280,
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: '5px 8px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
};
const attachmentThumbStyle: CSSProperties = {
width: 28,
height: 28,
borderRadius: 6,
objectFit: 'cover',
flexShrink: 0,
};
const attachmentMediaBadgeStyle: CSSProperties = {
width: 28,
height: 28,
borderRadius: 6,
display: 'inline-grid',
placeItems: 'center',
flexShrink: 0,
background: 'color-mix(in srgb, var(--color-primary) 14%, var(--color-bg))',
color: 'var(--color-primary)',
fontSize: 12,
fontWeight: 800,
};
const attachmentReferenceStyle: CSSProperties = {
border: '1px solid color-mix(in srgb, var(--color-primary) 24%, var(--color-border-light))',
borderRadius: 999,
padding: '2px 7px',
color: 'var(--color-primary)',
fontWeight: 800,
};
const attachmentReferenceButtonStyle: CSSProperties = {
...attachmentReferenceStyle,
background: 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg))',
cursor: 'pointer',
};
const attachmentNameStyle: CSSProperties = {
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const attachmentRemoveStyle: CSSProperties = {
border: 0,
background: 'transparent',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
};
const cursorStyle: CSSProperties = {
display: 'inline-block',
marginLeft: 2,
animation: 'pulse 1s steps(2, start) infinite',
};