feat: optimize video chat toolbar layout and frame slot upload
This commit is contained in:
parent
471305c5fe
commit
66bf492d81
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user