diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 7a1ab4d27..41c7173ab 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,28 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## Agent 聊天附件、局部重绘次数与多图展开 + +- Agent 输入框附件改为 80px 预览,标签在图片下方,不再盖住画面;点击/双击放大,可用 `@图片N` 插入正文,鼠标移开后图片保持可见。 +- **在线 Agent**(画布助手面板)选中节点引用同步改为 80px 预览、点击放大、`@图片N` 插入,并接入输入框 `@` 候选;预览走 `storageKey` 解析,避免 blob 失效导致黑块。引用列表与画布选区解耦,逐个点选图片会累积到 Agent,不再只能保留最后一张。 +- 提示词内联 `@图片N` 芯片恢复紧凑尺寸(约 22px 高 / 18px 缩略图),不再撑高整行文本。 +- 图片/视频节点提示词面板的参考素材改为 44px 横向胶囊(缩略图 + `@标签`),单行左右滑动;「参考图 · N」移到标题栏「图片/视频生成」后面;**单击缩略图预览**,**点击 `@标签` 插入引用**。 +- 画布局部重绘:核心区只保留笔刷、修改要求、生成张数;模型/质量/尺寸/比例收进「高级生成设置」折叠区,侧栏限高滚动,底部操作栏固定。 +- 画布局部重绘增加模型选择、尺寸和生成张数;张数大于 1 时按多图组并行出图并默认展开。 +- 局部重绘多图取消时清掉未出图的占位节点;单路失败不影响其余并行任务,部分失败会提示并保留成功结果。 +- 重新生成会把源节点的参考图连线带到新结果,避免提示词里的 `@图片` 缩略图丢失。 +- 管理员尺寸预设每个分辨率档位提供全选/全不选;左侧档位标题列加宽,4K / 8 个比例分行显示不再挤成「4K8 个比例」。 +- 新的多图组默认展开成 4 张,不再叠在旧结果上;取消或重生成会清掉没有结果的占位子图,已有结果拆成独立节点。 +- [ ] 登录画布验证 Agent 附件放大、`@` 插入、局部重绘侧栏不溢出且高级设置可折叠、局部重绘多张、改提示词重生成后参考图仍在,以及 4 张结果直接铺开。 +- [ ] 管理员尺寸预设页确认 1K/2K/4K 左侧标题不换行、全选/全不选可用。 + +## 刷新画布后提示词引用缩略图恢复 + +- 修复参考图可见、正文 `@引用` 图片却加载失败:图片地址异步解析完成后,即使提示词未变化,也按引用 ID 同步正文缩略图,不再保留旧地址。 +- 仅替换发生变化的预览元素,保留引用标签、文字和光标;地址不变时复用现有图片,不触发提示词保存。 +- [x] 真实浏览器挂载实际编辑器,验证失效 blob 地址切换到有效图片后解码成功、光标和标签保持、相同地址复用图片,且没有触发 onChange。 +- [ ] 在登录后的真实画布中刷新页面,确认原有参考图及正文引用均恢复;覆盖素材库引用和多个引用。 + ## 连线目标节点 3D 倾斜反馈 - 拖动连线进入现有合法目标吸附区时,目标节点按接近点倾斜,最大约 5°,以接近点作为支点;支持单条和批量连线,不扩大连线命中区域或改变节点坐标。移出、完成或取消后恢复,普通 hover、点击和选中不启用此效果。 @@ -143,7 +165,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 ## 画布多图分组与展开动画 -- 新生成的多图组默认收起;复制图片或替换节点生成多图时,结果也保留分组归属,可展开、收起和设置主图。 +- 新生成的多图组默认展开,直接看到全部结果;仍可手动收起,复制图片或替换节点生成多图时保留分组归属、收起和设置主图。 - 子图入场动画仅在主动展开图片组时触发;普通选择、拖动或重新进入视口不播放入场动画,快速切换展开与收起时清理上一轮动画状态。 - 22 项相关测试及前端生产构建通过,包含子图普通挂载、选中与展开/收起的组件渲染回归;浏览器已确认本地画布可加载,尚未复验截图中的多图场景。 - [ ] 人工确认多图生成后的堆叠预览、数量按钮展开/收起、子图点击与拖动、主图切换,以及保存后重新打开画布。已有画布保留用户保存的展开状态。 diff --git a/web/src/components/canvas/canvas-agent-chat-ui.tsx b/web/src/components/canvas/canvas-agent-chat-ui.tsx index 0fd610a33..85422a4c1 100644 --- a/web/src/components/canvas/canvas-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-agent-chat-ui.tsx @@ -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(null); const [slash, setSlash] = useState<{ start: number; query: string } | null>(null); const [slashIndex, setSlashIndex] = useState(0); + const [previewAttachment, setPreviewAttachment] = useState(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 (
event.stopPropagation()}>
: null} {attachments.length ? (
- {attachments.map((item) => ( -
- {item.name} - {onRemoveAttachment ? ( + {attachments.map((item, index) => ( +
+ +
- ) : null} + {onRemoveAttachment ? ( + + ) : null} +
))}
@@ -412,7 +441,7 @@ export function AgentChatComposer({
+ {previewAttachment ? setPreviewAttachment(null)} /> : null}
); } @@ -576,15 +606,45 @@ function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (type } function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChatAttachment[] }) { + const [preview, setPreview] = useState(null); return ( -
- {attachments.map((item) => ( - {item.name} - ))} + <> +
+ {attachments.map((item) => ( + + ))} +
+ {preview ? setPreview(null)} /> : null} + + ); +} + +export function AgentImagePreview({ attachment, onClose }: { attachment: CanvasAgentChatAttachment; onClose: () => void }) { + return ( +
+ {attachment.name} event.stopPropagation()} /> +
); } +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(); diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 975bae7ab..2b26a9b22 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -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([]); const [onlineLogs, setOnlineLogs] = useState([]); const [composerSkills, setComposerSkills] = useState([]); - const [removedReferenceIds, setRemovedReferenceIds] = useState>(new Set()); + const [attachedReferenceIds, setAttachedReferenceIds] = useState>(() => new Set()); const [localSessions, setLocalSessions] = useState(() => (sessions.length ? sessions : [createSession()])); const localSessionsRef = useRef(localSessions); const [localActiveSessionId, setLocalActiveSessionIdState] = useState(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 ? ( -
+
+ 参考 · {selectedReferences.length} {selectedReferences.map((item, index) => ( { - 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)); + }} /> ))}
@@ -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 ( + <> + + + {label && onInsertMention ? ( + + ) : label ? ( + + + {label} + + ) : null} + {onRemove ? ( + + ) : null} + + {previewOpen && imageUrl ? setPreviewOpen(false)} /> : null} + + ); + } + return ( -
- {item.dataUrl ? ( - - - {label ? {label} : null} - - ) : ( - - {text} - - )} + + + {text} + + {item.title} {onRemove ? ( ) : null} -
+ ); } +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) { diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index f332c6fea..d13fec717 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -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()} > -
); @@ -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) { diff --git a/web/src/components/canvas/canvas-create-menu.tsx b/web/src/components/canvas/canvas-create-menu.tsx index 0b8e1627e..a67513375 100644 --- a/web/src/components/canvas/canvas-create-menu.tsx +++ b/web/src/components/canvas/canvas-create-menu.tsx @@ -63,7 +63,7 @@ function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreate const reducedMotion = useReducedMotion(); return ( -
+
{commands.map((command) => ( - {command.icon} + {command.icon} {command.badge ? {command.badge} : null} {command.label} diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index c17521a09..f9c7766e9 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -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); diff --git a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx index c377e354b..10cd82797 100644 --- a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx +++ b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx @@ -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>; }; 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(null); const previewCanvasRef = useRef(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("paint"); const [error, setError] = useState(""); + const [generationConfig, setGenerationConfig] = useState(() => 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 ( -
+
@@ -122,45 +134,93 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
-
-
-

局部重绘

-
{image ? `${image.width} x ${image.height}px` : "读取中"}
-
- -
- - -
- -
-
- 笔刷大小 - {brushSize}px +
+
+
+

局部重绘

+
{image ? `${image.width} x ${image.height}px` : "读取中"}
- -
-
-
修改要求
- { - setPrompt(event.target.value); - setError(""); - }} +
+ + +
+ +
+
+ 笔刷大小 + {brushSize}px +
+ +
+ +
+
修改要求
+ { + setPrompt(event.target.value); + setError(""); + }} + /> + {error ?
{error}
: null} +
+ + setGenerationConfig((current) => ({ ...current, [key]: value }))} /> - {error ?
{error}
: null} + +
+ + {advancedOpen ? ( +
+
+
生成模型
+ setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))} + /> +
+ setGenerationConfig((current) => ({ ...current, [key]: value }))} + /> +
+ ) : null} +
-
+
diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 391964400..fdb6d73df 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -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 ? ( - onConfigChange(node.id, { portraitTexture })} /> + <> + onConfigChange(node.id, { portraitTexture })} /> + {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null} + ) : (
{modeDisplayName(mode)}生成 + {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null}
)}
@@ -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(null); @@ -532,47 +541,51 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { re return ( <> -
- - {activeReferences.every((reference) => reference.kind === "image" || reference.kind === "character") ? "参考图" : "参考素材"} · {activeReferences.length} - - {activeReferences.map((reference) => { - const canPreview = (reference.kind === "image" || reference.kind === "character") && Boolean(reference.previewUrl); - return ( - - - - {onRemove ? ( +
+
+ {activeReferences.map((reference) => { + const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video"); + return ( + - ) : null} - - ); - })} + + {onRemove ? ( + + ) : null} + + ); + })} +
{imagePreview?.previewUrl ? ( (null); const [activeIndex, setActiveIndex] = useState(-1); const [nativeDropReferenceId, setNativeDropReferenceId] = useState(null); + const [previewReference, setPreviewReference] = useState(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 props.onMouseDown?.(event as unknown as React.MouseEvent)} onPointerDown={(event) => props.onPointerDown?.(event as unknown as React.PointerEvent)} + onDoubleClick={(event) => { + const chip = event.target instanceof Element ? event.target.closest("[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); @@ -371,6 +381,7 @@ export const CanvasResourceMentionTextarea = forwardRef
{menu} + {previewReference ? setPreviewReference(null)} /> : null}
); } @@ -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( +
+
event.stopPropagation()}> + {reference.label} + +
+
, + 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("[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[]; diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index b05c57f00..0e12e4bd1 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -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(null); + const [activeOption, setActiveOption] = useState(null); + const lastPointerRef = useRef(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 ( - { + 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("[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("[data-create-option]")?.dataset.createOption; + if (option) setActiveOption(option); + }} + onPointerLeave={() => { + lastPointerRef.current = null; + setActiveOption(null); + }} onPointerDown={(event) => event.stopPropagation()} > -
-
- - - 创建下一步{pending.batchSourceNodeIds?.length ? `引用已选 ${pending.batchSourceNodeIds.length} 个节点` : "引用当前节点"} - - -
- } title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> - } title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> - } title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> - } title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} /> - {canCreateDrawing ? } title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} - } title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> - } title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} /> + } title="文本生成" description="引用当前内容,生成或改写文本" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> + } title="分镜脚本" description="根据剧情拆解镜头,编排分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> + } title="图片生成" description="结合提示词和参考图,生成新的画面" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> + } title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} /> + {canCreateDrawing ? } title="绘图" description="以参考图片为底图,自由绘制和标注" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} + } title="视频生成" description="结合提示词与参考素材,生成动态视频" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> + } title="音频参考" description="连接文本或角色卡,创建音频生成节点" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
- + ); } -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 ( - + ); } diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index cd029bc8a..256725c2c 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -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 ?
图像设置
: null} - {profile.quality.supported && !imageResolutionUsesQuality(profile) ?
+ {showQuality && profile.quality.supported && !imageResolutionUsesQuality(profile) ?
{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}
{activeQualityOptions.map((item) => ( @@ -85,7 +88,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = ))}
: null} - {profile.transparentBackground.supported ?
+ {showTransparent && profile.transparentBackground.supported ?
透明背景
@@ -100,7 +103,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = />
: null} - { onConfigChange("size", size); if (nextQuality) onConfigChange("quality", nextQuality); }} /> + {showSize ? { onConfigChange("size", size); if (nextQuality) onConfigChange("quality", nextQuality); }} /> : null} {showCount && effectiveMaxCount > 1 ? (
生成张数 diff --git a/web/src/components/image-size-picker.css b/web/src/components/image-size-picker.css index 87e4f7da8..c3d5ffa79 100644 --- a/web/src/components/image-size-picker.css +++ b/web/src/components/image-size-picker.css @@ -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; diff --git a/web/src/components/image-size-presets-editor.tsx b/web/src/components/image-size-presets-editor.tsx index 367fe22ce..01c684ab0 100644 --- a/web/src/components/image-size-presets-editor.tsx +++ b/web/src/components/image-size-presets-editor.tsx @@ -50,8 +50,14 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil return (
- {tier.toUpperCase()} - {items.length ? `${items.length} 个比例` : "未启用"} +
+ {tier.toUpperCase()} + {items.length ? `${items.length} 个比例` : "未启用"} +
+
+ + +
{!imageTierAvailable(profile, tier) ?

需先配置此档位对应的质量值,用户端才会开放。

: null} diff --git a/web/src/lib/canvas/canvas-image-batch-retry.ts b/web/src/lib/canvas/canvas-image-batch-retry.ts index 03afb6eb7..e0fc87f2b 100644 --- a/web/src/lib/canvas/canvas-image-batch-retry.ts +++ b/web/src/lib/canvas/canvas-image-batch-retry.ts @@ -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 []; diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index bd1e3aa11..294e365b4 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -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; diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index e80b0a2ca..7d34f9410 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -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 ? "部分图片生成失败" : "全部图片生成失败"); diff --git a/web/src/pages/canvas/canvas-project-media-dialogs.tsx b/web/src/pages/canvas/canvas-project-media-dialogs.tsx index 137f58775..14f1b5877 100644 --- a/web/src/pages/canvas/canvas-project-media-dialogs.tsx +++ b/web/src/pages/canvas/canvas-project-media-dialogs.tsx @@ -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 ? onCrop(cropNode, crop)} /> : null} {annotationNode?.metadata?.content ? onAnnotate(annotationNode, dataUrl)} /> : null} - {maskEditNode?.metadata?.content ? onMaskEdit(maskEditNode, payload)} /> : null} + {maskEditNode?.metadata?.content ? onMaskEdit(maskEditNode, payload)} /> : null} {splitNode?.metadata?.content ? onSplit(splitNode, params)} /> : null} {upscaleNode?.metadata?.content ? onUpscale(upscaleNode, params)} /> : null} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 8a17176ae..910dba7df 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -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} /> { 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]); diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 1bb1346c0..e12f31e6b 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -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 { diff --git a/web/test/canvas-connection-create-menu.test.ts b/web/test/canvas-connection-create-menu.test.ts new file mode 100644 index 000000000..f62803b53 --- /dev/null +++ b/web/test/canvas-connection-create-menu.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +const component = readFileSync(new URL("../src/components/canvas/canvas-workspace-overlays.tsx", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8"); + +test("connection menu omits the header and provides descriptions for every option", () => { + expect(component).not.toContain("引用当前节点"); + expect(component).not.toContain(">创建下一步"); + expect(component).not.toContain("ChevronRight"); + expect(component).not.toContain("absolute inset-x-8 top-0 h-px"); + const options = component.match(/ { + const menu = readFileSync(new URL("../src/components/canvas/canvas-create-menu.tsx", import.meta.url), "utf8"); + const context = readFileSync(new URL("../src/components/canvas/canvas-context-menu.tsx", import.meta.url), "utf8"); + const styles = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8"); + expect(menu).toContain('variant === "node" ? "grid-cols-4"'); + expect(menu).toContain("size-6 shrink-0"); + expect(context).toContain('w-[360px]'); + expect(styles).toContain("--canvas-create-node-height: var(--space-16)"); +}); + +test("only the active option expands; stationary pointer retargeting cannot switch rows", () => { + const descriptionRule = css.match(/\.canvas-connection-create-description \{([^}]+)\}/)?.[1] || ""; + expect(descriptionRule).toContain("grid-template-rows: 0fr"); + expect(descriptionRule).not.toContain("min-height"); + const option = component.slice(component.indexOf("function ConnectionCreateOption("), component.indexOf("function clamp(")); + expect(option).not.toContain("motion.button"); + expect(css).toContain('.canvas-connection-create-option[data-expanded="true"]'); + expect(component).toContain("previous?.x === event.clientX && previous.y === event.clientY"); + expect(component).not.toContain("SpotlightSurface"); + expect(css).toContain('.canvas-connection-create-option[data-motion="reduced"]'); + expect(component).toContain('aria-disabled={Boolean(disabledReason)}'); + expect(component).toContain("if (!disabledReason) onClick()"); + expect(component).toContain('event.key === "Escape"'); +}); diff --git a/web/test/canvas-image-batch-retry.test.ts b/web/test/canvas-image-batch-retry.test.ts index eff99e704..a330cb34b 100644 --- a/web/test/canvas-image-batch-retry.test.ts +++ b/web/test/canvas-image-batch-retry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "../src/lib/canvas/canvas-image-batch-retry"; +import { cancelIncompleteImageBatch, failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild, retireImageBatchChildren } from "../src/lib/canvas/canvas-image-batch-retry"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeStatus } from "../src/types/canvas"; function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial> = {}): CanvasNodeData { @@ -85,4 +85,27 @@ describe("canvas image batch retry", () => { expect(restoreUnsubmittedImageBatchChild(loading, original).metadata).toMatchObject({ status: "error", errorDetails: "原始失败" }); expect(restoreUnsubmittedImageBatchChild(imageNode("failed", "success", { content: "image:success" }), original).metadata?.status).toBe("success"); }); + + test("重新生成时清掉未完成子图,并把已有结果从折叠组里拆出去", () => { + const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["done", "pending"], imageBatchExpanded: false }); + const done = imageNode("done", "success", { batchRootId: root.id, content: "image:done" }); + const pending = imageNode("pending", "loading", { batchRootId: root.id }); + const retired = retireImageBatchChildren(root, [root, done, pending], [{ id: "c1", fromNodeId: root.id, toNodeId: done.id }, { id: "c2", fromNodeId: root.id, toNodeId: pending.id }]); + expect(retired.removedIds).toEqual(["pending"]); + expect(retired.nodes.map((node) => node.id)).toEqual(["root", "done"]); + expect(retired.nodes.find((node) => node.id === "done")?.metadata?.batchRootId).toBeUndefined(); + expect(retired.nodes.find((node) => node.id === "root")?.metadata).toMatchObject({ imageBatchExpanded: true, status: "idle" }); + expect(retired.connections.map((item) => item.id)).toEqual(["c1"]); + }); + + test("取消生成时删除没有结果的子图并展开剩余批次", () => { + const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["done", "pending"] }); + const done = imageNode("done", "success", { batchRootId: root.id, content: "image:done" }); + const pending = imageNode("pending", "loading", { batchRootId: root.id }); + const cancelled = cancelIncompleteImageBatch(root.id, ["done", "pending"], [root, done, pending], [{ id: "c2", fromNodeId: root.id, toNodeId: pending.id }]); + expect(cancelled.removedIds).toEqual(["pending"]); + expect(cancelled.nodes.map((node) => node.id)).toEqual(["root", "done"]); + expect(cancelled.nodes.find((node) => node.id === "root")?.metadata?.batchChildIds).toBeUndefined(); + expect(cancelled.nodes.find((node) => node.id === "done")?.metadata?.batchRootId).toBeUndefined(); + }); }); diff --git a/web/test/canvas-image-source.test.ts b/web/test/canvas-image-source.test.ts index b12a7b313..f1a90b1f2 100644 --- a/web/test/canvas-image-source.test.ts +++ b/web/test/canvas-image-source.test.ts @@ -47,6 +47,11 @@ describe("上传图片是输入素材", () => { expect(portrait).toContain("fromNodeId: node.id, toNodeId: child.id"); expect(portrait).toContain("setDialogNodeId(child.id)"); expect(portrait).not.toContain("current.map"); + expect(mediaTools).toContain("payload.generationConfig"); + expect(mediaTools).toContain("imageBatchExpanded: requestedCount > 1 ? true : undefined"); + const dialog = read("components/canvas/canvas-node-mask-edit-dialog.tsx"); + expect(dialog).toContain("ModelPicker"); + expect(dialog).toContain("ImageSettingsPanel"); }); }); diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts index f4092fac1..6d51511fb 100644 --- a/web/test/canvas-resource-mention-editor.test.ts +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -7,6 +7,18 @@ function source(path: string) { } describe("canvas resource mention editor", () => { + test("refreshes async reference previews even when prompt text has not changed", () => { + const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx"); + const unchangedTextBranch = component.match(/if \(currentValue === value && lastRenderedValueRef.current === value\) \{([^}]+)\}/)?.[1] || ""; + expect(unchangedTextBranch).toContain("syncInlineMentionPreviews(editor, activeReferences)"); + const sync = component.slice(component.indexOf("function syncInlineMentionPreviews("), component.indexOf("function MentionMenu(")); + expect(sync).toContain('byId.get(chip.dataset.mentionReferenceId || "")'); + expect(sync).toContain('preview.getAttribute("src") !== src'); + expect(sync).toContain("preview.replaceWith(createInlinePreview(reference))"); + expect(sync).not.toContain("replaceChildren"); + expect(sync).not.toContain("onChange("); + }); + test("uses stable component classes for inline media references", () => { const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx"); @@ -62,6 +74,9 @@ describe("canvas resource mention editor", () => { const generationExecutor = source("../src/pages/canvas/use-canvas-generation-executor.ts"); expect(imageExecutor.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBeGreaterThanOrEqual(3); + expect(imageExecutor).toContain("imageBatchExpanded: count > 1 ? true : undefined"); + expect(imageExecutor).toContain("imageGenerationReferenceConnections"); + expect(imageExecutor).toContain("retireImageBatchChildren"); expect(mediaExecutors.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBe(2); expect(textExecutor.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBe(2); expect(generationExecutor).toContain("composerContent: prompt"); @@ -89,4 +104,14 @@ describe("canvas resource mention editor", () => { expect(css).toContain(".canvas-resource-mention-item.is-skill"); expect(css).toContain(".canvas-resource-mention-meta"); }); + + test("agent composer attachments stay large, previewable, and mentionable", () => { + const component = source("../src/components/canvas/canvas-agent-chat-ui.tsx"); + expect(component).toContain("w-20 shrink-0"); + expect(component).toContain("insertAttachmentMention"); + expect(component).toContain("@[attachment:"); + expect(component).toContain("AgentImagePreview"); + expect(component).toContain("composerReferences"); + expect(component).toContain("点击放大预览"); + }); }); diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts index 45cf68cd6..2513d9baa 100644 --- a/web/test/canvas-resource-references.test.ts +++ b/web/test/canvas-resource-references.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references"; +import { applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections } from "../src/lib/canvas/canvas-resource-references"; import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset"; import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; @@ -260,3 +260,16 @@ describe("remove canvas resource mention tokens", () => { expect(nextTarget.metadata?.composerContent).toBe("比较 和 @图片1"); }); }); + +describe("image generation reference connections", () => { + test("把源节点的参考图连线复制到新结果,避免提示词图片丢失", () => { + const imageA = imageNode("image-a"); + const imageB = imageNode("image-b"); + const source = textNode("prompt"); + const nodes = [imageA, imageB, source]; + const connections = [connection(imageA.id, source.id), connection(imageB.id, source.id)]; + const copied = imageGenerationReferenceConnections(source.id, "result", nodes, connections, () => "new-id"); + expect(copied.map((item) => item.fromNodeId)).toEqual(["image-a", "image-b"]); + expect(copied.every((item) => item.toNodeId === "result")).toBe(true); + }); +}); diff --git a/web/test/image-size-presets.test.tsx b/web/test/image-size-presets.test.tsx index e80fe0f0f..1c707980d 100644 --- a/web/test/image-size-presets.test.tsx +++ b/web/test/image-size-presets.test.tsx @@ -139,6 +139,8 @@ describe("统一图片分辨率与宽高比", () => { expect(editor).toContain(`aria-label="${tier} 16:9"`); } expect(editor).toContain('aria-label="默认输出"'); + expect(editor).toContain("全不选"); + expect(editor).toContain("全选"); const readOnly = renderToStaticMarkup( {}} />); const buttons = [...readOnly.matchAll(/]*>/g)]; expect(buttons.length).toBeGreaterThan(24);