diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx
index c40a9c278..bff15d394 100644
--- a/app/canvas/[id]/page.tsx
+++ b/app/canvas/[id]/page.tsx
@@ -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) */}
-
-
-
-
- 返回首页
-
-
-
- {/* 2. Floating Header (Top Left) */}
-
-
-
-
-
- 返回首页
-
-
-
-
- {/* 文件列表按钮 & 标题展示 */}
-
-
-
-
- 查看文件列表
-
-
- {(isGenerating || isSaving) && (
- <>
-
-
-
-
- {isGenerating ? "生成中..." : "保存中..."}
-
-
- >
+
- {/* 3. Floating Actions (Top Right) */}
+ {/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && (
-
-
-
-
- 打开聊天面板
-
+
)}
@@ -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)`,
}}>
-
+
{panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
@@ -775,7 +714,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
{/* 5. Floating Toolbar (Bottom) */}
-
+
@@ -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 ? (
- {assistantModelName}
+ {displayAssistantModelName}
) : null}
{showVoteActions ? (
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx
index 8ece8f2e0..429d4ad1b 100644
--- a/components/canvas/nodes/ImageNode.tsx
+++ b/components/canvas/nodes/ImageNode.tsx
@@ -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
(null);
const containerRef = useRef(null);
const compareFrameRef = useRef(null);
+ const toolbarRef = useRef(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) => {
@@ -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(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(
e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}