Files
video-gen/video-gen-app/src/pages/GenerateConver.tsx
T
2026-06-30 17:03:07 +08:00

2425 lines
100 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 React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Layout,
Button,
Input,
Select,
Upload,
message,
Space,
Typography,
Tooltip,
Popconfirm,
Modal,
} from 'antd';
import bg1 from '../assets/bg1.png';
import bg2 from '../assets/bg2.png';
import bg3 from '../assets/bg3.png';
import text from '../assets/testb.png';
import {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
} from '../api';
import { useAppStore } from '../store/useAppStore';
import {
PlusOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
SendOutlined,
DeleteOutlined,
RobotOutlined,
LoadingOutlined,
PictureOutlined,
VideoCameraOutlined,
CaretDownOutlined,
SwapOutlined,
WarningOutlined,
SettingOutlined,
LayoutOutlined,
ArrowUpOutlined,
DownloadOutlined,
} from '@ant-design/icons';
const { Header, Sider, Content } = Layout;
// 解构Input组件
const { TextArea } = Input;
// 解构Select组件
const { Option } = Select;
// 解构Typography组件
const { Text } = Typography;
interface MediaReference {
name: string;
type: 'image' | 'video';
url: string;
}
interface Message {
id: string;
original_prompt: string;
gen_type: string;
idempotency_key: string;
media_references?: MediaReference[];
image_size?: string;
image_proportion?: string;
image_px?: string;
duration?: number;
ratio?: string;
resolution?: string;
timestamp?: string;
engine_id: string;
}
interface Conversation {
id: string;
title: string;
lastMessage: string;
timestamp: string;
messages: any;
}
const mockUpload = (file: File): Promise<{ url: string }> => {
return new Promise((resolve) => {
setTimeout(() => {
// 使用URL.createObjectURL创建本地文件预览URL
const url = URL.createObjectURL(file);
resolve({ url });
}, 500);
});
};
const AIChatPage: React.FC = () => {
// ==================== 状态定义 ====================
const [collapsed, setCollapsed] = useState<boolean>(false);
const [currentConversationId, setCurrentConversationId] = useState<string | null>('1');
const [conversations, setConversations] = useState<Conversation[]>([
]);
// 从 store 读取生成配置状态(包括输入框内容)
const {
mediaType,
countType,
selectedRatio,
selectedResolution,
width,
height,
videoDuration,
videoAspectRatio,
videoResolution,
engineOptions,
enginesele,
inputValue,
setMediaType,
setCountType,
setSelectedRatio,
setSelectedResolution,
setWidth,
setHeight,
setVideoDuration,
setVideoAspectRatio,
setVideoResolution,
setEngineOptions,
setEnginesele,
setInputValue,
} = useAppStore();
const [uploading, setUploading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [currentMedia, setCurrentMedia] = useState<{ name: string; type: 'image' | 'video'; url: string }[]>([]);
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = useRef<HTMLVideoElement>(null);
// 提示词展开状态
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
// 从URL中提取exp时间戳(支持相对路径和完整URL)
const extractExpTimestamp = (url: string): number | null => {
if (!url) return null;
try {
// 尝试作为完整URL解析
const urlObj = new URL(url);
const expStr = urlObj.searchParams.get('exp');
if (expStr) {
return parseInt(expStr, 10);
}
return null;
} catch {
// 如果完整URL解析失败,尝试解析相对路径中的查询参数
try {
// 查找 ? 后面的部分
const queryStart = url.indexOf('?');
if (queryStart !== -1) {
const queryString = url.substring(queryStart + 1);
const params = new URLSearchParams(queryString);
const expStr = params.get('exp');
if (expStr) {
return parseInt(expStr, 10);
}
}
return null;
} catch {
return null;
}
}
};
// 检查媒体是否过期
const isMediaExpired = (url: string): boolean => {
const expTimestamp = extractExpTimestamp(url);
if (!expTimestamp) {
return false; // 没有exp参数,视为不过期
}
const currentTimestamp = Math.floor(Date.now() / 1000);
return currentTimestamp > expTimestamp;
};
// 附件预览弹窗状态(独立弹窗)
const [attachmentPreviewVisible, setAttachmentPreviewVisible] = useState<boolean>(false);
const [attachmentPreviewUrl, setAttachmentPreviewUrl] = useState<string>('');
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video'>('image');
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
// 附件详情悬浮窗状态
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
const [attachmentPopupMessageId, setAttachmentPopupMessageId] = useState<string | null>(null);
const [attachmentPopupPosition, setAttachmentPopupPosition] = useState({ x: 0, y: 0 });
const [showImageSettingsModal, setShowImageSettingsModal] = useState(false);
const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false);
const [ratioOptions, setRatioOptions] = useState([]);
const [resolutionOptions, setResolutionOptions] = useState([]);
const [widthandheight, setWidthandHeight] = useState([]);
const [blindex, setBlindex] = useState<any>(0);
const [fblindex, setFBlindex] = useState<any>(0);
const [showEngineModal, setShowEngineModal] = useState(false);
const [creditRatios, setCreditRatios] = useState<any[]>([]);
const [cimage, setCimage] = useState<any[]>([]);
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
// 获取预估积分 - 根据引擎ID、类型和分辨率计算
const getEstimatedCredits = (): number => {
// 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置
let config: any = {};
config = creditCalculationData.find((item: any) =>
item.modelConfigId === countType &&
item.genType === mediaType &&
item.resolution === (mediaType === 'video' ? videoResolution : selectedResolution)
);
if (!config) {
if (mediaType === 'video') {
// 如果没有找到对应的配置,则使用默认配置
config = {
perSecondCredits: 2,
baseCredits: 60,
ratio: 1,
};
if (videoResolution === '1080p') {
config.ratio = 2;
} else if (videoResolution === '720p') {
config.ratio = 1.5;
} else if (videoResolution === '480p') {
config.ratio = 1;
}
}
else {
// 如果没有找到对应的配置,则使用默认配置
config = {
perSecondCredits: 0.1,
baseCredits: 4,
ratio: 1,
};
if (selectedResolution === '2K') {
config.ratio = 1;
} else if (selectedResolution === '4K') {
config.ratio = 2;
}
}
}
// 根据配置计算积分
if (mediaType === 'video') {
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
return Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
} else {
// 图片:baseCredits × ratio
return config.baseCredits * config.ratio;
}
};
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const pollingRef = useRef<number | null>(null);
const isFirstLoadRef = useRef<boolean>(true);
const currentConversation = conversations.find((c) => c.id === currentConversationId);
const [gen_list, setGen_list] = useState<any>([]);
const [Pagebreak, setPagebreak] = useState<any>({
page: 1,
pageSize: 20,
});
const [Totalnumber, setTotalnumber] = useState<any>(0);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const isInitialLoadDone = useRef(false);
// ==================== 副作用 ====================
// 滚动到底部的辅助函数
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'instant') => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const doScroll = () => {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
};
requestAnimationFrame(doScroll);
}, []);
// 初次打开或页面刷新时自动滚动到底部(只在初始加载时执行一次)
useEffect(() => {
if (gen_list.length > 0 && !isInitialLoadDone.current) {
const timer = setTimeout(() => {
scrollToBottom('smooth');
isInitialLoadDone.current = true;
}, 300);
return () => clearTimeout(timer);
}
}, [gen_list.length, scrollToBottom]);
useEffect(() => {
const newOptions = mediaType === 'image' ? enginesele.image : enginesele.video;
if (newOptions && newOptions.length > 0) {
// 如果当前 countType 不在新选项中,重置为第一个选项
const isValidOption = newOptions.some((item: any) => item.id === countType);
if (!isValidOption) {
setCountType(newOptions[0].id);
}
}
}, [mediaType, enginesele]);
// 点击外部关闭弹窗
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 关闭引擎选择弹窗
if (showEngineModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowEngineModal(false);
}
// 关闭图片设置弹窗
if (showImageSettingsModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowImageSettingsModal(false);
}
// 关闭视频设置弹窗
if (showVideoSettingsModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowVideoSettingsModal(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
// 初始化获取参数 - 只在组件挂载时执行一次
useEffect(() => {
getEngine()
.then((data: any) => {
// console.log('引擎', data);
setEnginesele(data.engine);
// 如果是视频模式,初始化视频参数选项
if (data.engine.video && data.engine.video.length > 0) {
const defaultEngine = data.engine.video[0];
// 查找用户之前选择的引擎(如果存在)
const savedEngine = data.engine.video.find((e: any) => e.id === countType);
const targetEngine = savedEngine || defaultEngine;
// 更新引擎选项为当前引擎支持的参数
setEngineOptions({
ratios: targetEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: targetEngine.supportedResolutions || ['480p', '720p', '1080p'],
durations: targetEngine.supportedDurations || [5, 8, 10, 12, 15],
});
// 确保视频参数在引擎支持的范围内
if (!targetEngine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(targetEngine.supportedRatios?.[0] || '16:9');
}
if (!targetEngine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(targetEngine.supportedResolutions?.[0] || '720p');
}
if (!targetEngine.supportedDurations?.includes(videoDuration)) {
setVideoDuration(targetEngine.supportedDurations?.[0] || 5);
}
// 如果之前没有选择过引擎(还是默认值),才设置默认引擎
if (countType === '请选择') {
setCountType(targetEngine.id);
}
}
})
.catch(() => {
});
getCreditRatios()
.then((data: any) => {
// console.log('积分', data);
setCreditRatios(data.video || []);
setCimage(data.image || []);
})
.catch((error) => {
});
calculateCredits().then((data: any) => {
// console.log('积分计算', data);
// 保存积分计算数据
setCreditCalculationData(data);
})
getgen_list(Pagebreak).then((data: any) => {
let mess_list = data.items
let total = data.total
setGen_list(mess_list)
setTotalnumber(total)
})
getParameters()
.then((data) => {
let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
let supportedResolutions = [];
let twokwidth = [];
let fourkwidth = [];
for (let key in supportedSizes["2K"]) {
supportedResolutions.push(key);
twokwidth.push(supportedSizes["2K"][key]);
}
const newRatioOptions = supportedResolutions.map((res: any, index: number) => ({
value: res,
label: String(index),
}));
setRatioOptions(newRatioOptions);
for (let key in supportedSizes["4K"]) {
fourkwidth.push(supportedSizes["4K"][key]);
}
const newWidthandHeight = [twokwidth, fourkwidth];
setWidthandHeight(newWidthandHeight);
setResolutionOptions([
{ value: '2K', label: '高清 2K' },
{ value: '4K', label: '超清 4K' },
]);
})
.catch(() => { });
}, []);
// 轮询逻辑 - 当有任务在生成时,每5秒查询一次状态
useEffect(() => {
// 先清理之前的定时器
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
// 判断是否有正在生成的任务
const hasGeneratingTask = gen_list.some((item: any) => item.status === 'generating');
if (!hasGeneratingTask) return;
// 设置5秒轮询
pollingRef.current = window.setInterval(() => {
getgen_list(Pagebreak).then((data: any) => {
// 只更新正在生成的任务,不影响其他已加载的数据
setGen_list((prevList: any[]) => {
return prevList.map((prevItem: any) => {
// 只更新正在生成的任务
if (prevItem.status === 'generating') {
const newItem = data.items.find((item: any) => item.id === prevItem.id);
// 如果找到了对应的新数据,使用新数据;否则保持旧数据
return newItem || prevItem;
}
// 非生成中的任务保持不变
return prevItem;
});
});
setTotalnumber(data.total);
}).catch((error) => {
});
}, 10000);
// 清理定时器
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [gen_list, Pagebreak]);
// 点击空白处关闭图片设置浮层
useEffect(() => {
if (!showImageSettingsModal) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest(".image-settings-popover") &&
!target.closest(".image-settings-trigger")
) {
setShowImageSettingsModal(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [showImageSettingsModal]);
useEffect(() => {
if (!showVideoSettingsModal) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest(".image-settings-popover") &&
!target.closest(".image-settings-trigger")
) {
setShowVideoSettingsModal(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [showVideoSettingsModal]);
// 监听 blindex 状态变化
useEffect(() => {
if (
widthandheight[fblindex] &&
widthandheight[fblindex][blindex] !== undefined
) {
setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4)));
setHeight(Number(widthandheight[fblindex][blindex].substring(5)));
}
}, [blindex]);
useEffect(() => {
if (
widthandheight[fblindex] &&
widthandheight[fblindex][blindex] !== undefined
) {
setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4)));
setHeight(Number(widthandheight[fblindex][blindex].substring(5)));
}
}, [fblindex]);
// 根据比例计算尺寸
const calculateSizeFromRatione = (ratio: string) => {
setBlindex(ratio);
};
const calculateSizeFromRatiotwo = (ratio: string) => {
setFBlindex(parseInt(ratio === '2K' ? '0' : '1'));
};
// 交换宽高
const handleSwap = () => {
setWidth(height);
setHeight(width);
};
// ==================== 事处理函数 ====================
const handleNewChat = () => {
// 生成唯一ID(使用时间戳)
const newId = Date.now().toString();
const newConversation: Conversation = {
id: newId,
title: '新对话',
lastMessage: '',
timestamp: new Date().toLocaleString('zh-CN'),
messages: [],
};
// 将新对话插入到列表顶部
setConversations((prev) => [newConversation, ...prev]);
// 切换到新对话
setCurrentConversationId(newId);
// 清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
// 显示提示消息
message.info('已开启新对话');
};
const handleDeleteChat = (conversationId: string) => {
// 过滤掉要删除的对话
setConversations((prev) => prev.filter((c) => c.id !== conversationId));
// 如果删除的是当前选中的对话,切换到其他对话
if (currentConversationId === conversationId) {
setCurrentConversationId(
conversations[0]?.id === conversationId
? conversations[1]?.id || null
: conversations[0]?.id || null
);
}
// 显示成功提示
message.success('对话已删除');
};
const handleSelectChat = (conversationId: string) => {
setCurrentConversationId(conversationId);
setCurrentMedia([]);
};
const handleSend = async () => {
// 验证:必须有内容或图片或视频
if (!inputValue.trim() && currentMedia.length === 0) {
message.warning('请输入内容或上传图片/视频');
return;
}
// 创建用户消息对象
const newMessage: Message = {
id: '',
gen_type: mediaType,
original_prompt: inputValue.trim(),
engine_id: countType,
idempotency_key: new Date().toLocaleString('zh-CN'),
// 统一的媒体数组,包含 name、type、url
media_references: currentMedia.length > 0 ? [...currentMedia] : undefined,
// 图片参数(仅图片模式时添加)
...(mediaType === 'image' && {
image_size: selectedResolution,
image_proportion: selectedRatio,
image_px: width + "x" + height,
}),
// 视频参数(仅视频模式时添加)
...(mediaType === 'video' && {
duration: videoDuration,
aspect_ratio: videoAspectRatio,
resolution: videoResolution,
}),
};
// 设置加载状态
setLoading(true);
createGenerationTask(newMessage).then(() => {
// 创建任务成功后,清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
// 创建任务成功后,重置页数为1,获取最新列表
const newPagebreak = { ...Pagebreak, page: 1 };
setPagebreak(newPagebreak);
getgen_list(newPagebreak).then((data: any) => {
// 将data.items的最后一个元素添加到gen_list末尾
setGen_list((prev: any[]) => [...prev, data.items[data.items.length - 1]]);
setTotalnumber(data.total);
// 发送消息后滚动到底部
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}).catch((error) => {
});
}).catch((error: any) => {
// 失败时不清空输入框和媒体
// 尝试从错误对象中提取detail字段
let errorMessage = "创建任务失败";
if (error?.response?.data?.detail) {
errorMessage = error.response.data.detail;
} else if (error?.data?.detail) {
errorMessage = error.data.detail;
} else if (error?.detail) {
errorMessage = error.detail;
} else if (error?.message) {
errorMessage = error.message;
}
message.error({
content: errorMessage,
duration: 3,
});
}).finally(() => {
setLoading(false);
});
setTimeout(() => {
setLoading(false);
}, 3000);
};
// 加载更多
const handleLoadMore = async () => {
// 检查是否还有更多数据
if (gen_list.length >= Totalnumber) {
return;
}
// 保存当前滚动位置(相对于顶部的偏移)
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const scrollTopBefore = scrollContainer.scrollTop;
const scrollHeightBefore = scrollContainer.scrollHeight;
setIsLoadingMore(true);
try {
const nextPage = Pagebreak.page + 1;
const result = await getgen_list({
...Pagebreak,
page: nextPage,
});
// 处理返回数据,兼容数组或对象格式
const data = Array.isArray(result) ? { items: result, total: result.length } : result;
// 更新总数
if (data.total) {
setTotalnumber(data.total);
}
// 更新页码
setPagebreak((prev: any) => ({ ...prev, page: nextPage }));
// 去重后合并数据(根据 id 去重,新数据添加到前面)
setGen_list((prev: any[]) => {
const existingIds = new Set(prev.map((item: any) => item.id));
// 只添加不存在的新数据,保持新数据的原有顺序
const newItems = data.items.filter((item: any) => {
if (!item.id) return false;
if (existingIds.has(item.id)) return false;
existingIds.add(item.id);
return true;
});
// 新数据在前,旧数据在后
return [...newItems, ...prev];
});
// 恢复滚动位置(等待DOM更新完成后执行,增加超时时间确保DOM完全更新)
setTimeout(() => {
const scrollHeightAfter = scrollContainer.scrollHeight;
const addedHeight = scrollHeightAfter - scrollHeightBefore;
// 新数据添加到前面,保持当前查看内容的位置不变
scrollContainer.scrollTop = scrollTopBefore + addedHeight;
}, 200);
} catch (error) {
} finally {
setIsLoadingMore(false);
}
};
const handleUpload = async (file: File) => {
// 验证文件类型
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (!isImage && !isVideo) {
message.error('仅支持图片或视频文件');
return false;
}
// 验证文件大小
const maxMB = isVideo ? 100 : 10;
if (file.size / 1024 / 1024 > maxMB) {
message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`);
return false;
}
// 验证图片数量(最多4张)
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
if (isImage && imageCount >= 4) {
message.error('最多上传4张图片');
return false;
}
// 验证视频数量(最多1个)
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
if (isVideo && videoCount >= 1) {
message.error('最多上传1个视频');
return false;
}
// 设置上传状态
setUploading(true);
try {
// 根据文件类型调用相应的上传函数
const uploadFn = isImage ? uploadImage : uploadVideo;
const res = await uploadFn(file);
// 添加到统一的媒体列表
setCurrentMedia((prev) => [...prev, {
name: file.name,
type: isImage ? 'image' : 'video',
url: res.url,
}]);
message.success(`${isImage ? '图片' : '视频'}上传成功`);
} catch (error) {
message.error('上传失败');
} finally {
setUploading(false);
}
// 返回false阻止Ant Design的自动上传行为
return false;
};
const handleRemoveMedia = (index: number) => {
setCurrentMedia((prev) => prev.filter((_, i) => i !== index));
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClosePreview = () => {
setPreviewVisible(false);
setPreviewUrl('');
if (videoRef.current) {
videoRef.current.pause();
}
};
const handleDownload = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!previewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// ==================== 渲染 ====================
return (
<Layout style={{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
background: 'linear-gradient(135deg, #f8fafc 0%, #f0f4ff 50%, #faf5ff 100%)',
backgroundImage: `url(${bg3})`,
backgroundRepeat: 'no-repeat',
backgroundSize: '100% 100%',
backgroundPosition: 'center',
}}>
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
{false && (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={220}
style={{
background: 'rgba(255,255,255,0.7)',
backdropFilter: 'blur(20px)',
borderRight: '1px solid rgba(99, 102, 241, 0.08)',
}}
>
<div style={{ padding: 12 }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ marginBottom: 12, width: '100%' }}
/>
{!collapsed && (
<Button
block
type="primary"
icon={<PlusOutlined />}
onClick={handleNewChat}
style={{ marginBottom: 16, borderRadius: 8 }}
>
新对话
</Button>
)}
{!collapsed && conversations.length > 0 && (
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
{conversations.map((conversation) => (
<div
key={conversation.id}
onClick={() => handleSelectChat(conversation.id)}
style={{
display: 'flex',
alignItems: 'center',
padding: '10px 12px',
marginBottom: 4,
borderRadius: 8,
cursor: 'pointer',
background: currentConversationId === conversation.id ? '#f0f5ff' : 'transparent',
border: currentConversationId === conversation.id ? '1px solid #e0e8ff' : 'none',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : '#fafafa';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : 'transparent';
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#333', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{conversation.title}
</p>
<p style={{ margin: 2, fontSize: 11, color: '#999', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{conversation.lastMessage || '暂无消息'}
</p>
</div>
<Popconfirm
title="确定删除此对话?"
onConfirm={() => handleDeleteChat(conversation.id)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
icon={<DeleteOutlined />}
style={{ color: '#999', padding: 4 }}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</div>
))}
</div>
)}
</div>
</Sider>
)}
{/* 主内容区 */}
<Layout style={{ display: 'flex', flex: 1, background: 'transparent' }}>
{/* 头部 - 显示对话标题和模型信息 */}
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 24, padding: '16px 24px', borderRadius: 16,
background: 'rgba(255,255,255,0.6)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(99, 102, 241, 0.08)',
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
<div>
<h2 style={{
margin: 0,
fontSize: 18,
fontWeight: 700,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}}>
AI创作
</h2>
<p style={{ fontSize: 13, color: '#64748b', margin: '4px 0 0 0' }}>
输入想法、剧本或上传参考,智能生成视频/图片
</p>
</div>
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #8b5cf6, #6366f1, transparent)', borderRadius: 1 }} />
</div>
</div>
{/* 消息区域 */}
<Content
style={{
flex: 1,
margin: 0,
padding: 24,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
}}
>
{/* 空状态 - 没有对话或当前对话没有消息时显示 */}
{gen_list.length === 0 && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<div
style={{
width: 80,
height: 80,
borderRadius: 50,
background: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
}}
>
<RobotOutlined style={{ fontSize: 40, color: '#fff' }} />
</div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 500, color: '#333' }}>
你好,想创作什么?
</h2>
<p style={{ margin: 8, fontSize: 13, color: '#999' }}>
输入想法、剧本或上传参考,开始你的创作之旅
</p>
</div>
)}
{/* 消息列表 - gen_list 有数据时显示 */}
{gen_list.length > 0 && (
<div
ref={scrollContainerRef}
style={{
flex: 1,
overflowY: 'auto',
// paddingBottom: 24,
paddingRight: 8,
// backgroundImage: `url(${bg1})`,
// backgroundRepeat: 'no-repeat',
// backgroundSize: 'cover',
// backgroundPosition: 'center',
}}
>
{/* 加载更多按钮 - 在列表顶部 */}
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
{gen_list.length >= Totalnumber ? (
<span style={{ fontSize: 12, color: '#999' }}>消息全部加载</span>
) : (
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
style={{
fontSize: 12,
color: '#6366f1',
background: 'none',
border: 'none',
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
}}
>
{isLoadingMore ? '加载中...' : '加载更多'}
</button>
)}
</div>
{/* 遍历消息列表 */}
{(() => {
const msgApi = message;
return gen_list.map((msg) => (
<div
key={msg.id}
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start', // 所有消息左对齐
marginBottom: 16,
}}
>
<div style={{ width: '100%', display: 'flex', gap: 10 }}>
{/* 头像 */}
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}}
>
<RobotOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
{/* 消息内容 */}
<div style={{ flex: 1 }}>
{/* 时间戳和参数信息 */}
{/* 消息气泡 */}
<div
style={{
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(20px)',
borderRadius: '16px 16px 16px 4px',
padding: '12px 16px',
width: '70%',
minWidth: 500,
boxSizing: 'border-box',
boxShadow: '0 4px 20px rgba(99, 102, 241, 0.08), 0 1px 3px rgba(0,0,0,0.04)',
position: 'relative',
border: '1px solid rgba(99, 102, 241, 0.06)',
}}
>
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
<span style={{
background: msg.genType === 'image' ? 'rgba(34, 197, 94, 0.12)' : 'rgba(249, 115, 22, 0.12)',
color: msg.genType === 'image' ? '#22c55e' : '#f97316',
padding: '2px 8px',
borderRadius: 12,
fontWeight: 500,
fontSize: 11,
marginLeft: 8
}}>
{msg.genType === 'image' ? '图片生成' : '视频生成'}
</span>
</div>
{/* 删除按钮 - 右上角 */}
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
<Popconfirm
title="确定要删除吗?"
onConfirm={async () => {
try {
await deleteHistory(msg.id);
msgApi.success('删除成功');
// 直接在本地列表中删除对应数据
setGen_list(prev => prev.filter(item => item.id !== msg.id));
setTotalnumber(prev => prev - 1);
} catch (error: any) {
const errorMsg = error?.response?.data?.message || error?.message || '删除失败';
msgApi.error(errorMsg);
}
}}
okText="确定"
cancelText="取消"
>
<button
onClick={(e) => e.stopPropagation()}
style={{
width: 28,
height: 28,
borderRadius: 8,
border: 'none',
background: 'rgba(99, 102, 241, 0.08)',
color: '#6366f1',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
padding: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
e.currentTarget.style.color = '#ef4444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)';
e.currentTarget.style.color = '#6366f1';
}}
>
<DeleteOutlined style={{ fontSize: 12 }} />
</button>
</Popconfirm>
</div>
{/* 文本内容 */}
{/* <div
style={{
width: '100%',
position: 'relative',
margin: '8px 0',
padding: '12px 16px',
backgroundColor: 'rgba(99, 102, 241, 0.04)',
borderRadius: 10,
border: '1px solid rgba(99, 102, 241, 0.08)',
fontSize: 13,
color: '#475569',
lineHeight: 1.6,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 1px 3px rgba(99, 102, 241, 0.04)',
}}
onMouseEnter={() => {
setExpandedPrompts(prev => {
const newSet = new Set(prev);
newSet.add(msg.id);
return newSet;
});
}}
onMouseLeave={() => {
setExpandedPrompts(prev => {
const newSet = new Set(prev);
newSet.delete(msg.id);
return newSet;
});
}}
>
默认显示:一行省略
<div style={{
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: expandedPrompts.has(msg.id) ? 'none' : 'block',
}}>
{msg.originalPrompt}
</div>
鼠标移入显示:完整内容
<div style={{
width: '100%',
maxHeight: 200,
overflowY: 'auto',
display: expandedPrompts.has(msg.id) ? 'block' : 'none',
wordBreak: 'break-word',
}}>
{msg.originalPrompt}
</div>
</div> */}
{/* 根据 status 显示不同内容 */}
{(msg.status === 'generating' || msg.status === 'failed' || msg.status === 'completed') && (
<div style={{ display: 'flex', gap: 16, marginBottom: 16, marginTop: 16, width: '100%', alignItems: 'flex-start',
}}>
<div style={{ width: '50%', overflow: 'hidden', borderRadius: 12, position: 'relative', height: 220, border: '1px solid rgba(99, 102, 241, 0.1)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #f8fafc 0%, #eef2ff 100%)' }}>
{msg.status === 'generating' ? (
<>
<div style={{ position: 'absolute', top: 12, left: 12, display: 'flex', alignItems: 'center', gap: 8, zIndex: 2 }}>
{/* <div style={{ width: 20, height: 20, border: '2px solid #c7d2fe', borderTopColor: '#6366f1', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> */}
</div>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.6) 50%, transparent 100%)', animation: 'shimmer 2s infinite' }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', background: 'rgba(99, 102, 241, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(99, 102, 241, 0.2)' }}>
<div style={{ width: 48, height: 48, border: '3px solid #c7d2fe', borderTopColor: '#6366f1', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</div>
<span style={{ fontSize: 12, color: '#6366f1', fontWeight: 500 }}>生成中...</span>
{/* <div style={{ display: 'flex', gap: 4 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#6366f1', animation: 'pulse 1.5s ease-in-out infinite' }} />
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#818cf8', animation: 'pulse 1.5s ease-in-out 0.2s infinite' }} />
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#a5b4fc', animation: 'pulse 1.5s ease-in-out 0.4s infinite' }} />
</div> */}
</div>
</>
) : msg.status === 'failed' ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'rgba(254, 226, 226, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<WarningOutlined style={{ color: '#ef4444', fontSize: 22 }} />
</div>
<span style={{ fontSize: 14, color: '#94a3b8' }}>生成失败</span>
</div>
) : (
<>
{msg.genType === 'image' ? (
<img src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.imageUrl}&w=300&p=50`} alt={msg.name} style={{ width: '100%', height: '100%', borderRadius: 12, objectFit: 'contain', cursor: 'pointer', transition: 'transform 0.3s ease' }} onClick={() => { setPreviewUrl(msg.imageUrl); setPreviewType('image'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} />
) : (
<>
<img src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.videoCoverUrl}&w=300&p=50`} alt={msg.name} style={{ width: '100%', height: '100%', borderRadius: 12, objectFit: 'contain', cursor: 'pointer', transition: 'transform 0.3s ease' }} onClick={() => { setPreviewUrl(msg.videoUrl); setPreviewType('video'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: 56, height: 56, background: 'rgba(99, 102, 241, 0.85)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none', boxShadow: '0 4px 20px rgba(99, 102, 241, 0.4)' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z" /></svg>
</div>
</>
)}
</>
)}
</div>
<div style={{ width: '50%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 12, padding: 12, border: '1px solid rgba(99, 102, 241, 0.08)', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p style={{width: '100%' ,height: 150, overflow: 'auto', padding: 0, marginTop: 0, marginBottom: 0, lineHeight: 2, color: '#475569', fontSize: 13 }}>{msg.originalPrompt}</p>
</div>
<div style={{ fontSize: 12, color: '#64748b', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', justifyContent: 'space-between',marginBottom: 12 }}>
<span style={{
// background: 'rgba(99, 102, 241, 0.08)',
borderRadius: 16, color: '#6366f1', fontWeight: 500 }}>{msg.engineSnapshot.name}</span>
<span style={{
// background: 'rgba(99, 102, 241, 0.08)',
borderRadius: 16, color: '#64748b' }}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
<span style={{
// background: 'rgba(239, 68, 68, 0.08)',
fontSize: 14,
borderRadius: 16, color: '#ef4444', fontWeight: 500 }}>消耗积分:{msg.creditsCost}</span>
</div>
</div>
</div>
)}
<div style={{ textAlign: 'right' }}>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#6366f1', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(99, 102, 241, 0.2)', background: 'rgba(99, 102, 241, 0.04)' }} onClick={(e) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}>附件详情</span>
)}
</div>
</div>
</div>
</div>
</div>
))
})()}
{/* 消息列表底部标记 - 用于自动滚动 */}
<div ref={messagesEndRef} />
</div>
)}
{/* 附件详情悬浮窗 - 包含遮罩层 */}
{attachmentPopupVisible && attachmentPopupMessageId && (
<>
{/* 遮罩层 - 点击关闭悬浮窗 */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'transparent',
zIndex: 9998,
}}
onClick={() => {
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}}
/>
{/* 悬浮窗内容 */}
<div
style={{
position: 'fixed',
top: attachmentPopupPosition.y - 100,
left: attachmentPopupPosition.x,
background: '#fff',
borderRadius: 12,
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: 16,
minWidth: 200,
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 14, fontWeight: 500 }}>附件列表</span>
<button
onClick={() => {
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}}
style={{
width: 24,
height: 24,
borderRadius: '50%',
border: 'none',
background: '#ff4d4f',
cursor: 'pointer',
fontSize: 14,
color: '#fff',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#ff7875';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ff4d4f';
e.currentTarget.style.transform = 'scale(1)';
}}
>
×
</button>
</div>
{gen_list.find((msg: any) => msg.id === attachmentPopupMessageId)?.mediaReferences?.map((ref: any, idx: number) => (
<div
key={idx}
onClick={() => {
setAttachmentPreviewUrl(ref.url);
setAttachmentPreviewType(ref.url.includes('.mp4') || ref.url.includes('.mov') || ref.url.includes('.avi') || ref.url.includes('.video') ? 'video' : 'image');
setAttachmentPreviewName(ref.name);
setAttachmentPreviewVisible(true);
// 关闭附件悬浮窗,避免层级覆盖问题
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
borderRadius: 6,
cursor: 'pointer',
fontSize: 13,
color: '#333',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
{ref.type === 'image' ? (
<PictureOutlined style={{ color: '#3b82f6', fontSize: 14 }} />
) : (
<VideoCameraOutlined style={{ color: '#f59e0b', fontSize: 14 }} />
)}
{ref.name}
</div>
))}
</div>
</>
)}
{/* 输入区域 - 始终显示 */}
<div
style={{
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)',
borderRadius: 24,
padding: 16,
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.1), 0 2px 8px rgba(0,0,0,0.04)',
transition: 'all 0.3s ease',
border: '1px solid rgba(99, 102, 241, 0.08)',
// margin: '0 24px 24px',
}}
>
{/* 已上传媒体预览 */}
{currentMedia.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12, overflowX: 'auto' }}>
{currentMedia.map((media, idx) => (
<div key={`media-${idx}`} style={{ position: 'relative', width: media.type === 'video' ? 120 : 80, flexShrink: 0 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
/>
)}
{/* 删除已上传媒体按钮 */}
<button
onClick={() => handleRemoveMedia(idx)}
style={{
position: 'absolute',
top: 0,
right: 0,
width: 20,
height: 20,
border: 'none',
background: '#ff4d4f',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
×
</button>
</div>
))}
</div>
)}
{/* 输入框区域 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
backgroundColor: 'rgba(248,250,252,0.6)',
borderRadius: 16,
border: '1px solid rgba(99, 102, 241, 0.08)',
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
transition: 'all 0.2s ease',
}}>
{/* 上传按钮 */}
<Upload
accept="image/*,video/*"
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={`图片${currentMedia.filter(m => m.type === 'image').length}/4,视频${currentMedia.filter(m => m.type === 'video').length}/1`}>
<div
style={{
width: 48,
height: 48,
borderRadius: 14,
border: '2px dashed rgba(99, 102, 241, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
flexShrink: 0,
backgroundColor: 'rgba(255,255,255,0.7)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.2)';
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.7)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
{uploading ? (
<LoadingOutlined style={{ fontSize: 18, color: '#6366f1' }} />
) : (
<PlusOutlined style={{ fontSize: 18, color: '#64748b' }} />
)}
</div>
</Tooltip>
</Upload>
{/* 文本输入框 */}
<TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="上传最多4张参考图,输入提示词描述您想生成的画面..."
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
flex: 1,
height: '100%',
borderRadius: 12,
border: 'none',
outline: 'none',
boxShadow: 'none',
fontSize: 14,
lineHeight: 1.5,
color: '#1e293b',
// resize: 'none',
// boxShadow: 'none',
// padding: '8px 12px',
}}
disabled={loading}
/>
{/* 发送按钮 */}
<Button
type="primary"
shape="circle"
icon={<ArrowUpOutlined />}
onClick={handleSend}
disabled={!inputValue.trim() && currentMedia.length === 0}
loading={loading}
style={{
flexShrink: 0,
width: 40,
height: 40,
borderRadius: 14,
background: (inputValue.trim() || currentMedia.length > 0)
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
: '#c7cfdaff',
boxShadow: (inputValue.trim() || currentMedia.length > 0)
? '0 4px 16px rgba(99, 102, 241, 0.4)'
: 'none',
transition: 'all 0.2s ease',
border: 'none',
}}
/>
</div>
{/* 底部选择器 - 媒体类型和数量 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
<Space size="middle">
{/* 文件类型选择器 */}
{/* <div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '5px 12px',
borderRadius: 8,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
height: 28,
}}> */}
{/* <Text style={{ fontSize: 12, color: '#94a3b8' }}>类型</Text> */}
<Select
value={mediaType}
onChange={(val) => setMediaType(val)}
style={{
width: 100,
height: 34,
outline: 'none',
border: 'none',
backgroundColor: '#f1f5f9',
borderRadius: 10,
}}
size="middle"
>
<Option value="video">
<VideoCameraOutlined style={{ marginRight: 6, fontSize: 14 }} />
<span style={{ fontSize: 14, fontWeight: 500, color: '#64748b' }}>视频</span>
</Option>
<Option value="image">
<PictureOutlined style={{ marginRight: 6, fontSize: 14 }} />
<span style={{ fontSize: 14, fontWeight: 500, color: '#64748b' }}>图片</span>
</Option>
</Select>
{/* </div> */}
{/* 引擎选择器 */}
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setShowEngineModal(true)}
className="image-settings-trigger"
style={{
minWidth: 200,
padding: '6px 14px',
height: 34,
borderRadius: 10,
border: 'none',
backgroundColor: '#f1f5f9',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.3)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.15)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(99, 102, 241, 0.04)';
}}
>
<SettingOutlined style={{ fontSize: 14, color: '#64748b' }} />
<Text style={{
fontSize: 14,
fontWeight: 500,
color: '#64748b',
}}>
{mediaType === 'image' ? enginesele.image?.find((e: any) => e.id === countType)?.name : enginesele.video?.find((e: any) => e.id === countType)?.name || '选择引擎'}
</Text>
</button>
{showEngineModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 360,
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 14,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 16,
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择引擎 */}
<div style={{ marginBottom: 8 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 600,
color: '#475569',
}}>
选择引擎
</Text>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
{(mediaType === 'image' ? enginesele.image : enginesele.video)?.map((engine: any) => (
<button
key={engine.id}
onClick={() => {
setCountType(engine.id);
// 根据选中的引擎更新视频参数选项
if (mediaType === 'video') {
setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
});
// 更新默认选中值,确保在新引擎支持的范围内
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
}
if (!engine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(engine.supportedResolutions?.[0] || '720p');
}
if (!engine.supportedDurations?.includes(videoDuration)) {
setVideoDuration(engine.supportedDurations?.[0] || 5);
}
}
setShowEngineModal(false);
}}
style={{
flex: 1,
minHeight: 48,
borderRadius: 8,
border: countType === engine.id
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: countType === engine.id
? '#fff'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
}}
>
<span style={{
fontSize: 13,
fontWeight: countType === engine.id ? 600 : 500,
color: countType === engine.id ? '#6366f1' : '#4b5563',
marginBottom: 2,
}}>
{engine.name}
</span>
<span style={{
fontSize: 11,
color: '#9ca3af',
}}>
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* 图片设置按钮 */}
{mediaType === 'image' && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setShowImageSettingsModal(!showImageSettingsModal)}
className="image-settings-trigger"
style={{
minWidth: 220,
padding: '4px 12px',
height: 34,
borderRadius: 8,
border: 'none',
backgroundColor: '#f1f5f9',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s',
}}
>
<LayoutOutlined style={{ fontSize: 14, color: '#64748b' }} />
<Text style={{
fontSize: 14,
fontWeight: 500,
color: '#64748b',
}}>
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution == '2K' ? '2K高清' : '4K超清'} · {width}×{height}
</Text>
</button>
{showImageSettingsModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 480,
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 14,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 16,
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择比例 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择比例
</Text>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
}}>
{ratioOptions.map((item: any) => (
<button
key={item.value}
onClick={() => {
setSelectedRatio(item.value);
calculateSizeFromRatione(item.label);
}}
style={{
flex: '0 0 calc(11.11% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
border: selectedRatio === item.value
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: selectedRatio === item.value
? '#fff'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<div style={{
width: item.value === 'auto' ? 14 : 18,
height: item.value === 'auto' ? 14 : 18,
border: `2px solid ${selectedRatio === item.value ? '#6366f1' : '#9ca3af'}`,
borderRadius: 2,
marginBottom: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{item.value === 'auto' && (
<span style={{
fontSize: 7,
color: selectedRatio === item.value
? '#6366f1'
: '#9ca3af',
}}>
</span>
)}
</div>
<span style={{
fontSize: 9,
color: selectedRatio === item.value
? '#6366f1'
: '#6b7280',
fontWeight: selectedRatio === item.value ? 600 : 400,
}}>
{item.value}
</span>
</button>
))}
</div>
</div>
{/* 选择分辨率 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择分辨率
</Text>
<div style={{ display: 'flex', gap: 6 }}>
{resolutionOptions.map((item: any) => (
<button
key={item.value}
onClick={() => {
setSelectedResolution(item.value);
calculateSizeFromRatiotwo(item.value);
}}
style={{
flex: 1,
height: 42,
borderRadius: 6,
border: selectedResolution === item.value
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: selectedResolution === item.value
? '#6366f1'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<span style={{
fontSize: 12,
fontWeight: 600,
color: selectedResolution === item.value
? '#fff'
: '#4b5563',
}}>
{item.value}
{item.value === '4K' && (
<span style={{ marginLeft: 2 }}></span>
)}
</span>
</button>
))}
</div>
</div>
{/* 尺寸 */}
<div>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
尺寸
</Text>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
flex: 1,
maxWidth: 120,
}}>
<span style={{
color: '#9ca3af',
fontSize: 11,
marginRight: 4,
}}>
W
</span>
<Input
value={width}
onChange={(e) => setWidth(Number(e.target.value) || 0)}
style={{
flex: 1,
textAlign: 'center',
borderRadius: 4,
height: 34,
border: '1px solid #e5e7eb',
backgroundColor: '#f9fafb',
fontSize: 12,
fontWeight: 600,
color: '#1f2937',
}}
/>
</div>
<button
onClick={handleSwap}
style={{
width: 24,
height: 24,
borderRadius: 4,
border: '1px solid #e5e7eb',
backgroundColor: '#fff',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: '#6366f1',
}}
>
<SwapOutlined style={{ fontSize: 10 }} />
</button>
<div style={{
display: 'flex',
alignItems: 'center',
flex: 1,
maxWidth: 120,
}}>
<span style={{
color: '#9ca3af',
fontSize: 11,
marginRight: 4,
}}>
H
</span>
<Input
value={height}
onChange={(e) => setHeight(Number(e.target.value) || 0)}
style={{
flex: 1,
textAlign: 'center',
borderRadius: 4,
height: 34,
border: '1px solid #e5e7eb',
backgroundColor: '#f9fafb',
fontSize: 12,
fontWeight: 600,
color: '#1f2937',
}}
/>
</div>
<span style={{ color: '#9ca3af', fontSize: 11 }}>
PX
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* 视频参数设置 */}
{mediaType === 'video' && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setShowVideoSettingsModal(!showVideoSettingsModal)}
className="image-settings-trigger"
style={{
minWidth: 220,
padding: '4px 12px',
height: 34,
borderRadius: 8,
border: 'none',
backgroundColor: '#f1f5f9',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s',
}}
>
<LayoutOutlined style={{ fontSize: 14, color: '#64748b' }} />
<Text style={{
fontSize: 14,
fontWeight: 500,
color: '#64748b',
}}>
{videoAspectRatio} · {videoDuration}s · {videoResolution}
</Text>
</button>
{showVideoSettingsModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 400,
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 14,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 16,
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 选择比例 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择比例
</Text>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
}}>
{engineOptions.ratios.map((ratio) => (
<button
key={ratio}
onClick={() => setVideoAspectRatio(ratio)}
style={{
flex: '0 0 calc(14.28% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
border: videoAspectRatio === ratio
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: videoAspectRatio === ratio
? '#fff'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<div style={{
width: 18,
height: 18,
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
borderRadius: 2,
marginBottom: 2,
}} />
<span style={{
fontSize: 9,
color: videoAspectRatio === ratio
? '#6366f1'
: '#6b7280',
fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}>
{ratio}
</span>
</button>
))}
</div>
</div>
{/* 选择时长 */}
<div style={{ marginBottom: 16 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择时长
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1, position: 'relative', height: 24, display: 'flex', alignItems: 'center' }}>
{/* 背景轨道 */}
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: 6,
borderRadius: 3,
background: '#e5e7eb',
transform: 'translateY(-50%)',
}} />
{/* 已滑动部分 */}
<div style={{
position: 'absolute',
top: '50%',
left: 0,
height: 6,
borderRadius: 3,
background: '#6366f1',
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`,
transform: 'translateY(-50%)',
}} />
{/* 滑块 */}
<input
type="range"
min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)}
value={videoDuration}
onChange={(e) => setVideoDuration(Number(e.target.value))}
style={{
position: 'relative',
width: '100%',
height: 24,
borderRadius: 3,
background: 'transparent',
outline: 'none',
appearance: 'none',
cursor: 'pointer',
zIndex: 1,
}}
/>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 12px',
backgroundColor: '#f1f5f9',
borderRadius: 6,
}}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#64748b' }}>
{videoDuration}
</span>
<span style={{ fontSize: 12, color: '#9ca3af' }}></span>
</div>
</div>
</div>
{/* 选择分辨率 */}
<div>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
}}>
选择分辨率
</Text>
<div style={{ display: 'flex', gap: 6 }}>
{engineOptions.resolutions.map((resolution) => (
<button
key={resolution}
onClick={() => setVideoResolution(resolution)}
style={{
flex: 1,
height: 42,
borderRadius: 6,
border: videoResolution === resolution
? '2px solid #6366f1'
: '1px solid #e5e7eb',
backgroundColor: videoResolution === resolution
? '#6366f1'
: '#f9fafb',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
}}
>
<span style={{
fontSize: 12,
fontWeight: 600,
color: videoResolution === resolution
? '#fff'
: '#4b5563',
}}>
{resolution}
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* 预估积分 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', backgroundColor: 'rgba(99, 102, 241, 0.06)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.1)' }}>
<Text style={{ fontSize: 13, color: '#6366f1', fontWeight: 500 }}>预估积分</Text>
<Text style={{ fontSize: 14, color: '#6366f1', fontWeight: 600 }}>{getEstimatedCredits()}</Text>
</div>
</Space>
</div>
</div>
</Content>
</Layout>
{/* 自定义CSS动画 - 加载中闪烁效果 */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0.3; }
}
`}</style>
{/* 图片/视频预览弹窗 */}
<Modal
open={previewVisible}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
预览
</span>
</div>
}
onCancel={handleClosePreview}
width={800}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
disabled={isMediaExpired(previewUrl)}
>
{isMediaExpired(previewUrl) ? '资源已过期' : '下载'}
</Button>
</div>
}
centered
style={{ borderRadius: 16 }}
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
},
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{isMediaExpired(previewUrl) ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<p style={{ fontSize: 16, color: '#ff4d4f', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
</div>
) : previewType === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewUrl}&w=300&q=50`}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={videoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
{/* 附件预览弹窗(独立弹窗) */}
<Modal
open={attachmentPreviewVisible}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
附件预览
</span>
</div>
}
onCancel={() => setAttachmentPreviewVisible(false)}
width={800}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={() => {
if (!attachmentPreviewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}&download=1`;
link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
>
下载
</Button>
</div>
}
centered
style={{ borderRadius: 16 }}
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
},
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{attachmentPreviewType === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
</Layout>
);
};
export default AIChatPage;