修改AI创作界面和后台视频模型添加选择是否支持全能或者收尾帧

This commit is contained in:
2026-07-02 11:38:11 +08:00
parent 8b21d7cdde
commit c2b203f658
11 changed files with 1727 additions and 138 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DveJ8Oia.js"></script>
<script type="module" crossorigin src="/assets/index-DfKYC1kL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
@@ -20,6 +20,8 @@ interface VideoEngine {
maxDuration: number;
maxImageCount: number;
maxVideoCount: number;
supportsFirstLastFrame: boolean;
supportsUniversalReference: boolean;
isActive: boolean;
priority: number;
}
@@ -72,6 +74,8 @@ const AdminVideoEngines: React.FC = () => {
max_duration: values.maxDuration ?? 15,
max_image_count: values.maxImageCount ?? 2,
max_video_count: values.maxVideoCount ?? 0,
supports_first_last_frame: values.supportsFirstLastFrame ?? false,
supports_universal_reference: values.supportsUniversalReference ?? true,
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
};
@@ -112,6 +116,8 @@ const AdminVideoEngines: React.FC = () => {
maxDuration: 15,
maxImageCount: 2,
maxVideoCount: 0,
supportsFirstLastFrame: false,
supportsUniversalReference: true,
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
@@ -159,6 +165,14 @@ const AdminVideoEngines: React.FC = () => {
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
render: (v: number) => <Tag color="cyan">{v} </Tag>,
},
{
title: '首尾帧', dataIndex: 'supportsFirstLastFrame', width: 90,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
},
{
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -196,7 +210,7 @@ const AdminVideoEngines: React.FC = () => {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
scroll={{ x: 1100 }}
/>
</Card>
@@ -263,7 +277,18 @@ const AdminVideoEngines: React.FC = () => {
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Form.Item name="supportsFirstLastFrame" label="首尾帧模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
</Form.Item>
<Form.Item name="supportsUniversalReference" label="全能参考模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
{ value: 1, label: '1' },
@@ -273,9 +298,6 @@ const AdminVideoEngines: React.FC = () => {
{ value: 10, label: '10 (最高)' },
]} />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
@@ -0,0 +1,46 @@
"""d4e5f6a7b8c9 - 视频引擎增加首尾帧和全能参考支持字段
Revision ID: d4e5f6a7b8c9
Revises: 6idufv2q1c
Create Date: 2026-07-02 00:00:00.000000
该文件包含 2026-07-02 的数据库迁移内容:
1. 视频引擎表增加 supports_first_last_frame 字段(是否支持首尾帧模式)
2. 视频引擎表增加 supports_universal_reference 字段(是否支持全能参考模式)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, None] = '6idufv2q1c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'video_engines',
sa.Column(
'supports_first_last_frame',
sa.Boolean(),
server_default=sa.text('false'),
nullable=False,
),
)
op.add_column(
'video_engines',
sa.Column(
'supports_universal_reference',
sa.Boolean(),
server_default=sa.text('true'),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column('video_engines', 'supports_universal_reference')
op.drop_column('video_engines', 'supports_first_last_frame')
@@ -51,5 +51,7 @@ async def list_active_engines(
"supported_durations": durations,
"max_image_count": e.max_image_count,
"max_video_count": e.max_video_count,
"supports_first_last_frame": e.supports_first_last_frame,
"supports_universal_reference": e.supports_universal_reference,
})
return {"items": items}
+2
View File
@@ -19,6 +19,8 @@ class VideoEngine(Base, TimestampMixin):
max_duration: Mapped[int] = mapped_column(Integer, default=15)
max_image_count: Mapped[int] = mapped_column(Integer, default=2)
max_video_count: Mapped[int] = mapped_column(Integer, default=0)
supports_first_last_frame: Mapped[bool] = mapped_column(Boolean, default=False)
supports_universal_reference: Mapped[bool] = mapped_column(Boolean, default=True)
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
query_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -15,6 +15,8 @@ class VideoEngineCreate(BaseModel):
max_duration: int = Field(default=15)
max_image_count: int = Field(default=2)
max_video_count: int = Field(default=0)
supports_first_last_frame: bool = Field(default=False, description="是否支持首尾帧模式")
supports_universal_reference: bool = Field(default=True, description="是否支持全能参考模式")
generate_url: str = Field(default="", max_length=512)
query_url: str = Field(default="", max_length=512)
is_active: bool = True
@@ -37,6 +39,8 @@ class VideoEnginePublic(BaseModel):
supported_durations: list[int] = []
max_image_count: int = 2
max_video_count: int = 0
supports_first_last_frame: bool = False
supports_universal_reference: bool = True
class VideoEngineListResponse(BaseModel):
+5 -2
View File
@@ -126,12 +126,15 @@ async def submit_video_task(
for ref in refs:
ref_type = ref.get("type")
ref_url = ref.get("url", "")
ref_role = ref.get("role")
if ref_type == "image" and ref_url:
resolved = _resolve_url(ref_url)
content.append({"type": "image_url", "image_url": {"url": resolved},"role":"reference_image"})
role = ref_role if ref_role in ("first_frame", "last_frame") else "reference_image"
content.append({"type": "image_url", "image_url": {"url": resolved}, "role": role})
elif ref_type == "video" and ref_url:
resolved = _resolve_url(ref_url)
content.append({"type": "video_url", "video_url": {"url": resolved},"role":"reference_video"})
role = ref_role if ref_role else "reference_video"
content.append({"type": "video_url", "video_url": {"url": resolved}, "role": role})
except (json.JSONDecodeError, TypeError):
pass
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DjAbNPLm.js"></script>
<script type="module" crossorigin src="/assets/index-dEsTkAeV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bi8mcSs8.css">
</head>
<body>
+649 -129
View File
@@ -65,6 +65,8 @@ interface MediaReference {
type: 'image' | 'video';
url: string;
duration?: number;
role?: string;
label?: string;
}
interface Message {
@@ -156,6 +158,13 @@ const AIChatPage: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false);
const [referenceMode, setReferenceMode] = useState<'universal' | 'first_last_frame'>('universal');
const [firstFrame, setFirstFrame] = useState<MediaReference | null>(null);
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
const [mediaStackHovered, setMediaStackHovered] = useState(false);
// @ 提及相关状态
const [mentionVisible, setMentionVisible] = useState(false);
const mentionInputRef = useRef<any>(null);
@@ -408,6 +417,24 @@ const AIChatPage: React.FC = () => {
};
}, [showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
useEffect(() => {
if (mediaType !== 'video') return;
const engine = enginesele?.video?.find((e: any) => e.id === countType);
if (!engine) return;
const supportsFLF = engine.supportsFirstLastFrame ?? false;
const supportsUR = engine.supportsUniversalReference ?? true;
setReferenceMode((prevMode) => {
if (prevMode === 'first_last_frame' && !supportsFLF) {
if (supportsUR) return 'universal';
} else if (prevMode === 'universal' && !supportsUR) {
if (supportsFLF) return 'first_last_frame';
}
return prevMode;
});
}, [countType, mediaType, enginesele]);
// 初始化获取参数 - 只在组件挂载时执行一次
useEffect(() => {
getEngine()
@@ -648,6 +675,31 @@ const AIChatPage: React.FC = () => {
setHeight(width);
};
const currentVideoEngine = enginesele?.video?.find((e: any) => e.id === countType);
const supportsFirstLastFrame = currentVideoEngine?.supportsFirstLastFrame ?? false;
const supportsUniversalReference = currentVideoEngine?.supportsUniversalReference ?? true;
const handleReferenceModeChange = (mode: 'universal' | 'first_last_frame') => {
if (mode === 'first_last_frame' && !supportsFirstLastFrame) return;
if (mode === 'universal' && !supportsUniversalReference) return;
setReferenceMode(mode);
setReferenceModeDropdownVisible(false);
};
const handleSwapFrames = () => {
const temp = firstFrame;
setFirstFrame(lastFrame);
setLastFrame(temp);
};
const handleRemoveFirstFrame = () => {
setFirstFrame(null);
};
const handleRemoveLastFrame = () => {
setLastFrame(null);
};
// ==================== 事处理函数 ====================
const handleNewChat = () => {
@@ -668,6 +720,8 @@ const AIChatPage: React.FC = () => {
// 清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
// 显示提示消息
message.info('已开启新对话');
@@ -693,14 +747,44 @@ const AIChatPage: React.FC = () => {
const handleSelectChat = (conversationId: string) => {
setCurrentConversationId(conversationId);
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
};
const handleSend = async () => {
// 验证:必须有内容或图片或视频
if (!inputValue.trim() && currentMedia.length === 0) {
message.warning('请输入内容或上传图片/视频');
return;
const isFirstLastFrameMode = mediaType === 'video' && referenceMode === 'first_last_frame';
if (isFirstLastFrameMode) {
if (!inputValue.trim() && !firstFrame) {
message.warning('请输入内容或上传首帧图片');
return;
}
if (!firstFrame) {
message.warning('请上传首帧图片');
return;
}
} else {
if (!inputValue.trim() && currentMedia.length === 0) {
message.warning('请输入内容或上传图片/视频');
return;
}
}
let mediaReferences: MediaReference[] | undefined;
if (isFirstLastFrameMode) {
mediaReferences = [];
if (firstFrame) {
mediaReferences.push({ ...firstFrame, role: 'first_frame' });
}
if (lastFrame) {
mediaReferences.push({ ...lastFrame, role: 'last_frame' });
}
if (mediaReferences.length === 0) {
mediaReferences = undefined;
}
} else {
mediaReferences = currentMedia.length > 0 ? [...currentMedia] : undefined;
}
// 创建用户消息对象
@@ -711,8 +795,7 @@ const AIChatPage: React.FC = () => {
engine_id: countType,
idempotency_key: new Date().toLocaleString('zh-CN'),
// 统一的媒体数组,包含 name、type、url
media_references: currentMedia.length > 0 ? [...currentMedia] : undefined,
media_references: mediaReferences,
// 图片参数(仅图片模式时添加)
...(mediaType === 'image' && {
image_size: selectedResolution,
@@ -739,6 +822,8 @@ const AIChatPage: React.FC = () => {
// 创建任务成功后,清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
// 创建任务成功后,重置页数为1,获取最新列表
const newPagebreak = { ...Pagebreak, page: 1 };
@@ -866,49 +951,79 @@ const AIChatPage: React.FC = () => {
};
const handleUpload = async (file: File) => {
// 验证文件类型
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (mediaType === 'video' && referenceMode === 'first_last_frame') {
if (!isImage) {
message.error('首尾帧模式仅支持上传图片');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('图片大小不能超过10MB');
return false;
}
if (!uploadTarget) {
return false;
}
setUploading(true);
try {
const res = await uploadImage(file);
const mediaRef: MediaReference = {
name: file.name,
type: 'image',
url: res.url,
role: uploadTarget === 'first' ? 'first_frame' : 'last_frame',
};
if (uploadTarget === 'first') {
setFirstFrame(mediaRef);
} else {
setLastFrame(mediaRef);
}
message.success('图片上传成功');
} catch (error) {
message.error('上传失败');
} finally {
setUploading(false);
setUploadTarget(null);
}
return false;
}
if (!isImage && !isVideo) {
message.error('仅支持图片或视频文件');
return false;
}
// 获取当前选中引擎的限制
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
const maxImage = currentEngine?.maxImageCount ?? 4;
const maxVideo = currentEngine?.maxVideoCount ?? 1;
// 根据 mediaType 判断是否允许该文件类型
if (mediaType === 'image' && isVideo) {
message.error('图片模式仅支持上传图片');
return false;
}
// 验证文件大小
const maxMB = isVideo ? 100 : 10;
if (file.size / 1024 / 1024 > maxMB) {
message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`);
return false;
}
// 验证图片数量
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
if (isImage && imageCount >= maxImage) {
message.error(`该引擎最多上传${maxImage}张图片`);
return false;
}
// 验证视频数量
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
if (isVideo && videoCount >= maxVideo) {
message.error(`该引擎最多上传${maxVideo}个视频`);
return false;
}
// 获取视频时长并验证
let videoDuration = 0;
if (isVideo) {
try {
@@ -930,14 +1045,11 @@ const AIChatPage: React.FC = () => {
}
}
// 设置上传状态
setUploading(true);
try {
// 根据文件类型调用相应的上传函数
const uploadFn = isImage ? uploadImage : uploadVideo;
const res = await uploadFn(file);
// 添加到统一的媒体列表
const mediaType: 'image' | 'video' = isImage ? 'image' : 'video';
const newList = [...currentMedia, {
name: file.name,
@@ -955,7 +1067,6 @@ const AIChatPage: React.FC = () => {
setUploading(false);
}
// 返回false阻止Ant Design的自动上传行为
return false;
};
@@ -1341,14 +1452,30 @@ const AIChatPage: React.FC = () => {
e.stopPropagation();
setInputValue(msg.originalPrompt || '');
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
name: ref.name,
type: ref.type,
url: ref.url,
label: ref.label || '',
})));
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
if (hasFirstLastFrame && msg.genType === 'video') {
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
setFirstFrame(first ? { ...first, label: first.label || '' } : null);
setLastFrame(last ? { ...last, label: last.label || '' } : null);
setCurrentMedia([]);
setReferenceMode('first_last_frame');
} else {
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
name: ref.name,
type: ref.type,
url: ref.url,
label: ref.label || '',
role: ref.role,
})));
setFirstFrame(null);
setLastFrame(null);
setReferenceMode('universal');
}
} else {
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
}
msgApi.success('已加载到编辑区');
}}
@@ -1680,8 +1807,8 @@ const AIChatPage: React.FC = () => {
style={{
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)',
borderRadius: 24,
padding: 16,
borderRadius: 28,
padding: 20,
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)',
@@ -1689,63 +1816,275 @@ const AIChatPage: React.FC = () => {
}}
>
{/* 已上传媒体预览 */}
{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, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
{media.type === 'image' ? (
{mediaType === 'video' && referenceMode === 'first_last_frame' ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 16, marginBottom: 16, padding: '8px 0' }}>
{/* 首帧 */}
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#6366f1', zIndex: 10 }}></span>
{firstFrame ? (
<div style={{ position: 'relative', width: 100, height: 100 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`}
alt={firstFrame.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewUrl(firstFrame.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewName(firstFrame.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(99, 102, 241, 0.3)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
<button
onClick={handleRemoveFirstFrame}
style={{
position: 'absolute',
top: -6,
right: -6,
width: 22,
height: 22,
border: 'none',
background: '#ef4444',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
zIndex: 10,
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
/>
)}
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 500 }}>
{media.label}
</span>
{/* 删除已上传媒体按钮 */}
<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',
}}
>
<DeleteOutlined style={{ fontSize: 11 }} />
</button>
</div>
) : (
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => { setUploadTarget('first'); return handleUpload(file); }}
>
<DeleteOutlined style={{ fontSize: 12 }} />
</button>
</div>
))}
<div
style={{
width: 100,
height: 100,
borderRadius: 12,
border: '2px dashed rgba(99, 102, 241, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
backgroundColor: 'rgba(99, 102, 241, 0.04)',
flexDirection: 'column',
gap: 4,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.3)';
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
}}
>
<PlusOutlined style={{ fontSize: 20, color: '#6366f1' }} />
<span style={{ fontSize: 10, color: '#6366f1', fontWeight: 500 }}></span>
</div>
</Upload>
)}
</div>
{/* 调换按钮 */}
<button
onClick={handleSwapFrames}
disabled={!firstFrame || !lastFrame}
style={{
width: 36,
height: 36,
borderRadius: 50,
border: 'none',
background: firstFrame && lastFrame ? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)' : '#e2e8f0',
cursor: firstFrame && lastFrame ? 'pointer' : 'not-allowed',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: firstFrame && lastFrame ? '0 4px 12px rgba(99, 102, 241, 0.4)' : 'none',
transition: 'all 0.2s ease',
flexShrink: 0,
}}
onMouseEnter={(e) => {
if (firstFrame && lastFrame) {
e.currentTarget.style.transform = 'scale(1.1)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<SwapOutlined style={{ fontSize: 16 }} />
</button>
{/* 尾帧 */}
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#8b5cf6', zIndex: 10 }}></span>
{lastFrame ? (
<div style={{ position: 'relative', width: 100, height: 100 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`}
alt={lastFrame.name}
onClick={() => {
setAttachmentPreviewUrl(lastFrame.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(lastFrame.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(139, 92, 246, 0.3)', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.15)' }}
/>
<button
onClick={handleRemoveLastFrame}
style={{
position: 'absolute',
top: -6,
right: -6,
width: 22,
height: 22,
border: 'none',
background: '#ef4444',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
zIndex: 10,
}}
>
<DeleteOutlined style={{ fontSize: 11 }} />
</button>
</div>
) : (
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => { setUploadTarget('last'); return handleUpload(file); }}
>
<div
style={{
width: 100,
height: 100,
borderRadius: 12,
border: '2px dashed rgba(139, 92, 246, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
backgroundColor: 'rgba(139, 92, 246, 0.04)',
flexDirection: 'column',
gap: 4,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#8b5cf6';
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.3)';
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
}}
>
<PlusOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />
<span style={{ fontSize: 10, color: '#8b5cf6', fontWeight: 500 }}></span>
</div>
</Upload>
)}
</div>
</div>
) : (
currentMedia.length > 0 && (
<div
style={{ position: 'relative', marginBottom: 12, minHeight: 90, paddingLeft: 8 }}
onMouseEnter={() => setMediaStackHovered(true)}
onMouseLeave={() => setMediaStackHovered(false)}
>
<div style={{ display: 'flex', position: 'relative' }}>
{currentMedia.map((media, idx) => {
const reversedIdx = currentMedia.length - 1 - idx;
const offset = mediaStackHovered ? idx * 88 : idx * 4;
return (
<div
key={`media-${idx}`}
style={{
position: 'absolute',
left: offset,
top: 0,
zIndex: idx + 1,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: `scale(${1 - reversedIdx * 0.03})`,
opacity: mediaStackHovered ? 1 : (1 - reversedIdx * 0.15),
}}
>
<div style={{ position: 'relative', width: media.type === 'video' ? 100 : 80, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
/>
)}
<span style={{ fontSize: 10, color: '#64748b', fontWeight: 500 }}>
{media.label}
</span>
<button
onClick={() => handleRemoveMedia(idx)}
style={{
position: 'absolute',
top: -4,
right: -4,
width: 20,
height: 20,
border: 'none',
background: '#ef4444',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(239, 68, 68, 0.4)',
}}
>
<DeleteOutlined style={{ fontSize: 10 }} />
</button>
</div>
</div>
);
})}
</div>
</div>
)
)}
{/* 输入框区域 */}
@@ -1753,57 +2092,59 @@ const AIChatPage: React.FC = () => {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
padding: '14px 18px',
backgroundColor: 'rgba(248,250,252,0.6)',
borderRadius: 16,
borderRadius: 18,
border: '1px solid rgba(99, 102, 241, 0.08)',
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
transition: 'all 0.2s ease',
position: 'relative',
}}>
{/* 上传按钮 */}
<Upload
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
}>
<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>
{/* 上传按钮 - 首尾帧模式下隐藏 */}
{!(mediaType === 'video' && referenceMode === 'first_last_frame') && (
<Upload
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
}>
<div
style={{
width: 50,
height: 50,
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: 20, color: '#6366f1' }} />
) : (
<PlusOutlined style={{ fontSize: 20, color: '#64748b' }} />
)}
</div>
</Tooltip>
</Upload>
)}
{/* 文本输入框 */}
<TextArea
@@ -1814,7 +2155,9 @@ const AIChatPage: React.FC = () => {
onKeyPress={handleKeyPress}
placeholder={mediaType === 'image'
? `上传最多${maxImageCount}张参考图,输入提示词描述您想生成的画面...`
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
: referenceMode === 'first_last_frame'
? '上传首帧图片(必填)和尾帧图片(可选),输入提示词描述您想生成的视频...'
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
}
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
@@ -1824,15 +2167,16 @@ const AIChatPage: React.FC = () => {
border: 'none',
outline: 'none',
boxShadow: 'none',
fontSize: 14,
lineHeight: 1.5,
fontSize: 15,
lineHeight: 1.6,
color: '#1e293b',
fontWeight: 400,
}}
disabled={loading}
/>
{/* @ 提及下拉列表 */}
{mentionVisible && currentMedia.length > 0 && (
{mentionVisible && currentMedia.length > 0 && referenceMode === 'universal' && (
<div
style={{
position: 'absolute',
@@ -1891,18 +2235,30 @@ const AIChatPage: React.FC = () => {
shape="circle"
icon={<ArrowUpOutlined />}
onClick={handleSend}
disabled={!inputValue.trim() && currentMedia.length === 0}
disabled={
mediaType === 'video' && referenceMode === 'first_last_frame'
? !inputValue.trim() && !firstFrame
: !inputValue.trim() && currentMedia.length === 0
}
loading={loading}
style={{
flexShrink: 0,
width: 40,
height: 40,
width: 44,
height: 44,
borderRadius: 14,
background: (inputValue.trim() || currentMedia.length > 0)
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
background: (
mediaType === 'video' && referenceMode === 'first_last_frame'
? (inputValue.trim() || firstFrame)
: (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)'
boxShadow: (
mediaType === 'video' && referenceMode === 'first_last_frame'
? (inputValue.trim() || firstFrame)
: (inputValue.trim() || currentMedia.length > 0)
)
? '0 4px 16px rgba(99, 102, 241, 0.4)'
: 'none',
transition: 'all 0.2s ease',
border: 'none',
@@ -2089,6 +2445,170 @@ const AIChatPage: React.FC = () => {
)}
</div>
{/* 参考模式切换按钮 - 仅视频模式显示 */}
{mediaType === 'video' && (supportsFirstLastFrame || supportsUniversalReference) && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setReferenceModeDropdownVisible(!referenceModeDropdownVisible)}
className="image-settings-trigger"
style={{
minWidth: 120,
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.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '0 2px 8px rgba(99, 102, 241, 0.04)';
}}
>
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
<Text style={{
fontSize: 14,
fontWeight: 500,
color: '#6366f1',
}}>
{referenceMode === 'universal' ? '全能参考' : '首尾帧'}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#6366f1', marginLeft: 2 }} />
</button>
{referenceModeDropdownVisible && (
<>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9998,
}}
onClick={() => setReferenceModeDropdownVisible(false)}
/>
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
minWidth: 160,
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 12,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 8,
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => handleReferenceModeChange('universal')}
disabled={!supportsUniversalReference}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: 8,
border: referenceMode === 'universal'
? '1px solid rgba(99, 102, 241, 0.3)'
: '1px solid transparent',
backgroundColor: referenceMode === 'universal'
? 'rgba(99, 102, 241, 0.08)'
: 'transparent',
cursor: supportsUniversalReference ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s',
textAlign: 'left',
opacity: supportsUniversalReference ? 1 : 0.4,
}}
onMouseEnter={(e) => {
if (supportsUniversalReference && referenceMode !== 'universal') {
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
}
}}
onMouseLeave={(e) => {
if (referenceMode !== 'universal') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
<span style={{
fontSize: 13,
fontWeight: referenceMode === 'universal' ? 600 : 500,
color: referenceMode === 'universal' ? '#6366f1' : '#4b5563',
flex: 1,
}}>
</span>
{!supportsUniversalReference && (
<span style={{ fontSize: 10, color: '#9ca3af' }}></span>
)}
</button>
<button
onClick={() => handleReferenceModeChange('first_last_frame')}
disabled={!supportsFirstLastFrame}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: 8,
border: referenceMode === 'first_last_frame'
? '1px solid rgba(139, 92, 246, 0.3)'
: '1px solid transparent',
backgroundColor: referenceMode === 'first_last_frame'
? 'rgba(139, 92, 246, 0.08)'
: 'transparent',
cursor: supportsFirstLastFrame ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s',
textAlign: 'left',
opacity: supportsFirstLastFrame ? 1 : 0.4,
}}
onMouseEnter={(e) => {
if (supportsFirstLastFrame && referenceMode !== 'first_last_frame') {
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
}
}}
onMouseLeave={(e) => {
if (referenceMode !== 'first_last_frame') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<SwapOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
<span style={{
fontSize: 13,
fontWeight: referenceMode === 'first_last_frame' ? 600 : 500,
color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#4b5563',
flex: 1,
}}>
</span>
{!supportsFirstLastFrame && (
<span style={{ fontSize: 10, color: '#9ca3af' }}></span>
)}
</button>
</div>
</>
)}
</div>
)}
{/* 图片设置按钮 */}
{mediaType === 'image' && (
<div style={{ position: 'relative', display: 'inline-block' }}>