feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-08 22:59:49 +08:00
parent 897981b893
commit 6c1d17dbf6
26 changed files with 999 additions and 236 deletions

View File

@@ -1,9 +1,9 @@
import { Button } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
import { ArrowUp, AtSign, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
@@ -318,7 +318,10 @@ export function AgentChatComposer({
const fileInputRef = useRef<HTMLInputElement>(null);
const [slash, setSlash] = useState<{ start: number; query: string } | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [previewAttachment, setPreviewAttachment] = useState<CanvasAgentChatAttachment | null>(null);
const availableSlashSkills = slashSkills ?? [];
const attachmentReferences = useMemo(() => agentAttachmentReferences(attachments), [attachments]);
const composerReferences = useMemo(() => [...references, ...attachmentReferences], [attachmentReferences, references]);
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
const reducedMotion = useReducedMotion();
const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(availableSlashSkills.length - 1, 0));
@@ -377,6 +380,12 @@ export function AgentChatComposer({
void onAddFiles(images);
};
const insertAttachmentMention = (item: CanvasAgentChatAttachment) => {
const token = `@[attachment:${item.id}] `;
if (prompt.includes(`@[attachment:${item.id}]`)) return;
onPromptChange(prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token);
};
return (
<div className="px-3 pb-3 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
<div
@@ -390,20 +399,40 @@ export function AgentChatComposer({
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
{attachments.length ? (
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
{attachments.map((item) => (
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-md" title={item.name}>
<img src={item.url} alt={item.name} className="size-full object-cover" />
{onRemoveAttachment ? (
{attachments.map((item, index) => (
<div key={item.id} className="group relative w-20 shrink-0">
<button
type="button"
className="relative block size-20 overflow-hidden rounded-lg"
title="点击放大预览"
aria-label={`预览 ${item.name || `图片${index + 1}`}`}
onClick={() => setPreviewAttachment(item)}
onDoubleClick={() => setPreviewAttachment(item)}
>
<img src={item.url} alt={item.name} className="size-full object-cover" />
</button>
<div className="mt-1 flex min-w-0 items-center justify-between gap-1">
<button
type="button"
className="absolute right-1 top-1 grid size-5 place-items-center rounded-full opacity-0 shadow-sm transition group-hover:opacity-100"
style={{ background: theme.toolbar.panel, color: theme.node.text }}
onClick={() => onRemoveAttachment(item.id)}
aria-label="移除图片"
className="flex min-w-0 items-center gap-0.5 truncate text-[var(--fs-tiny)] opacity-80 hover:opacity-100"
title={`插入 @图片${index + 1}`}
onClick={() => insertAttachmentMention(item)}
>
<X className="size-3" />
<AtSign className="size-2.5 shrink-0" />
<span className="truncate">图片{index + 1}</span>
</button>
) : null}
{onRemoveAttachment ? (
<button
type="button"
className="grid size-4 shrink-0 place-items-center rounded-full opacity-70 hover:opacity-100"
style={{ background: theme.toolbar.panel, color: theme.node.text }}
onClick={() => onRemoveAttachment(item.id)}
aria-label="移除图片"
>
<X className="size-3" />
</button>
) : null}
</div>
</div>
))}
</div>
@@ -412,7 +441,7 @@ export function AgentChatComposer({
<div className="thin-scrollbar max-h-40 min-h-[60px] overflow-y-auto">
<CanvasResourceMentionTextarea
value={prompt}
references={references}
references={composerReferences}
includeAssetLibrary={includeAssetLibrary}
sendOnEnter={false}
disabled={disabled}
@@ -507,6 +536,7 @@ export function AgentChatComposer({
</motion.button>
</div>
</div>
{previewAttachment ? <AgentImagePreview attachment={previewAttachment} onClose={() => setPreviewAttachment(null)} /> : null}
</div>
);
}
@@ -576,15 +606,45 @@ function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (type
}
function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChatAttachment[] }) {
const [preview, setPreview] = useState<CanvasAgentChatAttachment | null>(null);
return (
<div className="mt-2 grid grid-cols-3 gap-1.5">
{attachments.map((item) => (
<img key={item.id} src={item.url} alt={item.name} className="aspect-square w-full rounded-lg object-cover" />
))}
<>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{attachments.map((item) => (
<button key={item.id} type="button" className="group relative overflow-hidden rounded-lg" onClick={() => setPreview(item)} onDoubleClick={() => setPreview(item)} aria-label={`查看图片 ${item.name}`}>
<img src={item.url} alt={item.name} className="aspect-square w-full object-cover transition-transform group-hover:scale-105" />
</button>
))}
</div>
{preview ? <AgentImagePreview attachment={preview} onClose={() => setPreview(null)} /> : null}
</>
);
}
export function AgentImagePreview({ attachment, onClose }: { attachment: CanvasAgentChatAttachment; onClose: () => void }) {
return (
<div className="fixed inset-0 z-[var(--z-dialog-popover)] grid place-items-center bg-black/80 p-6" role="dialog" aria-label={attachment.name} onClick={onClose}>
<img src={attachment.url} alt={attachment.name} className="max-h-[90vh] max-w-[92vw] rounded-xl object-contain shadow-2xl" onClick={(event) => event.stopPropagation()} />
<button type="button" className="absolute right-5 top-5 rounded-full bg-black/60 p-2 text-white" onClick={onClose} aria-label="关闭图片预览">
<X className="size-5" />
</button>
</div>
);
}
function agentAttachmentReferences(attachments: CanvasAgentChatAttachment[]): CanvasResourceReference[] {
return attachments.map((item, index) => ({
id: `attachment:${item.id}`,
nodeId: "",
kind: "image",
label: `图片${index + 1}`,
title: item.name || `图片${index + 1}`,
previewUrl: item.url,
active: true,
mentionToken: `@[attachment:${item.id}]`,
}));
}
function toolCardState(title: string, text: string, detail?: unknown) {
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
const lower = raw.toLowerCase();

View File

@@ -2,7 +2,7 @@ import { Button, Modal, Segmented, Select } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState } from "react";
import copyToClipboard from "copy-to-clipboard";
import { Copy, Cpu, Settings2, Trash2, X } from "lucide-react";
import { Copy, Cpu, Settings2, Trash2, X, AtSign } from "lucide-react";
import { motion } from "motion/react";
@@ -23,12 +23,13 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { navigateToSettings } from "@/lib/settings-navigation";
import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
import { AgentChatComposer, AgentChatMessage, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { AgentChatComposer, AgentChatMessage, AgentImagePreview, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { ModelLogo } from "@/components/model-logo";
import { AgentChatEmptyState, AgentPanelChrome } from "./canvas-agent-panel-chrome";
import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel";
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendingBackendSession, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
@@ -374,7 +375,7 @@ export function CanvasAssistantPanel({
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
const [composerSkills, setComposerSkills] = useState<Skill[]>([]);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [attachedReferenceIds, setAttachedReferenceIds] = useState<Set<string>>(() => new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
const localSessionsRef = useRef(localSessions);
const [localActiveSessionId, setLocalActiveSessionIdState] = useState<string | null>(activeSessionId);
@@ -442,9 +443,11 @@ export function CanvasAssistantPanel({
const messages = activeSession?.messages || [];
const hasMessages = messages.length > 0;
const agentBusy = isRunning || safeSessions.some((session) => session.pendingBackendSession?.status === "pending");
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]);
const selectedReferences = useMemo(() => buildAssistantReferences(nodes, attachedReferenceIds), [attachedReferenceIds, nodes]);
const composerImageReferences = useMemo(() => assistantReferencesToMentionReferences(selectedReferences), [selectedReferences]);
const resolvedComposerImageReferences = useResolvedCanvasResourceReferences(composerImageReferences);
const composerReferences = useMemo(() => [...buildSkillMentionReferences(composerSkills), ...resolvedComposerImageReferences], [composerSkills, resolvedComposerImageReferences]);
const resolvedPreviewById = useMemo(() => new Map(resolvedComposerImageReferences.map((item) => [item.id, item.previewUrl || ""])), [resolvedComposerImageReferences]);
const contextSummary = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
const iconButtonStyle = { color: theme.node.muted };
@@ -455,8 +458,27 @@ export function CanvasAssistantPanel({
}, [agentBusy, agentMode, localActiveSessionId, messages, view]);
useEffect(() => {
setRemovedReferenceIds(new Set());
}, [selectedNodeKey]);
setAttachedReferenceIds((current) => {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const next = new Set(current);
let changed = false;
selectedNodeIds.forEach((id) => {
const node = nodeById.get(id);
if (!node || !nodeToReference(node) || next.has(id)) return;
next.add(id);
changed = true;
});
return changed ? next : current;
});
}, [nodes, selectedNodeIds]);
useEffect(() => {
setAttachedReferenceIds((current) => {
const nodeIds = new Set(nodes.map((node) => node.id));
const next = new Set(Array.from(current).filter((id) => nodeIds.has(id)));
return next.size === current.size ? current : next;
});
}, [nodes]);
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
const next = localSessionsRef.current.map((session) => (session.id === sessionId ? updater(session) : session));
@@ -1104,16 +1126,26 @@ export function CanvasAssistantPanel({
{view === "chat" ? (
<>
{selectedReferences.length ? (
<div className="thin-scrollbar flex max-w-full gap-1.5 overflow-x-auto px-3 pb-1">
<div className="thin-scrollbar flex max-w-full items-center gap-1.5 overflow-x-auto px-3 py-1.5">
<span className="shrink-0 text-[var(--fs-micro)] font-medium opacity-55">参考 · {selectedReferences.length}</span>
{selectedReferences.map((item, index) => (
<AssistantReferenceChip
key={item.id}
item={item}
label={assistantImageReferenceLabel(selectedReferences, index)}
previewUrl={resolvedPreviewById.get(item.id) || item.dataUrl}
onRemove={() => {
setRemovedReferenceIds((prev) => new Set(prev).add(item.id));
setAttachedReferenceIds((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
if (selectedNodeIds.has(item.id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== item.id)));
}}
onInsertMention={(label) => {
const token = `@${label} `;
setPrompt((prev) => (promptAlreadyHasMention(prev, label) ? prev : prev.trim() ? `${prev.replace(/\s+$/u, "")} ${token}` : token));
}}
/>
))}
</div>
@@ -1123,7 +1155,7 @@ export function CanvasAssistantPanel({
sending={agentBusy}
placeholder={cinematicEntryActive ? "一句话描述题材、角色和核心冲突" : "描述你想让 Agent 如何操作画布"}
theme={theme}
references={buildSkillMentionReferences(composerSkills)}
references={composerReferences}
slashSkills={composerSkills}
onPromptChange={setPrompt}
onSubmit={cinematicEntryActive ? () => submitCinematicProject(prompt) : submit}
@@ -1388,39 +1420,121 @@ function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
);
}
function AssistantReferenceChip({ item, label, onRemove }: { item: CanvasAssistantReference; label?: string; onRemove?: () => void }) {
function promptAlreadyHasMention(prompt: string, label: string) {
const token = `@${label}`;
let from = 0;
while (from <= prompt.length) {
const index = prompt.indexOf(token, from);
if (index < 0) return false;
const next = prompt[index + token.length];
if (!next || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(next)) return true;
from = index + 1;
}
return false;
}
function AssistantReferenceChip({ item, label, previewUrl, onRemove, onInsertMention }: { item: CanvasAssistantReference; label?: string; previewUrl?: string; onRemove?: () => void; onInsertMention?: (label: string) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [previewOpen, setPreviewOpen] = useState(false);
const mentionReference = useMemo(() => assistantReferencesToMentionReferences([item]), [item]);
const resolvedReference = useResolvedCanvasResourceReferences(mentionReference)[0];
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
const imageUrl = previewUrl || resolvedReference?.previewUrl || item.dataUrl;
const hasImage = Boolean(imageUrl || item.storageKey);
const chipStyle = { background: theme.spatial.surface, color: theme.node.text };
const actionStyle = { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
if (hasImage) {
return (
<>
<span className="group/chip relative inline-flex h-12 shrink-0 items-center overflow-visible rounded-md" style={chipStyle}>
<button
type="button"
className="grid size-12 shrink-0 overflow-hidden rounded-l-md border-0 p-0"
style={{ background: theme.toolbar.itemHover }}
title="点击放大预览"
aria-label={`预览 ${label || item.title}`}
disabled={!imageUrl}
onClick={() => imageUrl && setPreviewOpen(true)}
onDoubleClick={() => imageUrl && setPreviewOpen(true)}
>
{imageUrl ? (
<img src={imageUrl} alt="" className="size-full object-cover" />
) : (
<span className="text-[var(--fs-micro)] opacity-50">…</span>
)}
</button>
{label && onInsertMention ? (
<button
type="button"
className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 border-0 bg-transparent px-2.5 text-[var(--fs-tiny)] opacity-80 hover:opacity-100"
title={`插入 @${label}`}
onClick={() => onInsertMention(label)}
>
<AtSign className="size-2.5 shrink-0" />
<span className="truncate">{label}</span>
</button>
) : label ? (
<span className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 px-2.5 text-[var(--fs-tiny)] opacity-80">
<AtSign className="size-2.5 shrink-0" />
<span className="truncate">{label}</span>
</span>
) : null}
{onRemove ? (
<button
type="button"
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100 group-focus-within/chip:opacity-100"
style={actionStyle}
onClick={onRemove}
aria-label="移除引用"
>
<X className="size-3" />
</button>
) : null}
</span>
{previewOpen && imageUrl ? <AgentImagePreview attachment={{ id: item.id, url: imageUrl, name: item.title || label || "图片" }} onClose={() => setPreviewOpen(false)} /> : null}
</>
);
}
return (
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
{item.dataUrl ? (
<span className="relative block size-8 shrink-0">
<img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" />
{label ? <span className="absolute left-0.5 top-0.5 rounded bg-black/60 px-1 py-0.5 text-[var(--fs-micro)] font-medium leading-none text-white">{label}</span> : null}
</span>
) : (
<span className="grid size-8 place-items-center rounded-md text-sm font-medium" style={{ background: theme.spatial.surface }}>
{text}
</span>
)}
<span className="group/chip relative inline-flex h-12 max-w-[168px] shrink-0 items-center gap-2 overflow-hidden rounded-md px-2.5 text-sm" style={chipStyle}>
<span className="grid size-8 shrink-0 place-items-center rounded-md text-sm font-medium" style={{ background: theme.toolbar.itemHover }}>
{text}
</span>
<span className="min-w-0 truncate text-[var(--fs-tiny)] opacity-80">{item.title}</span>
{onRemove ? (
<button
type="button"
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}
style={actionStyle}
onClick={onRemove}
aria-label="移除引用"
>
<X className="size-3" />
</button>
) : null}
</div>
</span>
);
}
function assistantReferencesToMentionReferences(references: CanvasAssistantReference[]): CanvasResourceReference[] {
return references.flatMap((item, index): CanvasResourceReference[] => {
if (item.dataUrl || item.storageKey) {
const label = assistantImageReferenceLabel(references, index) || item.title;
return [{ id: item.id, nodeId: item.id, kind: "image", label, title: item.title, previewUrl: item.dataUrl, storageKey: item.storageKey, active: true }];
}
if (item.text) {
const label = item.type === CanvasNodeType.Skill ? `技能${index + 1}` : `文本${index + 1}`;
return [{ id: item.id, nodeId: item.id, kind: item.type === CanvasNodeType.Skill ? "skill" : "text", label, title: item.title, text: item.text, active: true }];
}
return [];
});
}
function assistantImageReferenceLabel(references: CanvasAssistantReference[], index: number) {
if (!references[index]?.dataUrl) return undefined;
const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl).length - 1;
if (!references[index]?.dataUrl && !references[index]?.storageKey) return undefined;
const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl || item.storageKey).length - 1;
return imageIndex >= 0 ? imageReferenceLabel(imageIndex) : undefined;
}
@@ -1889,7 +2003,7 @@ function explainNoop(ops: CanvasAgentOp[], snapshot: CanvasAgentSnapshot) {
}
function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) {
if (node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey)) {
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
}
if (node.type === CanvasNodeType.Text && node.metadata?.content) {

View File

@@ -297,12 +297,11 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, on
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl"
className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[360px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl"
style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="absolute inset-x-8 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />
<CanvasCreateMenu commands={commands} />
</SpotlightSurface>
);
@@ -358,7 +357,7 @@ function getContextMenuPosition(menu: ContextMenuState) {
function getSubmenuLeft(parentLeft: number) {
if (typeof window === "undefined") return parentLeft + 192;
return parentLeft + 224 + 8 + 260 <= window.innerWidth - 12 ? parentLeft + 232 : Math.max(12, parentLeft - 268);
return parentLeft + 224 + 8 + 360 <= window.innerWidth - 12 ? parentLeft + 232 : Math.max(12, parentLeft - 368);
}
function clamp(value: number, min: number, max: number) {

View File

@@ -63,7 +63,7 @@ function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreate
const reducedMotion = useReducedMotion();
return (
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-5" : variant === "workflow" ? "grid-cols-1" : "grid-cols-2")}>
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-4" : variant === "workflow" ? "grid-cols-1" : "grid-cols-2")}>
{commands.map((command) => (
<motion.button
key={command.id}
@@ -74,7 +74,7 @@ function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreate
className={cn(
"group min-w-0 overflow-hidden border border-black/10 bg-white/70 outline-none transition-colors hover:border-black/20 hover:bg-black/5 focus-visible:ring-2 dark:border-white/10 dark:bg-white/[.04] dark:hover:border-white/20 dark:hover:bg-white/8",
variant === "node"
? "flex h-[var(--canvas-create-node-height)] flex-col items-start justify-between rounded-[var(--dock-item-radius)] px-1 py-1.5 text-left"
? "flex h-[var(--canvas-create-node-height)] flex-col items-start justify-between rounded-[var(--dock-item-radius)] px-2 py-2 text-left"
: variant === "workflow"
? "flex h-[var(--canvas-create-resource-height)] items-center justify-start gap-2 rounded-[var(--dock-item-radius)] px-2 text-left"
: "flex h-[var(--canvas-create-resource-height)] items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-center",
@@ -87,7 +87,7 @@ function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreate
{variant === "node" ? (
<>
<span className="flex w-full min-w-0 items-center justify-between gap-1">
<span className="grid size-4 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-4">{command.icon}</span>
<span className="grid size-6 shrink-0 place-items-center opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-5">{command.icon}</span>
{command.badge ? <span className="shrink-0 font-medium leading-none" style={{ color: theme.node.muted, fontSize: "var(--fs-tiny)" }}>{command.badge}</span> : null}
</span>
<span className="block w-full overflow-hidden text-ellipsis whitespace-nowrap font-medium leading-none" style={{ fontSize: "var(--fs-label)" }}>{command.label}</span>

View File

@@ -351,6 +351,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
const files = state.attachments;
addMessage({ role: "user", text: payload.text || "发送了图片", attachments: files });
addEventLog("用户发送", { text: payload.text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
// 保留附件的 data URL 供已发送消息持续显示和放大预览;仅释放 composer 的临时 object URL。
files.forEach((item) => {
URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url);

View File

@@ -1,12 +1,19 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Slider } from "antd";
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
import { Brush, ChevronDown, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import { ImageSettingsPanel } from "@/components/image-settings-panel";
import { ModelPicker } from "@/components/model-picker";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import type { AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
export type CanvasImageMaskEditPayload = {
prompt: string;
maskDataUrl: string;
generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality" | "count" | "transparentBackground">>;
};
type DrawMode = "paint" | "erase";
@@ -15,7 +22,7 @@ const defaultBrushSize = 100;
const maskFillColor = "rgba(37, 99, 235, .38)";
const maskBorderColor = "rgba(255, 255, 255, .72)";
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onConfirm }: { dataUrl: string; open: boolean; config: AiConfig; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
@@ -24,6 +31,9 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
const [brushSize, setBrushSize] = useState(defaultBrushSize);
const [mode, setMode] = useState<DrawMode>("paint");
const [error, setError] = useState("");
const [generationConfig, setGenerationConfig] = useState<AiConfig>(() => config);
const [advancedOpen, setAdvancedOpen] = useState(false);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useEffect(() => {
if (!open) return;
@@ -31,8 +41,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
setBrushSize(defaultBrushSize);
setMode("paint");
setError("");
setAdvancedOpen(false);
setGenerationConfig(config);
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
}, [config, dataUrl, open]);
useEffect(() => {
clearCanvas(maskCanvasRef.current);
@@ -95,12 +107,12 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
if (!nextPrompt) return setError("请输入修改要求");
if (!canvas) return;
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) });
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas), generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality, count: generationConfig.count, transparentBackground: generationConfig.transparentBackground } });
};
return (
<Modal className="workspace-modal workspace-modal-wide" title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden>
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_340px]">
<div className="flex min-h-[360px] items-center justify-center rounded-lg bg-surface-active p-0">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
@@ -122,45 +134,93 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
</div>
</div>
<div className="flex min-h-[360px] flex-col gap-5">
<div>
<h2 className="text-xl font-semibold">局部重绘</h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button type={mode === "paint" ? "primary" : "default"} aria-pressed={mode === "paint"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
画笔
</Button>
<Button type={mode === "erase" ? "primary" : "default"} aria-pressed={mode === "erase"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
擦除
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75">笔刷大小</span>
<span className="font-semibold">{brushSize}px</span>
<div className="flex max-h-[68vh] min-h-[360px] flex-col overflow-hidden">
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<h2 className="text-xl font-semibold">局部重绘</h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
</div>
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">修改要求</div>
<Input.TextArea
rows={6}
value={prompt}
status={error && !prompt.trim() ? "error" : undefined}
placeholder="例如:把选中区域改成金属材质,保持原图光影"
onChange={(event) => {
setPrompt(event.target.value);
setError("");
}}
<div className="grid grid-cols-2 gap-2">
<Button type={mode === "paint" ? "primary" : "default"} aria-pressed={mode === "paint"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
画笔
</Button>
<Button type={mode === "erase" ? "primary" : "default"} aria-pressed={mode === "erase"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
擦除
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75">笔刷大小</span>
<span className="font-semibold">{brushSize}px</span>
</div>
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">修改要求</div>
<Input.TextArea
rows={4}
value={prompt}
status={error && !prompt.trim() ? "error" : undefined}
placeholder="例如:把选中区域改成金属材质,保持原图光影"
onChange={(event) => {
setPrompt(event.target.value);
setError("");
}}
/>
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
</div>
<ImageSettingsPanel
config={generationConfig}
showTitle={false}
showQuality={false}
showTransparent={false}
showSize={false}
className="space-y-2"
theme={theme}
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
/>
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
<div className="rounded-xl border border-border/60">
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-medium"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((current) => !current)}
>
<span>高级生成设置</span>
<ChevronDown className={`size-4 shrink-0 opacity-60 transition-transform ${advancedOpen ? "rotate-180" : ""}`} />
</button>
{advancedOpen ? (
<div className="space-y-3 border-t border-border/60 px-3 pb-3 pt-3">
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">生成模型</div>
<ModelPicker
config={generationConfig}
value={generationConfig.imageModel || generationConfig.model}
capability="image"
fullWidth
showSelectedPrice={false}
onChange={(model) => setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))}
/>
</div>
<ImageSettingsPanel
config={generationConfig}
showTitle={false}
showCount={false}
className="space-y-3"
theme={theme}
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
/>
</div>
) : null}
</div>
</div>
<div className="mt-auto flex items-center justify-between gap-2">
<div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border/50 pt-3">
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
重置
</Button>

View File

@@ -1,7 +1,7 @@
import { Button, Image as AntImage, InputNumber, Modal } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ArrowUp, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -48,7 +48,7 @@ type CanvasNodePromptPanelProps = {
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
const PROMPT_REFERENCE_SHELF_HEIGHT = 36;
const PROMPT_REFERENCE_SHELF_HEIGHT = 58;
const PROMPT_EDITOR_MIN_HEIGHT = 44;
const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 76;
const PROMPT_EDITOR_LINE_HEIGHT = 20;
@@ -244,13 +244,17 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
}}
>
{isPortraitTexture ? (
<CanvasPortraitTexturePopover value={node.metadata?.portraitTexture} placement={expanded ? "topRight" : "topLeft"} onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })} />
<>
<CanvasPortraitTexturePopover value={node.metadata?.portraitTexture} placement={expanded ? "topRight" : "topLeft"} onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })} />
{activeReferenceCount > 0 ? <span className="canvas-node-composer-reference-heading">{referenceShelfHeading(activeReferences)}</span> : null}
</>
) : (
<div className="canvas-node-composer-mode">
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: monochromeAccent }}>
<GenerationModeIcon mode={mode} />
</span>
<span className="truncate text-[var(--fs-tiny)] font-medium">{modeDisplayName(mode)}生成</span>
{activeReferenceCount > 0 ? <span className="canvas-node-composer-reference-heading">{referenceShelfHeading(activeReferences)}</span> : null}
</div>
)}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
@@ -525,6 +529,11 @@ function modeDisplayName(mode: CanvasNodeGenerationMode) {
return "文本";
}
function referenceShelfHeading(references: CanvasResourceReference[]) {
const label = references.every((reference) => reference.kind === "image" || reference.kind === "character") ? "参考图" : "参考素材";
return `${label} · ${references.length}`;
}
function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void; onRemove?: (reference: CanvasResourceReference) => void }) {
const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
const [imagePreview, setImagePreview] = useState<CanvasResourceReference | null>(null);
@@ -532,47 +541,51 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { re
return (
<>
<div className="canvas-node-composer-references thin-scrollbar" role="group" aria-label="已连接素材">
<span className="canvas-node-composer-reference-heading">
{activeReferences.every((reference) => reference.kind === "image" || reference.kind === "character") ? "参考图" : "参考素材"} · {activeReferences.length}
</span>
{activeReferences.map((reference) => {
const canPreview = (reference.kind === "image" || reference.kind === "character") && Boolean(reference.previewUrl);
return (
<span key={reference.id} className="canvas-node-reference-chip">
<button
type="button"
className="canvas-node-reference-preview"
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.node.activeStroke }}
title={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
aria-label={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
onClick={() => (canPreview ? setImagePreview(reference) : onInsert(reference))}
>
<ReferenceThumbnail reference={reference} />
</button>
<button type="button" className="canvas-node-reference-label" title={`插入 @${reference.label}`} onClick={() => onInsert(reference)}>
<AtSign className="size-2.5" />
<span>{reference.label}</span>
</button>
{onRemove ? (
<div className="canvas-node-composer-references" role="group" aria-label="已连接素材">
<div className="canvas-node-composer-references-track thin-scrollbar">
{activeReferences.map((reference) => {
const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video");
return (
<span key={reference.id} className="canvas-node-reference-chip">
<button
type="button"
className="canvas-node-reference-remove"
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }}
title="移除参考并删除连接"
aria-label={`移除参考 ${reference.label}`}
onClick={(event) => {
event.stopPropagation();
onRemove(reference);
}}
onPointerDown={(event) => event.stopPropagation()}
className="canvas-node-reference-preview"
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.node.activeStroke }}
title={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
aria-label={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
onClick={() => (canPreview ? setImagePreview(reference) : onInsert(reference))}
>
<X className="size-3" />
<ReferenceThumbnail reference={reference} />
{canPreview ? (
<span className="canvas-node-reference-preview-hint" aria-hidden="true">
<Maximize2 className="size-3" />
</span>
) : null}
</button>
) : null}
</span>
);
})}
<button type="button" className="canvas-node-reference-label" title={`插入 @${reference.label}`} onClick={() => onInsert(reference)}>
<span className="opacity-55">@</span>
<span className="truncate">{reference.label}</span>
</button>
{onRemove ? (
<button
type="button"
className="canvas-node-reference-remove"
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }}
title="移除参考并删除连接"
aria-label={`移除参考 ${reference.label}`}
onClick={(event) => {
event.stopPropagation();
onRemove(reference);
}}
onPointerDown={(event) => event.stopPropagation()}
>
<X className="size-3" />
</button>
) : null}
</span>
);
})}
</div>
</div>
{imagePreview?.previewUrl ? (
<AntImage

View File

@@ -64,6 +64,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(-1);
const [nativeDropReferenceId, setNativeDropReferenceId] = useState<string | null>(null);
const [previewReference, setPreviewReference] = useState<CanvasResourceReference | null>(null);
const canvasReferences = useResolvedCanvasResourceReferences(references);
const rawAssetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]);
const assetReferences = useResolvedCanvasResourceReferences(rawAssetReferences);
@@ -102,6 +103,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const isFocused = document.activeElement === editor;
const currentValue = serializeEditableValue(editor);
if (currentValue === value && lastRenderedValueRef.current === value) {
syncInlineMentionPreviews(editor, activeReferences);
pendingSelectionRef.current = null;
return;
}
@@ -349,6 +351,14 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
}}
onMouseDown={(event) => props.onMouseDown?.(event as unknown as React.MouseEvent<HTMLTextAreaElement>)}
onPointerDown={(event) => props.onPointerDown?.(event as unknown as React.PointerEvent<HTMLTextAreaElement>)}
onDoubleClick={(event) => {
const chip = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-mention-reference-id]") : null;
const reference = chip ? availableReferences.find((item) => item.id === chip.dataset.mentionReferenceId) : undefined;
if (!reference || !referencePreviewUrl(reference)) return;
event.preventDefault();
event.stopPropagation();
setPreviewReference(reference);
}}
onPointerUp={(event) => {
syncEditableMentionFromSelection();
props.onPointerUp?.(event as unknown as React.PointerEvent<HTMLTextAreaElement>);
@@ -371,6 +381,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
>
</div>
{menu}
{previewReference ? <InlineReferencePreview reference={previewReference} onClose={() => setPreviewReference(null)} /> : null}
</div>
);
}
@@ -464,6 +475,7 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri
chip.dataset.mentionToken = token;
chip.dataset.mentionReferenceId = reference.id;
chip.className = "canvas-resource-inline-mention";
chip.title = "双击放大预览";
const at = document.createElement("span");
at.className = "canvas-resource-inline-at";
@@ -480,6 +492,24 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri
return chip;
}
function referencePreviewUrl(reference: CanvasResourceReference) {
return reference.previewUrl || (reference.kind === "video" ? reference.mediaUrl : "") || "";
}
function InlineReferencePreview({ reference, onClose }: { reference: CanvasResourceReference; onClose: () => void }) {
const url = referencePreviewUrl(reference);
if (!url) return null;
return createPortal(
<div className="fixed inset-0 z-[var(--z-dialog-popover)] grid place-items-center bg-black/80 p-6" role="dialog" aria-label={`预览${reference.label}`} onClick={onClose}>
<div className="relative max-h-[92vh] max-w-[92vw]" onClick={(event) => event.stopPropagation()}>
<img src={url} alt={reference.label} className="max-h-[88vh] max-w-[88vw] rounded-xl object-contain shadow-2xl" />
<button type="button" className="absolute -right-3 -top-3 rounded-full bg-black/75 p-2 text-white shadow-lg" onClick={onClose} aria-label="关闭图片预览">×</button>
</div>
</div>,
document.body,
);
}
function createInlinePreview(reference: CanvasResourceReference) {
if ((reference.kind === "image" || reference.kind === "video" || reference.kind === "character") && reference.previewUrl) {
const media = document.createElement("img");
@@ -505,6 +535,26 @@ function createInlinePreview(reference: CanvasResourceReference) {
return fallback;
}
/** Resource URLs resolve independently of prompt text; keep chips fresh without replacing the editable selection. */
function syncInlineMentionPreviews(editor: HTMLElement, references: CanvasResourceReference[]) {
const byId = new Map(references.map((reference) => [reference.id, reference]));
editor.querySelectorAll<HTMLElement>("[data-mention-reference-id]").forEach((chip) => {
const reference = byId.get(chip.dataset.mentionReferenceId || "");
if (!reference) return;
const preview = chip.querySelector(".canvas-resource-inline-preview");
const hasImage = ["image", "video", "character"].includes(reference.kind) && Boolean(reference.previewUrl);
const hasVideo = !hasImage && reference.kind === "video" && Boolean(reference.mediaUrl);
const tag = hasImage ? "IMG" : hasVideo ? "VIDEO" : "SPAN";
const className = `canvas-resource-inline-preview is-${hasImage || hasVideo ? reference.kind : "fallback"}`;
const src = hasImage ? reference.previewUrl : hasVideo ? reference.mediaUrl : null;
if (preview && (preview.tagName !== tag || preview.className !== className || preview.getAttribute("src") !== src)) {
preview.replaceWith(createInlinePreview(reference));
}
const label = chip.querySelector(".canvas-resource-inline-label");
if (label && label.textContent !== reference.label) label.textContent = reference.label;
});
}
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: {
anchor: HTMLElement;
connectedReferences: CanvasResourceReference[];

View File

@@ -1,8 +1,7 @@
import { motion, useReducedMotion } from "motion/react";
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { ChevronRight, Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, WandSparkles, Workflow as WorkflowIcon, X } from "lucide-react";
import { Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, Workflow as WorkflowIcon } from "lucide-react";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { canvasThemes } from "@/lib/canvas-theme";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -157,9 +156,11 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const menuRef = useRef<HTMLDivElement>(null);
const [activeOption, setActiveOption] = useState<string | null>(null);
const lastPointerRef = useRef<Position | null>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("connection-create-menu", "var(--z-modal-overlay)");
const menuWidth = Math.min(288, viewportSize.width - 24);
const menuHeight = canCreateDrawing ? 456 : 412;
const menuHeight = canCreateDrawing ? 360 : 316;
const gap = 12;
const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap);
@@ -182,53 +183,69 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
}, [containerRef, pending.position, viewport, viewportSize.height, viewportSize.width]);
return (
<SpotlightSurface
spotlightColor={theme.toolbar.itemHover}
<motion.div
ref={menuRef}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.97, rotateX: 2 }}
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="aceternity-floating-panel absolute origin-top-left overflow-x-hidden overflow-y-auto rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl"
className="thin-scrollbar absolute origin-top-left overflow-x-hidden overflow-y-auto rounded-[var(--r-2xl)] border p-2"
data-canvas-no-zoom
data-connection-create-menu
aria-label="创建下一步"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
onClose();
}
}}
style={{ width: menuWidth, maxHeight: Math.max(120, viewportSize.height - 84), left: initialPosition.left, top: initialPosition.top, zIndex, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
onFocusCapture={(event) => {
bringToFront();
if (event.target.matches(":focus-visible")) setActiveOption(event.target.closest<HTMLElement>("[data-create-option]")?.dataset.createOption || null);
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setActiveOption(null);
}}
onPointerMove={(event) => {
if (event.pointerType === "touch") return;
const previous = lastPointerRef.current;
// Layout changes can retarget a stationary pointer; only real movement selects a new row.
if (previous?.x === event.clientX && previous.y === event.clientY) return;
lastPointerRef.current = { x: event.clientX, y: event.clientY };
const option = (event.target as Element).closest<HTMLElement>("[data-create-option]")?.dataset.createOption;
if (option) setActiveOption(option);
}}
onPointerLeave={() => {
lastPointerRef.current = null;
setActiveOption(null);
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="absolute inset-x-8 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />
<div className="mb-1.5 flex items-center justify-between gap-2 px-1 py-0.5">
<span className="flex min-w-0 items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border opacity-75" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}><WandSparkles className="size-3.5" /></span>
<span className="min-w-0"><span className="block truncate text-[var(--fs-label)] font-semibold">创建下一步</span><span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{pending.batchSourceNodeIds?.length ? `引用已选 ${pending.batchSourceNodeIds.length} 个节点` : "引用当前节点"}</span></span>
</span>
<button type="button" className="grid size-6 shrink-0 place-items-center rounded-full border opacity-55 transition-opacity hover:opacity-100" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} onClick={onClose} aria-label="关闭连线创建菜单"><X className="size-3" /></button>
</div>
<div className="grid min-w-0 grid-cols-1 gap-1">
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<WorkflowIcon className="size-4" />} title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} />
{canCreateDrawing ? <ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption expanded={activeOption === "文本生成"} motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" description="引用当前内容,生成或改写文本" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption expanded={activeOption === "分镜脚本"} motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" description="根据剧情拆解镜头,编排分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} />
<ConnectionCreateOption expanded={activeOption === "图片生成"} motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" description="结合提示词和参考图,生成新的画面" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption expanded={activeOption === "生成配置"} motionEnabled={!reducedMotion} icon={<WorkflowIcon className="size-4" />} title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} />
{canCreateDrawing ? <ConnectionCreateOption expanded={activeOption === "绘图"} motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" description="以参考图片为底图,自由绘制和标注" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
<ConnectionCreateOption expanded={activeOption === "视频生成"} motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" description="结合提示词与参考素材,生成动态视频" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption expanded={activeOption === "音频参考"} motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" description="连接文本或角色卡,创建音频生成节点" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
</div>
</SpotlightSurface>
</motion.div>
);
}
function ConnectionCreateOption({ motionEnabled, icon, title, description, disabledReason, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; disabledReason?: string; onClick: () => void }) {
function ConnectionCreateOption({ expanded, motionEnabled, icon, title, description, disabledReason, onClick }: { expanded: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; disabledReason?: string; onClick: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<motion.button type="button" disabled={Boolean(disabledReason)} title={disabledReason} whileHover={motionEnabled && !disabledReason ? { x: 2 } : undefined} whileTap={motionEnabled && !disabledReason ? { scale: 0.98 } : undefined} transition={aceternityMotion.spring.dock} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-[var(--dock-item-radius)] border border-transparent px-2 py-1.5 text-left outline-none hover:border-black/10 hover:bg-black/5 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:border-white/10 dark:hover:bg-white/8" style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties} onClick={onClick}>
<button type="button" aria-disabled={Boolean(disabledReason)} aria-label={title} aria-description={disabledReason || description} data-create-option={title} data-expanded={expanded} data-motion={motionEnabled ? "enabled" : "reduced"} className="canvas-connection-create-option group flex min-h-10 w-full cursor-pointer items-start gap-2 rounded-[var(--dock-item-radius)] px-2 py-1.5 text-left outline-none focus-visible:ring-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-40" style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted, background: expanded ? theme.toolbar.itemHover : undefined } as CSSProperties} onClick={() => { if (!disabledReason) onClick(); }}>
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: theme.toolbar.itemHover }}>{icon}</span>
<span className="min-w-0 flex-1">
<span className="min-w-0 flex-1 pt-1.5">
<span className="flex items-center gap-2 text-[var(--fs-tiny)] font-semibold leading-4">{title}</span>
{disabledReason || description ? <span className="mt-0.5 block whitespace-normal break-words text-[var(--fs-micro)] leading-relaxed" style={{ color: theme.node.muted }}>{disabledReason || description}</span> : null}
<span aria-hidden="true" className="canvas-connection-create-description" style={{ color: theme.node.muted }}><span className="min-h-0 overflow-hidden"><span className="block pt-1 whitespace-normal break-words text-[var(--fs-micro)] leading-relaxed">{disabledReason || description}</span></span></span>
</span>
<ChevronRight className="size-3.5 shrink-0 opacity-35 transition-transform group-hover:translate-x-0.5" />
</motion.button>
</button>
);
}

View File

@@ -46,13 +46,16 @@ type ImageSettingsPanelProps = {
onConfigChange: (key: "quality" | "size" | "transparentBackground" | "count", value: string) => void;
theme: CanvasTheme;
showTitle?: boolean;
showQuality?: boolean;
showTransparent?: boolean;
showSize?: boolean;
showCount?: boolean;
className?: string;
maxCount?: number;
quickCount?: number;
};
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) {
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showQuality = true, showTransparent = true, showSize = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) {
const profile = mergedImageCapabilityConfig(config, config.model || config.imageModel);
const normalized = normalizeImageValue(profile, config);
const quality = normalized.quality;
@@ -75,7 +78,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
}}
>
{showTitle ? <div className="text-base font-semibold">图像设置</div> : null}
{profile.quality.supported && !imageResolutionUsesQuality(profile) ? <div className="space-y-2">
{showQuality && profile.quality.supported && !imageResolutionUsesQuality(profile) ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{activeQualityOptions.map((item) => (
@@ -85,7 +88,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
))}
</div>
</div> : null}
{profile.transparentBackground.supported ? <div className="flex items-center justify-between gap-3">
{showTransparent && profile.transparentBackground.supported ? <div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<SettingTitle color={theme.node.muted}>透明背景</SettingTitle>
<div className="mt-1 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
@@ -100,7 +103,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
/>
</span>
</div> : null}
<ImageSizePicker profile={profile} size={activeSize} quality={quality} onChange={(size, nextQuality) => { onConfigChange("size", size); if (nextQuality) onConfigChange("quality", nextQuality); }} />
{showSize ? <ImageSizePicker profile={profile} size={activeSize} quality={quality} onChange={(size, nextQuality) => { onConfigChange("size", size); if (nextQuality) onConfigChange("quality", nextQuality); }} /> : null}
{showCount && effectiveMaxCount > 1 ? (
<div className="space-y-2">
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>

View File

@@ -99,7 +99,7 @@
}
.image-size-preset-group {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
grid-template-columns: minmax(96px, 112px) minmax(0, 1fr);
min-width: 0;
gap: 16px;
padding: 16px;
@@ -122,14 +122,35 @@
gap: 4px;
padding-top: 4px;
}
.image-size-preset-bulk-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
}
.image-size-preset-bulk-actions .ant-btn {
min-height: 24px;
padding-inline: 4px;
}
.image-size-preset-heading > div {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.image-size-preset-heading strong {
display: block;
font-size: var(--fs-body);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.image-size-preset-heading > span,
.image-size-preset-heading div > span,
.image-size-defaults-heading > span {
display: block;
color: var(--muted-foreground);
font-size: var(--fs-label);
white-space: nowrap;
}
.image-size-preset-body {
display: grid;

View File

@@ -50,8 +50,14 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
return (
<section key={tier} className="image-size-preset-group" aria-labelledby={`${id}-${tier}-title`}>
<header className="image-size-preset-heading">
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
<div>
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
</div>
<div className="image-size-preset-bulk-actions">
<Button type="link" size="small" disabled={disabled || !items.length} onClick={() => update(tier, [])}>全不选</Button>
<Button type="link" size="small" disabled={disabled || items.length === ratios.length} onClick={() => update(tier, ratios)}>全选</Button>
</div>
</header>
<div className="image-size-preset-body">
{!imageTierAvailable(profile, tier) ? <p className="image-size-hint">需先配置此档位对应的质量值,用户端才会开放。</p> : null}

View File

@@ -1,4 +1,91 @@
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
export function liveImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[]) {
if (root.type !== CanvasNodeType.Image) return [];
const listed = new Set(root.metadata?.batchChildIds || []);
return nodes.filter((node) => node.id !== root.id && (node.metadata?.batchRootId === root.id || listed.has(node.id)));
}
export function retireImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const children = liveImageBatchChildren(root, nodes);
if (!children.length) return { nodes, connections, removedIds: [] as string[] };
const removedIds = children.filter((node) => !node.metadata?.content).map((node) => node.id);
const removed = new Set(removedIds);
const kept = children.filter((node) => Boolean(node.metadata?.content));
const nextNodes = nodes
.filter((node) => !removed.has(node.id))
.map((node) => {
if (node.id === root.id) {
const metadata = { ...node.metadata, imageBatchExpanded: true };
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.batchFailedCount;
if (!metadata.content) metadata.status = "idle";
return { ...node, metadata };
}
const index = kept.findIndex((item) => item.id === node.id);
if (index < 0) return node;
const metadata = { ...node.metadata };
delete metadata.batchRootId;
return {
...node,
position: { x: root.position.x - node.width - 48, y: root.position.y + index * (node.height + 24) },
metadata,
};
});
return {
nodes: nextNodes,
connections: connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)),
removedIds,
};
}
export function cancelIncompleteImageBatch(rootId: string, childIds: string[], nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const childIdSet = new Set(childIds);
const removedIds = nodes.filter((node) => childIdSet.has(node.id) && !node.metadata?.content).map((node) => node.id);
const removed = new Set(removedIds);
const nextNodes = nodes
.filter((node) => !removed.has(node.id))
.map((node) => {
if (node.id !== rootId) return node;
const remaining = (node.metadata?.batchChildIds || []).filter((id) => !removed.has(id) && nodes.some((item) => item.id === id && item.metadata?.content));
const hasContent = Boolean(node.metadata?.content);
const metadata = {
...node.metadata,
batchChildIds: remaining.length ? remaining : undefined,
isBatchRoot: remaining.length > 1 ? true : undefined,
imageBatchExpanded: remaining.length > 1 ? true : undefined,
status: hasContent ? node.metadata?.status : "idle",
};
if (!hasContent) {
delete metadata.errorDetails;
delete metadata.generationErrorCode;
delete metadata.failedPromptFingerprint;
}
if (remaining.length <= 1) {
delete metadata.batchFailedCount;
delete metadata.primaryImageId;
delete metadata.isBatchRoot;
delete metadata.imageBatchExpanded;
delete metadata.batchChildIds;
}
return { ...node, metadata };
});
const remainingIds = new Set(
nextNodes.find((node) => node.id === rootId)?.metadata?.batchChildIds || [],
);
const detached = nextNodes.map((node) => {
if (!childIdSet.has(node.id) || remainingIds.has(node.id)) return node;
const metadata = { ...node.metadata };
delete metadata.batchRootId;
return { ...node, metadata };
});
return {
nodes: detached,
connections: connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)),
removedIds,
};
}
export function failedImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[]) {
if (root.type !== CanvasNodeType.Image || !root.metadata?.isBatchRoot) return [];

View File

@@ -226,6 +226,14 @@ export function buildOrderedCanvasResourceReferences(nodes: CanvasNodeData[], ac
return labelResourceNodes(nodes.filter(isResourceNode), active);
}
export function imageGenerationReferenceConnections(sourceNodeId: string, targetNodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], createId: () => string): CanvasConnection[] {
if (!sourceNodeId || sourceNodeId === targetNodeId) return [];
const existing = new Set(connections.filter((connection) => connection.toNodeId === targetNodeId).map((connection) => connection.fromNodeId));
return getMentionResourceNodes(sourceNodeId, nodes, connections)
.filter((node) => node.id !== sourceNodeId && node.id !== targetNodeId && !existing.has(node.id))
.map((node) => ({ id: createId(), fromNodeId: node.id, toNodeId: targetNodeId }));
}
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
if (configInputs.length) return configInputs;

View File

@@ -2,9 +2,11 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { cancelIncompleteImageBatch, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry";
import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { imageGenerationReferenceConnections } from "@/lib/canvas/canvas-resource-references";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -21,6 +23,7 @@ export async function executeImageGeneration({
nodeId,
sourceNode,
canvasNodes,
canvasConnections,
prompt,
effectivePrompt,
generationConfig,
@@ -52,6 +55,9 @@ export async function executeImageGeneration({
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
const retired = reuseSourceNode && sourceNode ? retireImageBatchChildren(sourceNode, canvasNodes, canvasConnections) : { nodes: canvasNodes, connections: canvasConnections, removedIds: [] as string[] };
const workingNodes = retired.nodes;
const workingConnections = retired.connections;
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
const referenceImages = generationContext.referenceImages;
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
@@ -78,7 +84,7 @@ export async function executeImageGeneration({
x: parentPosition.x + parentWidth + 96,
y: parentPosition.y + parentHeight / 2 - rootHeight / 2,
};
const rootPosition = reuseSourceNode ? parentPosition : findAvailableGenerationGroupPosition(canvasNodes, preferredPosition, imageGenerationGroupSize({ width: rootWidth, height: rootHeight }, outputNodeSize, childIds.length));
const rootPosition = reuseSourceNode ? parentPosition : findAvailableGenerationGroupPosition(workingNodes, preferredPosition, imageGenerationGroupSize({ width: rootWidth, height: rootHeight }, outputNodeSize, childIds.length));
const rootNode: CanvasNodeData = {
id: rootId,
@@ -101,7 +107,7 @@ export async function executeImageGeneration({
...generationMetadata,
...styleMetadata,
...skillMetadata,
imageBatchExpanded: count > 1 ? false : undefined,
imageBatchExpanded: count > 1 ? true : undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
@@ -127,10 +133,14 @@ export async function executeImageGeneration({
failedPromptFingerprint: undefined,
},
}));
const batchConnections = [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
const batchConnections = [
...(reuseSourceNode ? [] : imageGenerationReferenceConnections(nodeId, rootId, workingNodes, workingConnections, nanoid)),
...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]),
...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId })),
];
const nextNodes: CanvasNodeData[] = [
...canvasNodes.map((node) => {
...workingNodes.map((node) => {
if (node.id !== nodeId) return node;
if (isConfigNode) return { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
@@ -150,7 +160,8 @@ export async function executeImageGeneration({
setNodes(nextNodes);
setConnections((current) => {
const nextConnections = [...current, ...batchConnections];
const removed = new Set(retired.removedIds);
const nextConnections = [...current.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)), ...batchConnections];
if (projectId) {
useCanvasStore.getState().updateProject(projectId, { nodes: nextNodes, connections: nextConnections });
}
@@ -253,7 +264,14 @@ export async function executeImageGeneration({
);
if (count > 1) finishGenerationRequest(rootId, controller);
if (controller.signal.aborted) {
setNodes((current) => current.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
setNodes((current) => {
const cancelled = cancelIncompleteImageBatch(rootId, childIds, current, []);
if (cancelled.removedIds.length) {
const removed = new Set(cancelled.removedIds);
setConnections((connections) => connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)));
}
return cancelled.nodes.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node));
});
return;
}
if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");

View File

@@ -4,6 +4,7 @@ import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "@/com
import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasNodeData } from "@/types/canvas";
import type { AiConfig } from "@/stores/use-config-store";
type CanvasProjectMediaDialogsProps = {
cropNode: CanvasNodeData | null;
@@ -21,6 +22,7 @@ type CanvasProjectMediaDialogsProps = {
onMaskEdit: (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => void;
onSplit: (node: CanvasNodeData, params: CanvasImageSplitParams) => void;
onUpscale: (node: CanvasNodeData, params: CanvasImageUpscaleParams) => void;
config: AiConfig;
};
export function CanvasProjectMediaDialogs({
@@ -39,12 +41,13 @@ export function CanvasProjectMediaDialogs({
onMaskEdit,
onSplit,
onUpscale,
config,
}: CanvasProjectMediaDialogsProps) {
return (
<>
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open onClose={onCloseCrop} onConfirm={(crop) => onCrop(cropNode, crop)} /> : null}
{annotationNode?.metadata?.content ? <CanvasNodeAnnotationDialog image={{ url: annotationNode.metadata.content, storageKey: annotationNode.metadata.storageKey }} open onClose={onCloseAnnotation} onConfirm={(dataUrl) => onAnnotate(annotationNode, dataUrl)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={config} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open onClose={onCloseSplit} onConfirm={(params) => onSplit(splitNode, params)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
</>

View File

@@ -2893,6 +2893,7 @@ function InfiniteCanvasPage() {
onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
onSplit={(node, params) => void splitImageNode(node, params)}
onUpscale={(node, params) => void upscaleImageNode(node, params)}
config={effectiveConfig}
/>
<CanvasProjectStatusDialogs

View File

@@ -13,6 +13,9 @@ import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { cancelIncompleteImageBatch } from "@/lib/canvas/canvas-image-batch-retry";
import { buildAngleLabel, buildAnglePrompt, createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { validateVideoSegmentBatch } from "@/lib/canvas/canvas-video-regeneration";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
@@ -67,6 +70,7 @@ type UseCanvasMediaToolsOptions = {
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
const NODE_STATUS_IDLE = "idle" as const;
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
要求:
@@ -571,46 +575,152 @@ export function useCanvasMediaTools({
const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" };
const baseGenerationConfig = buildGenerationConfig(effectiveConfig, node, "image");
const generationConfig = {
...baseGenerationConfig,
...payload.generationConfig,
model: payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model,
imageModel: payload.generationConfig?.imageModel || payload.generationConfig?.model || effectiveConfig.imageModel,
count: String(payload.generationConfig?.count || 1),
size: payload.generationConfig?.size || node.metadata?.size || "auto",
};
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
const userPrompt = payload.prompt.trim();
const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`;
const childId = nanoid();
const source = nodeReferenceImage(node);
if (!source) return;
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
if (!styleExecution) return;
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
const requestedCount = Math.max(1, Number(generationConfig.count) || 1);
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, requestedCount, [source]);
setMaskEditNodeId(null);
setRunningNodeId(childId);
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: userPrompt.slice(0, 32) || "局部编辑结果", position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
const rootId = nanoid();
const childIds = requestedCount > 1 ? Array.from({ length: requestedCount }, () => nanoid()) : [];
const targetIds = requestedCount > 1 ? childIds : [rootId];
const imageSize = { width: node.width, height: node.height };
const preferredPosition = { x: node.position.x + node.width + 96, y: node.position.y };
const rootPosition = findAvailableGenerationGroupPosition(nodesRef.current, preferredPosition, imageGenerationGroupSize(imageSize, imageSize, childIds.length));
const rootNode: CanvasNodeData = {
id: rootId,
type: CanvasNodeType.Image,
title: userPrompt.slice(0, 32) || "局部编辑结果",
position: rootPosition,
width: node.width,
height: node.height,
metadata: {
...canvasGenerationPromptMetadata(userPrompt, effectivePrompt),
status: NODE_STATUS_LOADING,
isBatchRoot: requestedCount > 1,
batchChildIds: requestedCount > 1 ? childIds : undefined,
batchFailedCount: requestedCount > 1 ? 0 : undefined,
imageBatchExpanded: requestedCount > 1 ? true : undefined,
...generationMetadata,
...styleMetadata,
},
};
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id,
type: CanvasNodeType.Image,
title: `${userPrompt.slice(0, 28) || "局部编辑结果"} · ${index + 1}`,
position: imageGenerationChildPosition(rootNode.position, rootNode.width, imageSize, index),
width: node.width,
height: node.height,
metadata: {
...canvasGenerationPromptMetadata(userPrompt, effectivePrompt),
status: NODE_STATUS_LOADING,
batchRootId: rootId,
...generationMetadata,
...styleMetadata,
},
}));
setRunningNodeId(rootId);
setNodes((current) => [...current, rootNode, ...childNodes]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: rootId }, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]);
setSelectedNodeIds(new Set([rootId, ...childIds]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
const controller = startGenerationRequest(childId, node.id, childId);
setDialogNodeId(rootId);
const controller = startGenerationRequest(rootId, node.id, rootId);
targetIds.forEach((targetId) => startGenerationRequest(targetId, node.id, rootId, controller));
let hasSuccess = false;
let failureCount = 0;
let representativeError: string | undefined;
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], mask: { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "mask", ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
const currentNode = nodesRef.current.find((item) => item.id === childId);
if (!currentNode) throw new Error("局部编辑节点已被删除");
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
await persistMediaNodes([finalizedNode]);
} catch (error) {
if (isGenerationCanceled(error)) return;
const details = generationErrorMessage(error);
message.error(details);
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
await Promise.all(targetIds.map(async (targetId) => {
try {
const result = await runBackendCanvasGenerationTask({
projectId,
nodeId: targetId,
mode: "image",
prompt: effectivePrompt,
config: { ...generationConfig, count: "1" },
referenceImages: [source],
mask: { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl },
signal: controller.signal,
metadata: { sourceNodeId: node.id, edit: "mask", ...styleMetadata },
onTaskCreated: (task) => bindGenerationTask(targetId, task),
});
const image = result.images?.find((item) => item?.dataUrl);
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
const currentNode = nodesRef.current.find((item) => item.id === targetId);
if (!currentNode) throw new Error("局部编辑节点已被删除");
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
setNodes((current) => current.map((item) => {
if (item.id === targetId) return finalizedNode;
if (item.id !== rootId || requestedCount <= 1 || item.metadata?.primaryImageId) return item;
return { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), primaryImageId: targetId, status: NODE_STATUS_SUCCESS } };
}));
await persistMediaNodes([finalizedNode]);
hasSuccess = true;
} catch (error) {
if (isGenerationCanceled(error)) return;
failureCount += 1;
const details = generationErrorMessage(error);
representativeError = details;
setNodes((current) => current.map((item) => (item.id === targetId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item)));
} finally {
finishGenerationRequest(targetId, controller);
}
}));
if (controller.signal.aborted) {
setNodes((current) => {
const cancelled = cancelIncompleteImageBatch(rootId, childIds, current, []);
if (cancelled.removedIds.length) {
const removed = new Set(cancelled.removedIds);
setConnections((connections) => connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)));
}
return cancelled.nodes.map((item) => {
if (item.id !== rootId) return item;
if (item.metadata?.content) return item;
return { ...item, metadata: { ...item.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } };
});
});
return;
}
if (failureCount > 0) {
message.error(hasSuccess ? "部分局部编辑失败" : representativeError || "局部编辑失败");
}
setNodes((current) => current.map((item) => {
if (item.id !== rootId) return item;
return {
...item,
metadata: {
...item.metadata,
status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR,
batchFailedCount: requestedCount > 1 ? failureCount : undefined,
...(hasSuccess
? { errorDetails: undefined }
: { errorDetails: representativeError || "局部编辑失败" }),
},
};
}));
} finally {
finishGenerationRequest(childId, controller);
if (requestedCount > 1) finishGenerationRequest(rootId, controller);
setRunningNodeId(null);
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);

View File

@@ -959,7 +959,7 @@
--page-topbar-border: var(--workspace-border);
--canvas-topbar-surface: var(--workspace-glass-surface);
/* Canvas 创建菜单:固定行高保证图标、徽标和单行标签互不挤占 */
--canvas-create-node-height: var(--space-12);
--canvas-create-node-height: var(--space-16);
--canvas-create-resource-height: var(--space-8);
/* Node 组件:节点首先是内容容器,常态不使用卡片投影或发光。 */
@@ -983,11 +983,12 @@
--canvas-mention-menu-max-height: 320px;
--canvas-mention-search-height: 38px;
--canvas-mention-item-height: 38px;
--canvas-mention-chip-height: 1.68em;
--canvas-mention-chip-preview-size: 1.18em;
--canvas-mention-chip-gap: 0.18em;
--canvas-mention-chip-padding-inline: 0.22em;
--canvas-mention-chip-offset-y: 0.08em;
--canvas-mention-chip-height: 22px;
--canvas-mention-chip-preview-size: 18px;
--canvas-mention-chip-gap: 0.12em;
--canvas-mention-chip-padding-inline: 0.16em;
--canvas-mention-chip-offset-y: 0.14em;
--canvas-reference-chip-size: 44px;
/* Modal 组件(对话框,对应 #98 面板层级)*/
--modal-radius: var(--r-lg);
@@ -8038,7 +8039,29 @@
background-image: linear-gradient(to bottom, color-mix(in srgb, var(--cn-frame-fill) 90%, transparent), var(--cn-frame-fill));
}
.canvas-node-shell {
.canvas-connection-create-description {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
pointer-events: none;
transition: grid-template-rows var(--motion-dur-base-calc) var(--motion-ease-out), opacity var(--motion-dur-base-calc) var(--motion-ease-out);
}
.canvas-connection-create-option[data-expanded="true"] .canvas-connection-create-description {
grid-template-rows: 1fr;
opacity: 1;
}
.canvas-connection-create-option[data-motion="reduced"] .canvas-connection-create-description,
.no-motion .canvas-connection-create-description {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.canvas-connection-create-description { transition: none; }
}
.canvas-node-shell {
box-shadow: none;
transition: transform 160ms ease-out;
}
@@ -9192,10 +9215,14 @@
}
.canvas-node-composer-reference-heading {
flex-shrink: 0;
margin-right: var(--space-1);
font-size: var(--fs-micro);
color: color-mix(in srgb, var(--cn-text) 65%, var(--canvas-composer-surface));
margin-left: 6px;
padding-left: 8px;
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
font-size: var(--fs-tiny);
font-weight: 500;
line-height: 1;
color: color-mix(in srgb, var(--cn-text) 62%, var(--canvas-composer-surface));
white-space: nowrap;
}
.canvas-node-composer-icon-button:hover,
@@ -9218,56 +9245,91 @@
}
.canvas-node-composer-references {
display: block;
min-width: 0;
flex-shrink: 0;
padding: 6px 12px 10px;
}
.canvas-node-composer-references-track {
display: flex;
min-width: 0;
height: 36px;
flex-shrink: 0;
align-items: center;
gap: var(--space-1);
flex-wrap: nowrap;
align-items: stretch;
gap: 8px;
overflow-x: auto;
padding-inline: var(--space-2);
overflow-y: hidden;
padding: 2px;
scroll-padding-inline: 2px;
}
.canvas-node-reference-chip {
position: relative;
display: inline-flex;
height: var(--canvas-reference-chip-size, 44px);
min-width: 0;
height: 30px;
flex: 0 0 auto;
align-items: center;
flex-direction: row;
align-items: stretch;
overflow: visible;
border-radius: var(--r-md);
background: var(--canvas-composer-control-surface);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent);
}
.canvas-node-reference-preview {
width: 30px;
height: 30px;
flex: 0 0 30px;
position: relative;
width: var(--canvas-reference-chip-size, 44px);
height: var(--canvas-reference-chip-size, 44px);
flex: 0 0 var(--canvas-reference-chip-size, 44px);
overflow: hidden;
border: 0;
border-radius: var(--r-md) 0 0 var(--r-md);
background: transparent;
padding: 0;
cursor: zoom-in;
}
.canvas-node-reference-preview-hint {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--color-neutral-950) 44%, transparent);
color: var(--color-neutral-0);
opacity: 0;
transition: opacity var(--motion-dur-fast) var(--motion-ease-out);
pointer-events: none;
}
.canvas-node-reference-chip:hover .canvas-node-reference-preview-hint,
.canvas-node-reference-preview:focus-visible .canvas-node-reference-preview-hint {
opacity: 1;
}
.canvas-node-reference-label {
display: inline-flex;
max-width: 112px;
height: 30px;
max-width: 96px;
min-width: 0;
height: var(--canvas-reference-chip-size, 44px);
align-items: center;
gap: var(--space-1);
gap: 2px;
overflow: hidden;
border: 0;
border-left: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
background: transparent;
padding: 0 var(--space-2);
color: color-mix(in srgb, var(--foreground) 64%, transparent);
padding: 0 10px 0 8px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
cursor: pointer;
font-size: var(--fs-tiny);
line-height: 1;
white-space: nowrap;
}
.canvas-node-reference-label span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.canvas-node-reference-label:hover,
.canvas-node-reference-label:focus-visible {
background: var(--canvas-composer-control-hover);
color: var(--foreground);
}
.canvas-node-reference-remove {