2398 lines
77 KiB
TypeScript
2398 lines
77 KiB
TypeScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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',
|
||
};
|