Save current image generation workspace
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
deleteCanvasProject,
|
||||
getCanvasProjectById,
|
||||
updateCanvasProject,
|
||||
} from "@/lib/server/repositories/canvas-project-repository";
|
||||
import { isCanvasProjectData } from "@/lib/canvas/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const project = getCanvasProjectById(id);
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Canvas project not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ item: project });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const patch: {
|
||||
title?: string;
|
||||
data?: unknown;
|
||||
} = {};
|
||||
|
||||
if (typeof payload.title === "string") {
|
||||
patch.title = payload.title;
|
||||
}
|
||||
if (isCanvasProjectData(payload.data)) {
|
||||
patch.data = payload.data;
|
||||
}
|
||||
|
||||
const project = updateCanvasProject(id, {
|
||||
title: patch.title,
|
||||
data: isCanvasProjectData(patch.data) ? patch.data : undefined,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Canvas project not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ item: project });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const removed = deleteCanvasProject(id);
|
||||
|
||||
if (!removed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Canvas project not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
createCanvasProject,
|
||||
listCanvasProjects,
|
||||
} from "@/lib/server/repositories/canvas-project-repository";
|
||||
import { defaultCanvasProjectData, isCanvasProjectData } from "@/lib/canvas/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ items: listCanvasProjects() });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const title =
|
||||
typeof payload.title === "string" && payload.title.trim()
|
||||
? payload.title
|
||||
: "未命名画布";
|
||||
const data = isCanvasProjectData(payload.data)
|
||||
? payload.data
|
||||
: defaultCanvasProjectData;
|
||||
const project = createCanvasProject({ title, data });
|
||||
|
||||
return NextResponse.json({ item: project }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { persistUploadedImageFile } from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const image = formData.get("image");
|
||||
|
||||
if (!(image instanceof File)) {
|
||||
return NextResponse.json({ error: "缺少图片文件" }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
const id = crypto.randomUUID();
|
||||
const persisted = persistUploadedImageFile(id, image.name || "canvas-upload.png", buffer);
|
||||
|
||||
return NextResponse.json({
|
||||
item: {
|
||||
id,
|
||||
imageUrl: persisted.imageUrl,
|
||||
filePath: persisted.filePath,
|
||||
fileName: persisted.fileName,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listHistory } from "@/lib/server/repositories/history-repository";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ items: listHistory() });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
getImageJobResponse,
|
||||
submitImageJobRequest,
|
||||
} from "@/lib/server/services/image-job-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return submitImageJobRequest(request);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return getImageJobResponse(request);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
cleanupExpiredHistory,
|
||||
getRetentionDays,
|
||||
setRetentionDays,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
cleanupExpiredHistory();
|
||||
return NextResponse.json({ retentionDays: getRetentionDays() });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const retentionDays = setRetentionDays(Number(payload.retentionDays));
|
||||
return NextResponse.json({ retentionDays });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CanvasEditorClient } from "@/components/canvas/canvas-editor-client";
|
||||
|
||||
export default async function CanvasProjectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <CanvasEditorClient projectId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HomeShell } from "@/components/direct/home-shell";
|
||||
|
||||
export default function CanvasProjectsPage() {
|
||||
return <HomeShell initialView="canvas" />;
|
||||
}
|
||||
+15
-9
@@ -1,22 +1,21 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #f4f4f5;
|
||||
--foreground: #18181b;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: Arial, Helvetica, sans-serif;
|
||||
--font-mono: "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -24,3 +23,10 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
+5
-16
@@ -1,20 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Image Studio Admin",
|
||||
description: "A shadcn-admin style image generation workspace",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +13,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
lang="zh-CN"
|
||||
className="h-full antialiased"
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="h-full overflow-hidden">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-63
@@ -1,65 +1,15 @@
|
||||
import Image from "next/image";
|
||||
import { HomeShell, type ActiveView } from "@/components/direct/home-shell";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
function normalizeView(value: string | string[] | undefined): ActiveView {
|
||||
const candidate = Array.isArray(value) ? value[0] : value;
|
||||
if (candidate === "history" || candidate === "settings" || candidate === "canvas") {
|
||||
return candidate;
|
||||
}
|
||||
return "studio";
|
||||
}
|
||||
|
||||
export default async function Home(props: PageProps<"/">) {
|
||||
const searchParams = await props.searchParams;
|
||||
const view = normalizeView(searchParams.view);
|
||||
return <HomeShell key={`home-${view}`} initialView={view} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { CanvasConnection, CanvasNode, CanvasPosition } from "@/lib/canvas/types";
|
||||
import { getActiveConnectionPath, getConnectionPath } from "@/lib/canvas/geometry";
|
||||
|
||||
type CanvasConnectionsProps = {
|
||||
connections: CanvasConnection[];
|
||||
nodes: CanvasNode[];
|
||||
selectedConnectionId: string | null;
|
||||
activeConnection?: {
|
||||
fromNodeId: string;
|
||||
mouseWorld: CanvasPosition;
|
||||
} | null;
|
||||
onSelectConnection: (id: string) => void;
|
||||
};
|
||||
|
||||
export function CanvasConnections({
|
||||
connections,
|
||||
nodes,
|
||||
selectedConnectionId,
|
||||
activeConnection,
|
||||
onSelectConnection,
|
||||
}: CanvasConnectionsProps) {
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]));
|
||||
const activeFrom = activeConnection
|
||||
? byId.get(activeConnection.fromNodeId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute left-0 top-0 overflow-visible"
|
||||
style={{ width: 1, height: 1 }}
|
||||
>
|
||||
{connections.map((connection) => {
|
||||
const from = byId.get(connection.fromNodeId);
|
||||
const to = byId.get(connection.toNodeId);
|
||||
if (!from || !to) return null;
|
||||
|
||||
return (
|
||||
<ConnectionPath
|
||||
key={connection.id}
|
||||
active={connection.id === selectedConnectionId}
|
||||
connection={connection}
|
||||
from={from}
|
||||
to={to}
|
||||
onSelect={() => onSelectConnection(connection.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{activeConnection && activeFrom ? (
|
||||
<path
|
||||
d={getActiveConnectionPath(
|
||||
{
|
||||
x: activeFrom.position.x + activeFrom.width,
|
||||
y: activeFrom.position.y + activeFrom.height / 2,
|
||||
},
|
||||
activeConnection.mouseWorld,
|
||||
)}
|
||||
fill="none"
|
||||
stroke="#18181b"
|
||||
strokeDasharray="7 7"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionPath({
|
||||
connection,
|
||||
from,
|
||||
to,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
connection: CanvasConnection;
|
||||
from: CanvasNode;
|
||||
to: CanvasNode;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const path = getConnectionPath(from, to);
|
||||
const stroke = active ? "#18181b" : "#a1a1aa";
|
||||
|
||||
function handleContextMenu(event: ReactMouseEvent<SVGPathElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
data-connection-id={connection.id}
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth="18"
|
||||
style={{ cursor: "pointer", pointerEvents: "stroke" }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeOpacity={active ? 1 : 0.72}
|
||||
strokeWidth={active ? 3 : 2}
|
||||
style={{
|
||||
filter: active ? "drop-shadow(0 0 8px rgba(17,24,39,.25))" : undefined,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,972 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { 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";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
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";
|
||||
import type {
|
||||
CanvasConfigNodeMetadata,
|
||||
CanvasImageNodeMetadata,
|
||||
CanvasNode,
|
||||
CanvasPromptNodeMetadata,
|
||||
} from "@/lib/canvas/types";
|
||||
import {
|
||||
getComputedSize,
|
||||
inferSizeSelection,
|
||||
resolutionOptions,
|
||||
sizeOptions,
|
||||
type HistoryItem,
|
||||
type ImageJobPayload,
|
||||
type Resolution,
|
||||
} from "@/lib/image-workflow";
|
||||
|
||||
type CanvasNodeInspectorProps = {
|
||||
node: CanvasNode | null;
|
||||
history: HistoryItem[];
|
||||
isHistoryLoading: boolean;
|
||||
job: ImageJobPayload | null;
|
||||
error: string | null;
|
||||
showLibrary: boolean;
|
||||
promptMentionSources: PromptMentionSource[];
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
onDeleteNode: (id: string) => void;
|
||||
onGenerateNode: (id: string) => void;
|
||||
onInsertHistory: (item: HistoryItem) => void;
|
||||
onUploadImage: (file: File) => void;
|
||||
onReplaceNodeImage: (nodeId: string, file: File) => void;
|
||||
onCloseLibrary: () => void;
|
||||
};
|
||||
|
||||
export function CanvasNodeInspector({
|
||||
node,
|
||||
history,
|
||||
isHistoryLoading,
|
||||
job,
|
||||
error,
|
||||
showLibrary,
|
||||
promptMentionSources,
|
||||
onPatchNode,
|
||||
onDeleteNode,
|
||||
onGenerateNode,
|
||||
onInsertHistory,
|
||||
onUploadImage,
|
||||
onReplaceNodeImage,
|
||||
onCloseLibrary,
|
||||
}: CanvasNodeInspectorProps) {
|
||||
const [historyFilter, setHistoryFilter] = useState("");
|
||||
const filteredHistory = history.filter((item) =>
|
||||
`${item.prompt} ${item.model} ${item.size}`
|
||||
.toLowerCase()
|
||||
.includes(historyFilter.toLowerCase()),
|
||||
);
|
||||
|
||||
if (!node && !showLibrary && !job?.progressMessage && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto pr-1">
|
||||
<SelectedNodeForm
|
||||
node={node}
|
||||
promptMentionSources={promptMentionSources}
|
||||
onDeleteNode={onDeleteNode}
|
||||
onGenerateNode={onGenerateNode}
|
||||
onPatchNode={onPatchNode}
|
||||
onReplaceNodeImage={onReplaceNodeImage}
|
||||
/>
|
||||
</div>
|
||||
</FloatingCard>
|
||||
) : null}
|
||||
|
||||
{showLibrary ? (
|
||||
<FloatingCard className="pointer-events-auto max-h-[calc(100vh-150px)] overflow-auto">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-950">素材库</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
从历史或本地图片插入参考节点
|
||||
</div>
|
||||
</div>
|
||||
<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={onCloseLibrary}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
placeholder="搜索提示词、模型或尺寸..."
|
||||
value={historyFilter}
|
||||
onChange={(event) => setHistoryFilter(event.target.value)}
|
||||
/>
|
||||
<label className="inline-flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-xl bg-zinc-950 px-3 text-sm font-medium text-white hover:bg-zinc-800">
|
||||
<Upload className="size-4" />
|
||||
上传
|
||||
<input
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onUploadImage(file);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2">
|
||||
{isHistoryLoading ? (
|
||||
<LibraryPlaceholder>正在加载历史...</LibraryPlaceholder>
|
||||
) : null}
|
||||
{!isHistoryLoading && filteredHistory.length === 0 ? (
|
||||
<LibraryPlaceholder>暂无可插入的历史图片。</LibraryPlaceholder>
|
||||
) : null}
|
||||
{filteredHistory.slice(0, 10).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white p-2.5 text-left transition hover:border-zinc-950 hover:shadow-[0_10px_24px_rgba(24,24,27,.08)]"
|
||||
onClick={() => onInsertHistory(item)}
|
||||
>
|
||||
<div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-zinc-900">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
alt={item.prompt}
|
||||
className="h-full w-full object-cover"
|
||||
src={item.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="size-5 text-white/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 text-sm leading-5 text-zinc-700">
|
||||
{item.prompt || "无提示词"}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
{item.model} · {item.size}
|
||||
</div>
|
||||
</div>
|
||||
<Plus className="size-4 shrink-0 text-zinc-500" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FloatingCard>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedNodeForm({
|
||||
node,
|
||||
promptMentionSources,
|
||||
onPatchNode,
|
||||
onDeleteNode,
|
||||
onGenerateNode,
|
||||
onReplaceNodeImage,
|
||||
}: {
|
||||
node: CanvasNode;
|
||||
promptMentionSources: PromptMentionSource[];
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
onDeleteNode: (id: string) => void;
|
||||
onGenerateNode: (id: string) => void;
|
||||
onReplaceNodeImage: (nodeId: string, file: File) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-zinc-950">
|
||||
{node.type === "image"
|
||||
? "图像设置"
|
||||
: node.type === "prompt"
|
||||
? "提示词配置"
|
||||
: "生成配置"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.2em] text-[#8a7b60]">
|
||||
{node.type}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{node.type === "image" ? (
|
||||
<Button
|
||||
className="rounded-full bg-zinc-950 text-white shadow-none hover:bg-zinc-800"
|
||||
size="icon"
|
||||
onClick={() => onGenerateNode(node.id)}
|
||||
title="从当前图像节点生成"
|
||||
>
|
||||
<Sparkles />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDeleteNode(node.id)}
|
||||
title="删除节点"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<Field label="标题">
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
value={node.title}
|
||||
onChange={(event) =>
|
||||
onPatchNode(node.id, { title: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{node.type === "prompt" ? (
|
||||
<PromptFields
|
||||
mentionSources={promptMentionSources}
|
||||
node={node}
|
||||
onPatchNode={onPatchNode}
|
||||
/>
|
||||
) : null}
|
||||
{node.type === "config" ? (
|
||||
<ConfigFields node={node} onPatchNode={onPatchNode} />
|
||||
) : null}
|
||||
{node.type === "image" ? (
|
||||
<ImageFields
|
||||
node={node}
|
||||
onPatchNode={onPatchNode}
|
||||
onReplaceNodeImage={onReplaceNodeImage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptFields({
|
||||
mentionSources,
|
||||
node,
|
||||
onPatchNode,
|
||||
}: {
|
||||
mentionSources: PromptMentionSource[];
|
||||
node: CanvasNode;
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
}) {
|
||||
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPromptRef = useRef(metadata.prompt || "");
|
||||
const [mentionState, setMentionState] = useState<{
|
||||
query: string;
|
||||
} | null>(null);
|
||||
|
||||
const filteredMentionSources = mentionState
|
||||
? mentionSources.filter((source) =>
|
||||
`${source.alias} ${source.token} ${source.label} ${source.description}`
|
||||
.toLowerCase()
|
||||
.includes(mentionState.query.toLowerCase()),
|
||||
)
|
||||
: mentionSources;
|
||||
|
||||
useEffect(() => {
|
||||
const currentPrompt = metadata.prompt || "";
|
||||
if (lastPromptRef.current === currentPrompt) return;
|
||||
lastPromptRef.current = currentPrompt;
|
||||
renderPromptEditor(editorRef.current, currentPrompt, mentionSources);
|
||||
}, [mentionSources, metadata.prompt]);
|
||||
|
||||
function updateMentionState(value: string) {
|
||||
const atIndex = value.lastIndexOf("@");
|
||||
if (atIndex === -1) {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
function handleEditorInput(event: FormEvent<HTMLDivElement>) {
|
||||
const nextPrompt = serializePromptEditor(event.currentTarget);
|
||||
event.currentTarget.dataset.renderedPrompt = nextPrompt;
|
||||
patchPrompt(nextPrompt);
|
||||
updateMentionState(nextPrompt);
|
||||
}
|
||||
|
||||
function insertMention(source: PromptMentionSource) {
|
||||
const element = editorRef.current;
|
||||
if (!element) return;
|
||||
|
||||
replaceActiveMentionQuery(element, mentionState?.query ?? "", source);
|
||||
const nextPrompt = serializePromptEditor(element);
|
||||
element.dataset.renderedPrompt = nextPrompt;
|
||||
patchPrompt(nextPrompt);
|
||||
setMentionState(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field label="提示词">
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={(element) => {
|
||||
editorRef.current = element;
|
||||
renderPromptEditor(element, metadata.prompt || "", 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
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
tabIndex={0}
|
||||
onInput={handleEditorInput}
|
||||
onKeyDown={(event) => handleEditorKeyDown(event, handleEditorInput)}
|
||||
onKeyUp={(event) => updateMentionState(serializePromptEditor(event.currentTarget))}
|
||||
onPaste={pastePlainText}
|
||||
onFocus={() => {
|
||||
if (!editorRef.current?.textContent?.trim()) {
|
||||
placeCaretAtEnd(editorRef.current);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => setMentionState(null), 120);
|
||||
}}
|
||||
/>
|
||||
{!(metadata.prompt || "").trim() ? (
|
||||
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-6 text-zinc-400">
|
||||
{mentionSources.length
|
||||
? "输入提示词,键入 @ 引用已连接素材"
|
||||
: "输入提示词"}
|
||||
</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) => (
|
||||
<button
|
||||
key={source.nodeId}
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-zinc-100"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
insertMention(source);
|
||||
}}
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-zinc-200 bg-zinc-100">
|
||||
{source.imageUrl ? (
|
||||
<img
|
||||
alt={source.description}
|
||||
className="h-full w-full object-cover"
|
||||
src={source.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="size-4 text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-zinc-900">
|
||||
{source.label}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{source.description}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-700">
|
||||
{source.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</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="mt-2 flex flex-wrap gap-2">
|
||||
{mentionSources.map((source) => (
|
||||
<button
|
||||
key={source.nodeId}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:border-zinc-950 hover:text-zinc-950"
|
||||
onClick={() => insertMention(source)}
|
||||
>
|
||||
<span className="flex size-5 items-center justify-center overflow-hidden rounded-full bg-zinc-100">
|
||||
{source.imageUrl ? (
|
||||
<img
|
||||
alt={source.description}
|
||||
className="h-full w-full object-cover"
|
||||
src={source.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="size-3 text-zinc-400" />
|
||||
)}
|
||||
</span>
|
||||
{source.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-zinc-500">
|
||||
只允许引用已连接到当前提示词节点的图片素材。
|
||||
</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://...)` 引用图片。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPromptEditor(
|
||||
element: HTMLDivElement | null,
|
||||
prompt: string,
|
||||
mentionSources: PromptMentionSource[],
|
||||
) {
|
||||
if (!element || element.dataset.renderedPrompt === prompt) return;
|
||||
|
||||
element.replaceChildren();
|
||||
const segments = splitPromptSegments(prompt, mentionSources);
|
||||
if (!segments.length) {
|
||||
element.dataset.renderedPrompt = prompt;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.type === "text") {
|
||||
element.append(document.createTextNode(segment.value));
|
||||
continue;
|
||||
}
|
||||
|
||||
element.append(createMentionCard(segment.source));
|
||||
}
|
||||
element.dataset.renderedPrompt = prompt;
|
||||
}
|
||||
|
||||
function createMentionCard(source: PromptMentionSource) {
|
||||
const card = document.createElement("span");
|
||||
card.contentEditable = "false";
|
||||
card.dataset.mentionToken = source.token;
|
||||
card.className =
|
||||
"mx-0.5 inline-flex max-w-full align-middle items-center gap-1 rounded-full border border-zinc-200 bg-white px-1.5 py-0.5 text-xs font-medium text-zinc-800 shadow-sm";
|
||||
|
||||
const imageWrap = document.createElement("span");
|
||||
imageWrap.className =
|
||||
"flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-full bg-zinc-100";
|
||||
if (source.imageUrl) {
|
||||
const image = document.createElement("img");
|
||||
image.alt = source.description;
|
||||
image.className = "h-full w-full object-cover";
|
||||
image.src = source.imageUrl;
|
||||
imageWrap.append(image);
|
||||
}
|
||||
|
||||
const copy = document.createElement("span");
|
||||
copy.className = "min-w-0";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "block text-sky-700";
|
||||
label.textContent = source.label;
|
||||
|
||||
copy.append(label);
|
||||
card.append(imageWrap, copy);
|
||||
return card;
|
||||
}
|
||||
|
||||
function serializePromptEditor(element: HTMLDivElement) {
|
||||
let result = "";
|
||||
|
||||
for (const node of element.childNodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
result += node.textContent || "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.dataset.mentionToken) {
|
||||
result += node.dataset.mentionToken;
|
||||
continue;
|
||||
}
|
||||
result += node.innerText || node.textContent || "";
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceActiveMentionQuery(
|
||||
element: HTMLDivElement,
|
||||
query: string,
|
||||
source: PromptMentionSource,
|
||||
) {
|
||||
element.focus();
|
||||
const selection = window.getSelection();
|
||||
const textNode = selection?.anchorNode;
|
||||
if (!selection || !textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
||||
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 parent = textNode.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (before) fragment.append(document.createTextNode(before));
|
||||
fragment.append(createMentionCard(source), document.createTextNode(" "));
|
||||
if (after) fragment.append(document.createTextNode(after));
|
||||
parent.replaceChild(fragment, textNode);
|
||||
placeCaretAtEnd(element);
|
||||
}
|
||||
|
||||
function handleEditorKeyDown(
|
||||
event: KeyboardEvent<HTMLDivElement>,
|
||||
onInput: (event: FormEvent<HTMLDivElement>) => void,
|
||||
) {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
insertPlainText("\n");
|
||||
onInput(event);
|
||||
}
|
||||
|
||||
function pastePlainText(event: ClipboardEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
insertPlainText(event.clipboardData.getData("text/plain"));
|
||||
}
|
||||
|
||||
function insertPlainText(text: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
|
||||
selection.deleteFromDocument();
|
||||
const node = document.createTextNode(text);
|
||||
const range = selection.getRangeAt(0);
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(node);
|
||||
range.setEndAfter(node);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(element: HTMLElement | null) {
|
||||
if (!element) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
|
||||
function ConfigFields({
|
||||
node,
|
||||
onPatchNode,
|
||||
}: {
|
||||
node: CanvasNode;
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
}) {
|
||||
const metadata = node.metadata as CanvasConfigNodeMetadata;
|
||||
const patch = (next: Partial<CanvasConfigNodeMetadata>) =>
|
||||
onPatchNode(node.id, { metadata: { ...metadata, ...next } });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MiniBadge active>生图</MiniBadge>
|
||||
<MiniBadge>文本</MiniBadge>
|
||||
<MiniBadge>视频</MiniBadge>
|
||||
<MiniBadge>音频</MiniBadge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<InfoChip>提示词 0 个</InfoChip>
|
||||
<InfoChip>参考图 0 张</InfoChip>
|
||||
<InfoChip>参考视频 0 个</InfoChip>
|
||||
<InfoChip>参考音频 0 个</InfoChip>
|
||||
</div>
|
||||
<Field label="模型">
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
value={metadata.model}
|
||||
onChange={(event) => patch({ model: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<SizeSelector
|
||||
size={metadata.size}
|
||||
onSizeChange={(size) => patch({ size })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="质量">
|
||||
<Select
|
||||
value={metadata.quality}
|
||||
onValueChange={(value) => patch({ quality: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">low</SelectItem>
|
||||
<SelectItem value="medium">medium</SelectItem>
|
||||
<SelectItem value="high">high</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="格式">
|
||||
<Select
|
||||
value={metadata.outputFormat}
|
||||
onValueChange={(value) => patch({ outputFormat: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="png">png</SelectItem>
|
||||
<SelectItem value="jpeg">jpeg</SelectItem>
|
||||
<SelectItem value="webp">webp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl bg-zinc-100 p-3">
|
||||
<Label className="text-xs text-zinc-700">保持身份一致性</Label>
|
||||
<Switch
|
||||
checked={metadata.preserveIdentity}
|
||||
onCheckedChange={(value) => patch({ preserveIdentity: value })}
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
开始生成
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageFields({
|
||||
node,
|
||||
onPatchNode,
|
||||
onReplaceNodeImage,
|
||||
}: {
|
||||
node: CanvasNode;
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
onReplaceNodeImage: (nodeId: string, file: File) => void;
|
||||
}) {
|
||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||
const patch = (next: Partial<CanvasImageNodeMetadata>) =>
|
||||
onPatchNode(node.id, { metadata: { ...metadata, ...next } });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="提示词">
|
||||
<Textarea
|
||||
className="min-h-28 rounded-2xl border-zinc-200 bg-white font-mono"
|
||||
placeholder="描述当前图像节点想要生成或编辑的内容"
|
||||
value={metadata.prompt || ""}
|
||||
onChange={(event) => patch({ prompt: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="图片文件">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="inline-flex h-10 cursor-pointer items-center gap-2 rounded-xl bg-zinc-950 px-3 text-sm font-medium text-white transition hover:bg-zinc-800">
|
||||
<Upload className="size-4" />
|
||||
{metadata.imageUrl ? "替换图片" : "上传图片"}
|
||||
<input
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onReplaceNodeImage(node.id, file);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 text-xs text-zinc-500">
|
||||
{metadata.imageUrl ? "当前节点已绑定图片文件" : "尚未上传图片文件"}
|
||||
</div>
|
||||
</div>
|
||||
{metadata.imageUrl ? (
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-zinc-200 bg-white">
|
||||
<img
|
||||
alt={node.title}
|
||||
className="h-32 w-full object-cover"
|
||||
src={metadata.imageUrl}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
placeholder="gpt-image-2"
|
||||
value={metadata.model || ""}
|
||||
onChange={(event) => patch({ model: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="模式">
|
||||
<Select
|
||||
value={metadata.mode}
|
||||
onValueChange={(value) =>
|
||||
patch({ mode: value === "result" ? "result" : "reference" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reference">reference</SelectItem>
|
||||
<SelectItem value="result">result</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="质量">
|
||||
<Select
|
||||
value={metadata.quality || "high"}
|
||||
onValueChange={(value) => patch({ quality: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">auto</SelectItem>
|
||||
<SelectItem value="low">low</SelectItem>
|
||||
<SelectItem value="medium">medium</SelectItem>
|
||||
<SelectItem value="high">high</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="格式">
|
||||
<Select
|
||||
value={metadata.outputFormat || "png"}
|
||||
onValueChange={(value) => patch({ outputFormat: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="png">png</SelectItem>
|
||||
<SelectItem value="jpeg">jpeg</SelectItem>
|
||||
<SelectItem value="webp">webp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<SizeSelector
|
||||
size={metadata.size}
|
||||
onSizeChange={(size) => patch({ size })}
|
||||
/>
|
||||
<div className="rounded-2xl bg-zinc-100 p-3 text-xs leading-5 text-zinc-600">
|
||||
当前节点可作为参考图、编辑图或结果图使用。这里配置的提示词和图像参数会跟随节点一起保存。
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SizeSelector({
|
||||
size,
|
||||
onSizeChange,
|
||||
}: {
|
||||
size?: string;
|
||||
onSizeChange: (size: string) => void;
|
||||
}) {
|
||||
const selection = inferSizeSelection(size);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="比例">
|
||||
<Select
|
||||
value={selection.ratio}
|
||||
onValueChange={(ratio) =>
|
||||
onSizeChange(getComputedSize(ratio, selection.resolution))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sizeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="分辨率">
|
||||
<Select
|
||||
value={selection.resolution}
|
||||
onValueChange={(resolution) =>
|
||||
onSizeChange(
|
||||
getComputedSize(selection.ratio, resolution as Resolution),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resolutionOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
|
||||
最终尺寸:{selection.computedSize}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[26px] bg-white/95 p-4 shadow-[0_20px_60px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-zinc-500">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoChip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-500">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBadge({
|
||||
children,
|
||||
active = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium ${
|
||||
active
|
||||
? "bg-zinc-950 text-white"
|
||||
: "border border-zinc-200 bg-zinc-50 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPlaceholder({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-100 p-3 text-sm text-zinc-500">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Code2,
|
||||
Download,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { PromptMentionPreview } from "@/components/canvas/prompt-mention-preview";
|
||||
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
|
||||
import type {
|
||||
CanvasConfigNodeMetadata,
|
||||
CanvasImageNodeMetadata,
|
||||
CanvasNode as CanvasNodeType,
|
||||
CanvasPromptNodeMetadata,
|
||||
} from "@/lib/canvas/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ResizeCorner = "bottom-right";
|
||||
|
||||
type CanvasNodeProps = {
|
||||
node: CanvasNodeType;
|
||||
scale: number;
|
||||
selected: boolean;
|
||||
related: boolean;
|
||||
imageReferenceLabel?: string;
|
||||
activeConnecting: boolean;
|
||||
connectionTarget: boolean;
|
||||
promptMentionSources?: PromptMentionSource[];
|
||||
onSelect: (id: string) => void;
|
||||
onDragStart: (event: ReactMouseEvent, node: CanvasNodeType) => void;
|
||||
onConnectStart: (id: string) => void;
|
||||
onConnectEnd: (id: string) => void;
|
||||
onResize: (id: string, width: number, height: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onGenerate: (id: string) => void;
|
||||
onReplaceImage: (id: string, file: File) => void;
|
||||
};
|
||||
|
||||
export function CanvasNode({
|
||||
node,
|
||||
scale,
|
||||
selected,
|
||||
related,
|
||||
imageReferenceLabel,
|
||||
activeConnecting,
|
||||
connectionTarget,
|
||||
promptMentionSources = [],
|
||||
onSelect,
|
||||
onDragStart,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
onResize,
|
||||
onDelete,
|
||||
onGenerate,
|
||||
onReplaceImage,
|
||||
}: CanvasNodeProps) {
|
||||
const [previewImage, setPreviewImage] = useState<{
|
||||
title: string;
|
||||
url: string;
|
||||
} | null>(null);
|
||||
const imageMetadata =
|
||||
node.type === "image" ? (node.metadata as CanvasImageNodeMetadata) : null;
|
||||
const canShowImageToolbar = node.type === "image" && Boolean(imageMetadata?.imageUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
className={cn(
|
||||
"group absolute z-10 flex select-none flex-col rounded-[28px] border-2 bg-white shadow-[0_18px_46px_rgba(24,24,27,.10)] transition-shadow",
|
||||
selected && "z-30 border-zinc-950 shadow-[0_20px_54px_rgba(17,24,39,.22)]",
|
||||
!selected && related && "border-zinc-400",
|
||||
!selected && !related && "border-zinc-200",
|
||||
connectionTarget && "ring-4 ring-zinc-300/60",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${node.position.x}px, ${node.position.y}px)`,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
}}
|
||||
onMouseDown={(event) => onDragStart(event, node)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(node.id);
|
||||
if (connectionTarget) onConnectEnd(node.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-[inherit]">
|
||||
{node.type !== "image" ? (
|
||||
<NodeHeader
|
||||
node={node}
|
||||
onDelete={onDelete}
|
||||
onGenerate={onGenerate}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : null}
|
||||
<NodeBody
|
||||
imageReferenceLabel={imageReferenceLabel}
|
||||
node={node}
|
||||
onOpenImagePreview={(image) => setPreviewImage(image)}
|
||||
onSelect={onSelect}
|
||||
promptMentionSources={promptMentionSources}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canShowImageToolbar && imageMetadata?.imageUrl ? (
|
||||
<ImageNodeToolbar
|
||||
imageUrl={imageMetadata.imageUrl}
|
||||
node={node}
|
||||
visible={selected}
|
||||
onDelete={onDelete}
|
||||
onGenerate={onGenerate}
|
||||
onOpenImagePreview={(image) => setPreviewImage(image)}
|
||||
onReplaceImage={onReplaceImage}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : null}
|
||||
<ConnectionHandle
|
||||
side="left"
|
||||
visible={activeConnecting || selected}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (connectionTarget) onConnectEnd(node.id);
|
||||
}}
|
||||
/>
|
||||
<ConnectionHandle
|
||||
side="right"
|
||||
visible={selected || activeConnecting}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onConnectStart(node.id);
|
||||
}}
|
||||
/>
|
||||
{node.type === "image" || node.type === "prompt" ? (
|
||||
<ResizeHandle
|
||||
scale={scale}
|
||||
node={node}
|
||||
corner="bottom-right"
|
||||
onResize={onResize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{previewImage ? (
|
||||
<ImageZoomDialog
|
||||
image={previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeHeader({
|
||||
node,
|
||||
onDelete,
|
||||
onGenerate,
|
||||
onSelect,
|
||||
}: {
|
||||
node: CanvasNodeType;
|
||||
onDelete: (id: string) => void;
|
||||
onGenerate: (id: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<NodeIcon type={node.type} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-950">{node.title}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-400">
|
||||
{node.type}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeBody({
|
||||
imageReferenceLabel,
|
||||
node,
|
||||
onOpenImagePreview,
|
||||
onSelect,
|
||||
promptMentionSources,
|
||||
}: {
|
||||
imageReferenceLabel?: string;
|
||||
node: CanvasNodeType;
|
||||
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
||||
onSelect: (id: string) => void;
|
||||
promptMentionSources: PromptMentionSource[];
|
||||
}) {
|
||||
if (node.type === "prompt") {
|
||||
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto px-4 py-3 text-sm leading-6 text-zinc-700">
|
||||
<PromptMentionPreview
|
||||
chipClassName="h-6 px-1.5 text-[11px]"
|
||||
className="font-mono"
|
||||
mentionSources={promptMentionSources}
|
||||
placeholder={<span className="text-zinc-400">在右侧面板填写提示词</span>}
|
||||
prompt={metadata.prompt || ""}
|
||||
textClassName="text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageNodeBody
|
||||
imageReferenceLabel={imageReferenceLabel}
|
||||
metadata={node.metadata as CanvasImageNodeMetadata}
|
||||
node={node}
|
||||
onOpenImagePreview={onOpenImagePreview}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageNodeBody({
|
||||
imageReferenceLabel,
|
||||
metadata,
|
||||
node,
|
||||
onOpenImagePreview,
|
||||
onSelect,
|
||||
}: {
|
||||
imageReferenceLabel?: string;
|
||||
metadata: CanvasImageNodeMetadata;
|
||||
node: CanvasNodeType;
|
||||
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
if (metadata.status === "loading") {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
|
||||
<ImageNodeTitle node={node} />
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 text-zinc-500">
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
<span className="text-xs tracking-[0.2em]">生成中</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metadata.imageUrl) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
|
||||
<ImageNodeTitle node={node} />
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 rounded-[18px] bg-zinc-50 text-zinc-400">
|
||||
<ImageIcon className="size-9" />
|
||||
<span className="text-xs">空图片节点,可从历史或上传插入</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 bg-zinc-50">
|
||||
{imageReferenceLabel ? (
|
||||
<div className="absolute right-2 top-2 z-10 rounded-full bg-[#2e77ff] px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||||
{imageReferenceLabel}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="h-full cursor-zoom-in overflow-hidden"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(node.id);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenImagePreview({
|
||||
title: node.title,
|
||||
url: metadata.imageUrl,
|
||||
});
|
||||
}}
|
||||
title="双击预览图片"
|
||||
>
|
||||
<img
|
||||
alt={node.title}
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
draggable={false}
|
||||
src={metadata.imageUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageNodeTitle({ node }: { node: CanvasNodeType }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ImageIcon className="size-4 shrink-0 text-zinc-700" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-950">{node.title}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-400">
|
||||
IMAGE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageZoomDialog({
|
||||
image,
|
||||
onClose,
|
||||
}: {
|
||||
image: { title: string; url: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [naturalSize, setNaturalSize] = useState<{
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [dragState, setDragState] = useState<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
} | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const zoomIn = () => setZoom((current) => current * 1.25);
|
||||
const zoomOut = () => setZoom((current) => Math.max(0.05, current / 1.25));
|
||||
const dialogSize = useMemo(() => {
|
||||
if (!naturalSize || naturalSize.url !== image.url) {
|
||||
return { width: "min(92vw, 880px)", height: "min(92vh, 680px)" };
|
||||
}
|
||||
return {
|
||||
width: `min(92vw, ${naturalSize.width}px)`,
|
||||
height: `min(92vh, ${naturalSize.height + 112}px)`,
|
||||
};
|
||||
}, [image.url, naturalSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setNaturalSize({
|
||||
url: image.url,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
};
|
||||
img.src = image.url;
|
||||
}, [image.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState) return;
|
||||
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
const scroller = scrollRef.current;
|
||||
if (!scroller) return;
|
||||
scroller.scrollLeft = dragState.scrollLeft - (event.clientX - dragState.startX);
|
||||
scroller.scrollTop = dragState.scrollTop - (event.clientY - dragState.startY);
|
||||
};
|
||||
const handleUp = () => setDragState(null);
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [dragState]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/42 p-6 backdrop-blur-[1px]"
|
||||
data-canvas-ui
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
setZoom((current) =>
|
||||
event.deltaY < 0
|
||||
? current * 1.18
|
||||
: Math.max(0.05, current / 1.18),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col overflow-hidden rounded-lg bg-white p-4 shadow-2xl ring-1 ring-zinc-200"
|
||||
style={dialogSize}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-4">
|
||||
<h2 className="truncate 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"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"thin-scrollbar flex min-h-0 flex-1 overflow-auto bg-white",
|
||||
dragState ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
const scroller = scrollRef.current;
|
||||
if (!scroller) return;
|
||||
setDragState({
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
scrollLeft: scroller.scrollLeft,
|
||||
scrollTop: scroller.scrollTop,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="m-auto">
|
||||
<img
|
||||
alt={image.title}
|
||||
className="block max-w-none select-none"
|
||||
draggable={false}
|
||||
src={image.url}
|
||||
style={{
|
||||
height: "auto",
|
||||
width: `${Math.max(
|
||||
80,
|
||||
zoom *
|
||||
(naturalSize?.url === image.url ? naturalSize.width : 960),
|
||||
)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 flex items-center gap-2 rounded-2xl border border-zinc-200 bg-white/92 px-3 py-2 shadow-[0_14px_42px_rgba(24,24,27,.14)] backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={() => setZoom(1)}
|
||||
title="适配视图"
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={zoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-12 text-center text-sm font-medium text-zinc-700">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={zoomIn}
|
||||
title="放大"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function NodeIcon({ type }: { type: CanvasNodeType["type"] }) {
|
||||
const className = "size-4";
|
||||
if (type === "prompt") return <FileText className={className} />;
|
||||
if (type === "config") return <Code2 className={className} />;
|
||||
return <ImageIcon className={className} />;
|
||||
}
|
||||
|
||||
function ImageNodeToolbar({
|
||||
imageUrl,
|
||||
node,
|
||||
visible,
|
||||
onDelete,
|
||||
onGenerate,
|
||||
onOpenImagePreview,
|
||||
onReplaceImage,
|
||||
onSelect,
|
||||
}: {
|
||||
imageUrl: string;
|
||||
node: CanvasNodeType;
|
||||
visible: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
onGenerate: (id: string) => void;
|
||||
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
||||
onReplaceImage: (id: string, file: File) => void;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const replaceInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={replaceInputRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) onReplaceImage(node.id, file);
|
||||
}}
|
||||
/>
|
||||
<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);
|
||||
onOpenImagePreview({ title: node.title, url: imageUrl });
|
||||
}}
|
||||
/>
|
||||
<ImageToolbarButton
|
||||
danger
|
||||
icon={<Trash2 className="size-4" />}
|
||||
label="删除"
|
||||
onClick={() => onDelete(node.id)}
|
||||
/>
|
||||
<ImageToolbarButton
|
||||
icon={<Download className="size-4" />}
|
||||
label="下载"
|
||||
onClick={() => downloadImage(imageUrl, getImageDownloadName(node))}
|
||||
/>
|
||||
<ImageToolbarButton
|
||||
icon={<Upload className="size-4" />}
|
||||
label="替换图片"
|
||||
onClick={() => replaceInputRef.current?.click()}
|
||||
/>
|
||||
<ImageToolbarButton
|
||||
icon={<Sparkles className="size-4" />}
|
||||
label="生成"
|
||||
onClick={() => {
|
||||
onSelect(node.id);
|
||||
onGenerate(node.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageToolbarButton({
|
||||
danger = false,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
danger?: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-sm font-medium text-zinc-700 transition",
|
||||
danger
|
||||
? "hover:bg-red-50 hover:text-red-600"
|
||||
: "hover:bg-zinc-100 hover:text-zinc-950",
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{icon}
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadImage(url: string, filename: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function getImageDownloadName(node: CanvasNodeType) {
|
||||
const safeTitle = (node.title || "canvas-image").replace(/[\\/:*?"<>|]+/g, "-");
|
||||
return `${safeTitle || "canvas-image"}.png`;
|
||||
}
|
||||
|
||||
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">
|
||||
<span className="text-zinc-500">{label}</span>
|
||||
<span className="truncate font-medium text-zinc-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionHandle({
|
||||
side,
|
||||
visible,
|
||||
onMouseDown,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
visible: boolean;
|
||||
onMouseDown: (event: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute top-1/2 z-40 flex size-12 -translate-y-1/2 cursor-crosshair items-center justify-center transition-opacity",
|
||||
side === "left" ? "-left-6" : "-right-6",
|
||||
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
onMouseDown={onMouseDown}
|
||||
title={side === "right" ? "创建连接" : "接收连接"}
|
||||
>
|
||||
<span className="size-3 rounded-full border-2 border-zinc-950 bg-white shadow-sm transition-transform hover:scale-125" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizeHandle({
|
||||
node,
|
||||
scale,
|
||||
corner,
|
||||
onResize,
|
||||
}: {
|
||||
node: CanvasNodeType;
|
||||
scale: number;
|
||||
corner: ResizeCorner;
|
||||
onResize: (id: string, width: number, height: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -bottom-3 -right-3 z-50 flex size-8 cursor-nwse-resize items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-500 opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startWidth = node.width;
|
||||
const startHeight = node.height;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
const dx = (moveEvent.clientX - startX) / scale;
|
||||
const dy = (moveEvent.clientY - startY) / scale;
|
||||
onResize(
|
||||
node.id,
|
||||
Math.max(node.type === "image" ? 120 : 220, startWidth + dx),
|
||||
Math.max(node.type === "image" ? 120 : 170, startHeight + dy),
|
||||
);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
}}
|
||||
title={`缩放 ${corner}`}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CanvasProjectListItem } from "@/lib/canvas/types";
|
||||
|
||||
export function CanvasProjectsClient() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, []);
|
||||
|
||||
async function loadProjects() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/projects", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
setProjects(Array.isArray(payload.items) ? payload.items : []);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "未命名画布" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
const id = payload?.item?.id;
|
||||
if (id) {
|
||||
router.push(`/canvas/${id}`);
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
|
||||
<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>
|
||||
<h1 className="text-lg font-semibold">无限画布</h1>
|
||||
<p className="text-sm text-zinc-500">一画布一项目,支持节点流式生成。</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => void loadProjects()}>
|
||||
<RefreshCcw />
|
||||
刷新
|
||||
</Button>
|
||||
<Button disabled={isCreating} onClick={() => void createProject()}>
|
||||
<Plus />
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-6 text-sm text-zinc-500">
|
||||
正在加载画布...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
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}`}
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
{!projects.length ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-6 text-sm text-zinc-500">
|
||||
还没有画布,先新建一个。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Eraser,
|
||||
FolderOpen,
|
||||
Grid2X2,
|
||||
Hand,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Menu,
|
||||
Minus,
|
||||
Plus,
|
||||
Redo2,
|
||||
Sparkles,
|
||||
SquareDashedMousePointer,
|
||||
Trash2,
|
||||
Type,
|
||||
Undo2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CanvasBackgroundMode, CanvasNodeType } from "@/lib/canvas/types";
|
||||
|
||||
type CanvasToolbarProps = {
|
||||
title: string;
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
scale: number;
|
||||
showLibrary: boolean;
|
||||
selectionMode: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
canDeleteSelection: boolean;
|
||||
onBack: () => void;
|
||||
onAddNode: (type: CanvasNodeType) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onGenerate: () => void;
|
||||
onUploadMaterial: () => void;
|
||||
onDeleteSelection: () => void;
|
||||
onClearCanvas: () => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
onToggleLibrary: () => void;
|
||||
onToggleSelectionMode: () => void;
|
||||
};
|
||||
|
||||
export function CanvasToolbar({
|
||||
title,
|
||||
isSaving,
|
||||
isGenerating,
|
||||
backgroundMode,
|
||||
scale,
|
||||
showLibrary,
|
||||
selectionMode,
|
||||
canUndo,
|
||||
canRedo,
|
||||
canDeleteSelection,
|
||||
onBack,
|
||||
onAddNode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onGenerate,
|
||||
onUploadMaterial,
|
||||
onDeleteSelection,
|
||||
onClearCanvas,
|
||||
onBackgroundModeChange,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetView,
|
||||
onToggleLibrary,
|
||||
onToggleSelectionMode,
|
||||
}: CanvasToolbarProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-[90] flex items-start justify-between p-5"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-2xl bg-white/92 px-4 py-3 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 items-center justify-center rounded-full text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onBack}
|
||||
title="返回画布列表"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-950">{title}</div>
|
||||
<div className="mt-0.5 text-[11px] tracking-[0.18em] text-zinc-400 uppercase">
|
||||
无限画布
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 transition ${
|
||||
backgroundMode === "lines"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => onBackgroundModeChange("lines")}
|
||||
title="线框背景"
|
||||
>
|
||||
<Grid2X2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex size-9 items-center justify-center rounded-full transition ${
|
||||
backgroundMode === "dots"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => onBackgroundModeChange("dots")}
|
||||
title="点阵背景"
|
||||
>
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
</button>
|
||||
<Button
|
||||
className="rounded-full bg-zinc-950 px-4 shadow-none hover:bg-zinc-800"
|
||||
disabled={isGenerating}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin" /> : <WandSparkles />}
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-5 left-5 z-[90]"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-2xl bg-white/92 px-3 py-2 shadow-[0_14px_42px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onResetView}
|
||||
title="重置视图"
|
||||
>
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onZoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-14 text-center text-sm font-medium text-zinc-700">
|
||||
{Math.round(scale * 100)}%
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onZoomIn}
|
||||
title="放大"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-5 left-1/2 z-[90] -translate-x-1/2"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-[26px] bg-white/92 px-3 py-2 shadow-[0_16px_44px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<DockButton
|
||||
active={selectionMode}
|
||||
icon={
|
||||
selectionMode ? (
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
) : (
|
||||
<Hand className="size-4" />
|
||||
)
|
||||
}
|
||||
label={selectionMode ? "框选" : "拖拽"}
|
||||
onClick={onToggleSelectionMode}
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canUndo}
|
||||
icon={<Undo2 className="size-4" />}
|
||||
label="撤回"
|
||||
onClick={onUndo}
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canRedo}
|
||||
icon={<Redo2 className="size-4" />}
|
||||
label="重做"
|
||||
onClick={onRedo}
|
||||
/>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
icon={<Type className="size-4" />}
|
||||
label="文本"
|
||||
onClick={() => onAddNode("prompt")}
|
||||
/>
|
||||
<DockButton
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
label="图片"
|
||||
onClick={() => onAddNode("image")}
|
||||
/>
|
||||
<DockButton
|
||||
icon={<Sparkles className="size-4" />}
|
||||
label="配置"
|
||||
onClick={() => onAddNode("config")}
|
||||
/>
|
||||
<DockButton
|
||||
icon={<Upload className="size-4" />}
|
||||
label="上传"
|
||||
onClick={onUploadMaterial}
|
||||
/>
|
||||
<DockButton
|
||||
active={showLibrary}
|
||||
icon={<FolderOpen className="size-4" />}
|
||||
label="素材库"
|
||||
onClick={onToggleLibrary}
|
||||
/>
|
||||
{canDeleteSelection ? (
|
||||
<>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
danger
|
||||
icon={<Trash2 className="size-4" />}
|
||||
label="删除"
|
||||
onClick={onDeleteSelection}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
danger
|
||||
icon={<Eraser className="size-4" />}
|
||||
label="清空"
|
||||
onClick={onClearCanvas}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
isSaving,
|
||||
isGenerating,
|
||||
}: {
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
}) {
|
||||
const label = isGenerating ? "生成中" : isSaving ? "保存中" : "已同步";
|
||||
const tone = isGenerating
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: isSaving
|
||||
? "bg-sky-100 text-sky-800"
|
||||
: "bg-emerald-100 text-emerald-800";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${tone}`}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DockButton({
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-11 items-center gap-2 rounded-2xl px-3 text-sm font-medium transition disabled:pointer-events-none disabled:opacity-40 ${
|
||||
danger
|
||||
? active
|
||||
? "bg-red-50 text-red-600"
|
||||
: "text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||
: active
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950"
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
>
|
||||
{icon}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DockDivider() {
|
||||
return <div className="mx-1 h-7 w-px bg-zinc-200" />;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
|
||||
import type {
|
||||
CanvasBackgroundMode,
|
||||
CanvasPosition,
|
||||
CanvasViewport,
|
||||
} from "@/lib/canvas/types";
|
||||
import {
|
||||
CANVAS_GRID_SIZE,
|
||||
clampScale,
|
||||
screenToWorld,
|
||||
} from "@/lib/canvas/geometry";
|
||||
|
||||
type InfiniteCanvasProps = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
viewport: CanvasViewport;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
selectionMode?: boolean;
|
||||
onBackgroundPointerDown?: () => boolean | void;
|
||||
onViewportChange: (viewport: CanvasViewport) => void;
|
||||
onCanvasPointerMove?: (point: CanvasPosition) => void;
|
||||
onCanvasPointerUp?: () => void;
|
||||
onCanvasDeselect?: () => void;
|
||||
onSelectionStart?: (point: CanvasPosition) => void;
|
||||
onSelectionMove?: (point: CanvasPosition) => void;
|
||||
onSelectionEnd?: (point: CanvasPosition) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function InfiniteCanvas({
|
||||
containerRef,
|
||||
viewport,
|
||||
backgroundMode,
|
||||
selectionMode = false,
|
||||
onBackgroundPointerDown,
|
||||
onViewportChange,
|
||||
onCanvasPointerMove,
|
||||
onCanvasPointerUp,
|
||||
onCanvasDeselect,
|
||||
onSelectionStart,
|
||||
onSelectionMove,
|
||||
onSelectionEnd,
|
||||
children,
|
||||
}: InfiniteCanvasProps) {
|
||||
const panState = useRef({
|
||||
mode: "idle" as "idle" | "pending" | "panning" | "selecting",
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
initialX: 0,
|
||||
initialY: 0,
|
||||
moved: false,
|
||||
pointerWorld: { x: 0, y: 0 },
|
||||
longPressTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
});
|
||||
const viewportRef = useRef(viewport);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const nextViewportRef = useRef<CanvasViewport | null>(null);
|
||||
const [spacePressed, setSpacePressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
viewportRef.current = viewport;
|
||||
}, [viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
setSpacePressed(true);
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code === "Space") setSpacePressed(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const preventNativeScroll = (event: WheelEvent) => event.preventDefault();
|
||||
container.addEventListener("wheel", preventNativeScroll, { passive: false });
|
||||
return () => container.removeEventListener("wheel", preventNativeScroll);
|
||||
}, [containerRef]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
if (panState.current.longPressTimer) {
|
||||
clearTimeout(panState.current.longPressTimer);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const worldPoint = screenToWorld(
|
||||
{ x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
viewportRef.current,
|
||||
);
|
||||
panState.current.pointerWorld = worldPoint;
|
||||
onCanvasPointerMove?.(worldPoint);
|
||||
|
||||
if (panState.current.mode === "selecting") {
|
||||
onSelectionMove?.(worldPoint);
|
||||
}
|
||||
}
|
||||
|
||||
if (panState.current.mode === "pending") {
|
||||
const dx = event.clientX - panState.current.startX;
|
||||
const dy = event.clientY - panState.current.startY;
|
||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
||||
if (panState.current.longPressTimer) {
|
||||
clearTimeout(panState.current.longPressTimer);
|
||||
panState.current.longPressTimer = null;
|
||||
}
|
||||
panState.current.mode = "panning";
|
||||
document.body.style.cursor = "grabbing";
|
||||
}
|
||||
}
|
||||
|
||||
if (panState.current.mode !== "panning") return;
|
||||
const dx = event.clientX - panState.current.startX;
|
||||
const dy = event.clientY - panState.current.startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||||
panState.current.moved = true;
|
||||
}
|
||||
|
||||
nextViewportRef.current = {
|
||||
x: panState.current.initialX + dx,
|
||||
y: panState.current.initialY + dy,
|
||||
k: viewportRef.current.k,
|
||||
};
|
||||
|
||||
if (frameRef.current) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
if (nextViewportRef.current) onViewportChange(nextViewportRef.current);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (panState.current.longPressTimer) {
|
||||
clearTimeout(panState.current.longPressTimer);
|
||||
panState.current.longPressTimer = null;
|
||||
}
|
||||
|
||||
if (panState.current.mode === "selecting") {
|
||||
onSelectionEnd?.(panState.current.pointerWorld);
|
||||
} else if (
|
||||
(panState.current.mode === "pending" || panState.current.mode === "panning") &&
|
||||
!panState.current.moved
|
||||
) {
|
||||
onCanvasDeselect?.();
|
||||
}
|
||||
panState.current.mode = "idle";
|
||||
document.body.style.cursor = "";
|
||||
onCanvasPointerUp?.();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [
|
||||
containerRef,
|
||||
onCanvasDeselect,
|
||||
onCanvasPointerMove,
|
||||
onCanvasPointerUp,
|
||||
onViewportChange,
|
||||
onSelectionEnd,
|
||||
onSelectionMove,
|
||||
]);
|
||||
|
||||
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
|
||||
if (event.target instanceof Element && event.target.closest("[data-canvas-ui]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const mouse = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
const delta = -event.deltaY;
|
||||
const factor = Math.pow(1.12, delta / 100);
|
||||
const nextScale = clampScale(viewport.k * factor);
|
||||
const world = screenToWorld(mouse, viewport);
|
||||
|
||||
onViewportChange({
|
||||
x: mouse.x - world.x * nextScale,
|
||||
y: mouse.y - world.y * nextScale,
|
||||
k: nextScale,
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerDown(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const isBackground = !target?.closest("[data-node-id],[data-connection-id],[data-canvas-ui]");
|
||||
if (!isBackground) return;
|
||||
if (onBackgroundPointerDown?.()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const pointerWorld = rect
|
||||
? screenToWorld(
|
||||
{ x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
viewport,
|
||||
)
|
||||
: { x: 0, y: 0 };
|
||||
|
||||
if (event.button === 0 || event.button === 1 || spacePressed) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
panState.current = {
|
||||
mode: selectionMode ? "selecting" : event.button === 1 || spacePressed ? "panning" : "pending",
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
initialX: viewport.x,
|
||||
initialY: viewport.y,
|
||||
moved: false,
|
||||
pointerWorld,
|
||||
longPressTimer: null,
|
||||
};
|
||||
|
||||
if (selectionMode) {
|
||||
document.body.style.cursor = "crosshair";
|
||||
onSelectionStart?.(pointerWorld);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button === 1 || spacePressed) {
|
||||
document.body.style.cursor = "grabbing";
|
||||
return;
|
||||
}
|
||||
|
||||
panState.current.longPressTimer = setTimeout(() => {
|
||||
panState.current.mode = "selecting";
|
||||
panState.current.longPressTimer = null;
|
||||
document.body.style.cursor = "crosshair";
|
||||
onSelectionStart?.(panState.current.pointerWorld);
|
||||
}, 260);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full cursor-grab select-none overflow-hidden bg-zinc-100"
|
||||
onPointerDown={handlePointerDown}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<CanvasGrid mode={backgroundMode} viewport={viewport} />
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasGrid({
|
||||
mode,
|
||||
viewport,
|
||||
}: {
|
||||
mode: CanvasBackgroundMode;
|
||||
viewport: CanvasViewport;
|
||||
}) {
|
||||
if (mode === "blank") return null;
|
||||
|
||||
const gridSize = CANVAS_GRID_SIZE * viewport.k;
|
||||
const x = viewport.x % gridSize;
|
||||
const y = viewport.y % gridSize;
|
||||
const backgroundImage =
|
||||
mode === "dots"
|
||||
? "radial-gradient(circle, rgba(113, 113, 122, 0.22) 1.2px, transparent 1.5px)"
|
||||
: "linear-gradient(rgba(161, 161, 170, 0.22) 1px, transparent 1px), linear-gradient(90deg, rgba(161, 161, 170, 0.22) 1px, transparent 1px)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-80"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundPosition: `${x}px ${y}px`,
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
parsePromptMentionUrl,
|
||||
type PromptMentionSource,
|
||||
} from "@/lib/canvas/prompt-mentions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PromptSegment =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mention"; source: PromptMentionSource };
|
||||
|
||||
type PromptMentionPreviewProps = {
|
||||
prompt: string;
|
||||
mentionSources: PromptMentionSource[];
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
chipClassName?: string;
|
||||
variant?: "link" | "image";
|
||||
placeholder?: ReactNode;
|
||||
};
|
||||
|
||||
export function PromptMentionPreview({
|
||||
prompt,
|
||||
mentionSources,
|
||||
className,
|
||||
textClassName,
|
||||
chipClassName,
|
||||
variant = "link",
|
||||
placeholder,
|
||||
}: PromptMentionPreviewProps): ReactElement | null {
|
||||
const segments = splitPromptSegments(prompt, mentionSources);
|
||||
|
||||
if (!segments.length) {
|
||||
return placeholder ? <>{placeholder}</> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("whitespace-pre-wrap break-words leading-6", className)}>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
return (
|
||||
<span
|
||||
key={`text-${index}`}
|
||||
className={cn("text-zinc-900", textClassName)}
|
||||
>
|
||||
{segment.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "image") {
|
||||
return (
|
||||
<span
|
||||
key={`mention-${segment.source.nodeId}-${index}`}
|
||||
className={cn(
|
||||
"mx-0.5 inline-flex max-w-full align-middle items-center gap-1 rounded-full border border-zinc-200 bg-white px-1.5 py-0.5 text-xs font-medium text-zinc-800 shadow-sm",
|
||||
chipClassName,
|
||||
)}
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-full bg-zinc-100">
|
||||
{segment.source.imageUrl ? (
|
||||
<img
|
||||
alt={segment.source.description}
|
||||
className="h-full w-full object-cover"
|
||||
src={segment.source.imageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sky-700">{segment.source.label}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`mention-${segment.source.nodeId}-${index}`}
|
||||
className={cn(
|
||||
"my-0.5 inline-flex h-7 max-w-full align-middle items-center rounded-md border border-sky-200 bg-sky-50 px-2 text-xs font-medium text-sky-800",
|
||||
chipClassName,
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{segment.source.label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function splitPromptSegments(
|
||||
prompt: string,
|
||||
mentionSources: PromptMentionSource[],
|
||||
): PromptSegment[] {
|
||||
if (!prompt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedSources = [...mentionSources].sort(
|
||||
(left, right) =>
|
||||
Math.max(right.token.length, right.alias.length) -
|
||||
Math.max(left.token.length, left.alias.length),
|
||||
);
|
||||
const segments: PromptSegment[] = [];
|
||||
|
||||
let cursor = 0;
|
||||
while (cursor < prompt.length) {
|
||||
const markdownMatch = parseMarkdownMentionAt(prompt, cursor);
|
||||
const matchedSource = markdownMatch
|
||||
? sortedSources.find((source) => source.nodeId === markdownMatch.nodeId)
|
||||
: sortedSources.find((source) => prompt.startsWith(source.alias, cursor));
|
||||
|
||||
if (!matchedSource) {
|
||||
let nextCursor = cursor + 1;
|
||||
while (nextCursor < prompt.length) {
|
||||
const hasMentionAhead =
|
||||
Boolean(parseMarkdownMentionAt(prompt, nextCursor)) ||
|
||||
sortedSources.some((source) => prompt.startsWith(source.alias, nextCursor));
|
||||
if (hasMentionAhead) break;
|
||||
nextCursor += 1;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: "text",
|
||||
value: prompt.slice(cursor, nextCursor),
|
||||
});
|
||||
cursor = nextCursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: "mention",
|
||||
source: matchedSource,
|
||||
});
|
||||
cursor += markdownMatch?.length ?? matchedSource.alias.length;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseMarkdownMentionAt(prompt: string, cursor: number) {
|
||||
const match = /^\[([^\]]+)\]\(([^)]+)\)/.exec(prompt.slice(cursor));
|
||||
if (!match) return null;
|
||||
|
||||
const nodeId = parsePromptMentionUrl(match[2]);
|
||||
if (!nodeId) return null;
|
||||
|
||||
return {
|
||||
label: match[1],
|
||||
nodeId,
|
||||
length: match[0].length,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Aperture,
|
||||
ImageIcon,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RefreshCcw,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CanvasProjectsClient } from "@/components/canvas/canvas-projects-client";
|
||||
import { DirectStudio } from "@/components/direct/direct-studio";
|
||||
|
||||
export type ActiveView = "studio" | "history" | "settings" | "canvas";
|
||||
|
||||
export function HomeShell({
|
||||
initialView = "studio",
|
||||
}: {
|
||||
initialView?: ActiveView;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [activeView, setActiveView] = useState<ActiveView>(initialView);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const routeView: ActiveView = pathname.startsWith("/canvas") ? "canvas" : activeView;
|
||||
|
||||
function handleViewChange(view: ActiveView) {
|
||||
if (view === "canvas") {
|
||||
router.push("/canvas", { scroll: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveView(view);
|
||||
const href = view === "studio" ? "/" : `/?view=${view}`;
|
||||
router.push(href, { scroll: false });
|
||||
}
|
||||
|
||||
const pageMeta = {
|
||||
studio: {
|
||||
title: "图片生成应用",
|
||||
description: "直接模式:文生图、图生图、历史查看",
|
||||
},
|
||||
history: {
|
||||
title: "作品记录",
|
||||
description: "查看本次会话生成的图片与任务参数",
|
||||
},
|
||||
settings: {
|
||||
title: "模型设置",
|
||||
description: "配置默认模型、输出尺寸、质量和编辑保护策略",
|
||||
},
|
||||
canvas: {
|
||||
title: "无限画布",
|
||||
description: "一画布一项目,支持节点流式生成",
|
||||
},
|
||||
}[routeView];
|
||||
|
||||
return (
|
||||
<main className="h-screen w-full overflow-hidden bg-zinc-100 text-zinc-950">
|
||||
<div
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "grid min-h-screen overflow-x-hidden lg:grid-cols-[72px_minmax(0,1fr)]"
|
||||
: "grid min-h-screen overflow-x-hidden lg:grid-cols-[248px_minmax(0,1fr)]"
|
||||
}
|
||||
>
|
||||
<aside className="hidden h-full overflow-hidden border-r border-zinc-200 bg-white lg:block">
|
||||
<div
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "flex h-14 items-center justify-center border-b border-zinc-200 px-3"
|
||||
: "flex h-14 items-center gap-2 border-b border-zinc-200 px-4"
|
||||
}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-zinc-950 text-white">
|
||||
<Aperture className="size-4" />
|
||||
</div>
|
||||
<div className={sidebarCollapsed ? "hidden" : ""}>
|
||||
<div className="text-sm font-semibold">Image Studio</div>
|
||||
<div className="text-xs text-zinc-500">direct mode</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex h-[calc(100%-56px)] flex-col px-3 py-3">
|
||||
<div className="space-y-1">
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "studio" ? "secondary" : "ghost"}
|
||||
title="生成工作台"
|
||||
onClick={() => handleViewChange("studio")}
|
||||
>
|
||||
<Sparkles />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>生成工作台</span>
|
||||
</Button>
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "history" ? "secondary" : "ghost"}
|
||||
title="作品记录"
|
||||
onClick={() => handleViewChange("history")}
|
||||
>
|
||||
<ImageIcon />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>作品记录</span>
|
||||
</Button>
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "settings" ? "secondary" : "ghost"}
|
||||
title="模型设置"
|
||||
onClick={() => handleViewChange("settings")}
|
||||
>
|
||||
<Settings2 />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>模型设置</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-zinc-200 pt-5">
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "canvas" ? "secondary" : "ghost"}
|
||||
title="无限画布"
|
||||
onClick={() => handleViewChange("canvas")}
|
||||
>
|
||||
<Workflow />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>无限画布</span>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="flex h-screen min-w-0 flex-col overflow-hidden">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 bg-white px-4 lg:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={sidebarCollapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
>
|
||||
<PanelLeft className="lg:hidden" />
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeftOpen className="hidden lg:block" />
|
||||
) : (
|
||||
<PanelLeftClose className="hidden lg:block" />
|
||||
)}
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold">{pageMeta.title}</h1>
|
||||
<p className="text-xs text-zinc-500">{pageMeta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{routeView === "canvas" ? "canvas" : "direct"}</Badge>
|
||||
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCcw />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{routeView === "canvas" ? (
|
||||
<CanvasProjectsClient />
|
||||
) : (
|
||||
<DirectStudio activeView={routeView} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Badge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center rounded-md border border-zinc-200 bg-zinc-50 px-2 text-xs font-medium text-zinc-700",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-zinc-950 text-white hover:bg-zinc-800",
|
||||
secondary: "bg-zinc-100 text-zinc-900 hover:bg-zinc-200",
|
||||
outline: "border border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50",
|
||||
ghost: "text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-3",
|
||||
sm: "h-8 px-2.5 text-xs",
|
||||
lg: "h-10 px-4",
|
||||
icon: "h-9 w-9 px-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-lg border border-zinc-200 bg-white shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("space-y-1.5 p-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-sm font-semibold leading-none text-zinc-950", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p className={cn("text-xs leading-5 text-zinc-500", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("p-4 pt-0", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-950 shadow-xs outline-none transition-colors placeholder:text-zinc-400 focus:border-zinc-400 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn("text-xs font-medium text-zinc-700", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Select = SelectPrimitive.Root;
|
||||
export const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
export function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-950 shadow-xs outline-none transition-colors focus:border-zinc-400 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 text-zinc-500" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-canvas-ui
|
||||
className={cn(
|
||||
"pointer-events-auto z-[140] min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white text-zinc-950 shadow-md",
|
||||
className,
|
||||
)}
|
||||
position="popper"
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-zinc-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-zinc-200",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-zinc-200 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-zinc-950",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Tabs = TabsPrimitive.Root;
|
||||
|
||||
export function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-md bg-zinc-100 p-1 text-zinc-500",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center justify-center rounded-sm px-3 text-sm font-medium transition-all data-[state=active]:bg-white data-[state=active]:text-zinc-950 data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content className={cn("mt-4", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(function Textarea({ className, ...props }, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-28 w-full rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-950 shadow-xs outline-none transition-colors placeholder:text-zinc-400 focus:border-zinc-400 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
CanvasConfigNodeMetadata,
|
||||
CanvasConnection,
|
||||
CanvasImageNodeMetadata,
|
||||
CanvasNode,
|
||||
CanvasPromptNodeMetadata,
|
||||
} from "@/lib/canvas/types";
|
||||
import { defaultCanvasConfig } from "@/lib/canvas/node-factory";
|
||||
import {
|
||||
getPromptMentionSources,
|
||||
resolvePromptMentions,
|
||||
} from "@/lib/canvas/prompt-mentions";
|
||||
|
||||
export type CanvasGenerationInput = {
|
||||
promptNode: CanvasNode;
|
||||
configNode?: CanvasNode;
|
||||
imageNode?: CanvasNode;
|
||||
imageNodes: CanvasNode[];
|
||||
prompt: string;
|
||||
config: CanvasConfigNodeMetadata;
|
||||
};
|
||||
|
||||
export function resolveCanvasGenerationInput(
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
selectedNodeId: string | null,
|
||||
): CanvasGenerationInput | { error: string } {
|
||||
const scopedNodes = getScopedNodes(nodes, connections, selectedNodeId);
|
||||
const scopedNodeIds = new Set(scopedNodes.map((node) => node.id));
|
||||
const scopedConnections = connections.filter(
|
||||
(connection) =>
|
||||
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 resolvedMentions = promptNode
|
||||
? resolvePromptMentions(
|
||||
rawPrompt,
|
||||
getPromptMentionSources(scopedNodes, scopedConnections, promptNode.id),
|
||||
)
|
||||
: null;
|
||||
const prompt = resolvedMentions?.prompt ?? "";
|
||||
|
||||
if (!promptNode || !prompt) {
|
||||
return { error: "请先添加并填写提示词节点" };
|
||||
}
|
||||
|
||||
const configNode =
|
||||
scopedNodes.find((node) => node.type === "config") ??
|
||||
nodes.find((node) => node.type === "config");
|
||||
const imageNodes = collectImageNodes(
|
||||
scopedNodes,
|
||||
resolvedMentions?.referencedNodeIds ?? [],
|
||||
);
|
||||
const fallbackImageNodes = imageNodes.length
|
||||
? imageNodes
|
||||
: collectImageNodes(nodes, []);
|
||||
|
||||
return {
|
||||
promptNode,
|
||||
configNode,
|
||||
imageNode: fallbackImageNodes[0],
|
||||
imageNodes: fallbackImageNodes.slice(0, 16),
|
||||
prompt,
|
||||
config: {
|
||||
...defaultCanvasConfig,
|
||||
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectImageNodes(nodes: CanvasNode[], preferredNodeIds: string[]) {
|
||||
const preferred = preferredNodeIds
|
||||
.map((id) => nodes.find((node) => node.id === id))
|
||||
.filter(isImageWithUrl);
|
||||
const preferredIds = new Set(preferred.map((node) => node.id));
|
||||
const remaining = nodes
|
||||
.filter(isImageWithUrl)
|
||||
.filter((node) => !preferredIds.has(node.id));
|
||||
|
||||
return [...preferred, ...remaining];
|
||||
}
|
||||
|
||||
function isImageWithUrl(node: CanvasNode | undefined): node is CanvasNode {
|
||||
return Boolean(
|
||||
node?.type === "image" &&
|
||||
(node.metadata as CanvasImageNodeMetadata).imageUrl,
|
||||
);
|
||||
}
|
||||
|
||||
function getScopedNodes(
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
selectedNodeId: string | null,
|
||||
) {
|
||||
if (!selectedNodeId) return nodes;
|
||||
|
||||
const visited = new Set<string>([selectedNodeId]);
|
||||
const queue = [selectedNodeId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) continue;
|
||||
|
||||
for (const connection of connections) {
|
||||
if (connection.fromNodeId === current && !visited.has(connection.toNodeId)) {
|
||||
visited.add(connection.toNodeId);
|
||||
queue.push(connection.toNodeId);
|
||||
}
|
||||
if (connection.toNodeId === current && !visited.has(connection.fromNodeId)) {
|
||||
visited.add(connection.fromNodeId);
|
||||
queue.push(connection.fromNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.filter((node) => visited.has(node.id));
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CanvasNode, CanvasPosition, CanvasViewport } from "@/lib/canvas/types";
|
||||
|
||||
export const CANVAS_MIN_SCALE = 0.08;
|
||||
export const CANVAS_MAX_SCALE = 3.5;
|
||||
export const CANVAS_GRID_SIZE = 48;
|
||||
|
||||
export function clampScale(scale: number) {
|
||||
return Math.min(Math.max(scale, CANVAS_MIN_SCALE), CANVAS_MAX_SCALE);
|
||||
}
|
||||
|
||||
export function screenToWorld(
|
||||
point: CanvasPosition,
|
||||
viewport: CanvasViewport,
|
||||
): CanvasPosition {
|
||||
return {
|
||||
x: (point.x - viewport.x) / viewport.k,
|
||||
y: (point.y - viewport.y) / viewport.k,
|
||||
};
|
||||
}
|
||||
|
||||
export function worldToScreen(
|
||||
point: CanvasPosition,
|
||||
viewport: CanvasViewport,
|
||||
): CanvasPosition {
|
||||
return {
|
||||
x: point.x * viewport.k + viewport.x,
|
||||
y: point.y * viewport.k + viewport.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodeCenter(node: CanvasNode): CanvasPosition {
|
||||
return {
|
||||
x: node.position.x + node.width / 2,
|
||||
y: node.position.y + node.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function getConnectionPath(from: CanvasNode, to: CanvasNode) {
|
||||
const start = {
|
||||
x: from.position.x + from.width,
|
||||
y: from.position.y + from.height / 2,
|
||||
};
|
||||
const end = {
|
||||
x: to.position.x,
|
||||
y: to.position.y + to.height / 2,
|
||||
};
|
||||
const distance = Math.abs(end.x - start.x);
|
||||
const curvature = Math.max(distance * 0.5, 64);
|
||||
|
||||
return `M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${
|
||||
end.x - curvature
|
||||
} ${end.y}, ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export function getActiveConnectionPath(
|
||||
start: CanvasPosition,
|
||||
end: CanvasPosition,
|
||||
) {
|
||||
const distance = Math.abs(end.x - start.x);
|
||||
const curvature = Math.max(distance * 0.5, 64);
|
||||
|
||||
return `M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${
|
||||
end.x - curvature
|
||||
} ${end.y}, ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export function placeResultNode(
|
||||
sourceNode: CanvasNode | undefined,
|
||||
nodes: CanvasNode[],
|
||||
fallback: CanvasPosition,
|
||||
) {
|
||||
const base = sourceNode
|
||||
? {
|
||||
x: sourceNode.position.x + sourceNode.width + 140,
|
||||
y: sourceNode.position.y,
|
||||
}
|
||||
: fallback;
|
||||
|
||||
let attempt = 0;
|
||||
let candidate = { ...base };
|
||||
while (nodes.some((node) => intersects(candidate, node))) {
|
||||
attempt += 1;
|
||||
candidate = {
|
||||
x: base.x + (attempt % 3) * 36,
|
||||
y: base.y + Math.floor(attempt / 3 + 1) * 72,
|
||||
};
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function intersects(position: CanvasPosition, node: CanvasNode) {
|
||||
const width = 280;
|
||||
const height = 220;
|
||||
return !(
|
||||
position.x + width < node.position.x ||
|
||||
position.x > node.position.x + node.width ||
|
||||
position.y + height < node.position.y ||
|
||||
position.y > node.position.y + node.height
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
CanvasConfigNodeMetadata,
|
||||
CanvasImageNodeMetadata,
|
||||
CanvasNode,
|
||||
CanvasNodeType,
|
||||
CanvasPosition,
|
||||
} from "@/lib/canvas/types";
|
||||
import type { HistoryItem } from "@/lib/image-workflow";
|
||||
|
||||
export const DEFAULT_NODE_WIDTH = 280;
|
||||
export const DEFAULT_NODE_HEIGHT = 170;
|
||||
export const DEFAULT_IMAGE_WIDTH = 320;
|
||||
export const DEFAULT_IMAGE_HEIGHT = 240;
|
||||
|
||||
export const defaultCanvasConfig: CanvasConfigNodeMetadata = {
|
||||
model: "gpt-image-2-2k",
|
||||
size: "1024x1024",
|
||||
quality: "high",
|
||||
outputFormat: "png",
|
||||
preserveIdentity: true,
|
||||
};
|
||||
|
||||
export function createCanvasNode(
|
||||
type: CanvasNodeType,
|
||||
position: CanvasPosition,
|
||||
): CanvasNode {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
if (type === "prompt") {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: "提示词",
|
||||
position,
|
||||
width: DEFAULT_NODE_WIDTH,
|
||||
height: DEFAULT_NODE_HEIGHT,
|
||||
metadata: {
|
||||
prompt: "描述你想生成的画面...",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "config") {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: "生成配置",
|
||||
position,
|
||||
width: DEFAULT_NODE_WIDTH,
|
||||
height: DEFAULT_NODE_HEIGHT,
|
||||
metadata: { ...defaultCanvasConfig },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: "参考图片",
|
||||
position,
|
||||
width: DEFAULT_IMAGE_WIDTH,
|
||||
height: DEFAULT_IMAGE_HEIGHT,
|
||||
metadata: {
|
||||
imageUrl: "",
|
||||
mode: "reference",
|
||||
status: "idle",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageNodeFromHistory(
|
||||
item: HistoryItem,
|
||||
position: CanvasPosition,
|
||||
): CanvasNode {
|
||||
const size = getImageNodeSize(item);
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "image",
|
||||
title: "历史图片",
|
||||
position,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: {
|
||||
historyId: item.id,
|
||||
imageUrl: item.imageUrl,
|
||||
mode: "reference",
|
||||
prompt: item.prompt,
|
||||
model: item.model,
|
||||
size: item.size,
|
||||
quality: item.quality,
|
||||
outputFormat: item.outputFormat,
|
||||
status: "success",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageNodeFromUpload(
|
||||
file: File,
|
||||
imageUrl: string,
|
||||
position: CanvasPosition,
|
||||
): CanvasNode {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "image",
|
||||
title: file.name || "上传图片",
|
||||
position,
|
||||
width: DEFAULT_IMAGE_WIDTH,
|
||||
height: DEFAULT_IMAGE_HEIGHT,
|
||||
metadata: {
|
||||
imageUrl,
|
||||
mode: "reference",
|
||||
status: "success",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageResultNode(
|
||||
result: HistoryItem,
|
||||
position: CanvasPosition,
|
||||
): CanvasNode {
|
||||
const size = getImageNodeSize(result);
|
||||
const metadata: CanvasImageNodeMetadata = {
|
||||
imageUrl: result.imageUrl,
|
||||
mode: "result",
|
||||
historyId: result.id,
|
||||
prompt: result.prompt,
|
||||
model: result.model,
|
||||
size: result.size,
|
||||
quality: result.quality,
|
||||
outputFormat: result.outputFormat,
|
||||
status: "success",
|
||||
};
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "image",
|
||||
title: "生成结果",
|
||||
position,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function getImageNodeSize(item: Pick<HistoryItem, "size">) {
|
||||
const match = /^(\d+)x(\d+)$/.exec(item.size || "");
|
||||
if (!match) {
|
||||
return { width: DEFAULT_IMAGE_WIDTH, height: DEFAULT_IMAGE_HEIGHT };
|
||||
}
|
||||
|
||||
const naturalWidth = Number(match[1]);
|
||||
const naturalHeight = Number(match[2]);
|
||||
if (!naturalWidth || !naturalHeight) {
|
||||
return { width: DEFAULT_IMAGE_WIDTH, height: DEFAULT_IMAGE_HEIGHT };
|
||||
}
|
||||
|
||||
const maxSide = 360;
|
||||
const minSide = 180;
|
||||
const scale = Math.min(maxSide / naturalWidth, maxSide / naturalHeight, 1);
|
||||
const imageWidth = Math.max(minSide, Math.round(naturalWidth * scale));
|
||||
const imageHeight = Math.max(minSide, Math.round(naturalHeight * scale));
|
||||
const padding = 16;
|
||||
const headerHeight = 34;
|
||||
const gap = 12;
|
||||
|
||||
return {
|
||||
width: imageWidth + padding * 2,
|
||||
height: imageHeight + padding * 2 + headerHeight + gap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CanvasConnection,
|
||||
CanvasImageNodeMetadata,
|
||||
CanvasNode,
|
||||
} from "@/lib/canvas/types";
|
||||
|
||||
export type PromptMentionSource = {
|
||||
alias: string;
|
||||
token: string;
|
||||
nodeId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const mentionUrlPrefix = "canvas-image://";
|
||||
|
||||
export function getPromptMentionSources(
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
promptNodeId: string,
|
||||
) {
|
||||
const imageById = new Map(
|
||||
nodes
|
||||
.filter((node) => node.type === "image")
|
||||
.map((node) => [node.id, node] as const),
|
||||
);
|
||||
|
||||
return connections
|
||||
.filter((connection) => connection.toNodeId === promptNodeId)
|
||||
.map((connection) => imageById.get(connection.fromNodeId))
|
||||
.filter((node): node is CanvasNode => Boolean(node))
|
||||
.map((node, index) => {
|
||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||
return {
|
||||
alias: `@图${index + 1}`,
|
||||
token: createPromptMentionToken(`图${index + 1}`, node.id),
|
||||
nodeId: node.id,
|
||||
label: `图${index + 1}`,
|
||||
index: index + 1,
|
||||
description:
|
||||
metadata.prompt?.trim() || node.title.trim() || `已连接素材 ${index + 1}`,
|
||||
imageUrl: metadata.imageUrl,
|
||||
} satisfies PromptMentionSource;
|
||||
})
|
||||
.filter((item) => Boolean(item.imageUrl));
|
||||
}
|
||||
|
||||
export function createPromptMentionToken(label: string, nodeId: string) {
|
||||
return `[${label}](${mentionUrlPrefix}${nodeId})`;
|
||||
}
|
||||
|
||||
export function parsePromptMentionUrl(value: string) {
|
||||
return value.startsWith(mentionUrlPrefix)
|
||||
? value.slice(mentionUrlPrefix.length)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolvePromptMentions(
|
||||
prompt: string,
|
||||
mentions: PromptMentionSource[],
|
||||
) {
|
||||
const explicitlyReferencedNodeIds: string[] = [];
|
||||
let resolvedPrompt = prompt;
|
||||
|
||||
for (const mention of mentions) {
|
||||
const replacement = `${mention.label}(第 ${mention.index} 张输入参考图)`;
|
||||
const usedMarkdownToken = resolvedPrompt.includes(mention.token);
|
||||
const usedAlias = resolvedPrompt.includes(mention.alias);
|
||||
if (!usedMarkdownToken && !usedAlias) continue;
|
||||
explicitlyReferencedNodeIds.push(mention.nodeId);
|
||||
resolvedPrompt = resolvedPrompt.split(mention.token).join(replacement);
|
||||
resolvedPrompt = resolvedPrompt.split(mention.alias).join(replacement);
|
||||
}
|
||||
|
||||
if (mentions.length > 0) {
|
||||
const referenceGuide = mentions
|
||||
.map(
|
||||
(mention) =>
|
||||
`${mention.label} = 第 ${mention.index} 张输入参考图:${mention.description}`,
|
||||
)
|
||||
.join("\n");
|
||||
resolvedPrompt = `${resolvedPrompt.trim()}\n\n已连接参考图编号:\n${referenceGuide}`;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: resolvedPrompt.trim(),
|
||||
referencedNodeIds: mentions.map((mention) => mention.nodeId),
|
||||
explicitlyReferencedNodeIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
export type CanvasViewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
k: number;
|
||||
};
|
||||
|
||||
export type CanvasBackgroundMode = "lines" | "dots" | "blank";
|
||||
|
||||
export type CanvasPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type CanvasNodeType = "prompt" | "config" | "image";
|
||||
|
||||
export type CanvasPromptNodeMetadata = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type CanvasConfigNodeMetadata = {
|
||||
model: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
preserveIdentity: boolean;
|
||||
};
|
||||
|
||||
export type CanvasImageNodeMetadata = {
|
||||
historyId?: string;
|
||||
imageUrl: string;
|
||||
filePath?: string;
|
||||
mode: "reference" | "result";
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
outputFormat?: string;
|
||||
status?: "idle" | "loading" | "success" | "error";
|
||||
errorMessage?: string;
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
};
|
||||
|
||||
export type CanvasNodeMetadata =
|
||||
| CanvasPromptNodeMetadata
|
||||
| CanvasConfigNodeMetadata
|
||||
| CanvasImageNodeMetadata;
|
||||
|
||||
export type CanvasNode = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
title: string;
|
||||
position: CanvasPosition;
|
||||
width: number;
|
||||
height: number;
|
||||
metadata: CanvasNodeMetadata;
|
||||
};
|
||||
|
||||
export type CanvasConnection = {
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type CanvasProjectData = {
|
||||
viewport: CanvasViewport;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
};
|
||||
|
||||
export type CanvasProjectRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
data: CanvasProjectData;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CanvasProjectListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nodeCount: number;
|
||||
};
|
||||
|
||||
export const defaultCanvasProjectData: CanvasProjectData = {
|
||||
viewport: { x: 0, y: 0, k: 1 },
|
||||
backgroundMode: "lines",
|
||||
nodes: [],
|
||||
connections: [],
|
||||
};
|
||||
|
||||
export function isCanvasProjectData(value: unknown): value is CanvasProjectData {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.viewport === "object" &&
|
||||
candidate.viewport !== null &&
|
||||
Array.isArray(candidate.nodes) &&
|
||||
Array.isArray(candidate.connections)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
export type Resolution = "1K" | "2K" | "4K" | "8K";
|
||||
export type ImageMode = "generate" | "edit";
|
||||
export type ImageJobStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
export type HistoryItem = {
|
||||
id: string;
|
||||
mode: ImageMode;
|
||||
prompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
imageUrl: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GenerationError = {
|
||||
title: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
requestId?: string;
|
||||
phase?: string;
|
||||
durationMs?: number;
|
||||
upstreamStatus?: number;
|
||||
upstreamRequestId?: string;
|
||||
code?: string;
|
||||
type?: string;
|
||||
details?: unknown;
|
||||
rawBody?: string;
|
||||
};
|
||||
|
||||
export type ImageJobPayload = {
|
||||
jobId?: string;
|
||||
requestId?: string;
|
||||
status?: ImageJobStatus;
|
||||
phase?: string;
|
||||
progressMessage?: string;
|
||||
durationMs?: number;
|
||||
result?: HistoryItem;
|
||||
error?: string;
|
||||
code?: string;
|
||||
type?: string;
|
||||
param?: string;
|
||||
upstreamStatus?: number;
|
||||
upstreamRequestId?: string;
|
||||
details?: unknown;
|
||||
pollUrl?: string;
|
||||
};
|
||||
|
||||
export const sizeOptions = [
|
||||
{ value: "1:1", width: 1, height: 1 },
|
||||
{ value: "4:3", width: 4, height: 3 },
|
||||
{ value: "3:4", width: 3, height: 4 },
|
||||
{ value: "3:2", width: 3, height: 2 },
|
||||
{ value: "2:3", width: 2, height: 3 },
|
||||
{ value: "16:9", width: 16, height: 9 },
|
||||
{ value: "9:16", width: 9, height: 16 },
|
||||
{ value: "5:3", width: 5, height: 3 },
|
||||
{ value: "18:9", width: 18, height: 9 },
|
||||
{ value: "9:18", width: 9, height: 18 },
|
||||
];
|
||||
|
||||
export const resolutionOptions: Resolution[] = ["1K", "2K", "4K", "8K"];
|
||||
|
||||
export const resolutionLongEdge: Record<Resolution, number> = {
|
||||
"1K": 1024,
|
||||
"2K": 2048,
|
||||
"4K": 3840,
|
||||
"8K": 7680,
|
||||
};
|
||||
|
||||
export function computeSizeFromRatio(
|
||||
ratioWidth: number,
|
||||
ratioHeight: number,
|
||||
resolution: Resolution,
|
||||
) {
|
||||
const longEdge = resolutionLongEdge[resolution];
|
||||
const isLandscape = ratioWidth >= ratioHeight;
|
||||
const scale = isLandscape ? longEdge / ratioWidth : longEdge / ratioHeight;
|
||||
const width = roundToMultipleOf16(ratioWidth * scale);
|
||||
const height = roundToMultipleOf16(ratioHeight * scale);
|
||||
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
export function getComputedSize(ratio: string, resolution: Resolution) {
|
||||
const option = sizeOptions.find((item) => item.value === ratio) ?? sizeOptions[0];
|
||||
return computeSizeFromRatio(option.width, option.height, resolution);
|
||||
}
|
||||
|
||||
export function inferSizeSelection(size?: string): {
|
||||
ratio: string;
|
||||
resolution: Resolution;
|
||||
computedSize: string;
|
||||
} {
|
||||
const fallbackRatio = sizeOptions[0]?.value ?? "1:1";
|
||||
const fallbackResolution: Resolution = "1K";
|
||||
|
||||
if (!size) {
|
||||
return {
|
||||
ratio: fallbackRatio,
|
||||
resolution: fallbackResolution,
|
||||
computedSize: getComputedSize(fallbackRatio, fallbackResolution),
|
||||
};
|
||||
}
|
||||
|
||||
const match = size.match(/^(\d+)x(\d+)$/i);
|
||||
if (!match) {
|
||||
return {
|
||||
ratio: fallbackRatio,
|
||||
resolution: fallbackResolution,
|
||||
computedSize: size,
|
||||
};
|
||||
}
|
||||
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return {
|
||||
ratio: fallbackRatio,
|
||||
resolution: fallbackResolution,
|
||||
computedSize: size,
|
||||
};
|
||||
}
|
||||
|
||||
const ratioScore = (option: { width: number; height: number }) =>
|
||||
Math.abs(width / height - option.width / option.height);
|
||||
const nearestRatio =
|
||||
sizeOptions.reduce((best, current) =>
|
||||
ratioScore(current) < ratioScore(best) ? current : best,
|
||||
) ?? sizeOptions[0];
|
||||
|
||||
const longEdge = Math.max(width, height);
|
||||
const nearestResolution =
|
||||
resolutionOptions.reduce((best, current) =>
|
||||
Math.abs(resolutionLongEdge[current] - longEdge) <
|
||||
Math.abs(resolutionLongEdge[best] - longEdge)
|
||||
? current
|
||||
: best,
|
||||
) ?? fallbackResolution;
|
||||
|
||||
return {
|
||||
ratio: nearestRatio.value,
|
||||
resolution: nearestResolution,
|
||||
computedSize: getComputedSize(nearestRatio.value, nearestResolution),
|
||||
};
|
||||
}
|
||||
|
||||
export function roundToMultipleOf16(value: number) {
|
||||
return Math.max(16, Math.round(value / 16) * 16);
|
||||
}
|
||||
|
||||
export function extensionFromFormat(format: string) {
|
||||
return format === "jpeg" ? "jpg" : format || "png";
|
||||
}
|
||||
|
||||
export function createClientRequestId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `client-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export async function parseJsonResponse(response: Response) {
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (!rawBody) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(rawBody) as Record<string, unknown>;
|
||||
return payload;
|
||||
} catch {
|
||||
return {
|
||||
error: response.statusText || "服务端返回了非 JSON 响应",
|
||||
rawBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGenerationError(
|
||||
response: Response,
|
||||
payload: Record<string, unknown>,
|
||||
requestStartedAt: number,
|
||||
): GenerationError {
|
||||
const responseRequestId = response.headers.get("x-request-id") || undefined;
|
||||
const requestId = getStringField(payload, "requestId") || responseRequestId;
|
||||
const message =
|
||||
getStringField(payload, "error") ||
|
||||
getStringField(payload, "message") ||
|
||||
response.statusText ||
|
||||
"生成失败";
|
||||
|
||||
return {
|
||||
title: `生成失败(HTTP ${response.status})`,
|
||||
message,
|
||||
status: response.status,
|
||||
requestId,
|
||||
phase: getStringField(payload, "phase"),
|
||||
durationMs:
|
||||
getNumberField(payload, "durationMs") ??
|
||||
Math.round(performance.now() - requestStartedAt),
|
||||
upstreamStatus: getNumberField(payload, "upstreamStatus"),
|
||||
upstreamRequestId: getStringField(payload, "upstreamRequestId"),
|
||||
code: getStringField(payload, "code"),
|
||||
type: getStringField(payload, "type"),
|
||||
details: payload.details,
|
||||
rawBody: getStringField(payload, "rawBody"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGenerationErrorFromJob(
|
||||
job: ImageJobPayload,
|
||||
requestStartedAt: number,
|
||||
): GenerationError {
|
||||
return {
|
||||
title: `生成失败${job.upstreamStatus ? `(上游 ${job.upstreamStatus})` : ""}`,
|
||||
message: job.error || "图片任务失败",
|
||||
status: job.upstreamStatus,
|
||||
requestId: job.requestId,
|
||||
phase: job.phase,
|
||||
durationMs:
|
||||
job.durationMs ?? Math.round(performance.now() - requestStartedAt),
|
||||
upstreamStatus: job.upstreamStatus,
|
||||
upstreamRequestId: job.upstreamRequestId,
|
||||
code: job.code,
|
||||
type: job.type,
|
||||
details: job.details,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGenerationError(
|
||||
error: unknown,
|
||||
requestStartedAt: number,
|
||||
clientRequestId: string,
|
||||
): GenerationError {
|
||||
if (isGenerationError(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return {
|
||||
title: "生成失败",
|
||||
message: error instanceof Error ? error.message : "请求未完成或响应无法解析",
|
||||
requestId: clientRequestId,
|
||||
durationMs: Math.round(performance.now() - requestStartedAt),
|
||||
details: error instanceof Error ? error.stack : error,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeImageJobPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): ImageJobPayload {
|
||||
const result = getObjectField(payload, "result");
|
||||
|
||||
return {
|
||||
jobId: getStringField(payload, "jobId"),
|
||||
requestId: getStringField(payload, "requestId"),
|
||||
status: normalizeJobStatus(getStringField(payload, "status")),
|
||||
phase: getStringField(payload, "phase"),
|
||||
progressMessage: getStringField(payload, "progressMessage"),
|
||||
durationMs: getNumberField(payload, "durationMs"),
|
||||
result: result
|
||||
? {
|
||||
id: getStringField(result, "id") || "",
|
||||
mode: "generate",
|
||||
prompt: "",
|
||||
model: getStringField(result, "model") || "",
|
||||
size: getStringField(result, "size") || "",
|
||||
quality: getStringField(result, "quality") || "",
|
||||
outputFormat: getStringField(result, "outputFormat") || "",
|
||||
imageUrl: getStringField(result, "imageUrl") || "",
|
||||
createdAt: getStringField(result, "createdAt") || "",
|
||||
}
|
||||
: undefined,
|
||||
error: getStringField(payload, "error"),
|
||||
code: getStringField(payload, "code"),
|
||||
type: getStringField(payload, "type"),
|
||||
param: getStringField(payload, "param"),
|
||||
upstreamStatus: getNumberField(payload, "upstreamStatus"),
|
||||
upstreamRequestId: getStringField(payload, "upstreamRequestId"),
|
||||
details: payload.details,
|
||||
pollUrl: getStringField(payload, "pollUrl"),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatJobSuccessDescription(job: ImageJobPayload) {
|
||||
return [
|
||||
job.jobId ? `任务 ID:${job.jobId}` : null,
|
||||
job.durationMs ? `耗时:${job.durationMs}ms` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function normalizeJobStatus(
|
||||
value: string | undefined,
|
||||
): ImageJobStatus | undefined {
|
||||
return ["queued", "running", "succeeded", "failed"].includes(value || "")
|
||||
? (value as ImageJobStatus)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getStringField(payload: Record<string, unknown>, key: string) {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function getNumberField(payload: Record<string, unknown>, key: string) {
|
||||
const value = payload[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function getObjectField(payload: Record<string, unknown>, key: string) {
|
||||
const value = payload[key];
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isGenerationError(value: unknown): value is GenerationError {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"title" in value &&
|
||||
"message" in value
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dataDir = join(/* turbopackIgnore: true */ process.cwd(), "data");
|
||||
const dbPath = join(dataDir, "imagegen.sqlite");
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDataDir() {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
getDataDir();
|
||||
|
||||
if (!db) {
|
||||
db = new Database(dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
size TEXT NOT NULL,
|
||||
quality TEXT NOT NULL,
|
||||
output_format TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS canvas_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('retentionDays', '7')",
|
||||
).run();
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
defaultCanvasProjectData,
|
||||
type CanvasProjectData,
|
||||
type CanvasProjectListItem,
|
||||
type CanvasProjectRecord,
|
||||
isCanvasProjectData,
|
||||
} from "@/lib/canvas/types";
|
||||
import { getDb } from "@/lib/server/db";
|
||||
|
||||
type CanvasProjectRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
data: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function listCanvasProjects(): CanvasProjectListItem[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM canvas_projects
|
||||
ORDER BY updated_at DESC`,
|
||||
)
|
||||
.all() as CanvasProjectRow[];
|
||||
|
||||
return rows.map((row) => {
|
||||
const data = parseCanvasProjectData(row.data);
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
nodeCount: data.nodes.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM canvas_projects
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.get(id) as CanvasProjectRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
data: parseCanvasProjectData(row.data),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCanvasProject(input?: {
|
||||
title?: string;
|
||||
data?: CanvasProjectData;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
const id = crypto.randomUUID();
|
||||
const title = input?.title?.trim() || "未命名画布";
|
||||
const data = input?.data ?? defaultCanvasProjectData;
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO canvas_projects (id, title, data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, title, JSON.stringify(data), now, now);
|
||||
|
||||
return getCanvasProjectById(id);
|
||||
}
|
||||
|
||||
export function updateCanvasProject(
|
||||
id: string,
|
||||
patch: {
|
||||
title?: string;
|
||||
data?: CanvasProjectData;
|
||||
},
|
||||
) {
|
||||
const current = getCanvasProjectById(id);
|
||||
if (!current) return null;
|
||||
|
||||
const title = patch.title?.trim() || current.title;
|
||||
const data = patch.data ?? current.data;
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE canvas_projects
|
||||
SET title = ?, data = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(title, JSON.stringify(data), updatedAt, id);
|
||||
|
||||
return getCanvasProjectById(id);
|
||||
}
|
||||
|
||||
export function deleteCanvasProject(id: string) {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM canvas_projects WHERE id = ?")
|
||||
.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function parseCanvasProjectData(raw: string): CanvasProjectData {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isCanvasProjectData(parsed) ? parsed : defaultCanvasProjectData;
|
||||
} catch {
|
||||
return defaultCanvasProjectData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
type CanvasImageNodeMetadata,
|
||||
isCanvasProjectData,
|
||||
} from "@/lib/canvas/types";
|
||||
import { getDb } from "@/lib/server/db";
|
||||
import { getImageDir } from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
export type StoredImage = {
|
||||
id: string;
|
||||
mode: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
imageUrl: string;
|
||||
filePath: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let cleanupTimerStarted = false;
|
||||
|
||||
export function startHistoryCleanupTimer() {
|
||||
if (cleanupTimerStarted) return;
|
||||
cleanupTimerStarted = true;
|
||||
const timer = setInterval(
|
||||
() => {
|
||||
cleanupExpiredHistory();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function getRetentionDays() {
|
||||
const row = getDb()
|
||||
.prepare("SELECT value FROM settings WHERE key = 'retentionDays'")
|
||||
.get() as { value?: string } | undefined;
|
||||
const parsed = Number(row?.value ?? "7");
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 7;
|
||||
}
|
||||
|
||||
export function setRetentionDays(days: number) {
|
||||
const normalized = Number.isFinite(days) && days >= 0 ? Math.floor(days) : 7;
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('retentionDays', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
)
|
||||
.run(String(normalized));
|
||||
cleanupExpiredHistory();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function insertHistory(record: StoredImage) {
|
||||
startHistoryCleanupTimer();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO history
|
||||
(id, mode, prompt, model, size, quality, output_format, image_url, file_path, created_at)
|
||||
VALUES
|
||||
(@id, @mode, @prompt, @model, @size, @quality, @outputFormat, @imageUrl, @filePath, @createdAt)`,
|
||||
)
|
||||
.run(record);
|
||||
}
|
||||
|
||||
export function listHistory() {
|
||||
cleanupExpiredHistory();
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT id, mode, prompt, model, size, quality,
|
||||
output_format AS outputFormat,
|
||||
image_url AS imageUrl,
|
||||
file_path AS filePath,
|
||||
created_at AS createdAt
|
||||
FROM history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
.all() as StoredImage[];
|
||||
}
|
||||
|
||||
export function cleanupExpiredHistory() {
|
||||
startHistoryCleanupTimer();
|
||||
const days = getRetentionDays();
|
||||
if (days === 0) {
|
||||
deleteRowsBefore(new Date());
|
||||
cleanupOrphanedImages();
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
deleteRowsBefore(cutoff);
|
||||
cleanupOrphanedImages();
|
||||
}
|
||||
|
||||
function deleteRowsBefore(cutoff: Date) {
|
||||
const expired = getDb()
|
||||
.prepare("SELECT file_path AS filePath FROM history WHERE created_at < ?")
|
||||
.all(cutoff.toISOString()) as Pick<StoredImage, "filePath">[];
|
||||
|
||||
for (const item of expired) {
|
||||
removeFile(item.filePath);
|
||||
}
|
||||
|
||||
getDb().prepare("DELETE FROM history WHERE created_at < ?").run(cutoff.toISOString());
|
||||
}
|
||||
|
||||
function cleanupOrphanedImages() {
|
||||
const imageDir = getImageDir();
|
||||
if (!existsSync(imageDir)) return;
|
||||
|
||||
const livePaths = new Set<string>([
|
||||
...(getDb().prepare("SELECT file_path AS filePath FROM history").all() as Pick<
|
||||
StoredImage,
|
||||
"filePath"
|
||||
>[]).map((item) => item.filePath),
|
||||
...collectCanvasImagePaths(),
|
||||
]);
|
||||
|
||||
for (const entry of readdirSync(imageDir)) {
|
||||
const filePath = `${imageDir}\\${entry}`;
|
||||
try {
|
||||
if (statSync(filePath).isFile() && !livePaths.has(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup; stale files can be retried on the next request.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectCanvasImagePaths() {
|
||||
const rows = getDb()
|
||||
.prepare("SELECT data FROM canvas_projects")
|
||||
.all() as { data: string }[];
|
||||
|
||||
const filePaths = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.data) as unknown;
|
||||
if (!isCanvasProjectData(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const node of parsed.nodes) {
|
||||
if (node.type !== "image") continue;
|
||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||
if (metadata.filePath) {
|
||||
filePaths.add(metadata.filePath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed project payloads so cleanup cannot break reads.
|
||||
}
|
||||
}
|
||||
|
||||
return [...filePaths];
|
||||
}
|
||||
|
||||
function removeFile(filePath: string) {
|
||||
try {
|
||||
if (filePath && existsSync(filePath) && statSync(filePath).isFile()) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore deletion errors so history cleanup cannot break generation.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import OpenAI, { toFile } from "openai";
|
||||
import type { ImagesResponse } from "openai/resources/images";
|
||||
import {
|
||||
cleanupExpiredHistory,
|
||||
insertHistory,
|
||||
type StoredImage,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
import { persistBase64Image } from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
const identityGuard =
|
||||
"When editing a person or group photo, strictly preserve all original identities, facial features, expressions, pose, clothing layout, body proportions, and composition. Do not add, remove, crop, replace, redraw, or distort any person. Change only the requested lighting, color, background polish, clarity, and photographic finish.";
|
||||
const openAITimeoutMs = 270_000;
|
||||
const openAIMaxRetries = 0;
|
||||
const jobRetentionMs = 60 * 60 * 1000;
|
||||
|
||||
type ImageMode = "generate" | "edit";
|
||||
type OutputFormat = "png" | "jpeg" | "webp";
|
||||
type Quality = "low" | "medium" | "high" | "auto";
|
||||
type LogLevel = "info" | "error";
|
||||
type JobStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
export type UploadedFile = {
|
||||
buffer: Buffer;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ImageJobInput = {
|
||||
mode: ImageMode;
|
||||
prompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
outputFormat: OutputFormat;
|
||||
preserveIdentity: boolean;
|
||||
images: UploadedFile[];
|
||||
mask?: UploadedFile;
|
||||
};
|
||||
|
||||
type ImageJobResult = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
outputFormat: OutputFormat;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ImageJobError = {
|
||||
error: string;
|
||||
status: number;
|
||||
code?: string;
|
||||
type?: string;
|
||||
param?: string;
|
||||
upstreamStatus?: number;
|
||||
upstreamRequestId?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
type ImageJob = {
|
||||
jobId: string;
|
||||
requestId: string;
|
||||
status: JobStatus;
|
||||
phase: string;
|
||||
progressMessage: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
result?: ImageJobResult;
|
||||
error?: ImageJobError;
|
||||
};
|
||||
|
||||
const globalForImageJobs = globalThis as typeof globalThis & {
|
||||
imageJobs?: Map<string, ImageJob>;
|
||||
};
|
||||
const imageJobs = globalForImageJobs.imageJobs ?? new Map<string, ImageJob>();
|
||||
globalForImageJobs.imageJobs = imageJobs;
|
||||
|
||||
export async function submitImageJobRequest(request: Request) {
|
||||
cleanupOldJobs();
|
||||
|
||||
const requestId =
|
||||
request.headers.get("x-client-request-id") || crypto.randomUUID();
|
||||
const startedAt = performance.now();
|
||||
let phase = "init";
|
||||
const log = createLogger(requestId, startedAt, () => phase);
|
||||
|
||||
try {
|
||||
log("info", "job submission received", {
|
||||
method: request.method,
|
||||
contentType: request.headers.get("content-type"),
|
||||
contentLength: request.headers.get("content-length"),
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
});
|
||||
|
||||
phase = "cleanup";
|
||||
cleanupExpiredHistory();
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const baseURL = process.env.OPENAI_BASE_URL;
|
||||
|
||||
if (!apiKey) {
|
||||
phase = "validate-env";
|
||||
log("error", "missing OPENAI_API_KEY");
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: "Missing OPENAI_API_KEY in .env.local",
|
||||
requestId,
|
||||
phase,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
},
|
||||
500,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
phase = "parse-form";
|
||||
const formData = await request.formData();
|
||||
const input = await parseImageJobInput(formData);
|
||||
|
||||
log("info", "form parsed", {
|
||||
mode: input.mode,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
outputFormat: input.outputFormat,
|
||||
preserveIdentity: input.preserveIdentity,
|
||||
promptChars: input.prompt.length,
|
||||
promptPreview: preview(input.prompt),
|
||||
imageCount: input.images.length,
|
||||
imageNames: input.images.map((image) => image.name),
|
||||
imageTypes: input.images.map((image) => image.type),
|
||||
imageBytes: input.images.map((image) => image.size),
|
||||
hasMask: Boolean(input.mask),
|
||||
maskName: input.mask?.name,
|
||||
maskType: input.mask?.type,
|
||||
maskBytes: input.mask?.size,
|
||||
baseURL: baseURL ? redactUrl(baseURL) : "default",
|
||||
});
|
||||
|
||||
phase = "validate-input";
|
||||
const validationError = validateImageJobInput(input);
|
||||
if (validationError) {
|
||||
log("error", validationError);
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: validationError,
|
||||
requestId,
|
||||
phase,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
},
|
||||
400,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
phase = "queue-job";
|
||||
const job = createJob(requestId);
|
||||
imageJobs.set(job.jobId, job);
|
||||
log("info", "image job queued", { jobId: job.jobId });
|
||||
|
||||
void runImageJob(job, input, {
|
||||
apiKey,
|
||||
baseURL: baseURL || undefined,
|
||||
submittedAt: startedAt,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
jobId: job.jobId,
|
||||
requestId,
|
||||
status: job.status,
|
||||
phase: job.phase,
|
||||
progressMessage: job.progressMessage,
|
||||
pollUrl: `/api/images?jobId=${job.jobId}`,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
},
|
||||
{
|
||||
status: 202,
|
||||
headers: {
|
||||
"x-request-id": requestId,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const details = serializeError(error);
|
||||
const status = normalizeStatus(details.status);
|
||||
const durationMs = elapsedMs(startedAt);
|
||||
log("error", "job submission failed", {
|
||||
durationMs,
|
||||
status,
|
||||
error: details,
|
||||
});
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: details.message,
|
||||
requestId,
|
||||
phase,
|
||||
durationMs,
|
||||
status,
|
||||
code: details.code,
|
||||
type: details.type,
|
||||
param: details.param,
|
||||
upstreamStatus: details.status,
|
||||
upstreamRequestId: details.requestId,
|
||||
details: details.detail,
|
||||
},
|
||||
status,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageJobResponse(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 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
status: job.status === "failed" ? job.error?.status ?? 500 : 200,
|
||||
headers: {
|
||||
"x-request-id": job.requestId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createJob(requestId: string): ImageJob {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
jobId: crypto.randomUUID(),
|
||||
requestId,
|
||||
status: "queued",
|
||||
phase: "queued",
|
||||
progressMessage: "任务已提交,等待开始生成",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function runImageJob(
|
||||
job: ImageJob,
|
||||
input: ImageJobInput,
|
||||
config: { apiKey: string; baseURL?: string; submittedAt: number },
|
||||
) {
|
||||
const startedAt = performance.now();
|
||||
let phase = "start-job";
|
||||
const log = createLogger(job.requestId, config.submittedAt, () => phase);
|
||||
|
||||
try {
|
||||
updateJob(job, {
|
||||
status: "running",
|
||||
phase,
|
||||
progressMessage: "任务已开始,正在准备图片模型请求",
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL,
|
||||
timeout: openAITimeoutMs,
|
||||
maxRetries: openAIMaxRetries,
|
||||
});
|
||||
|
||||
const finalPrompt =
|
||||
input.preserveIdentity && input.mode === "edit"
|
||||
? `${input.prompt.trim()}\n\nConstraints: ${identityGuard}`
|
||||
: input.prompt.trim();
|
||||
|
||||
const common = {
|
||||
model: input.model,
|
||||
prompt: finalPrompt,
|
||||
n: 1,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
output_format: input.outputFormat,
|
||||
} as const;
|
||||
|
||||
phase =
|
||||
input.mode === "edit" ? "openai-images-edit" : "openai-images-generate";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "正在调用图片模型,前端会持续轮询结果",
|
||||
});
|
||||
log("info", "calling OpenAI image API", {
|
||||
jobId: job.jobId,
|
||||
mode: input.mode,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
outputFormat: input.outputFormat,
|
||||
timeoutMs: openAITimeoutMs,
|
||||
maxRetries: openAIMaxRetries,
|
||||
});
|
||||
|
||||
const result =
|
||||
input.mode === "edit"
|
||||
? await editImage(client, common, input.images, input.mask)
|
||||
: await client.images.generate(common);
|
||||
|
||||
log("info", "OpenAI image API completed", {
|
||||
jobId: job.jobId,
|
||||
upstreamDurationMs: elapsedMs(startedAt),
|
||||
dataItems: result.data?.length ?? 0,
|
||||
hasImageData: Boolean(result.data?.[0]?.b64_json),
|
||||
responseKeys: Object.keys(result),
|
||||
});
|
||||
|
||||
phase = "validate-openai-response";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "图片模型已返回,正在校验图片数据",
|
||||
});
|
||||
|
||||
const b64 = result.data?.[0]?.b64_json;
|
||||
if (!b64) {
|
||||
throw Object.assign(new Error("The image API returned no image data"), {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
|
||||
phase = "save-image";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "正在保存图片到本地",
|
||||
});
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const persisted = persistBase64Image(id, input.outputFormat, b64);
|
||||
const createdAt = new Date().toISOString();
|
||||
const resultPayload = {
|
||||
id,
|
||||
imageUrl: persisted.imageUrl,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
outputFormat: input.outputFormat,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
phase = "save-history";
|
||||
updateJob(job, {
|
||||
phase,
|
||||
progressMessage: "正在写入历史记录",
|
||||
});
|
||||
|
||||
const historyRecord: StoredImage = {
|
||||
...resultPayload,
|
||||
mode: input.mode,
|
||||
prompt: input.prompt,
|
||||
filePath: persisted.filePath,
|
||||
};
|
||||
insertHistory(historyRecord);
|
||||
|
||||
phase = "complete";
|
||||
updateJob(job, {
|
||||
status: "succeeded",
|
||||
phase,
|
||||
progressMessage: "图片已生成",
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: elapsedMs(startedAt),
|
||||
result: resultPayload,
|
||||
});
|
||||
log("info", "image job completed", {
|
||||
jobId: job.jobId,
|
||||
imageUrl: persisted.imageUrl,
|
||||
durationMs: job.durationMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const details = serializeError(error);
|
||||
const status = normalizeStatus(details.status);
|
||||
phase = phase || "failed";
|
||||
updateJob(job, {
|
||||
status: "failed",
|
||||
phase,
|
||||
progressMessage: "图片生成失败",
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: elapsedMs(startedAt),
|
||||
error: {
|
||||
error: details.message,
|
||||
status,
|
||||
code: details.code,
|
||||
type: details.type,
|
||||
param: details.param,
|
||||
upstreamStatus: details.status,
|
||||
upstreamRequestId: details.requestId,
|
||||
details: details.detail,
|
||||
},
|
||||
});
|
||||
log("error", "image job failed", {
|
||||
jobId: job.jobId,
|
||||
durationMs: job.durationMs,
|
||||
status,
|
||||
error: details,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateJob(job: ImageJob, patch: Partial<ImageJob>) {
|
||||
Object.assign(job, patch, { updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
|
||||
const images = formData.getAll("image");
|
||||
const mask = formData.get("mask");
|
||||
|
||||
return {
|
||||
mode: getString(formData, "mode", "generate") as ImageMode,
|
||||
prompt: getString(formData, "prompt"),
|
||||
model: getString(formData, "model", "gpt-image-2-2k"),
|
||||
size: getString(formData, "size", "auto"),
|
||||
quality: normalizeQuality(getString(formData, "quality", "high")),
|
||||
outputFormat: normalizeOutputFormat(
|
||||
getString(formData, "outputFormat", "png"),
|
||||
),
|
||||
preserveIdentity: getString(formData, "preserveIdentity", "true") === "true",
|
||||
images: await Promise.all(
|
||||
images
|
||||
.filter((image): image is File => image instanceof File)
|
||||
.map((image) => readUploadedFile(image)),
|
||||
),
|
||||
mask: mask instanceof File ? await readUploadedFile(mask) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function validateImageJobInput(input: ImageJobInput) {
|
||||
if (!input.prompt.trim()) {
|
||||
return "Prompt is required";
|
||||
}
|
||||
|
||||
if (input.mode === "edit" && input.images.length === 0) {
|
||||
return "Edit mode requires an uploaded image";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readUploadedFile(file: File): Promise<UploadedFile> {
|
||||
return {
|
||||
buffer: Buffer.from(await file.arrayBuffer()),
|
||||
name: file.name || "upload.png",
|
||||
type: file.type || "image/png",
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupOldJobs() {
|
||||
const cutoff = Date.now() - jobRetentionMs;
|
||||
for (const [jobId, job] of imageJobs.entries()) {
|
||||
if (new Date(job.createdAt).getTime() < cutoff) {
|
||||
imageJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonWithRequestId(
|
||||
body: Record<string, unknown>,
|
||||
status: number,
|
||||
requestId: string,
|
||||
) {
|
||||
return NextResponse.json(body, {
|
||||
status,
|
||||
headers: {
|
||||
"x-request-id": requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createLogger(
|
||||
requestId: string,
|
||||
startedAt: number,
|
||||
getPhase: () => string,
|
||||
) {
|
||||
return (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
data: Record<string, unknown> = {},
|
||||
) => {
|
||||
const payload = {
|
||||
requestId,
|
||||
route: "/api/images",
|
||||
phase: getPhase(),
|
||||
elapsedMs: elapsedMs(startedAt),
|
||||
message,
|
||||
...data,
|
||||
};
|
||||
const line = `[images:${level}] ${JSON.stringify(payload)}`;
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.info(line);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getString(formData: FormData, key: string, fallback = "") {
|
||||
const value = formData.get(key);
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeQuality(value: string): Quality {
|
||||
return ["low", "medium", "high", "auto"].includes(value)
|
||||
? (value as Quality)
|
||||
: "high";
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(value: string): OutputFormat {
|
||||
return ["png", "jpeg", "webp"].includes(value)
|
||||
? (value as OutputFormat)
|
||||
: "png";
|
||||
}
|
||||
|
||||
async function editImage(
|
||||
client: OpenAI,
|
||||
common: {
|
||||
model: string;
|
||||
prompt: string;
|
||||
n: 1;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
output_format: OutputFormat;
|
||||
},
|
||||
images: UploadedFile[],
|
||||
mask: UploadedFile | undefined,
|
||||
): Promise<ImagesResponse> {
|
||||
if (images.length === 0) {
|
||||
throw new Error("Edit mode requires an uploaded image");
|
||||
}
|
||||
|
||||
const uploads = await Promise.all(
|
||||
images.map((image, index) =>
|
||||
toFile(image.buffer, image.name || `input-image-${index + 1}.png`, {
|
||||
type: image.type || "image/png",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const editParams = {
|
||||
...common,
|
||||
image: uploads.length === 1 ? uploads[0] : uploads,
|
||||
};
|
||||
|
||||
if (mask && mask.size > 0) {
|
||||
const maskUpload = await toFile(mask.buffer, mask.name || "mask.png", {
|
||||
type: mask.type || "image/png",
|
||||
});
|
||||
|
||||
return client.images.edit({
|
||||
...editParams,
|
||||
mask: maskUpload,
|
||||
});
|
||||
}
|
||||
|
||||
return client.images.edit(editParams);
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: number) {
|
||||
return Math.round(performance.now() - startedAt);
|
||||
}
|
||||
|
||||
function preview(value: string, maxLength = 180) {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
return normalized.length > maxLength
|
||||
? `${normalized.slice(0, maxLength)}...`
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function redactUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.origin}${url.pathname}`;
|
||||
} catch {
|
||||
return value.replace(/([?&](?:api[_-]?key|key|token)=)[^&]+/gi, "$1***");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown) {
|
||||
return typeof value === "number" && value >= 400 && value < 600 ? value : 500;
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
const record = error as Error & {
|
||||
status?: number;
|
||||
code?: string;
|
||||
type?: string;
|
||||
param?: string;
|
||||
request_id?: string;
|
||||
requestID?: string;
|
||||
headers?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message || "Unknown image generation error",
|
||||
stack: error.stack,
|
||||
status: record.status,
|
||||
code: record.code,
|
||||
type: record.type,
|
||||
param: record.param,
|
||||
requestId: record.request_id || record.requestID,
|
||||
detail: safeJson(record.error),
|
||||
headers: safeJson(record.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Unknown image generation error",
|
||||
detail: safeJson(error),
|
||||
};
|
||||
}
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const imageDir = join(
|
||||
/* turbopackIgnore: true */ process.cwd(),
|
||||
"public",
|
||||
"generated",
|
||||
);
|
||||
|
||||
export type PersistedImageFile = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
export function getImageDir() {
|
||||
if (!existsSync(imageDir)) {
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
}
|
||||
|
||||
return imageDir;
|
||||
}
|
||||
|
||||
export function persistBase64Image(
|
||||
id: string,
|
||||
extension: string,
|
||||
base64: string,
|
||||
): PersistedImageFile {
|
||||
const normalizedExtension = extension === "jpeg" ? "jpg" : extension;
|
||||
const fileName = `${id}.${normalizedExtension}`;
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, Buffer.from(base64, "base64"));
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
}
|
||||
|
||||
export function persistUploadedImageFile(
|
||||
id: string,
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
): PersistedImageFile {
|
||||
const extension = getExtensionFromFilename(filename);
|
||||
const fileName = `${id}.${extension}`;
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, buffer);
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
}
|
||||
|
||||
function getExtensionFromFilename(filename: string) {
|
||||
const parts = filename.split(".");
|
||||
const extension = parts.at(-1)?.toLowerCase();
|
||||
return extension && extension.length <= 5 ? extension : "png";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
cleanupExpiredHistory,
|
||||
getRetentionDays,
|
||||
insertHistory,
|
||||
listHistory,
|
||||
setRetentionDays,
|
||||
startHistoryCleanupTimer,
|
||||
type StoredImage,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
export { getDb } from "@/lib/server/db";
|
||||
export {
|
||||
createCanvasProject,
|
||||
deleteCanvasProject,
|
||||
getCanvasProjectById,
|
||||
listCanvasProjects,
|
||||
updateCanvasProject,
|
||||
} from "@/lib/server/repositories/canvas-project-repository";
|
||||
export {
|
||||
getImageDir,
|
||||
persistBase64Image,
|
||||
persistUploadedImageFile,
|
||||
} from "@/lib/server/storage/image-file-storage";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user