feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user