Save current image generation workspace

This commit is contained in:
2026-06-22 23:36:52 +08:00
parent d2a1968d53
commit 36e6bedf1a
62 changed files with 13107 additions and 6948 deletions
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-6817
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -9,9 +9,24 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.1",
"@radix-ui/react-tabs": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.10",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.11.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
"openai": "^6.44.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+5386
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
better-sqlite3: true
sharp: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+76
View File
@@ -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 });
}
+26
View File
@@ -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 });
}
+26
View File
@@ -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,
},
});
}
+8
View File
@@ -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() });
}
+15
View File
@@ -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);
}
+19
View File
@@ -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 });
}
+10
View File
@@ -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} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { HomeShell } from "@/components/direct/home-shell";
export default function CanvasProjectsPage() {
return <HomeShell initialView="canvas" />;
}
+15 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+740
View File
@@ -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>
);
}
+316
View File
@@ -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" />;
}
+318
View File
@@ -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
+174
View File
@@ -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>
);
}
+14
View File
@@ -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}
/>
);
}
+50
View File
@@ -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}
/>
);
}
+37
View File
@@ -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} />;
}
+15
View File
@@ -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}
/>
);
}
+15
View File
@@ -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}
/>
);
}
+75
View File
@@ -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>
);
}
+23
View File
@@ -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}
/>
);
}
+24
View File
@@ -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>
);
}
+42
View File
@@ -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} />;
}
+18
View File
@@ -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}
/>
);
});
+122
View File
@@ -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));
}
+101
View File
@@ -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
);
}
+169
View File
@@ -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,
};
}
+92
View File
@@ -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,
};
}
+107
View File
@@ -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)
);
}
+329
View File
@@ -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
);
}
+58
View File
@@ -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";
}
+22
View File
@@ -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";
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}