feat: polish canvas chat dock and agent model label
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-03-06 08:06:26 +08:00
parent e3abf6dc2b
commit ed3f0fefb7
3 changed files with 155 additions and 114 deletions

View File

@ -4,7 +4,6 @@ import { useEffect, useState, useCallback, use, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
import useSWR from "swr";
import {
ArrowLeft,
Save,
Undo2,
Redo2,
@ -16,8 +15,7 @@ import {
ImagePlus,
Type,
Loader2,
PanelRightClose,
PanelRight,
MessageCircle,
Download,
Upload,
Wand2,
@ -52,7 +50,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
@ -65,7 +62,6 @@ import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn, fetcher } from "@/lib/utils";
import { Grid2X2 } from "lucide-react";
interface PageProps {
params: Promise<{ id: string }>;
@ -84,6 +80,7 @@ const SIZE_PRESETS = [
const FLOATING_PANEL_WIDTH = 460;
const FLOATING_PANEL_INSET = 16;
const DOCKED_PANEL_INSET = 0;
const FLOATING_UI_GAP = 16;
const FLOATING_BOTTOM_INSET = 20;
@ -104,7 +101,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
const tool = useCanvasStore(canvasSelectors.tool);
const canUndo = useCanvasStore(canvasSelectors.canUndo);
const canRedo = useCanvasStore(canvasSelectors.canRedo);
const isGenerating = useCanvasStore(canvasSelectors.isGenerating);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const nodes = useCanvasStore(canvasSelectors.nodes);
@ -157,9 +153,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
: null;
const resolvedPanelWidth = viewportWidth > 0
? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - FLOATING_PANEL_INSET, 0))
? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
: FLOATING_PANEL_WIDTH;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + FLOATING_PANEL_INSET : 0;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0;
const compactControlsLeft = FLOATING_UI_GAP;
@ -650,95 +646,38 @@ export default function CanvasEditorPage({ params }: PageProps) {
{/* Brand Entry (Top Left) */}
<div className="absolute top-4 left-4 z-20">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={goHome}
className="h-14 w-14 rounded-2xl border bg-background/85 p-0 shadow-sm backdrop-blur-md hover:bg-background"
>
{(siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark) ? (
<img
src={siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark || ""}
alt={siteSettingsData?.siteName || "返回首页"}
className="h-7 w-7 object-contain"
draggable={false}
/>
) : (
<Grid3X3 className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>返回首页</TooltipContent>
</Tooltip>
</div>
{/* 2. Floating Header (Top Left) */}
<div className="absolute top-4 left-[5.5rem] z-20 flex items-center gap-2 bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={goHome} className="rounded-lg h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>返回首页</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* 文件列表按钮 & 标题展示 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPanelMode('fileList');
setIsPanelOpen(true);
}}
className={cn(
"h-8 gap-2 px-2 font-normal hover:bg-muted rounded-lg",
panelMode === 'fileList' && isPanelOpen && "bg-muted"
)}
>
<Grid2X2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium max-w-[200px] truncate">
{projectTitle}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>查看文件列表</TooltipContent>
</Tooltip>
{(isGenerating || isSaving) && (
<>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden sm:inline">
{isGenerating ? "生成中..." : "保存中..."}
</span>
</div>
</>
<Button
variant="ghost"
onClick={goHome}
className="h-10 gap-2 rounded-none border-0 bg-transparent px-0 shadow-none hover:bg-transparent"
>
{(siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark) ? (
<img
src={siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark || ""}
alt={siteSettingsData?.siteName || "返回首页"}
className="h-7 w-7 object-contain"
draggable={false}
/>
) : (
<Grid3X3 className="h-5 w-5" />
)}
<span className="max-w-[180px] truncate text-base font-semibold text-foreground">
{siteSettingsData?.siteName || "返回首页"}
</span>
</Button>
</div>
{/* 3. Floating Actions (Top Right) */}
{/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && (
<div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
onClick={() => setIsPanelOpen(true)}
className="h-10 w-10 rounded-full border bg-background/90 shadow-sm backdrop-blur-md"
>
<PanelRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>打开聊天面板</TooltipContent>
</Tooltip>
<Button
variant="ghost"
onClick={() => setIsPanelOpen(true)}
className="h-10 rounded-full border bg-background/90 px-3 text-sm font-medium text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
>
<MessageCircle className="mr-1.5 h-4 w-4" />
对话
</Button>
</div>
)}
@ -748,13 +687,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
)}
style={{
top: FLOATING_PANEL_INSET,
right: FLOATING_PANEL_INSET,
bottom: FLOATING_PANEL_INSET,
top: DOCKED_PANEL_INSET,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: FLOATING_PANEL_WIDTH,
maxWidth: `calc(100vw - ${FLOATING_PANEL_INSET}px)`,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
}}>
<div className="h-full w-full rounded-2xl border bg-white dark:bg-zinc-950 shadow-2xl overflow-hidden flex flex-col">
<div className="h-full w-full border-l bg-white dark:bg-zinc-950 overflow-hidden flex flex-col">
{panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
@ -775,7 +714,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
{/* 5. Floating Toolbar (Bottom) */}
<div className="absolute left-1/2 z-20 -translate-x-1/2" style={{ bottom: `${FLOATING_BOTTOM_INSET}px` }}>
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: `${FLOATING_BOTTOM_INSET}px` }}
data-canvas-floating-toolbar="true"
>
<div
className="flex items-center gap-1 rounded-[24px] border bg-background/88 px-2.5 py-1.5 shadow-2xl backdrop-blur-md"
style={{ marginLeft: `${floatingToolbarOffset}px` }}

View File

@ -6,7 +6,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useSession } from "next-auth/react";
import { createPortal } from "react-dom";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
@ -6704,7 +6704,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
disabled={!onClose}
title="收起聊天面板"
>
<Maximize2 className="h-4 w-4" />
<ArrowRightToLine className="h-4 w-4" />
</Button>
</div>
</div>
@ -6826,6 +6826,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
extractVideoUrls(part.text || "").length > 0
);
const hasAssistantMedia = hasAssistantImages || hasAssistantVideos;
const isAgentAutoMode = selectedModelId === "auto";
const displayAssistantModelName =
isAgentAutoMode && hasAssistantMedia ? "agent model" : assistantModelName;
const hasAssistantText = assistantTextParts.some((part: any) =>
stripCanvasModelRoutingLines(stripMediaMarkup(part.text || "")).length > 0
);
@ -6851,10 +6854,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
message.role === "assistant" &&
((!hasAssistantMedia && nextAssistantHasMedia) || isLatestAssistantDuringGeneration);
const showVoteActions = message.role === "assistant" && !hideVoteActions;
const showModelMeta = hideVoteActions && Boolean(assistantModelName);
const showModelMeta = hideVoteActions && Boolean(displayAssistantModelName);
const mediaModelName =
assistantModelName ||
(message.role === "assistant" && selectedModelId !== "auto"
displayAssistantModelName ||
(message.role === "assistant" && !isAgentAutoMode
? currentModel?.displayName || ""
: "");
const shouldTypeAssistantMessage =
@ -7218,7 +7221,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{showModelMeta ? (
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium tracking-tight text-[#a2a9b5]">
<Eye className="h-3.5 w-3.5 opacity-80" />
<span>{assistantModelName}</span>
<span>{displayAssistantModelName}</span>
</span>
) : null}
{showVoteActions ? (

View File

@ -286,11 +286,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [isAddingToChat, setIsAddingToChat] = useState(false);
const [isCompareMode, setIsCompareMode] = useState(false);
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const compareFrameRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
@ -515,6 +517,51 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setDraftNote(data.note || "");
}
}, [data.note, notePanelOpen]);
useEffect(() => {
if (
typeof window === "undefined" ||
!isSelected ||
isMultiSelected ||
!data.src ||
isEditingTitle ||
sceneFusionDialogOpen
) {
return;
}
const toolbarEl = toolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const rect = toolbarEl.getBoundingClientRect();
const next = { width: rect.width, height: rect.height };
setToolbarSize((prev) =>
Math.abs(prev.width - next.width) < 0.5 && Math.abs(prev.height - next.height) < 0.5
? prev
: next
);
};
const scheduleMeasure = () => {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(measure);
};
scheduleMeasure();
window.addEventListener("resize", scheduleMeasure);
const resizeObserver =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleMeasure) : null;
resizeObserver?.observe(toolbarEl);
return () => {
window.removeEventListener("resize", scheduleMeasure);
resizeObserver?.disconnect();
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, [data.src, isEditingTitle, isMultiSelected, isSelected, sceneFusionDialogOpen]);
// 🆕 处理图片点击标注
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
@ -818,18 +865,60 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarHeight = 48;
const viewportPadding = 12;
const toolbarScale = 0.8;
const toolbarWidth = Math.max(280, toolbarSize.width * toolbarScale || 980);
const toolbarHeight = Math.max(42, toolbarSize.height * toolbarScale || 48);
const titleSafeHeight = 34;
const gap = 12;
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
if (top < 12) {
const maxTop = Math.max(12, window.innerHeight - toolbarHeight - 12);
top = Math.min(rect.bottom + 10, maxTop);
let unsafeBottomTop = window.innerHeight;
const bottomOverlaySelectors = [
'[data-canvas-chat-input-anchor="true"]',
'[data-canvas-floating-toolbar="true"]',
];
for (const selector of bottomOverlaySelectors) {
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
for (const element of elements) {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") continue;
const overlayRect = element.getBoundingClientRect();
if (overlayRect.width <= 0 || overlayRect.height <= 0) continue;
if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight) continue;
if (overlayRect.top < window.innerHeight * 0.55) continue;
unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top);
}
}
const safeBottom = Math.max(
viewportPadding,
Math.min(window.innerHeight - viewportPadding, unsafeBottomTop - viewportPadding)
);
const maxTop = Math.max(viewportPadding, safeBottom - toolbarHeight);
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
if (top < viewportPadding) {
top = rect.bottom + gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = window.innerWidth - viewportPadding * 2;
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
const halfToolbar = effectiveToolbarWidth / 2;
let left = rect.left + rect.width / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = window.innerWidth / 2;
} else {
left = Math.min(
Math.max(left, viewportPadding + halfToolbar),
window.innerWidth - viewportPadding - halfToolbar
);
}
const minLeft = 80;
const maxLeft = window.innerWidth - 80;
const left = Math.min(Math.max(rect.left + rect.width / 2, minLeft), maxLeft);
return {
top,
left,
@ -1649,6 +1738,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}
};
const toolbarPosition = getToolbarPosition();
return (
<>
{/* 选中时在图片上方显示标题 */}
@ -2085,13 +2176,17 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && typeof document !== 'undefined' && createPortal(
<TooltipProvider delayDuration={200}>
<div
ref={toolbarRef}
className="fixed z-[9999] flex items-center rounded-[24px] border border-slate-200 bg-white/95 px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] backdrop-blur-md dark:border-slate-700 dark:bg-slate-900/95 dark:text-gray-100"
style={{
top: getToolbarPosition().top,
left: getToolbarPosition().left,
top: toolbarPosition.top,
left: toolbarPosition.left,
transform: 'translateX(-50%) scale(0.8)',
transformOrigin: 'top center',
pointerEvents: 'auto',
maxWidth: 'calc(100vw - 24px)',
overflowX: 'auto',
scrollbarWidth: 'none',
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}