优化画布视频首尾帧入口与工具栏布局
This commit is contained in:
parent
2aa2aaf0e3
commit
34dbb11dde
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user