优化画布视频首尾帧入口与工具栏布局
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 23:59:06 +08:00
parent 2aa2aaf0e3
commit 34dbb11dde

View File

@ -300,6 +300,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
@ -321,6 +322,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageCount: 1,
});
const [videoParams, setVideoParams] = useState<VideoGenerationParams | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
// 🆕 保存图片参数到 localStorage
const handleImageParamsChange = (params: ImageGenerationParams) => {
@ -864,6 +866,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const allModels = [SMART_AUTO_MODEL, ...visualModels];
const currentModel = allModels.find((model) => model.id === selectedModelId);
const isVideoModel = Boolean(currentModel && VIDEO_PROVIDER_TYPES.has(currentModel.providerType));
const videoFrameSlots = useMemo(() => {
if (!isVideoModel || !videoParams) return [] as string[];
if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[];
if (videoParams.mode === "first-frame") return ["首帧"] as string[];
return [] as string[];
}, [isVideoModel, videoParams]);
// 默认选择模型(根据系统设置)
useEffect(() => {
@ -903,6 +911,22 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
}, [isVideoModel, currentModel?.providerType, videoParams]);
useEffect(() => {
if (!isVideoModel || videoFrameSlots.length === 0) return;
const maxAttachments = videoFrameSlots.length;
setAttachments((current) => {
if (current.length <= maxAttachments) return current;
return current.slice(0, maxAttachments);
});
}, [isVideoModel, videoFrameSlots.length]);
useEffect(() => {
if (videoFrameSlots.length === 0) return;
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv) return;
editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove());
}, [videoFrameSlots.length]);
// 合并 initialMessages 和历史消息
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
@ -1388,6 +1412,96 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return lines.join("\n");
}, [nodes, selectedImageNodes]);
const updateAttachmentAtVideoSlot = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
const slotCount = Math.max(videoFrameSlots.length, 2);
setAttachments((current) => {
const slots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
while (slots.length > 0 && !slots[slots.length - 1]) {
slots.pop();
}
return slots.filter((item): item is PickedObjectAttachment => Boolean(item));
});
},
[videoFrameSlots.length]
);
const handleVideoSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
event.currentTarget.value = "";
return;
}
setVideoSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = data.url as string;
updateAttachmentAtVideoSlot(slotIndex, {
url: imageUrl,
name: file.name,
contentType: file.type || "image/jpeg",
});
const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: imageUrl,
status: "completed",
title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""),
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
toast.success(`${slotIndex === 0 ? "首帧" : "尾帧"}上传成功`);
} catch (_error) {
toast.error("图片上传失败");
} finally {
setVideoSlotUploading(null);
event.currentTarget.value = "";
}
},
[addNode, nodes.length, updateAttachmentAtVideoSlot]
);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
if (!input.trim() && attachments.length === 0) return;
@ -1408,7 +1522,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
if (
effectiveAttachments.length === 0 &&
input.trim() &&
(!videoImageLimit || videoImageLimit.max > 0)
(!videoImageLimit || videoImageLimit.max > 0) &&
videoFrameSlots.length === 0
) {
const recentAttachments = getRecentImageAttachments();
if (recentAttachments.length > 0) {
@ -1546,6 +1661,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
buildAutoContext,
isVideoModel,
videoParams,
videoFrameSlots.length,
currentModel?.providerType,
selectedImageNodes,
]);
@ -1761,6 +1877,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
};
const isLoading = status === 'streaming' || status === 'submitted';
const currentVideoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType)
: null;
const showVideoAttachmentButton = Boolean(
isVideoModel &&
currentVideoImageLimit &&
currentVideoImageLimit.max > 0 &&
videoFrameSlots.length === 0
);
const lastUserIndexForMarkers = messages.findLastIndex(m => m.role === "user");
const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? messages.slice(lastUserIndexForMarkers + 1) : messages;
const hasGenerationStart = messagesAfterLastUser.some(msg =>
@ -2235,7 +2360,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
data-canvas-chat-input-anchor="true"
>
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div className="relative px-4 pt-3 pb-12 min-h-[60px]">
<div
className={cn(
"relative px-4 pt-3 min-h-[60px]",
isVideoModel && videoFrameSlots.length > 0 ? "pb-40" : "pb-12"
)}
>
<div
ref={textareaRef as any}
contentEditable={!isLoading}
@ -2252,7 +2382,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
.replace(/\s+/g, " ")
.trim();
setInput(textContent);
syncAttachmentsWithEditor(target);
if (!isVideoModel || videoFrameSlots.length === 0) {
syncAttachmentsWithEditor(target);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -2260,7 +2392,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
handleSend();
}
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
if (e.key === 'Backspace') {
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0)) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
@ -2287,6 +2419,60 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
</div>
{isVideoModel && videoFrameSlots.length > 0 && (
<div className="px-4 pb-2">
<div className="flex flex-row gap-2">
{videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index];
const isUploading = videoSlotUploading === index;
const isDisabled = isLoading || (index > 0 && !attachments[index - 1]);
return (
<button
key={`canvas-video-slot-${label}-${index}`}
type="button"
onClick={() => {
if (isDisabled) return;
videoSlotInputRefs.current[index]?.click();
}}
className={cn(
"group relative flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/30 text-muted-foreground transition-colors",
!isDisabled && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
isDisabled && "cursor-not-allowed opacity-55"
)}
>
{slotAttachment ? (
<>
<img
src={slotAttachment.url}
alt={slotAttachment.name || label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 text-center text-[11px] text-white">
{label}
</div>
<div
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
updateAttachmentAtVideoSlot(index, null);
}}
>
x
</div>
</>
) : (
<div className="flex flex-col items-center gap-1">
<span className="text-xl leading-none">+</span>
<span className="text-sm">{isUploading ? "上传中" : label}</span>
</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* Bottom Toolbar */}
<div className="absolute left-2 bottom-2 right-2 flex items-center gap-2">
@ -2299,99 +2485,90 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
className="hidden"
onChange={handleFileSelect}
/>
{isVideoModel ? (
<>
<Button
type="button"
variant="ghost"
className="h-8 rounded-full border border-blue-500 bg-blue-50 px-3 text-blue-600 hover:bg-blue-100"
>
<Video className="mr-1 h-4 w-4" />
<span className="text-sm font-medium">视频</span>
</Button>
<div className="shrink-0">
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
onParamsChange={handleVideoParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
</div>
</>
) : (
<>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
<Paperclip className="h-4 w-4" />
</Button>
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className={cn(
"h-8 text-xs transition-colors rounded-full px-2 gap-1.5 w-auto flex-shrink-0",
selectedModelId === "auto"
? "bg-gradient-to-r from-purple-50 to-blue-50 text-purple-600 border-purple-100 hover:from-purple-100 hover:to-blue-100"
: "bg-blue-50 text-blue-600 border-blue-100 hover:bg-blue-100 hover:border-blue-200"
)}>
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white">
{allModels.find((m) => m.id === selectedModelId)?.avatarUrl ? (
<img
src={allModels.find((m) => m.id === selectedModelId)?.avatarUrl || ""}
alt={allModels.find((m) => m.id === selectedModelId)?.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
{(allModels.find((m) => m.id === selectedModelId)?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
<span className="truncate max-w-[66px]">
{allModels.find((m) => m.id === selectedModelId)?.displayName || "Agent模式"}
</span>
</SelectTrigger>
{renderModelSelectContent()}
</Select>
</div>
<div className="shrink-0">
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
</div>
</>
{videoFrameSlots.map((_, index) => (
<input
key={`canvas-video-slot-input-${index}`}
type="file"
accept="image/*"
className="hidden"
ref={(element) => {
videoSlotInputRefs.current[index] = element;
}}
onChange={(event) => {
void handleVideoSlotFileChange(index, event);
}}
/>
))}
<Button
type="button"
variant="ghost"
className={cn(
"h-8 rounded-full px-3 text-sm font-medium",
isVideoModel
? "border border-blue-500 bg-blue-50 text-blue-600 hover:bg-blue-100"
: "border border-emerald-500 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
)}
>
{isVideoModel ? (
<Video className="mr-1 h-4 w-4" />
) : (
<ImagePlus className="mr-1 h-4 w-4" />
)}
{isVideoModel ? "视频" : "生图"}
</Button>
{(!isVideoModel || showVideoAttachmentButton) && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
<Paperclip className="h-4 w-4" />
</Button>
)}
<div className="shrink-0">
{isVideoModel ? (
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
onParamsChange={handleVideoParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
) : (
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isVideoModel && (
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-8 w-8 rounded-full border border-[#cfd4dc] bg-white p-0 justify-center [&>svg]:hidden">
<span className="h-6 w-6 overflow-hidden rounded-full bg-white flex items-center justify-center">
{allModels.find((m) => m.id === selectedModelId)?.avatarUrl ? (
<img
src={allModels.find((m) => m.id === selectedModelId)?.avatarUrl || ""}
alt={allModels.find((m) => m.id === selectedModelId)?.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#4b5563]">
{(allModels.find((m) => m.id === selectedModelId)?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
</SelectTrigger>
{renderModelSelectContent()}
</Select>
)}
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-8 w-8 rounded-full border border-[#cfd4dc] bg-white p-0 justify-center [&>svg]:hidden">
<span className="h-6 w-6 overflow-hidden rounded-full bg-white flex items-center justify-center">
{currentModel?.avatarUrl ? (
<img
src={currentModel.avatarUrl}
alt={currentModel.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#4b5563]">
{(currentModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
</SelectTrigger>
{renderModelSelectContent()}
</Select>
<Button
size="icon"