feat: 完善画布管理与快捷键体验

修改历史:

- 为画布编辑器补齐节点复制粘贴、全选、追加选择、缩放、删除、生成等快捷键,并新增快捷键说明弹窗

- 支持粘贴剪贴板文本/图片为画布节点、拖入图片上传,以及多选节点复制时保留内部连接

- 在 /canvas 画布管理页新增编辑和删除操作,可编辑标题与说明,并支持删除当前画布

- 为画布项目新增 description 字段,补充数据库兼容迁移、列表展示、API 保存以及导入导出保留说明

- 优化画布节点、提示词引用、配置节点尺寸、生成任务状态与相关服务逻辑
This commit is contained in:
2026-06-23 01:10:25 +08:00
parent 3b8ce48c56
commit 4ff348e808
38 changed files with 2069 additions and 310 deletions
@@ -22,6 +22,7 @@ export async function GET(
return NextResponse.json({
item: createCanvasProjectExport({
title: project.title,
description: project.description,
data: project.data,
}),
});
@@ -36,12 +36,16 @@ export async function PATCH(
const payload = await request.json().catch(() => ({}));
const patch: {
title?: string;
description?: string;
data?: CanvasProjectData;
} = {};
if (typeof payload.title === "string") {
patch.title = payload.title;
}
if (typeof payload.description === "string") {
patch.description = payload.description;
}
if ("data" in payload) {
const data = normalizeCanvasProjectData(payload.data);
if (!data) {
@@ -55,6 +59,7 @@ export async function PATCH(
const project = updateCanvasProject(id, {
title: patch.title,
description: patch.description,
data: patch.data,
});
+3 -1
View File
@@ -20,8 +20,10 @@ export async function POST(request: Request) {
typeof payload.title === "string" && payload.title.trim()
? payload.title
: "未命名画布";
const description =
typeof payload.description === "string" ? payload.description : "";
const data = normalizeCanvasProjectData(payload.data) ?? defaultCanvasProjectData;
const project = createCanvasProject({ title, data });
const project = createCanvasProject({ title, description, data });
return NextResponse.json({ item: project }, { status: 201 });
}
+9
View File
@@ -0,0 +1,9 @@
import { streamImageJobEvents } from "@/lib/server/services/image-job-service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
export async function GET(request: Request) {
return streamImageJobEvents(request);
}
+6
View File
@@ -30,3 +30,9 @@ textarea,
select {
letter-spacing: 0;
}
@keyframes canvas-connection-flow {
to {
stroke-dashoffset: -26;
}
}
+35 -5
View File
@@ -1,7 +1,12 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { CanvasConnection, CanvasNode, CanvasPosition } from "@/lib/canvas/types";
import type {
CanvasConnection,
CanvasImageNodeMetadata,
CanvasNode,
CanvasPosition,
} from "@/lib/canvas/types";
import { getActiveConnectionPath, getConnectionPath } from "@/lib/canvas/geometry";
type CanvasConnectionsProps = {
@@ -41,6 +46,7 @@ export function CanvasConnections({
<ConnectionPath
key={connection.id}
active={connection.id === selectedConnectionId}
animated={isConnectionAnimating(to)}
connection={connection}
from={from}
to={to}
@@ -72,16 +78,18 @@ function ConnectionPath({
from,
to,
active,
animated,
onSelect,
}: {
connection: CanvasConnection;
from: CanvasNode;
to: CanvasNode;
active: boolean;
animated: boolean;
onSelect: () => void;
}) {
const path = getConnectionPath(from, to);
const stroke = active ? "#18181b" : "#a1a1aa";
const stroke = active || animated ? "#18181b" : "#a1a1aa";
function handleContextMenu(event: ReactMouseEvent<SVGPathElement>) {
event.preventDefault();
@@ -109,13 +117,35 @@ function ConnectionPath({
fill="none"
stroke={stroke}
strokeLinecap="round"
strokeOpacity={active ? 1 : 0.72}
strokeWidth={active ? 3 : 2}
strokeOpacity={active || animated ? 1 : 0.72}
strokeWidth={active || animated ? 3 : 2}
style={{
filter: active ? "drop-shadow(0 0 8px rgba(17,24,39,.25))" : undefined,
filter:
active || animated
? "drop-shadow(0 0 8px rgba(17,24,39,.25))"
: undefined,
pointerEvents: "none",
}}
/>
{animated ? (
<path
d={path}
fill="none"
stroke="#2e77ff"
strokeDasharray="12 14"
strokeLinecap="round"
strokeWidth="3"
style={{
animation: "canvas-connection-flow 900ms linear infinite",
pointerEvents: "none",
}}
/>
) : null}
</g>
);
}
function isConnectionAnimating(to: CanvasNode) {
if (to.type !== "image") return false;
return (to.metadata as CanvasImageNodeMetadata).status === "loading";
}
File diff suppressed because it is too large Load Diff
+203 -86
View File
@@ -8,7 +8,7 @@ import {
type FormEvent,
type KeyboardEvent,
} from "react";
import { ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react";
import { FileText, ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -20,7 +20,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { splitPromptSegments } from "@/components/canvas/prompt-mention-preview";
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
import { cn } from "@/lib/utils";
@@ -45,7 +44,6 @@ type CanvasNodeInspectorProps = {
history: HistoryItem[];
isHistoryLoading: boolean;
job: ImageJobPayload | null;
error: string | null;
showLibrary: boolean;
promptMentionSources: PromptMentionSource[];
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
@@ -62,7 +60,6 @@ export function CanvasNodeInspector({
history,
isHistoryLoading,
job,
error,
showLibrary,
promptMentionSources,
onPatchNode,
@@ -80,7 +77,7 @@ export function CanvasNodeInspector({
.includes(historyFilter.toLowerCase()),
);
if (!node && !showLibrary && !job?.progressMessage && !error) {
if (!node && !showLibrary && !job?.progressMessage) {
return null;
}
@@ -89,23 +86,6 @@ export function CanvasNodeInspector({
className="pointer-events-none absolute inset-0 z-[85]"
data-canvas-ui
>
<div className="absolute bottom-24 left-5 flex max-h-[calc(100vh-170px)] w-[392px] flex-col gap-3">
{job?.progressMessage || error ? (
<FloatingCard className="pointer-events-auto">
{job?.progressMessage ? (
<div className="rounded-2xl bg-zinc-100 px-4 py-3 text-sm text-zinc-700">
{job.progressMessage}
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
</FloatingCard>
) : null}
</div>
<div className="absolute right-5 top-24 flex w-[380px] max-h-[calc(100vh-150px)] flex-col gap-3">
{node ? (
<FloatingCard className="pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200">
@@ -277,10 +257,15 @@ function SelectedNodeForm({
/>
) : null}
{node.type === "config" ? (
<ConfigFields node={node} onPatchNode={onPatchNode} />
<ConfigFields
node={node}
onGenerateNode={onGenerateNode}
onPatchNode={onPatchNode}
/>
) : null}
{node.type === "image" ? (
<ImageFields
mentionSources={promptMentionSources}
node={node}
onPatchNode={onPatchNode}
onReplaceNodeImage={onReplaceNodeImage}
@@ -301,8 +286,40 @@ function PromptFields({
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
}) {
const metadata = node.metadata as CanvasPromptNodeMetadata;
function patchPrompt(nextPrompt: string) {
onPatchNode(node.id, {
metadata: { ...metadata, prompt: nextPrompt },
});
}
return (
<MentionPromptEditor
emptyState="当前提示词节点还没有上游图片素材或文本,连接节点后即可使用 `@` 引用。"
mentionHelp="只允许引用已连接到当前提示词节点的图片素材或文本。"
mentionSources={mentionSources}
placeholder={mentionSources.length ? "输入提示词,键入 @ 引用已连接素材" : "输入提示词"}
value={metadata.prompt || ""}
onChange={patchPrompt}
/>
);
}
function MentionPromptEditor({
emptyState,
mentionHelp,
mentionSources,
placeholder,
value,
onChange,
}: {
emptyState: string;
mentionHelp: string;
mentionSources: PromptMentionSource[];
placeholder: string;
value: string;
onChange: (value: string) => void;
}) {
const editorRef = useRef<HTMLDivElement | null>(null);
const lastPromptRef = useRef(metadata.prompt || "");
const [mentionState, setMentionState] = useState<{
query: string;
} | null>(null);
@@ -316,66 +333,46 @@ function PromptFields({
: mentionSources;
useEffect(() => {
const currentPrompt = metadata.prompt || "";
if (lastPromptRef.current === currentPrompt) return;
lastPromptRef.current = currentPrompt;
renderPromptEditor(editorRef.current, currentPrompt, mentionSources);
}, [mentionSources, metadata.prompt]);
renderPromptEditor(editorRef.current, value, mentionSources);
}, [mentionSources, value]);
function updateMentionState(value: string) {
const atIndex = value.lastIndexOf("@");
if (atIndex === -1) {
function updateMentionState(element: HTMLDivElement) {
const activeQuery = getActiveMentionQuery(element);
if (!activeQuery) {
setMentionState(null);
return;
}
if (atIndex > 0 && /\S/.test(value[atIndex - 1] || "")) {
setMentionState(null);
return;
}
const query = value.slice(atIndex + 1);
if (query.includes(" ") || query.includes("\n")) {
setMentionState(null);
return;
}
setMentionState({
query,
});
}
function patchPrompt(nextPrompt: string) {
lastPromptRef.current = nextPrompt;
onPatchNode(node.id, {
metadata: { ...metadata, prompt: nextPrompt },
});
setMentionState({ query: activeQuery.query });
}
function handleEditorInput(event: FormEvent<HTMLDivElement>) {
normalizePromptEditorAliases(event.currentTarget, mentionSources);
const nextPrompt = serializePromptEditor(event.currentTarget);
event.currentTarget.dataset.renderedPrompt = nextPrompt;
patchPrompt(nextPrompt);
updateMentionState(nextPrompt);
onChange(nextPrompt);
updateMentionState(event.currentTarget);
}
function insertMention(source: PromptMentionSource) {
const element = editorRef.current;
if (!element) return;
replaceActiveMentionQuery(element, mentionState?.query ?? "", source);
replaceActiveMentionQuery(element, source);
const nextPrompt = serializePromptEditor(element);
element.dataset.renderedPrompt = nextPrompt;
patchPrompt(nextPrompt);
onChange(nextPrompt);
setMentionState(null);
}
return (
<div className="space-y-3">
<Field label="提示词">
<Field label="">
<div className="space-y-2">
<div className="relative">
<div
ref={(element) => {
editorRef.current = element;
renderPromptEditor(element, metadata.prompt || "", mentionSources);
renderPromptEditor(element, value, mentionSources);
}}
className="thin-scrollbar min-h-36 w-full overflow-auto whitespace-pre-wrap break-words rounded-2xl border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 text-zinc-950 shadow-xs outline-none transition-colors focus:border-zinc-400"
contentEditable
@@ -384,7 +381,7 @@ function PromptFields({
tabIndex={0}
onInput={handleEditorInput}
onKeyDown={(event) => handleEditorKeyDown(event, handleEditorInput)}
onKeyUp={(event) => updateMentionState(serializePromptEditor(event.currentTarget))}
onKeyUp={(event) => updateMentionState(event.currentTarget)}
onPaste={pastePlainText}
onFocus={() => {
if (!editorRef.current?.textContent?.trim()) {
@@ -395,18 +392,16 @@ function PromptFields({
window.setTimeout(() => setMentionState(null), 120);
}}
/>
{!(metadata.prompt || "").trim() ? (
{!value.trim() ? (
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-6 text-zinc-400">
{mentionSources.length
? "输入提示词,键入 @ 引用已连接素材"
: "输入提示词"}
{placeholder}
</div>
) : null}
</div>
{mentionState && filteredMentionSources.length > 0 ? (
<div className="rounded-2xl border border-zinc-200 bg-white p-2 shadow-sm">
<div className="mb-2 px-2 text-[11px] uppercase tracking-[0.18em] text-zinc-400">
/
</div>
<div className="grid gap-1">
{filteredMentionSources.map((source) => (
@@ -426,6 +421,8 @@ function PromptFields({
className="h-full w-full object-cover"
src={source.imageUrl}
/>
) : source.kind === "prompt" ? (
<FileText className="size-4 text-zinc-500" />
) : (
<ImageIcon className="size-4 text-zinc-400" />
)}
@@ -450,7 +447,7 @@ function PromptFields({
</Field>
{mentionSources.length ? (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs font-medium text-zinc-800"></div>
<div className="text-xs font-medium text-zinc-800">/</div>
<div className="mt-2 flex flex-wrap gap-2">
{mentionSources.map((source) => (
<button
@@ -466,6 +463,8 @@ function PromptFields({
className="h-full w-full object-cover"
src={source.imageUrl}
/>
) : source.kind === "prompt" ? (
<FileText className="size-3 text-zinc-500" />
) : (
<ImageIcon className="size-3 text-zinc-400" />
)}
@@ -475,12 +474,12 @@ function PromptFields({
))}
</div>
<div className="mt-2 text-xs leading-5 text-zinc-500">
{mentionHelp}
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50 p-3 text-xs leading-5 text-zinc-500">
使 `[图1](canvas-image://...)`
{emptyState}
</div>
)}
</div>
@@ -528,6 +527,9 @@ function createMentionCard(source: PromptMentionSource) {
image.className = "h-full w-full object-cover";
image.src = source.imageUrl;
imageWrap.append(image);
} else if (source.kind === "prompt") {
imageWrap.textContent = "文";
imageWrap.className += " text-[10px] font-semibold text-zinc-600";
}
const copy = document.createElement("span");
@@ -543,20 +545,24 @@ function createMentionCard(source: PromptMentionSource) {
}
function serializePromptEditor(element: HTMLDivElement) {
return serializePromptNode(element);
}
function serializePromptNode(node: Node): string {
let result = "";
for (const node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
result += node.textContent || "";
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
result += child.textContent || "";
continue;
}
if (node instanceof HTMLElement) {
if (node.dataset.mentionToken) {
result += node.dataset.mentionToken;
if (child instanceof HTMLElement) {
if (child.dataset.mentionToken) {
result += child.dataset.mentionToken;
continue;
}
result += node.innerText || node.textContent || "";
result += serializePromptNode(child);
}
}
@@ -565,32 +571,124 @@ function serializePromptEditor(element: HTMLDivElement) {
function replaceActiveMentionQuery(
element: HTMLDivElement,
query: string,
source: PromptMentionSource,
) {
element.focus();
const activeQuery = getActiveMentionQuery(element);
const selection = window.getSelection();
const textNode = selection?.anchorNode;
if (!selection || !textNode || textNode.nodeType !== Node.TEXT_NODE) {
const textNode = activeQuery?.textNode;
if (!selection || !activeQuery || !textNode) {
element.append(createMentionCard(source), document.createTextNode(" "));
placeCaretAtEnd(element);
return;
}
const text = textNode.textContent || "";
const cursor = selection.anchorOffset;
const replaceStart = Math.max(0, cursor - query.length - 1);
const before = text.slice(0, replaceStart);
const after = text.slice(cursor);
const before = text.slice(0, activeQuery.start);
const after = text.slice(activeQuery.end);
const parent = textNode.parentNode;
if (!parent) return;
const fragment = document.createDocumentFragment();
if (before) fragment.append(document.createTextNode(before));
fragment.append(createMentionCard(source), document.createTextNode(" "));
const card = createMentionCard(source);
const spacer = document.createTextNode(" ");
fragment.append(card, spacer);
if (after) fragment.append(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
placeCaretAtEnd(element);
placeCaretAfter(spacer);
}
function getActiveMentionQuery(element: HTMLDivElement) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
return null;
}
if (!element.contains(selection.anchorNode)) return null;
let textNode =
selection.anchorNode?.nodeType === Node.TEXT_NODE
? selection.anchorNode
: null;
let offset = selection.anchorOffset;
if (!textNode && selection.anchorNode instanceof HTMLElement) {
const child = selection.anchorNode.childNodes.item(
Math.max(0, selection.anchorOffset - 1),
);
if (child?.nodeType === Node.TEXT_NODE) {
textNode = child;
offset = child.textContent?.length ?? 0;
}
}
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return null;
const text = textNode.textContent || "";
const beforeCursor = text.slice(0, offset);
const match = /(^|\s)@([^\s@]*)$/.exec(beforeCursor);
if (!match) return null;
return {
textNode,
query: match[2],
start: beforeCursor.length - match[2].length - 1,
end: offset,
};
}
function normalizePromptEditorAliases(
element: HTMLDivElement,
mentionSources: PromptMentionSource[],
) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
let current = walker.nextNode();
while (current) {
if (!current.parentElement?.closest("[data-mention-token]")) {
textNodes.push(current as Text);
}
current = walker.nextNode();
}
for (const textNode of textNodes) {
replaceMentionAliasesInTextNode(textNode, mentionSources);
}
}
function replaceMentionAliasesInTextNode(
textNode: Text,
mentionSources: PromptMentionSource[],
) {
const text = textNode.textContent || "";
const sortedSources = [...mentionSources].sort(
(left, right) => right.alias.length - left.alias.length,
);
let cursor = 0;
let changed = false;
const fragment = document.createDocumentFragment();
while (cursor < text.length) {
const matchedSource = sortedSources.find((source) =>
text.startsWith(source.alias, cursor),
);
if (!matchedSource) {
const nextAliasIndex = sortedSources.reduce((nextIndex, source) => {
const index = text.indexOf(source.alias, cursor + 1);
return index === -1 ? nextIndex : Math.min(nextIndex, index);
}, text.length);
fragment.append(document.createTextNode(text.slice(cursor, nextAliasIndex)));
cursor = nextAliasIndex;
continue;
}
fragment.append(createMentionCard(matchedSource));
cursor += matchedSource.alias.length;
changed = true;
}
if (!changed) return;
textNode.replaceWith(fragment);
}
function handleEditorKeyDown(
@@ -632,12 +730,22 @@ function placeCaretAtEnd(element: HTMLElement | null) {
selection?.addRange(range);
}
function placeCaretAfter(node: Node) {
const range = document.createRange();
range.setStartAfter(node);
range.setEndAfter(node);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function ConfigFields({
node,
onGenerateNode,
onPatchNode,
}: {
node: CanvasNode;
onGenerateNode: (id: string) => void;
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
}) {
const metadata = node.metadata as CanvasConfigNodeMetadata;
@@ -711,6 +819,7 @@ function ConfigFields({
<button
type="button"
className="flex h-12 w-full items-center justify-center rounded-2xl bg-zinc-950 text-sm font-medium text-white transition hover:bg-zinc-800"
onClick={() => onGenerateNode(node.id)}
>
</button>
@@ -719,10 +828,12 @@ function ConfigFields({
}
function ImageFields({
mentionSources,
node,
onPatchNode,
onReplaceNodeImage,
}: {
mentionSources: PromptMentionSource[];
node: CanvasNode;
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
onReplaceNodeImage: (nodeId: string, file: File) => void;
@@ -734,11 +845,17 @@ function ImageFields({
return (
<>
<Field label="提示词">
<Textarea
className="min-h-28 rounded-2xl border-zinc-200 bg-white font-mono"
placeholder="描述当前图像节点想要生成或编辑的内容"
<MentionPromptEditor
emptyState="当前图像节点还没有上游图片素材或文本,连接节点后即可使用 `@` 引用。"
mentionHelp="只允许引用已连接到当前图像节点的图片素材或文本。"
mentionSources={mentionSources}
placeholder={
mentionSources.length
? "描述生成或编辑内容,键入 @ 引用已连接节点"
: "描述当前图像节点想要生成或编辑的内容"
}
value={metadata.prompt || ""}
onChange={(event) => patch({ prompt: event.target.value })}
onChange={(prompt) => patch({ prompt })}
/>
</Field>
<Field label="图片文件">
+179 -38
View File
@@ -19,6 +19,7 @@ import {
Maximize2,
Minimize2,
Minus,
Pencil,
Plus,
Sparkles,
Trash2,
@@ -27,6 +28,7 @@ import {
} from "lucide-react";
import { PromptMentionPreview } from "@/components/canvas/prompt-mention-preview";
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
import { getConfigNodeSize } from "@/lib/canvas/node-factory";
import type {
CanvasConfigNodeMetadata,
CanvasImageNodeMetadata,
@@ -43,10 +45,11 @@ type CanvasNodeProps = {
selected: boolean;
related: boolean;
imageReferenceLabel?: string;
promptReferenceLabel?: string;
activeConnecting: boolean;
connectionTarget: boolean;
promptMentionSources?: PromptMentionSource[];
onSelect: (id: string) => void;
onSelect: (id: string, additive?: boolean) => void;
onDragStart: (event: ReactMouseEvent, node: CanvasNodeType) => void;
onConnectStart: (id: string) => void;
onConnectEnd: (id: string) => void;
@@ -62,6 +65,7 @@ export function CanvasNode({
selected,
related,
imageReferenceLabel,
promptReferenceLabel,
activeConnecting,
connectionTarget,
promptMentionSources = [],
@@ -80,7 +84,27 @@ export function CanvasNode({
} | null>(null);
const imageMetadata =
node.type === "image" ? (node.metadata as CanvasImageNodeMetadata) : null;
const configDimensions = useMemo(
() =>
node.type === "config"
? getConfigNodeSize(node.title, node.metadata as CanvasConfigNodeMetadata)
: null,
[node.metadata, node.title, node.type],
);
const canShowImageToolbar = node.type === "image" && Boolean(imageMetadata?.imageUrl);
const canShowPromptToolbar = node.type === "prompt";
const canShowConfigToolbar = node.type === "config";
useEffect(() => {
if (!configDimensions) return;
if (
node.width === configDimensions.width &&
node.height === configDimensions.height
) {
return;
}
onResize(node.id, configDimensions.width, configDimensions.height);
}, [configDimensions, node.height, node.id, node.width, onResize]);
return (
<>
@@ -95,13 +119,13 @@ export function CanvasNode({
)}
style={{
transform: `translate(${node.position.x}px, ${node.position.y}px)`,
width: node.width,
height: node.height,
width: configDimensions?.width ?? node.width,
height: configDimensions?.height ?? node.height,
}}
onMouseDown={(event) => onDragStart(event, node)}
onClick={(event) => {
event.stopPropagation();
onSelect(node.id);
onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey);
if (connectionTarget) onConnectEnd(node.id);
}}
>
@@ -112,12 +136,14 @@ export function CanvasNode({
onDelete={onDelete}
onGenerate={onGenerate}
onSelect={onSelect}
promptReferenceLabel={promptReferenceLabel}
/>
) : null}
<NodeBody
imageReferenceLabel={imageReferenceLabel}
node={node}
onOpenImagePreview={(image) => setPreviewImage(image)}
onGenerate={onGenerate}
onSelect={onSelect}
promptMentionSources={promptMentionSources}
/>
@@ -135,6 +161,23 @@ export function CanvasNode({
onSelect={onSelect}
/>
) : null}
{canShowPromptToolbar ? (
<PromptNodeToolbar
node={node}
visible={selected}
onDelete={onDelete}
onGenerate={onGenerate}
onSelect={onSelect}
/>
) : null}
{canShowConfigToolbar ? (
<ConfigNodeToolbar
node={node}
visible={selected}
onDelete={onDelete}
onSelect={onSelect}
/>
) : null}
<ConnectionHandle
side="left"
visible={activeConnecting || selected}
@@ -176,11 +219,13 @@ function NodeHeader({
onDelete,
onGenerate,
onSelect,
promptReferenceLabel,
}: {
node: CanvasNodeType;
onDelete: (id: string) => void;
onGenerate: (id: string) => void;
onSelect: (id: string) => void;
promptReferenceLabel?: string;
}) {
return (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
@@ -193,33 +238,40 @@ function NodeHeader({
</div>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 hover:bg-zinc-100 hover:text-zinc-950"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onSelect(node.id);
onGenerate(node.id);
}}
title="从这个节点生成"
>
<Sparkles className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 hover:bg-red-50 hover:text-red-600"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onDelete(node.id);
}}
title="删除节点"
>
<Trash2 className="size-4" />
</button>
</div>
{node.type === "prompt" && promptReferenceLabel ? (
<div className="shrink-0 rounded-full bg-[#ff4d4f] px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
{promptReferenceLabel}
</div>
) : null}
{node.type !== "prompt" && node.type !== "config" ? (
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 hover:bg-zinc-100 hover:text-zinc-950"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onSelect(node.id);
onGenerate(node.id);
}}
title="从这个节点生成"
>
<Sparkles className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 hover:bg-red-50 hover:text-red-600"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onDelete(node.id);
}}
title="删除节点"
>
<Trash2 className="size-4" />
</button>
</div>
) : null}
</div>
);
}
@@ -228,12 +280,14 @@ function NodeBody({
imageReferenceLabel,
node,
onOpenImagePreview,
onGenerate,
onSelect,
promptMentionSources,
}: {
imageReferenceLabel?: string;
node: CanvasNodeType;
onOpenImagePreview: (image: { title: string; url: string }) => void;
onGenerate: (id: string) => void;
onSelect: (id: string) => void;
promptMentionSources: PromptMentionSource[];
}) {
@@ -256,11 +310,13 @@ function NodeBody({
if (node.type === "config") {
const metadata = node.metadata as CanvasConfigNodeMetadata;
return (
<div className="grid min-h-0 flex-1 content-center gap-2 px-4 py-3 text-xs text-zinc-600">
<ConfigLine label="Model" value={metadata.model} />
<ConfigLine label="Size" value={metadata.size} />
<ConfigLine label="Quality" value={metadata.quality} />
<ConfigLine label="Format" value={metadata.outputFormat} />
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto px-5 py-5 text-xs text-zinc-600">
<div className="grid gap-3">
<ConfigLine label="模型" value={metadata.model} />
<ConfigLine label="尺寸" value={metadata.size} />
<ConfigLine label="质量" value={metadata.quality} />
<ConfigLine label="格式" value={metadata.outputFormat} />
</div>
</div>
);
}
@@ -321,7 +377,7 @@ function ImageNodeBody({
</div>
) : null}
<div
className="h-full cursor-zoom-in overflow-hidden"
className="h-full cursor-grab overflow-hidden"
onClick={(event) => {
event.stopPropagation();
onSelect(node.id);
@@ -613,6 +669,91 @@ function ImageNodeToolbar({
);
}
function PromptNodeToolbar({
node,
visible,
onDelete,
onGenerate,
onSelect,
}: {
node: CanvasNodeType;
visible: boolean;
onDelete: (id: string) => void;
onGenerate: (id: string) => void;
onSelect: (id: string) => void;
}) {
return (
<div
className={cn(
"absolute left-1/2 top-0 z-[70] flex -translate-x-1/2 -translate-y-[calc(100%+12px)] items-center gap-1 rounded-full border border-zinc-200 bg-white/95 px-3 py-2 shadow-[0_14px_38px_rgba(24,24,27,.16)] backdrop-blur-xl transition-opacity",
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
data-canvas-ui
onMouseDown={(event) => event.stopPropagation()}
>
<ImageToolbarButton
icon={<Info className="size-4" />}
label="信息"
onClick={() => onSelect(node.id)}
/>
<ImageToolbarButton
danger
icon={<Trash2 className="size-4" />}
label="删除"
onClick={() => onDelete(node.id)}
/>
<ImageToolbarButton
icon={<Pencil className="size-4" />}
label="编辑"
onClick={() => onSelect(node.id)}
/>
<ImageToolbarButton
icon={<Sparkles className="size-4" />}
label="生图"
onClick={() => {
onSelect(node.id);
onGenerate(node.id);
}}
/>
</div>
);
}
function ConfigNodeToolbar({
node,
visible,
onDelete,
onSelect,
}: {
node: CanvasNodeType;
visible: boolean;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
}) {
return (
<div
className={cn(
"absolute left-1/2 top-0 z-[70] flex -translate-x-1/2 -translate-y-[calc(100%+12px)] items-center gap-1 rounded-full border border-zinc-200 bg-white/95 px-3 py-2 shadow-[0_14px_38px_rgba(24,24,27,.16)] backdrop-blur-xl transition-opacity",
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
data-canvas-ui
onMouseDown={(event) => event.stopPropagation()}
>
<ImageToolbarButton
icon={<Info className="size-4" />}
label="信息"
onClick={() => onSelect(node.id)}
/>
<ImageToolbarButton
danger
icon={<Trash2 className="size-4" />}
label="删除"
onClick={() => onDelete(node.id)}
/>
</div>
);
}
function ImageToolbarButton({
danger = false,
icon,
@@ -661,7 +802,7 @@ function getImageDownloadName(node: CanvasNodeType) {
function ConfigLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg bg-zinc-100 px-3 py-2">
<div className="flex min-h-10 items-center justify-between gap-4 rounded-xl bg-zinc-100 px-4 py-2.5">
<span className="text-zinc-500">{label}</span>
<span className="truncate font-medium text-zinc-800">{value}</span>
</div>
@@ -3,15 +3,25 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, RefreshCcw } from "lucide-react";
import { Pencil, Plus, RefreshCcw, Trash2, X } from "lucide-react";
import { toast, Toaster } from "sonner";
import { Button } from "@/components/ui/button";
import type { CanvasProjectListItem } from "@/lib/canvas/types";
type EditingProjectState = {
id: string;
title: string;
description: string;
} | null;
export function CanvasProjectsClient() {
const router = useRouter();
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [editingProject, setEditingProject] = useState<EditingProjectState>(null);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
void loadProjects();
@@ -23,6 +33,8 @@ export function CanvasProjectsClient() {
const response = await fetch("/api/canvas/projects", { cache: "no-store" });
const payload = await response.json();
setProjects(Array.isArray(payload.items) ? payload.items : []);
} catch (err) {
toast.error(err instanceof Error ? err.message : "加载画布失败");
} finally {
setIsLoading(false);
}
@@ -41,13 +53,82 @@ export function CanvasProjectsClient() {
if (id) {
router.push(`/canvas/${id}`);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "新建画布失败");
} finally {
setIsCreating(false);
}
}
async function saveProjectEdit() {
if (!editingProject || isSavingEdit) return;
const title = editingProject.title.trim();
if (!title) {
toast.error("标题不能为空");
return;
}
setIsSavingEdit(true);
try {
const response = await fetch(`/api/canvas/projects/${editingProject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description: editingProject.description,
}),
});
const payload = await response.json();
if (!response.ok || !payload.item) {
throw new Error(payload.error || "保存画布信息失败");
}
const updatedProject = payload.item as CanvasProjectListItem;
setProjects((current) =>
current.map((project) =>
project.id === updatedProject.id
? {
...project,
title: updatedProject.title,
description: updatedProject.description,
updatedAt: updatedProject.updatedAt,
}
: project,
),
);
setEditingProject(null);
toast.success("画布信息已更新");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存画布信息失败");
} finally {
setIsSavingEdit(false);
}
}
async function deleteProject(project: CanvasProjectListItem) {
const confirmed = window.confirm(`删除画布「${project.title}」?此操作不可撤销。`);
if (!confirmed) return;
setDeletingProjectId(project.id);
try {
const response = await fetch(`/api/canvas/projects/${project.id}`, {
method: "DELETE",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "删除画布失败");
}
setProjects((current) => current.filter((item) => item.id !== project.id));
toast.success("画布已删除");
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除画布失败");
} finally {
setDeletingProjectId(null);
}
}
return (
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
<Toaster richColors position="top-center" />
<div className="mx-auto max-w-6xl space-y-6">
<header className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white p-4">
<div>
@@ -73,17 +154,51 @@ export function CanvasProjectsClient() {
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<Link
<div
key={project.id}
className="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
href={`/canvas/${project.id}`}
className="group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
>
<div className="text-sm font-medium">{project.title}</div>
<div className="mt-2 text-xs text-zinc-500">
{project.nodeCount} · {" "}
{new Date(project.updatedAt).toLocaleString()}
<div className="flex items-start justify-between gap-3">
<Link
className="min-w-0 flex-1"
href={`/canvas/${project.id}`}
>
<div className="truncate text-sm font-medium">{project.title}</div>
<div className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-zinc-500">
{project.description || "暂无说明"}
</div>
<div className="mt-3 text-xs text-zinc-500">
{project.nodeCount} · {" "}
{new Date(project.updatedAt).toLocaleString()}
</div>
</Link>
<div className="flex shrink-0 gap-1 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={() =>
setEditingProject({
id: project.id,
title: project.title,
description: project.description,
})
}
title="编辑画布信息"
>
<Pencil className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-red-50 hover:text-red-600 disabled:pointer-events-none disabled:opacity-40"
disabled={deletingProjectId === project.id}
onClick={() => void deleteProject(project)}
title="删除画布"
>
<Trash2 className="size-4" />
</button>
</div>
</div>
</Link>
</div>
))}
{!projects.length ? (
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-6 text-sm text-zinc-500">
@@ -93,6 +208,88 @@ export function CanvasProjectsClient() {
</div>
)}
</div>
<ProjectEditDialog
project={editingProject}
isSaving={isSavingEdit}
onChange={setEditingProject}
onClose={() => {
if (!isSavingEdit) setEditingProject(null);
}}
onSave={() => void saveProjectEdit()}
/>
</div>
);
}
function ProjectEditDialog({
project,
isSaving,
onChange,
onClose,
onSave,
}: {
project: EditingProjectState;
isSaving: boolean;
onChange: (project: EditingProjectState) => void;
onClose: () => void;
onSave: () => void;
}) {
if (!project) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/24 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="w-full max-w-lg rounded-xl bg-white p-5 shadow-[0_24px_80px_rgba(24,24,27,.22)] ring-1 ring-zinc-200">
<div className="flex items-center justify-between gap-4">
<h2 className="text-base font-semibold text-zinc-950"></h2>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
disabled={isSaving}
onClick={onClose}
title="关闭"
>
<X className="size-4" />
</button>
</div>
<label className="mt-5 block text-sm font-medium text-zinc-700">
<input
className="mt-2 w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-zinc-400"
disabled={isSaving}
maxLength={80}
value={project.title}
onChange={(event) =>
onChange({ ...project, title: event.target.value })
}
/>
</label>
<label className="mt-4 block text-sm font-medium text-zinc-700">
<textarea
className="mt-2 h-28 w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 outline-none transition focus:border-zinc-400"
disabled={isSaving}
maxLength={300}
placeholder="给这个画布补充用途、阶段或备注"
value={project.description}
onChange={(event) =>
onChange({ ...project, description: event.target.value })
}
/>
</label>
<div className="mt-5 flex justify-end gap-2">
<Button variant="outline" disabled={isSaving} onClick={onClose}>
</Button>
<Button disabled={isSaving || !project.title.trim()} onClick={onSave}>
{isSaving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
);
}
+11
View File
@@ -8,6 +8,7 @@ import {
Grid2X2,
Hand,
ImageIcon,
Keyboard,
Loader2,
Menu,
Minus,
@@ -51,6 +52,7 @@ type CanvasToolbarProps = {
onResetView: () => void;
onToggleLibrary: () => void;
onToggleSelectionMode: () => void;
onShowShortcuts: () => void;
};
export function CanvasToolbar({
@@ -80,6 +82,7 @@ export function CanvasToolbar({
onResetView,
onToggleLibrary,
onToggleSelectionMode,
onShowShortcuts,
}: CanvasToolbarProps) {
return (
<>
@@ -106,6 +109,14 @@ export function CanvasToolbar({
<div className="pointer-events-auto flex items-center gap-2 rounded-2xl bg-white/92 px-3 py-2.5 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<StatusPill isGenerating={isGenerating} isSaving={isSaving} />
<button
type="button"
className="flex size-9 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onShowShortcuts}
title="快捷键"
>
<Keyboard className="size-4" />
</button>
<button
type="button"
className={`flex size-9 items-center justify-center rounded-full transition ${
@@ -66,6 +66,8 @@ export function PromptMentionPreview({
className="h-full w-full object-cover"
src={segment.source.imageUrl}
/>
) : segment.source.kind === "prompt" ? (
<span className="text-[10px] font-semibold text-zinc-600"></span>
) : null}
</span>
<span className="min-w-0">
+110 -35
View File
@@ -39,16 +39,15 @@ import {
computeSizeFromRatio,
createClientRequestId,
extensionFromFormat,
formatJobSuccessDescription,
getComputedSize,
normalizeGenerationError,
normalizeImageJobPayload,
parseJsonResponse,
watchImageJob,
type GenerationError,
type HistoryItem,
type ImageJobPayload,
type ImageMode,
type ImageJobStatus,
type Resolution,
resolutionOptions,
sizeOptions,
@@ -78,6 +77,13 @@ const promptPresets = [
const defaultPrompt =
"温馨治愈的幼儿园毕业纪实写真,画面干净明亮通透,柔和室内自然光,人物边缘清晰,色彩低饱和且高级,真实摄影质感,无文字水印。";
const activeDirectJobStorageKey = "imagegen:direct:active-job";
type StoredDirectJob = {
jobId: string;
clientRequestId: string;
requestStartedAt: number;
};
export function DirectStudio({ activeView }: { activeView: ActiveView }) {
const [mode, setMode] = useState<ImageMode>("generate");
@@ -100,6 +106,7 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
const [previewImage, setPreviewImage] = useState<PreviewImage | null>(null);
const [generationError, setGenerationError] = useState<GenerationError | null>(null);
const [currentJob, setCurrentJob] = useState<ImageJobPayload | null>(null);
const jobWatcherCleanupRef = useRef<(() => void) | null>(null);
const filePreviewUrl = useObjectUrl(file);
const maskPreviewUrl = useObjectUrl(maskFile);
@@ -130,6 +137,53 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
useEffect(() => {
void loadHistory();
void loadSettings();
const storedJob = readStoredDirectJob();
if (storedJob) {
queueMicrotask(() => {
setIsGenerating(true);
setGenerationError(null);
void watchDirectImageJob(
storedJob.jobId,
storedJob.requestStartedAt,
storedJob.clientRequestId,
)
.then(async (completedJob) => {
if (completedJob.status === "failed") {
throw buildGenerationErrorFromJob(
completedJob,
storedJob.requestStartedAt,
);
}
await loadHistory();
})
.catch((error) => {
const generationFailure = normalizeGenerationError(
error,
storedJob.requestStartedAt,
storedJob.clientRequestId,
);
setGenerationError(generationFailure);
toast.error(generationFailure.title, {
description: generationFailure.message,
});
})
.finally(() => {
setIsGenerating(false);
clearStoredDirectJob();
setCurrentJob((job) =>
job?.status === "succeeded" || job?.status === "failed"
? job
: null,
);
});
});
}
return () => {
jobWatcherCleanupRef.current?.();
jobWatcherCleanupRef.current = null;
};
}, []);
async function loadHistory() {
@@ -240,16 +294,18 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
toast.success("图片任务已提交", {
description: jobId ? `任务 ID${jobId}` : undefined,
});
writeStoredDirectJob({ jobId, clientRequestId, requestStartedAt });
const completedJob = await pollImageJob(jobId, requestStartedAt);
const completedJob = await watchDirectImageJob(
jobId,
requestStartedAt,
clientRequestId,
);
if (completedJob.status === "failed") {
throw buildGenerationErrorFromJob(completedJob, requestStartedAt);
}
await loadHistory();
toast.success("图片已生成", {
description: formatJobSuccessDescription(completedJob),
});
} catch (error) {
const generationFailure = normalizeGenerationError(
error,
@@ -262,40 +318,36 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
});
} finally {
setIsGenerating(false);
clearStoredDirectJob();
setCurrentJob((job) =>
job?.status === "succeeded" || job?.status === "failed" ? job : null,
);
}
}
async function pollImageJob(jobId: string, requestStartedAt: number) {
const maxPolls = 240;
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
await wait(attempt < 2 ? 1200 : 3000);
const response = await fetch(
`/api/images?jobId=${encodeURIComponent(jobId)}`,
{ cache: "no-store" },
);
const payload = await parseJsonResponse(response);
const job = normalizeImageJobPayload(payload);
setCurrentJob(job);
function watchDirectImageJob(
jobId: string,
requestStartedAt: number,
clientRequestId: string,
) {
jobWatcherCleanupRef.current?.();
if (!response.ok && job.status !== "failed") {
throw buildGenerationError(response, payload, requestStartedAt);
}
if (job.status === "succeeded" || job.status === "failed") {
return job;
}
}
throw {
title: "生成超时",
message: "轮询已超过 12 分钟,任务可能仍在后台运行,请稍后查看历史记录。",
requestId: currentJob?.requestId,
phase: currentJob?.phase,
durationMs: Math.round(performance.now() - requestStartedAt),
} satisfies GenerationError;
return new Promise<ImageJobPayload>((resolve, reject) => {
jobWatcherCleanupRef.current = watchImageJob({
jobId,
onJob: setCurrentJob,
onComplete: (job) => {
clearStoredDirectJob();
jobWatcherCleanupRef.current = null;
resolve(job);
},
onError: (error) => {
clearStoredDirectJob();
jobWatcherCleanupRef.current = null;
reject(normalizeGenerationError(error, requestStartedAt, clientRequestId));
},
});
});
}
function selectHistoryItem(item: HistoryItem) {
@@ -1034,6 +1086,29 @@ function getStringField(payload: Record<string, unknown>, key: string) {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
function readStoredDirectJob() {
try {
const raw = window.sessionStorage.getItem(activeDirectJobStorageKey);
if (!raw) return null;
const payload = JSON.parse(raw) as Partial<StoredDirectJob>;
if (!payload.jobId || !payload.clientRequestId) return null;
return {
jobId: payload.jobId,
clientRequestId: payload.clientRequestId,
requestStartedAt:
typeof payload.requestStartedAt === "number"
? payload.requestStartedAt
: performance.now(),
} satisfies StoredDirectJob;
} catch {
return null;
}
}
function writeStoredDirectJob(job: StoredDirectJob) {
window.sessionStorage.setItem(activeDirectJobStorageKey, JSON.stringify(job));
}
function clearStoredDirectJob() {
window.sessionStorage.removeItem(activeDirectJobStorageKey);
}
+57 -10
View File
@@ -7,6 +7,7 @@ import type {
} from "@/lib/canvas/types";
import { defaultCanvasConfig } from "@/lib/canvas/node-factory";
import {
getConnectedPromptSources,
getPromptMentionSources,
resolvePromptMentions,
} from "@/lib/canvas/prompt-mentions";
@@ -32,22 +33,47 @@ export function resolveCanvasGenerationInput(
scopedNodeIds.has(connection.fromNodeId) &&
scopedNodeIds.has(connection.toNodeId),
);
const promptNode =
scopedNodes.find((node) => node.type === "prompt") ??
nodes.find((node) => node.type === "prompt");
const rawPrompt = promptNode
? ((promptNode.metadata as CanvasPromptNodeMetadata).prompt || "").trim()
: "";
const connectedPromptSources = selectedNodeId
? getConnectedPromptSources(scopedNodes, scopedConnections, selectedNodeId)
: [];
const connectedPrompts = connectedPromptSources.filter((source) =>
source.prompt?.trim(),
);
const connectedPromptNode = connectedPrompts[0]
? scopedNodes.find((node) => node.id === connectedPrompts[0].nodeId)
: null;
const selectedNode = selectedNodeId
? scopedNodes.find((node) => node.id === selectedNodeId)
: null;
const selectedImagePrompt =
selectedNode?.type === "image"
? ((selectedNode.metadata as CanvasImageNodeMetadata).prompt || "").trim()
: "";
const promptNode = selectedImagePrompt
? selectedNode
: connectedPromptNode ??
scopedNodes.find((node) => node.type === "prompt") ??
nodes.find((node) => node.type === "prompt");
const rawPrompt =
promptNode?.type === "image"
? ((promptNode.metadata as CanvasImageNodeMetadata).prompt || "").trim()
: promptNode?.type === "prompt"
? ((promptNode.metadata as CanvasPromptNodeMetadata).prompt || "").trim()
: "";
const resolvedMentions = promptNode
? resolvePromptMentions(
rawPrompt,
getPromptMentionSources(scopedNodes, scopedConnections, promptNode.id),
)
: null;
const prompt = resolvedMentions?.prompt ?? "";
const prompt = selectedImagePrompt
? (resolvedMentions?.prompt ?? "")
: connectedPrompts.length
? formatConnectedPrompts(connectedPrompts)
: (resolvedMentions?.prompt ?? "");
if (!promptNode || !prompt) {
return { error: "请先添加并填写提示词节点" };
return { error: "请先添加并填写提示词节点或当前图像节点提示词" };
}
const configNode =
@@ -56,10 +82,10 @@ export function resolveCanvasGenerationInput(
const imageNodes = collectImageNodes(
scopedNodes,
resolvedMentions?.referencedNodeIds ?? [],
);
).filter((node) => node.id !== promptNode.id);
const fallbackImageNodes = imageNodes.length
? imageNodes
: collectImageNodes(nodes, []);
: collectImageNodes(nodes, []).filter((node) => node.id !== promptNode.id);
return {
promptNode,
@@ -70,10 +96,31 @@ export function resolveCanvasGenerationInput(
config: {
...defaultCanvasConfig,
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
...getImageGenerationConfig(promptNode),
},
};
}
function getImageGenerationConfig(node: CanvasNode | undefined) {
if (node?.type !== "image") return {};
const metadata = node.metadata as CanvasImageNodeMetadata;
return {
...(metadata.model ? { model: metadata.model } : {}),
...(metadata.size ? { size: metadata.size } : {}),
...(metadata.quality ? { quality: metadata.quality } : {}),
...(metadata.outputFormat ? { outputFormat: metadata.outputFormat } : {}),
} satisfies Partial<CanvasConfigNodeMetadata>;
}
function formatConnectedPrompts(
promptSources: ReturnType<typeof getConnectedPromptSources>,
) {
return promptSources
.filter((source) => source.prompt?.trim())
.map((source) => `${source.label}${source.prompt?.trim()}`)
.join("\n\n");
}
function collectImageNodes(nodes: CanvasNode[], preferredNodeIds: string[]) {
const preferred = preferredNodeIds
.map((id) => nodes.find((node) => node.id === id))
+81 -2
View File
@@ -41,13 +41,14 @@ export function createCanvasNode(
}
if (type === "config") {
const dimensions = getConfigNodeSize("生成配置", defaultCanvasConfig);
return {
id,
type,
title: "生成配置",
position,
width: DEFAULT_NODE_WIDTH,
height: DEFAULT_NODE_HEIGHT,
width: dimensions.width,
height: dimensions.height,
metadata: { ...defaultCanvasConfig },
};
}
@@ -141,6 +142,84 @@ export function createImageResultNode(
};
}
export function createImageLoadingNode(position: CanvasPosition): CanvasNode {
return {
id: crypto.randomUUID(),
type: "image",
title: "生成结果",
position,
width: DEFAULT_IMAGE_WIDTH,
height: DEFAULT_IMAGE_HEIGHT,
metadata: {
imageUrl: "",
mode: "result",
status: "loading",
},
};
}
export function getConfigNodeSize(
title: string,
metadata: CanvasConfigNodeMetadata,
) {
const labels = ["模型", "尺寸", "质量", "格式"];
const values = [
metadata.model,
metadata.size,
metadata.quality,
metadata.outputFormat,
];
const labelWidth = Math.max(...labels.map(getApproxTextWidth));
const valueWidth = Math.max(...values.map(getApproxTextWidth));
const titleWidth = getApproxTextWidth(title);
const horizontalPadding = 48;
const rowPadding = 32;
const rowGap = 16;
const iconAndTitleGap = 28;
const width = clamp(
Math.ceil(
Math.max(
titleWidth + iconAndTitleGap + horizontalPadding,
labelWidth + valueWidth + rowPadding + rowGap + horizontalPadding,
),
),
340,
560,
);
const headerHeight = 58;
const bodyPadding = 40;
const rowHeight = 40;
const rowCount = labels.length;
const rowGaps = 12 * (rowCount - 1);
const generateButtonHeight = 40;
const generateButtonMargin = 8;
const height =
headerHeight +
bodyPadding +
rowHeight * rowCount +
rowGaps +
generateButtonMargin +
generateButtonHeight;
return { width, height };
}
function getApproxTextWidth(text: string) {
return Array.from(text || "").reduce((width, char) => {
if (/[\u4e00-\u9fff]/.test(char)) return width + 14;
if (/[A-Z0-9]/.test(char)) return width + 8.5;
if (/[mw@#%&]/i.test(char)) return width + 9;
if (/[\s._-]/.test(char)) return width + 4.5;
return width + 7.5;
}, 0);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getImageNodeSize(item: Pick<HistoryItem, "size">) {
const match = /^(\d+)x(\d+)$/.exec(item.size || "");
if (!match) {
+108 -26
View File
@@ -2,59 +2,126 @@ import type {
CanvasConnection,
CanvasImageNodeMetadata,
CanvasNode,
CanvasPromptNodeMetadata,
} from "@/lib/canvas/types";
export type PromptMentionSource = {
alias: string;
token: string;
nodeId: string;
kind: "image" | "prompt";
label: string;
description: string;
imageUrl: string;
imageUrl?: string;
prompt?: string;
index: number;
};
const mentionUrlPrefix = "canvas-image://";
const imageMentionUrlPrefix = "canvas-image://";
const promptMentionUrlPrefix = "canvas-prompt://";
export function getPromptMentionSources(
nodes: CanvasNode[],
connections: CanvasConnection[],
promptNodeId: string,
) {
const imageById = new Map(
): PromptMentionSource[] {
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
const sources: PromptMentionSource[] = [];
let imageIndex = 0;
let promptIndex = 0;
for (const connection of connections) {
if (connection.toNodeId !== promptNodeId) continue;
const node = nodeById.get(connection.fromNodeId);
if (!node) continue;
if (node.type === "prompt") {
const metadata = node.metadata as CanvasPromptNodeMetadata;
const prompt = metadata.prompt?.trim() || "";
promptIndex += 1;
sources.push({
alias: `@文本${promptIndex}`,
token: createPromptMentionToken(`文本${promptIndex}`, node.id, "prompt"),
nodeId: node.id,
kind: "prompt",
label: `文本${promptIndex}`,
index: promptIndex,
description: prompt || node.title.trim() || `已连接文本 ${promptIndex}`,
prompt,
});
continue;
}
if (node.type === "image") {
const metadata = node.metadata as CanvasImageNodeMetadata;
if (!metadata.imageUrl) continue;
imageIndex += 1;
sources.push({
alias: `@图${imageIndex}`,
token: createPromptMentionToken(`${imageIndex}`, node.id, "image"),
nodeId: node.id,
kind: "image",
label: `${imageIndex}`,
index: imageIndex,
description:
metadata.prompt?.trim() || node.title.trim() || `已连接素材 ${imageIndex}`,
imageUrl: metadata.imageUrl,
});
}
}
return sources;
}
export function getConnectedPromptSources(
nodes: CanvasNode[],
connections: CanvasConnection[],
targetNodeId: string,
): PromptMentionSource[] {
const promptById = new Map(
nodes
.filter((node) => node.type === "image")
.filter((node) => node.type === "prompt")
.map((node) => [node.id, node] as const),
);
return connections
.filter((connection) => connection.toNodeId === promptNodeId)
.map((connection) => imageById.get(connection.fromNodeId))
.filter((connection) => connection.toNodeId === targetNodeId)
.map((connection) => promptById.get(connection.fromNodeId))
.filter((node): node is CanvasNode => Boolean(node))
.map((node, index) => {
const metadata = node.metadata as CanvasImageNodeMetadata;
const metadata = node.metadata as CanvasPromptNodeMetadata;
const prompt = metadata.prompt?.trim() || "";
return {
alias: `@${index + 1}`,
token: createPromptMentionToken(`${index + 1}`, node.id),
alias: `@文本${index + 1}`,
token: createPromptMentionToken(`文本${index + 1}`, node.id, "prompt"),
nodeId: node.id,
label: `${index + 1}`,
kind: "prompt",
label: `文本${index + 1}`,
index: index + 1,
description:
metadata.prompt?.trim() || node.title.trim() || `已连接素材 ${index + 1}`,
imageUrl: metadata.imageUrl,
description: prompt || node.title.trim() || `已连接文本 ${index + 1}`,
prompt,
} satisfies PromptMentionSource;
})
.filter((item) => Boolean(item.imageUrl));
});
}
export function createPromptMentionToken(label: string, nodeId: string) {
return `[${label}](${mentionUrlPrefix}${nodeId})`;
export function createPromptMentionToken(
label: string,
nodeId: string,
kind: PromptMentionSource["kind"] = "image",
) {
const prefix = kind === "prompt" ? promptMentionUrlPrefix : imageMentionUrlPrefix;
return `[${label}](${prefix}${nodeId})`;
}
export function parsePromptMentionUrl(value: string) {
return value.startsWith(mentionUrlPrefix)
? value.slice(mentionUrlPrefix.length)
: null;
if (value.startsWith(imageMentionUrlPrefix)) {
return value.slice(imageMentionUrlPrefix.length);
}
if (value.startsWith(promptMentionUrlPrefix)) {
return value.slice(promptMentionUrlPrefix.length);
}
return null;
}
export function resolvePromptMentions(
@@ -65,7 +132,10 @@ export function resolvePromptMentions(
let resolvedPrompt = prompt;
for (const mention of mentions) {
const replacement = `${mention.label}(第 ${mention.index} 张输入参考图)`;
const replacement =
mention.kind === "image"
? `${mention.label}(第 ${mention.index} 张输入参考图)`
: `${mention.label}(第 ${mention.index} 段输入文本)`;
const usedMarkdownToken = resolvedPrompt.includes(mention.token);
const usedAlias = resolvedPrompt.includes(mention.alias);
if (!usedMarkdownToken && !usedAlias) continue;
@@ -75,13 +145,25 @@ export function resolvePromptMentions(
}
if (mentions.length > 0) {
const referenceGuide = mentions
const imageGuide = mentions
.filter((mention) => mention.kind === "image")
.map(
(mention) =>
`${mention.label} = 第 ${mention.index} 张输入参考图:${mention.description}`,
)
.join("\n");
resolvedPrompt = `${resolvedPrompt.trim()}\n\n已连接参考图编号:\n${referenceGuide}`;
);
const promptGuide = mentions
.filter((mention) => mention.kind === "prompt")
.map(
(mention) =>
`${mention.label} = 第 ${mention.index} 段输入文本:${mention.description}`,
);
const guide = [
imageGuide.length ? `已连接参考图编号:\n${imageGuide.join("\n")}` : "",
promptGuide.length ? `已连接文本编号:\n${promptGuide.join("\n")}` : "",
]
.filter(Boolean)
.join("\n\n");
resolvedPrompt = `${resolvedPrompt.trim()}\n\n${guide}`;
}
return {
+2
View File
@@ -76,6 +76,7 @@ export type CanvasProjectData = {
export type CanvasProjectRecord = {
id: string;
title: string;
description: string;
data: CanvasProjectData;
createdAt: string;
updatedAt: string;
@@ -84,6 +85,7 @@ export type CanvasProjectRecord = {
export type CanvasProjectListItem = {
id: string;
title: string;
description: string;
createdAt: string;
updatedAt: string;
nodeCount: number;
+57 -2
View File
@@ -44,7 +44,14 @@ export type ImageJobPayload = {
upstreamStatus?: number;
upstreamRequestId?: string;
details?: unknown;
pollUrl?: string;
eventsUrl?: string;
};
type WatchImageJobOptions = {
jobId: string;
onJob: (job: ImageJobPayload) => void;
onComplete: (job: ImageJobPayload) => void;
onError: (error: unknown) => void;
};
export const sizeOptions = [
@@ -281,10 +288,48 @@ export function normalizeImageJobPayload(
upstreamStatus: getNumberField(payload, "upstreamStatus"),
upstreamRequestId: getStringField(payload, "upstreamRequestId"),
details: payload.details,
pollUrl: getStringField(payload, "pollUrl"),
eventsUrl: getStringField(payload, "eventsUrl"),
};
}
export function watchImageJob({
jobId,
onJob,
onComplete,
onError,
}: WatchImageJobOptions) {
const source = new EventSource(
`/api/images/events?jobId=${encodeURIComponent(jobId)}`,
);
let settled = false;
source.addEventListener("job", (event) => {
const payload = parseSsePayload(event);
const job = normalizeImageJobPayload(payload);
onJob(job);
if (job.status === "succeeded" || job.status === "failed") {
settled = true;
source.close();
onComplete(job);
}
});
source.addEventListener("error", (event) => {
const payload = parseSsePayload(event);
settled = true;
source.close();
onError(payload);
});
source.onerror = () => {
if (settled || source.readyState !== EventSource.CLOSED) return;
onError(new Error("生成任务监听已断开"));
};
return () => source.close();
}
export function formatJobSuccessDescription(job: ImageJobPayload) {
return [
job.jobId ? `任务 ID${job.jobId}` : null,
@@ -327,3 +372,13 @@ function isGenerationError(value: unknown): value is GenerationError {
"message" in value
);
}
function parseSsePayload(event: Event) {
if (!("data" in event) || typeof event.data !== "string") return {};
try {
return JSON.parse(event.data) as Record<string, unknown>;
} catch {
return { error: event.data };
}
}
+8
View File
@@ -43,12 +43,20 @@ export function getDb() {
CREATE TABLE IF NOT EXISTS canvas_projects (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
const canvasProjectColumns = db
.prepare("PRAGMA table_info(canvas_projects)")
.all() as Array<{ name: string }>;
if (!canvasProjectColumns.some((column) => column.name === "description")) {
db.exec("ALTER TABLE canvas_projects ADD COLUMN description TEXT NOT NULL DEFAULT ''");
}
db.prepare(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('retentionDays', '7')",
).run();
@@ -10,6 +10,7 @@ import { getDb } from "@/lib/server/db";
type CanvasProjectRow = {
id: string;
title: string;
description: string;
data: string;
createdAt: string;
updatedAt: string;
@@ -18,7 +19,7 @@ type CanvasProjectRow = {
export function listCanvasProjects(): CanvasProjectListItem[] {
const rows = getDb()
.prepare(
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
`SELECT id, title, description, data, created_at AS createdAt, updated_at AS updatedAt
FROM canvas_projects
ORDER BY updated_at DESC`,
)
@@ -29,6 +30,7 @@ export function listCanvasProjects(): CanvasProjectListItem[] {
return {
id: row.id,
title: row.title,
description: row.description,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
nodeCount: data.nodes.length,
@@ -39,7 +41,7 @@ export function listCanvasProjects(): CanvasProjectListItem[] {
export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
const row = getDb()
.prepare(
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
`SELECT id, title, description, data, created_at AS createdAt, updated_at AS updatedAt
FROM canvas_projects
WHERE id = ?`,
)
@@ -52,6 +54,7 @@ export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
return {
id: row.id,
title: row.title,
description: row.description,
data: parseCanvasProjectData(row.data),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -60,19 +63,21 @@ export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
export function createCanvasProject(input?: {
title?: string;
description?: string;
data?: CanvasProjectData;
}) {
const now = new Date().toISOString();
const id = crypto.randomUUID();
const title = input?.title?.trim() || "未命名画布";
const description = input?.description?.trim() || "";
const data = normalizeCanvasProjectData(input?.data) ?? defaultCanvasProjectData;
getDb()
.prepare(
`INSERT INTO canvas_projects (id, title, data, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO canvas_projects (id, title, description, data, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(id, title, JSON.stringify(data), now, now);
.run(id, title, description, JSON.stringify(data), now, now);
return getCanvasProjectById(id);
}
@@ -81,6 +86,7 @@ export function updateCanvasProject(
id: string,
patch: {
title?: string;
description?: string;
data?: CanvasProjectData;
},
) {
@@ -88,16 +94,20 @@ export function updateCanvasProject(
if (!current) return null;
const title = patch.title?.trim() || current.title;
const description =
typeof patch.description === "string"
? patch.description.trim()
: current.description;
const data = normalizeCanvasProjectData(patch.data) ?? current.data;
const updatedAt = new Date().toISOString();
getDb()
.prepare(
`UPDATE canvas_projects
SET title = ?, data = ?, updated_at = ?
SET title = ?, description = ?, data = ?, updated_at = ?
WHERE id = ?`,
)
.run(title, JSON.stringify(data), updatedAt, id);
.run(title, description, JSON.stringify(data), updatedAt, id);
return getCanvasProjectById(id);
}
@@ -24,12 +24,14 @@ export type CanvasProjectExportV1 = {
version: typeof CANVAS_EXPORT_VERSION;
exportedAt: string;
title: string;
description: string;
data: CanvasProjectData;
assets: CanvasProjectExportAsset[];
};
export function createCanvasProjectExport(input: {
title: string;
description?: string;
data: CanvasProjectData;
}): CanvasProjectExportV1 {
return {
@@ -37,6 +39,7 @@ export function createCanvasProjectExport(input: {
version: CANVAS_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
title: input.title,
description: input.description ?? "",
data: input.data,
assets: collectExportAssets(input.data),
};
@@ -72,7 +75,11 @@ export function importCanvasProjectExport(value: unknown) {
? `${payload.title.trim()} Copy`
: "导入画布";
return createCanvasProject({ title, data });
return createCanvasProject({
title,
description: payload.description,
data,
});
}
function collectExportAssets(data: CanvasProjectData) {
@@ -130,6 +137,8 @@ function normalizeCanvasProjectExport(value: unknown): CanvasProjectExportV1 | n
? candidate.exportedAt
: new Date().toISOString(),
title: candidate.title,
description:
typeof candidate.description === "string" ? candidate.description : "",
data,
assets,
};
+117 -3
View File
@@ -177,7 +177,7 @@ export async function submitImageJobRequest(request: Request) {
status: job.status,
phase: job.phase,
progressMessage: job.progressMessage,
pollUrl: `/api/images?jobId=${job.jobId}`,
eventsUrl: `/api/images/events?jobId=${job.jobId}`,
durationMs: elapsedMs(startedAt),
},
{
@@ -267,6 +267,93 @@ export function getImageJobResponse(request: Request) {
);
}
export function streamImageJobEvents(request: Request) {
cleanupOldJobs();
const url = new URL(request.url);
const jobId = url.searchParams.get("jobId");
if (!jobId) {
return NextResponse.json({ error: "Missing jobId" }, { status: 400 });
}
const job = imageJobs.get(jobId);
if (!job) {
return NextResponse.json(
{
error: "Image job not found or expired",
jobId,
},
{ status: 404 },
);
}
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
let lastUpdatedAt = "";
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const close = () => {
if (closed) return;
closed = true;
if (heartbeatTimer) clearInterval(heartbeatTimer);
controller.close();
};
const sendJob = () => {
if (closed) return;
const currentJob = imageJobs.get(jobId);
if (!currentJob) {
controller.enqueue(
encoder.encode(
serializeSseEvent("error", {
error: "Image job not found or expired",
jobId,
}),
),
);
close();
return;
}
if (currentJob.updatedAt === lastUpdatedAt) return;
lastUpdatedAt = currentJob.updatedAt;
controller.enqueue(
encoder.encode(serializeSseEvent("job", serializeJob(currentJob))),
);
if (
currentJob.status === "succeeded" ||
currentJob.status === "failed"
) {
close();
}
};
sendJob();
heartbeatTimer = setInterval(() => {
sendJob();
if (!closed) controller.enqueue(encoder.encode(": heartbeat\n\n"));
}, 1000);
request.signal.addEventListener("abort", close, { once: true });
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
"x-request-id": job.requestId,
},
});
}
function createJob(requestId: string): ImageJob {
const now = new Date().toISOString();
return {
@@ -322,7 +409,7 @@ async function runImageJob(
input.mode === "edit" ? "openai-images-edit" : "openai-images-generate";
updateJob(job, {
phase,
progressMessage: "正在调用图片模型,前端会持续轮询结果",
progressMessage: "正在调用图片模型,前端会持续接收结果",
});
log("info", "calling OpenAI image API", {
jobId: job.jobId,
@@ -398,7 +485,7 @@ async function runImageJob(
updateJob(job, {
status: "succeeded",
phase,
progressMessage: "图片已生成",
progressMessage: "生成完成",
completedAt: new Date().toISOString(),
durationMs: elapsedMs(startedAt),
result: resultPayload,
@@ -442,6 +529,33 @@ function updateJob(job: ImageJob, patch: Partial<ImageJob>) {
Object.assign(job, patch, { updatedAt: new Date().toISOString() });
}
function serializeJob(job: ImageJob) {
return {
jobId: job.jobId,
requestId: job.requestId,
status: job.status,
phase: job.phase,
progressMessage: job.progressMessage,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
durationMs: job.durationMs,
result: job.result,
error: job.error?.error,
code: job.error?.code,
type: job.error?.type,
param: job.error?.param,
upstreamStatus: job.error?.upstreamStatus,
upstreamRequestId: job.error?.upstreamRequestId,
details: job.error?.details,
};
}
function serializeSseEvent(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
const images = formData.getAll("image");
const mask = formData.get("mask");