feat: 完善画布管理与快捷键体验
修改历史: - 为画布编辑器补齐节点复制粘贴、全选、追加选择、缩放、删除、生成等快捷键,并新增快捷键说明弹窗 - 支持粘贴剪贴板文本/图片为画布节点、拖入图片上传,以及多选节点复制时保留内部连接 - 在 /canvas 画布管理页新增编辑和删除操作,可编辑标题与说明,并支持删除当前画布 - 为画布项目新增 description 字段,补充数据库兼容迁移、列表展示、API 保存以及导入导出保留说明 - 优化画布节点、提示词引用、配置节点尺寸、生成任务状态与相关服务逻辑
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -30,3 +30,9 @@ textarea,
|
||||
select {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@keyframes canvas-connection-flow {
|
||||
to {
|
||||
stroke-dashoffset: -26;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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="图片文件">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user