refactor: align canvas video toolbar layout with mode-tools-avatar flow
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:47:56 +08:00
parent 66bf492d81
commit 2aa2aaf0e3

View File

@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import useSWR from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, User, Bot, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp } from "lucide-react";
import { Send, Loader2, ImagePlus, Paperclip, X, User, Bot, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
@ -1776,6 +1776,38 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel;
const loadingImageCount = imageParams.imageCount || 1;
const renderModelSelectContent = () => (
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6] p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
{allModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="h-[34px] rounded-xl px-2.5 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<span className="h-5 w-5 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
{model.avatarUrl ? (
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-full items-center justify-center text-[9px] font-medium text-[#6b7280]">
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
</span>
)}
</span>
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
</span>
{selectedModelId === model.id ? (
<span className="text-sm font-semibold text-[#1f2937]">✓</span>
) : null}
</span>
</SelectItem>
))}
</div>
</SelectContent>
);
const createNewChatSession = useCallback(() => {
const now = Date.now();
const newChatSessionId = `canvas-${crypto.randomUUID()}`;
@ -2259,8 +2291,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{/* Bottom Toolbar */}
<div className="absolute left-2 bottom-2 right-2 flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto overscroll-x-contain touch-pan-x pr-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{/* 左侧:附件和模型选择 */}
<div className="flex items-center gap-2 shrink-0">
<input
ref={fileInputRef}
type="file"
@ -2269,113 +2299,118 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
className="hidden"
onChange={handleFileSelect}
/>
<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>
{/* Agent Selector (Model) - 包含Agent模式选项 */}
<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>
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6] p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
{allModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="h-[34px] rounded-xl px-2.5 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<span className="h-5 w-5 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
{model.avatarUrl ? (
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-full items-center justify-center text-[9px] font-medium text-[#6b7280]">
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
</span>
)}
</span>
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
</span>
{selectedModelId === model.id ? (
<span className="text-sm font-semibold text-[#1f2937]">✓</span>
) : null}
</span>
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
{/* 中间:图片参数(可收缩) */}
<div className="shrink-0">
{isVideoModel ? (
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
onParamsChange={handleVideoParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
<>
<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>
</>
) : (
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
<>
<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>
</>
)}
</div>
</div>
{/* 右侧:发送按钮(固定不收缩) */}
<Button
size="icon"
className={cn(
"h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0",
input.trim() || attachments.length > 0
? "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
: "bg-muted text-muted-foreground hover:bg-muted/80"
<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>
)}
onClick={handleSend}
disabled={isLoading || (!input.trim() && attachments.length === 0)}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
className={cn(
"h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0",
input.trim() || attachments.length > 0
? "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
onClick={handleSend}
disabled={isLoading || (!input.trim() && attachments.length === 0)}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>