feat: polish canvas chat dock and agent model label
This commit is contained in:
parent
e3abf6dc2b
commit
ed3f0fefb7
@ -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` }}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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()}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user