feat: optimize video chat toolbar layout and frame slot upload
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:42:36 +08:00
parent 471305c5fe
commit 66bf492d81

View File

@ -201,9 +201,11 @@ function PureMultimodalInput({
};
const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
const submitForm = useCallback(() => {
window.history.pushState({}, "", `/chat/${chatId}`);
@ -422,6 +424,60 @@ function PureMultimodalInput({
[usage]
);
const videoFrameSlots = useMemo(() => {
if (!isVideoModel || !videoParams) return [] as string[];
if (videoParams.mode === "first-last-frame") {
return ["首帧", "尾帧"];
}
if (videoParams.mode === "first-frame") {
return ["首帧"];
}
return [] as string[];
}, [isVideoModel, videoParams]);
const updateAttachmentAtSlot = useCallback(
(slotIndex: number, attachment: Attachment | null) => {
const slotCount = Math.max(videoFrameSlots.length, 2);
setAttachments((current) => {
const slots: Array<Attachment | 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 Attachment => Boolean(item));
});
},
[setAttachments, videoFrameSlots.length]
);
const handleVideoSlotFileChange = useCallback(
async (slotIndex: number, event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
setVideoSlotUploading(slotIndex);
setUploadQueue((prev) => [...prev, file.name]);
try {
const uploaded = await uploadFile(file);
if (uploaded) {
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
}
} finally {
setVideoSlotUploading(null);
setUploadQueue((prev) => prev.filter((name) => name !== file.name));
event.currentTarget.value = "";
}
},
[updateAttachmentAtSlot, uploadFile]
);
// 🆕 视频模式下的图片数量限制
const getVideoImageLimit = useCallback(() => {
if (!isVideoModel || !videoParams) {
@ -757,6 +813,18 @@ function PureMultimodalInput({
tabIndex={-1}
type="file"
/>
{videoFrameSlots.map((_, index) => (
<input
key={`video-slot-input-${index}`}
className="hidden"
type="file"
accept="image/*"
onChange={(event) => handleVideoSlotFileChange(index, event)}
ref={(element) => {
videoSlotInputRefs.current[index] = element;
}}
/>
))}
<PromptInput
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
@ -769,7 +837,59 @@ function PureMultimodalInput({
}
}}
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
{videoFrameSlots.length > 0 && (
<div className="mb-2 flex flex-row gap-2">
{videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index];
const disabled = index > 0 && !attachments[index - 1];
const isUploading = videoSlotUploading === index;
return (
<button
key={`video-slot-${label}-${index}`}
type="button"
onClick={() => {
if (disabled || status !== "ready") 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",
!disabled && status === "ready" && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
disabled && "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();
updateAttachmentAtSlot(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>
)}
{(attachments.length > 0 || uploadQueue.length > 0) && videoFrameSlots.length === 0 && (
<div
className="flex flex-row items-end gap-2 overflow-x-scroll"
data-testid="attachments-preview"
@ -884,37 +1004,56 @@ function PureMultimodalInput({
</div>
<PromptInputToolbar className="!border-top-0 border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
<PromptInputTools className="gap-0 sm:gap-0.5 flex-1 overflow-x-auto">
<AttachmentsButton
fileInputRef={fileInputRef}
selectedModelId={selectedModelId}
status={status}
/>
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
attachments={attachments}
/>
{/* 🆕 视频参数栏(内嵌在模型选择器后面,同一行) */}
{isVideoModel && onVideoParamsChange && (
<VideoParamsBar
key={`video-params-${selectedModelId}-${isVideoModel}`}
modelName={videoModelName || "视频 3.0"}
initialParams={videoParams || undefined}
onParamsChange={onVideoParamsChange}
inline={true}
providerType={videoProviderType}
/>
)}
{/* 🆕 图片参数栏(内嵌在模型选择器后面,同一行) */}
{isImageModel && onImageParamsChange && (
<ImageParamsBar
key={`image-params-${selectedModelId}-${isImageModel}`}
modelName={imageModelName || "Gemini 图片生成"}
initialParams={imageParams || undefined}
onParamsChange={onImageParamsChange}
inline={true}
providerType={imageProviderType}
/>
{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>
{onVideoParamsChange && (
<VideoParamsBar
key={`video-params-${selectedModelId}-${isVideoModel}`}
modelName={videoModelName || "视频 3.0"}
initialParams={videoParams || undefined}
onParamsChange={onVideoParamsChange}
inline={true}
providerType={videoProviderType}
/>
)}
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
attachments={attachments}
avatarOnly={true}
/>
</>
) : (
<>
<AttachmentsButton
fileInputRef={fileInputRef}
selectedModelId={selectedModelId}
status={status}
/>
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
attachments={attachments}
/>
{isImageModel && onImageParamsChange && (
<ImageParamsBar
key={`image-params-${selectedModelId}-${isImageModel}`}
modelName={imageModelName || "Gemini 图片生成"}
initialParams={imageParams || undefined}
onParamsChange={onImageParamsChange}
inline={true}
providerType={imageProviderType}
/>
)}
</>
)}
</PromptInputTools>
@ -1007,10 +1146,12 @@ function PureModelSelectorCompact({
selectedModelId,
onModelChange,
attachments,
avatarOnly = false,
}: {
selectedModelId: string;
onModelChange?: (modelId: string) => void;
attachments: Attachment[];
avatarOnly?: boolean;
}) {
const [optimisticModelId, setOptimisticModelId] = useState(selectedModelId);
@ -1018,6 +1159,12 @@ function PureModelSelectorCompact({
const { data: dynamicModelsData, isLoading } = useSWR<{
models: DynamicModel[];
defaultModelId: string | null;
agentModel?: {
id: string;
displayName: string;
avatarUrl?: string | null;
providerType: string;
} | null;
}>("/api/models", fetcher);
// 获取站点设置
@ -1064,7 +1211,7 @@ function PureModelSelectorCompact({
id: "auto",
modelId: "auto",
displayName: "Agent模式",
avatarUrl: null,
avatarUrl: dynamicModelsData?.agentModel?.avatarUrl || null,
description: "根据您的意图自动选择最合适的模型",
providerType: "system",
supportsVision: true,
@ -1122,15 +1269,32 @@ function PureModelSelectorCompact({
const selectedModel = useMemo(() => {
const dynamicModel = displayModels.find((m) => m.id === optimisticModelId);
if (dynamicModel) {
return { id: dynamicModel.id, name: dynamicModel.displayName, description: dynamicModel.description };
return {
id: dynamicModel.id,
name: dynamicModel.displayName,
description: dynamicModel.description,
avatarUrl: dynamicModel.avatarUrl || null,
};
}
if (displayModels.length > 0) {
return { id: displayModels[0].id, name: displayModels[0].displayName, description: displayModels[0].description };
return {
id: displayModels[0].id,
name: displayModels[0].displayName,
description: displayModels[0].description,
avatarUrl: displayModels[0].avatarUrl || null,
};
}
return { id: "", name: "未配置模型", description: null };
return { id: "", name: "未配置模型", description: null, avatarUrl: null };
}, [optimisticModelId, displayModels]);
if (isLoading) {
if (avatarOnly) {
return (
<Button className="h-8 w-8 rounded-full border border-border p-0" variant="ghost" disabled>
<CpuIcon size={14} />
</Button>
);
}
return (
<Button className="h-8 px-2" variant="ghost" disabled>
<CpuIcon size={16} />
@ -1140,6 +1304,13 @@ function PureModelSelectorCompact({
}
if (displayModels.length === 0) {
if (avatarOnly) {
return (
<Button className="h-8 w-8 rounded-full border border-border p-0" variant="ghost" disabled>
<Bot className="h-4 w-4" />
</Button>
);
}
return (
<Button className="h-8 px-2" variant="ghost" disabled>
<CpuIcon size={16} />
@ -1175,13 +1346,30 @@ function PureModelSelectorCompact({
value={selectedModel?.id}
>
<Trigger asChild>
<Button className="h-8 px-2" variant="ghost">
<CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block">
{selectedModel?.name}
</span>
<ChevronDownIcon size={16} />
</Button>
{avatarOnly ? (
<Button
className="h-8 w-8 rounded-full border border-border p-0 hover:bg-accent/60"
variant="ghost"
>
{selectedModel?.avatarUrl ? (
<img
src={selectedModel.avatarUrl}
alt={selectedModel.name}
className="h-7 w-7 rounded-full object-cover"
/>
) : (
<Bot className="h-4 w-4" />
)}
</Button>
) : (
<Button className="h-8 px-2" variant="ghost">
<CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block">
{selectedModel?.name}
</span>
<ChevronDownIcon size={16} />
</Button>
)}
</Trigger>
<PromptInputModelSelectContent className="!w-[266px] max-h-[372px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
<div className="max-h-[338px] space-y-0.5 overflow-y-auto pr-1">
@ -1251,6 +1439,9 @@ const ModelSelectorCompact = memo(
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
if (prevProps.avatarOnly !== nextProps.avatarOnly) {
return false;
}
if (!equal(prevProps.attachments, nextProps.attachments)) {
return false;
}