Implement canvas autosave and manager updates

This commit is contained in:
Codex
2026-07-16 00:07:50 +08:00
parent 9ee693c6a0
commit 520fa0aa89
26 changed files with 3266 additions and 2772 deletions
+1
View File
@@ -1,2 +1,3 @@
OPENAI_API_KEY=sk-your-key OPENAI_API_KEY=sk-your-key
OPENAI_BASE_URL=https://api.kkrich.ltd/v1 OPENAI_BASE_URL=https://api.kkrich.ltd/v1
OPENAI_IMAGE_TIMEOUT_MS=600000
+453
View File
@@ -0,0 +1,453 @@
"use client";
import {
ChevronDown,
ChevronUp,
Download,
ImageIcon,
Loader2,
Save,
Trash2,
X,
} from "lucide-react";
import {useEffect, useMemo, useState} from "react";
import {toast} from "sonner";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {Textarea} from "@/components/ui/textarea";
import type {HistoryItem} from "@/lib/image-workflow";
type AssetFilter = "all" | "direct" | "canvas" | "generate" | "edit";
const filters: Array<{ id: AssetFilter; label: string }> = [
{id: "all", label: "全部"},
{id: "direct", label: "直接模式"},
{id: "canvas", label: "无限画布"},
{id: "generate", label: "文生图"},
{id: "edit", label: "图像编辑"},
];
export default function AssetsPage() {
const [items, setItems] = useState<HistoryItem[]>([]);
const [filter, setFilter] = useState<AssetFilter>("all");
const [loading, setLoading] = useState(false);
const [savingIds, setSavingIds] = useState<string[]>([]);
const [deletingIds, setDeletingIds] = useState<string[]>([]);
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
useEffect(() => {
void loadAssets();
}, []);
const visibleItems = useMemo(
() =>
items.filter((item) => {
if (filter === "all") return true;
if (filter === "direct" || filter === "canvas") {
return (item.source ?? "direct") === filter;
}
return item.mode === filter;
}),
[filter, items],
);
const selectedItem =
items.find((item) => item.id === selectedItemId) ?? null;
const selectedVisibleIndex = visibleItems.findIndex(
(item) => item.id === selectedItemId,
);
const canSelectPrevious = selectedVisibleIndex > 0;
const canSelectNext =
selectedVisibleIndex >= 0 && selectedVisibleIndex < visibleItems.length - 1;
function selectRelativeAsset(offset: -1 | 1) {
const nextItem = visibleItems[selectedVisibleIndex + offset];
if (nextItem) setSelectedItemId(nextItem.id);
}
useEffect(() => {
if (!selectedItem) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSelectedItemId(null);
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
const previousItem = visibleItems[selectedVisibleIndex - 1];
if (previousItem) setSelectedItemId(previousItem.id);
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
const nextItem = visibleItems[selectedVisibleIndex + 1];
if (nextItem) setSelectedItemId(nextItem.id);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedItem, selectedVisibleIndex, visibleItems]);
async function loadAssets() {
setLoading(true);
try {
const response = await fetch("/api/history?source=all&limit=500", {
cache: "no-store",
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "读取资产失败");
}
const nextItems = Array.isArray(payload.items) ? payload.items : [];
setItems(nextItems);
setDraftNotes(
Object.fromEntries(
nextItems.map((item: HistoryItem) => [item.id, item.note ?? ""]),
),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "读取资产失败");
} finally {
setLoading(false);
}
}
async function saveNote(item: HistoryItem) {
setSavingIds((current) => [...current, item.id]);
try {
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({note: draftNotes[item.id] ?? ""}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "保存备注失败");
}
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? {...entry, note: payload.note ?? ""} : entry,
),
);
toast.success("备注已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存备注失败");
} finally {
setSavingIds((current) => current.filter((id) => id !== item.id));
}
}
async function deleteAsset(item: HistoryItem) {
setDeletingIds((current) => [...current, item.id]);
try {
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
method: "DELETE",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "删除素材失败");
}
setItems((current) => current.filter((entry) => entry.id !== item.id));
setSelectedItemId((current) => (current === item.id ? null : current));
toast.success("素材已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除素材失败");
} finally {
setDeletingIds((current) => current.filter((id) => id !== item.id));
}
}
return (
<div className="h-full min-h-0 overflow-hidden">
<div className="min-h-0 overflow-auto p-4 lg:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{filters.map((item) => (
<Button
key={item.id}
size="sm"
type="button"
variant={filter === item.id ? "default" : "outline"}
onClick={() => setFilter(item.id)}
>
{item.label}
</Button>
))}
</div>
<Button size="sm" variant="outline" onClick={() => void loadAssets()}>
{loading ? <Loader2 className="animate-spin"/> : <ImageIcon/>}
</Button>
</div>
{loading ? (
<div
className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
...
</div>
) : visibleItems.length ? (
<div className="columns-2 gap-3 sm:columns-3 lg:columns-4 xl:columns-5 2xl:columns-6">
{visibleItems.map((item) => {
const isDeleting = deletingIds.includes(item.id);
return (
<article
key={item.id}
className={
selectedItemId === item.id
? "group mb-3 break-inside-avoid overflow-hidden rounded-md border border-zinc-950 bg-white shadow-xs ring-1 ring-zinc-950"
: "group mb-3 break-inside-avoid overflow-hidden rounded-md border border-zinc-200 bg-white shadow-xs"
}
>
<div className="relative bg-zinc-100">
<button
aria-pressed={selectedItemId === item.id}
className="block w-full overflow-hidden text-left"
title="查看图片详情"
type="button"
onClick={() => setSelectedItemId(item.id)}
>
<img
alt={item.prompt || "生成图片"}
className="w-full cursor-pointer object-cover transition-transform duration-300 ease-out group-hover:scale-[1.035]"
src={item.imageUrl}
/>
</button>
{isDeleting ? (
<div
className="absolute inset-0 flex items-center justify-center rounded-t-md bg-white/70">
<Loader2 className="animate-spin"/>
</div>
) : null}
<div
className="pointer-events-none absolute inset-x-0 bottom-0 bg-linear-to-t from-zinc-950/45 via-zinc-950/18 to-transparent px-3 pb-3 pt-14 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
<div className="line-clamp-3 text-sm font-medium leading-5 drop-shadow">
{item.prompt || "无提示词"}
</div>
</div>
</div>
</article>
);
})}
</div>
) : (
<div
className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
</div>
)}
</div>
{selectedItem ? (
<AssetPreviewDialog
draftNote={draftNotes[selectedItem.id] ?? ""}
item={selectedItem}
isSaving={savingIds.includes(selectedItem.id)}
canSelectNext={canSelectNext}
canSelectPrevious={canSelectPrevious}
onClose={() => setSelectedItemId(null)}
onDelete={(item) => void deleteAsset(item)}
onNoteChange={(item, note) =>
setDraftNotes((current) => ({
...current,
[item.id]: note,
}))
}
onSaveNote={(item) => void saveNote(item)}
onSelectNext={() => selectRelativeAsset(1)}
onSelectPrevious={() => selectRelativeAsset(-1)}
/>
) : null}
</div>
);
}
function AssetPreviewDialog({
canSelectNext,
canSelectPrevious,
draftNote,
isSaving,
item,
onClose,
onDelete,
onNoteChange,
onSaveNote,
onSelectNext,
onSelectPrevious,
}: {
canSelectNext: boolean;
canSelectPrevious: boolean;
draftNote: string;
isSaving: boolean;
item: HistoryItem;
onClose: () => void;
onDelete: (item: HistoryItem) => void;
onNoteChange: (item: HistoryItem, note: string) => void;
onSaveNote: (item: HistoryItem) => void;
onSelectNext: () => void;
onSelectPrevious: () => void;
}) {
return (
<div
aria-label="资产图片详情"
aria-modal="true"
className="fixed inset-0 z-[200] bg-zinc-100"
role="dialog"
>
<div className="grid h-full min-h-0 grid-rows-[minmax(42vh,1fr)_minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_420px] lg:grid-rows-1">
<section className="relative flex min-h-0 items-center justify-center overflow-hidden p-4 sm:p-6 lg:p-8">
<Button
className="absolute right-4 top-4 z-10 bg-white shadow-sm"
size="icon"
type="button"
variant="outline"
onClick={onClose}
title="关闭"
>
<X/>
</Button>
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col gap-2">
<Button
className="bg-white shadow-sm"
disabled={!canSelectPrevious}
size="icon"
type="button"
variant="outline"
onClick={onSelectPrevious}
title="上一张"
>
<ChevronUp/>
</Button>
<Button
className="bg-white shadow-sm"
disabled={!canSelectNext}
size="icon"
type="button"
variant="outline"
onClick={onSelectNext}
title="下一张"
>
<ChevronDown/>
</Button>
</div>
<img
alt={item.prompt || "生成图片"}
className="max-h-full max-w-full rounded-md object-contain shadow-sm"
src={item.imageUrl}
/>
</section>
<aside className="flex min-h-0 flex-col border-t border-zinc-200 bg-white lg:border-l lg:border-t-0">
<div className="shrink-0 border-b border-zinc-200 px-5 py-4">
<div className="text-sm font-semibold text-zinc-950"></div>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.createdAt)}</div>
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto p-5">
<div className="flex flex-wrap gap-2">
<Badge>{(item.source ?? "direct") === "canvas" ? "无限画布" : "直接模式"}</Badge>
<Badge>{item.mode === "edit" ? "图像编辑" : "文生图"}</Badge>
<Badge>{item.outputFormat.toUpperCase()}</Badge>
</div>
<div className="mt-5 space-y-5">
<InfoBlock label="提示词">
<p className="whitespace-pre-wrap text-sm leading-6 text-zinc-950">
{item.prompt || "无提示词"}
</p>
</InfoBlock>
<div className="grid grid-cols-2 gap-2 text-sm">
<InfoCell label="模型" value={item.model}/>
<InfoCell label="尺寸" value={item.size}/>
<InfoCell label="质量" value={item.quality}/>
<InfoCell label="格式" value={item.outputFormat}/>
</div>
<InfoBlock label="备注">
<Textarea
className="min-h-28"
placeholder="添加备注"
value={draftNote}
onChange={(event) => onNoteChange(item, event.target.value)}
/>
<Button
className="mt-2 w-full"
disabled={isSaving}
size="sm"
type="button"
variant="outline"
onClick={() => onSaveNote(item)}
>
{isSaving ? <Loader2 className="animate-spin"/> : <Save/>}
</Button>
</InfoBlock>
</div>
</div>
<div className="grid shrink-0 grid-cols-2 gap-2 border-t border-zinc-200 p-4">
<Button
type="button"
variant="outline"
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
>
<Download/>
</Button>
<Button type="button" variant="ghost" onClick={() => onDelete(item)}>
<Trash2/>
</Button>
</div>
</aside>
</div>
</div>
);
}
function InfoBlock({
children,
label,
}: {
children: React.ReactNode;
label: string;
}) {
return (
<div>
<div className="mb-2 text-xs font-medium text-zinc-500">{label}</div>
{children}
</div>
);
}
function InfoCell({label, value}: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md bg-zinc-100 px-3 py-2">
<div className="text-xs text-zinc-500">{label}</div>
<div className="mt-1 truncate font-medium text-zinc-900" title={value}>
{value}
</div>
</div>
);
}
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 getAssetDownloadName(item: HistoryItem) {
const safePrompt = (item.prompt || "asset-image")
.slice(0, 32)
.replace(/[\\/:*?"<>|]+/g, "-");
return `${safePrompt || "asset-image"}.${item.outputFormat || "png"}`;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
+320
View File
@@ -0,0 +1,320 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Pencil, Plus, RefreshCcw, Trash2, X } from "lucide-react";
import { toast, Toaster } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import type { CanvasProjectListItem } from "@/lib/canvas/types";
type EditingProjectState = {
id: string;
title: string;
description: string;
} | null;
export default function CanvasProjectsPage() {
const router = useRouter();
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [editingProject, setEditingProject] = useState<EditingProjectState>(null);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
void loadProjects();
}, []);
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 : []);
} catch (err) {
toast.error(err instanceof Error ? err.message : "加载画布失败");
} 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}`);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "新建画布失败");
} finally {
setIsCreating(false);
}
}
async function saveProjectEdit() {
if (!editingProject || isSavingEdit) return;
const title = editingProject.title.trim();
if (!title) {
toast.error("标题不能为空");
return;
}
setIsSavingEdit(true);
try {
const response = await fetch(`/api/canvas/projects/${editingProject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description: editingProject.description,
}),
});
const payload = await response.json();
if (!response.ok || !payload.item) {
throw new Error(payload.error || "保存画布信息失败");
}
const updatedProject = payload.item as CanvasProjectListItem;
setProjects((current) =>
current.map((project) =>
project.id === updatedProject.id
? {
...project,
title: updatedProject.title,
description: updatedProject.description,
updatedAt: updatedProject.updatedAt,
}
: project,
),
);
setEditingProject(null);
toast.success("画布信息已更新");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存画布信息失败");
} finally {
setIsSavingEdit(false);
}
}
async function deleteProject(project: CanvasProjectListItem) {
setDeletingProjectId(project.id);
try {
const response = await fetch(`/api/canvas/projects/${project.id}`, {
method: "DELETE",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "删除画布失败");
}
setProjects((current) => current.filter((item) => item.id !== project.id));
toast.success("画布已删除");
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除画布失败");
} finally {
setDeletingProjectId(null);
}
}
return (
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
<Toaster richColors position="top-center" />
<div className="mx-auto max-w-6xl space-y-6">
<header className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white p-4">
<div>
<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) => (
<div
key={project.id}
className="group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<Link
className="min-w-0 flex-1"
href={`/canvas/${project.id}`}
>
<div className="truncate text-sm font-medium">{project.title}</div>
<div className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-zinc-500">
{project.description || "暂无说明"}
</div>
<div className="mt-3 text-xs text-zinc-500">
{project.nodeCount} · {" "}
{new Date(project.updatedAt).toLocaleString()}
</div>
</Link>
<div className="flex shrink-0 gap-1 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={() =>
setEditingProject({
id: project.id,
title: project.title,
description: project.description,
})
}
title="编辑画布信息"
>
<Pencil className="size-4" />
</button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-red-50 hover:text-red-600 disabled:pointer-events-none disabled:opacity-40"
disabled={deletingProjectId === project.id}
title="删除画布"
>
<Trash2 className="size-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{project.title}</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => void deleteProject(project)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
))}
{!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>
<ProjectEditDialog
project={editingProject}
isSaving={isSavingEdit}
onChange={setEditingProject}
onClose={() => {
if (!isSavingEdit) setEditingProject(null);
}}
onSave={() => void saveProjectEdit()}
/>
</div>
);
}
function ProjectEditDialog({
project,
isSaving,
onChange,
onClose,
onSave,
}: {
project: EditingProjectState;
isSaving: boolean;
onChange: (project: EditingProjectState) => void;
onClose: () => void;
onSave: () => void;
}) {
if (!project) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/24 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="w-full max-w-lg rounded-xl bg-white p-5 shadow-[0_24px_80px_rgba(24,24,27,.22)] ring-1 ring-zinc-200">
<div className="flex items-center justify-between gap-4">
<h2 className="text-base font-semibold text-zinc-950"></h2>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
disabled={isSaving}
onClick={onClose}
title="关闭"
>
<X className="size-4" />
</button>
</div>
<label className="mt-5 block text-sm font-medium text-zinc-700">
<input
className="mt-2 w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-zinc-400"
disabled={isSaving}
maxLength={80}
value={project.title}
onChange={(event) =>
onChange({ ...project, title: event.target.value })
}
/>
</label>
<label className="mt-4 block text-sm font-medium text-zinc-700">
<textarea
className="mt-2 h-28 w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 outline-none transition focus:border-zinc-400"
disabled={isSaving}
maxLength={300}
placeholder="给这个画布补充用途、阶段或备注"
value={project.description}
onChange={(event) =>
onChange({ ...project, description: event.target.value })
}
/>
</label>
<div className="mt-5 flex justify-end gap-2">
<Button variant="outline" disabled={isSaving} onClick={onClose}>
</Button>
<Button disabled={isSaving || !project.title.trim()} onClick={onSave}>
{isSaving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import {useState} from "react";
import Link from "next/link";
import {usePathname} from "next/navigation";
import {
Aperture,
Images,
PanelLeft,
PanelLeftClose,
PanelLeftOpen,
Settings2,
Sparkles,
Workflow,
} from "lucide-react";
import {Button} from "@/components/ui/button";
type ActiveView = "studio" | "assets" | "settings" | "canvas";
export default function ManagerLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const routeView = getActiveView(pathname);
const pageMeta = {
studio: {
title: "图片生成应用",
description: "直接模式:文生图、图生图、历史查看",
},
assets: {
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
asChild
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
variant={routeView === "studio" ? "secondary" : "ghost"}
>
<Link href="/" title="生成工作台">
<Sparkles/>
<span className={sidebarCollapsed ? "sr-only" : ""}></span>
</Link>
</Button>
<Button
asChild
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
variant={routeView === "assets" ? "secondary" : "ghost"}
>
<Link href="/assets" title="资产管理">
<Images/>
<span className={sidebarCollapsed ? "sr-only" : ""}></span>
</Link>
</Button>
<Button
asChild
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
variant={routeView === "settings" ? "secondary" : "ghost"}
>
<Link href="/settings" title="设置">
<Settings2/>
<span className={sidebarCollapsed ? "sr-only" : ""}></span>
</Link>
</Button>
<Button
asChild
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
variant={routeView === "canvas" ? "secondary" : "ghost"}
>
<Link href="/canvas" title="无限画布">
<Workflow/>
<span className={sidebarCollapsed ? "sr-only" : ""}></span>
</Link>
</Button>
</div>
<div
className="mt-auto flex flex-col items-center justify-center border-t border-zinc-200 pt-3">
<Button
size="icon"
variant="ghost"
title={sidebarCollapsed ? "展开侧边栏" : "折叠侧边栏"}
onClick={() => setSidebarCollapsed((value) => !value)}
>
<PanelLeft className="lg:hidden"/>
{sidebarCollapsed ? "" : ""}
{sidebarCollapsed ? (
<PanelLeftOpen className="hidden lg:block"/>
) : (
<div className="flex flex-row items-center gap-1">
<PanelLeftClose className="hidden lg:block"/>
</div>
)}
</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">
<div>
<h1 className="text-sm font-semibold">{pageMeta.title}</h1>
<p className="text-xs text-zinc-500">{pageMeta.description}</p>
</div>
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
{children}
</div>
</section>
</div>
</main>
);
}
function getActiveView(pathname: string): ActiveView {
if (pathname.startsWith("/assets")) return "assets";
if (pathname.startsWith("/settings")) return "settings";
if (pathname.startsWith("/canvas")) return "canvas";
return "studio";
}
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
"use client";
import { Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { toast, Toaster } from "sonner";
import { ChannelSettingsPanel } from "@/components/canvas/canvas-editor-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
defaultCanvasSettings,
normalizeCanvasSettings,
type CanvasSettings,
} from "@/lib/canvas/settings";
type SettingsTab = "channels" | "interface" | "history";
const settingsTabs = [
{ id: "channels", label: "渠道配置" },
{ id: "interface", label: "界面设置" },
{ id: "history", label: "历史记录" },
] satisfies Array<{ id: SettingsTab; label: string }>;
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<SettingsTab>("channels");
const [retentionDays, setRetentionDays] = useState("7");
const [canvasSettings, setCanvasSettings] =
useState<CanvasSettings>(defaultCanvasSettings);
const [isDirty, setIsDirty] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
let isActive = true;
async function loadInitialSettings() {
setIsLoading(true);
try {
const [appResponse, canvasResponse] = await Promise.all([
fetch("/api/settings", { cache: "no-store" }),
fetch("/api/canvas/settings", { cache: "no-store" }),
]);
const [appPayload, canvasPayload] = await Promise.all([
appResponse.json().catch(() => ({})),
canvasResponse.json().catch(() => ({})),
]);
if (!appResponse.ok) {
throw new Error(appPayload.error || "读取历史记录设置失败");
}
if (!canvasResponse.ok) {
throw new Error(canvasPayload.error || "读取画布设置失败");
}
if (isActive) {
setRetentionDays(String(appPayload.retentionDays ?? 7));
setCanvasSettings(normalizeCanvasSettings(canvasPayload.item));
setIsDirty(false);
}
} catch (error) {
if (isActive) {
toast.error(error instanceof Error ? error.message : "读取设置失败");
}
} finally {
if (isActive) setIsLoading(false);
}
}
void loadInitialSettings();
return () => {
isActive = false;
};
}, []);
function updateCanvasSettings(settings: CanvasSettings) {
setCanvasSettings(normalizeCanvasSettings(settings));
setIsDirty(true);
}
async function saveSettings() {
setIsSaving(true);
try {
const [appResponse, canvasResponse] = await Promise.all([
fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ retentionDays }),
}),
fetch("/api/canvas/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(canvasSettings),
}),
]);
const [appPayload, canvasPayload] = await Promise.all([
appResponse.json().catch(() => ({})),
canvasResponse.json().catch(() => ({})),
]);
if (!appResponse.ok) {
throw new Error(appPayload.error || "保存历史记录设置失败");
}
if (!canvasResponse.ok) {
throw new Error(canvasPayload.error || "保存画布设置失败");
}
setRetentionDays(String(appPayload.retentionDays));
setCanvasSettings(normalizeCanvasSettings(canvasPayload.item));
setIsDirty(false);
toast.success("设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存设置失败");
} finally {
setIsSaving(false);
}
}
return (
<div className="h-full min-h-0 overflow-auto bg-white">
<Toaster richColors position="top-center" />
<div className="mx-auto w-full max-w-6xl px-4 py-5 lg:px-8 lg:py-7">
<div className="sticky top-0 z-20 -mx-4 mb-6 flex items-center justify-between gap-4 border-b border-zinc-200 bg-white/95 px-4 pb-4 backdrop-blur lg:-mx-8 lg:px-8">
<div>
<h2 className="text-base font-semibold text-zinc-950"></h2>
<p className="mt-1 text-sm text-zinc-500">
</p>
</div>
<Button
disabled={isLoading || isSaving || !isDirty || retentionDays === ""}
type="button"
onClick={() => void saveSettings()}
>
{isSaving ? <Loader2 className="animate-spin" /> : <Save />}
{isSaving ? "保存中..." : "保存设置"}
</Button>
</div>
<div className="grid min-w-0 gap-6 md:grid-cols-[180px_minmax(0,1fr)] lg:gap-8">
<aside className="min-w-0 md:border-r md:border-zinc-200 md:pr-4">
<nav
className="flex gap-1 overflow-x-auto border-b border-zinc-200 md:sticky md:top-24 md:grid md:overflow-visible md:border-b-0"
aria-label="设置分类"
>
{settingsTabs.map((tab) => (
<button
key={tab.id}
type="button"
className={`shrink-0 border-b-2 px-3 py-2.5 text-left text-sm font-medium transition md:w-full md:rounded-md md:border-b-0 ${
activeTab === tab.id
? "border-zinc-950 text-zinc-950 md:bg-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-900 md:hover:bg-zinc-50"
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</aside>
<section className="min-w-0">
{isLoading ? (
<div className="flex min-h-64 items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 size-4 animate-spin" />
</div>
) : activeTab === "channels" ? (
<ChannelSettingsPanel
settings={canvasSettings}
onChange={updateCanvasSettings}
/>
) : activeTab === "interface" ? (
<InterfaceSettings
settings={canvasSettings}
onChange={updateCanvasSettings}
/>
) : (
<HistorySettings
disabled={isSaving}
retentionDays={retentionDays}
onChange={(value) => {
setRetentionDays(value);
setIsDirty(true);
}}
/>
)}
</section>
</div>
</div>
</div>
);
}
function InterfaceSettings({
settings,
onChange,
}: {
settings: CanvasSettings;
onChange: (settings: CanvasSettings) => void;
}) {
const patch = (value: Partial<CanvasSettings>) =>
onChange(normalizeCanvasSettings({ ...settings, ...value }));
return (
<div className="divide-y divide-zinc-200 border-y border-zinc-200">
<SettingToggle
checked={settings.hideToolbarText}
description="开启后底部操作栏仅显示图标,文字通过悬浮提示显示。"
label="操作栏隐藏文字"
onChange={(checked) => patch({ hideToolbarText: checked })}
/>
<SettingToggle
checked={settings.showImageInfoBadge}
description="开启后会在图片节点右下角显示分辨率和文件大小角标。"
label="显示图片信息"
onChange={(checked) => patch({ showImageInfoBadge: checked })}
/>
</div>
);
}
function SettingToggle({
checked,
description,
label,
onChange,
}: {
checked: boolean;
description: string;
label: string;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<div className="text-sm font-medium text-zinc-950">{label}</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">{description}</div>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}
function HistorySettings({
disabled,
retentionDays,
onChange,
}: {
disabled: boolean;
retentionDays: string;
onChange: (value: string) => void;
}) {
return (
<div className="max-w-xl space-y-2">
<Label htmlFor="retention-days"></Label>
<Input
id="retention-days"
disabled={disabled}
inputMode="numeric"
min="0"
type="number"
value={retentionDays}
onChange={(event) => onChange(event.target.value)}
/>
<p className="text-xs leading-5 text-zinc-500">
0
</p>
</div>
);
}
+15 -2
View File
@@ -17,7 +17,7 @@ export async function POST(request: Request) {
); );
} }
const response = await fetch(`${baseUrl}/v1/models`, { const response = await fetch(`${baseUrl}/models`, {
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
@@ -49,7 +49,20 @@ export async function POST(request: Request) {
function normalizeBaseUrl(value: string | undefined) { function normalizeBaseUrl(value: string | undefined) {
if (!value) return ""; if (!value) return "";
return value.trim().replace(/\/+$/, ""); const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return "";
try {
const url = new URL(trimmed);
const pathname = url.pathname.replace(/\/+$/, "");
if (pathname === "" || pathname === "/") {
url.pathname = "/v1";
return url.toString().replace(/\/+$/, "");
}
return trimmed;
} catch {
return trimmed;
}
} }
function parseJson(value: string) { function parseJson(value: string) {
+1 -1
View File
@@ -2,7 +2,7 @@ import { streamImageJobEvents } from "@/lib/server/services/image-job-service";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const maxDuration = 300; export const maxDuration = 600;
export async function GET(request: Request) { export async function GET(request: Request) {
return streamImageJobEvents(request); return streamImageJobEvents(request);
+1 -1
View File
@@ -4,7 +4,7 @@ import {
} from "@/lib/server/services/image-job-service"; } from "@/lib/server/services/image-job-service";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 600;
export async function POST(request: Request) { export async function POST(request: Request) {
return submitImageJobRequest(request); return submitImageJobRequest(request);
-5
View File
@@ -1,5 +0,0 @@
import { HomeShell } from "@/components/direct/home-shell";
export default function CanvasProjectsPage() {
return <HomeShell initialView="canvas" />;
}
-20
View File
@@ -1,20 +0,0 @@
import { HomeShell, type ActiveView } from "@/components/direct/home-shell";
function normalizeView(value: string | string[] | undefined): ActiveView {
const candidate = Array.isArray(value) ? value[0] : value;
if (
candidate === "history" ||
candidate === "assets" ||
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} />;
}
File diff suppressed because it is too large Load Diff
+28 -15
View File
@@ -970,6 +970,7 @@ function ConfigFields({
channelId={selectedChannelId} channelId={selectedChannelId}
model={metadata.model} model={metadata.model}
settings={canvasSettings} settings={canvasSettings}
useDefaultModel={metadata.useDefaultModel !== false}
onChange={(next) => patch(next)} onChange={(next) => patch(next)}
/> />
<SizeSelector <SizeSelector
@@ -1029,27 +1030,32 @@ function ConfigFields({
type ModelChannelPatch = { type ModelChannelPatch = {
channelId?: string; channelId?: string;
model?: string; model?: string;
useDefaultModel?: boolean;
}; };
function ModelChannelFields({ function ModelChannelFields({
channelId, channelId,
model, model,
settings, settings,
useDefaultModel = false,
onChange, onChange,
}: { }: {
channelId: string; channelId: string;
model: string; model: string;
settings: CanvasSettings; settings: CanvasSettings;
useDefaultModel?: boolean;
onChange: (next: ModelChannelPatch) => void; onChange: (next: ModelChannelPatch) => void;
}) { }) {
const imageChannels = settings.channels.filter(
(channel) => channel.kind === "image",
);
const selectedChannel = const selectedChannel =
settings.channels.find((channel) => channel.id === channelId) ?? imageChannels.find((channel) => channel.id === channelId) ??
settings.channels[0]; imageChannels[0];
const channelModels = selectedChannel?.models ?? []; const channelModels = selectedChannel?.models ?? [];
const resolvedModel = const resolvedModel =
model || (useDefaultModel ? selectedChannel?.defaultModel : model) ||
settings.defaultModels.image || selectedChannel?.defaultModel ||
settings.modelPreferences.image[0] ||
channelModels[0] || channelModels[0] ||
""; "";
const modelOptions = resolvedModel && !channelModels.includes(resolvedModel) const modelOptions = resolvedModel && !channelModels.includes(resolvedModel)
@@ -1066,7 +1072,7 @@ function ModelChannelFields({
</div> </div>
</div> </div>
<span className="rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-600"> <span className="rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-600">
{useDefaultModel ? "默认" : "节点级"}
</span> </span>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
@@ -1075,20 +1081,23 @@ function ModelChannelFields({
value={selectedChannel?.id ?? ""} value={selectedChannel?.id ?? ""}
onValueChange={(nextChannelId) => { onValueChange={(nextChannelId) => {
const nextChannel = const nextChannel =
settings.channels.find((channel) => channel.id === nextChannelId) ?? imageChannels.find((channel) => channel.id === nextChannelId) ??
settings.channels[0]; imageChannels[0];
const nextModels = nextChannel?.models ?? []; const nextModels = nextChannel?.models ?? [];
const nextModel = nextModels.includes(resolvedModel) const nextModel =
? resolvedModel nextChannel?.defaultModel || nextModels[0] || resolvedModel;
: nextModels[0] || resolvedModel; onChange({
onChange({ channelId: nextChannelId, model: nextModel }); channelId: nextChannelId,
model: nextModel,
useDefaultModel: true,
});
}} }}
> >
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white"> <SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
<SelectValue placeholder="选择渠道" /> <SelectValue placeholder="选择渠道" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{settings.channels.map((channel) => ( {imageChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}> <SelectItem key={channel.id} value={channel.id}>
{channel.name} {channel.name}
</SelectItem> </SelectItem>
@@ -1100,7 +1109,9 @@ function ModelChannelFields({
{modelOptions.length ? ( {modelOptions.length ? (
<Select <Select
value={resolvedModel} value={resolvedModel}
onValueChange={(nextModel) => onChange({ model: nextModel })} onValueChange={(nextModel) =>
onChange({ model: nextModel, useDefaultModel: false })
}
> >
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white"> <SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
<SelectValue placeholder="选择模型" /> <SelectValue placeholder="选择模型" />
@@ -1118,7 +1129,9 @@ function ModelChannelFields({
className="h-10 rounded-xl border-zinc-200 bg-white" className="h-10 rounded-xl border-zinc-200 bg-white"
placeholder="先在渠道配置中添加模型" placeholder="先在渠道配置中添加模型"
value={resolvedModel} value={resolvedModel}
onChange={(event) => onChange({ model: event.target.value })} onChange={(event) =>
onChange({ model: event.target.value, useDefaultModel: false })
}
/> />
)} )}
</Field> </Field>
@@ -1,320 +0,0 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Pencil, Plus, RefreshCcw, Trash2, X } from "lucide-react";
import { toast, Toaster } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import type { CanvasProjectListItem } from "@/lib/canvas/types";
type EditingProjectState = {
id: string;
title: string;
description: string;
} | null;
export function CanvasProjectsClient() {
const router = useRouter();
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [editingProject, setEditingProject] = useState<EditingProjectState>(null);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
void loadProjects();
}, []);
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 : []);
} catch (err) {
toast.error(err instanceof Error ? err.message : "加载画布失败");
} 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}`);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "新建画布失败");
} finally {
setIsCreating(false);
}
}
async function saveProjectEdit() {
if (!editingProject || isSavingEdit) return;
const title = editingProject.title.trim();
if (!title) {
toast.error("标题不能为空");
return;
}
setIsSavingEdit(true);
try {
const response = await fetch(`/api/canvas/projects/${editingProject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description: editingProject.description,
}),
});
const payload = await response.json();
if (!response.ok || !payload.item) {
throw new Error(payload.error || "保存画布信息失败");
}
const updatedProject = payload.item as CanvasProjectListItem;
setProjects((current) =>
current.map((project) =>
project.id === updatedProject.id
? {
...project,
title: updatedProject.title,
description: updatedProject.description,
updatedAt: updatedProject.updatedAt,
}
: project,
),
);
setEditingProject(null);
toast.success("画布信息已更新");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存画布信息失败");
} finally {
setIsSavingEdit(false);
}
}
async function deleteProject(project: CanvasProjectListItem) {
setDeletingProjectId(project.id);
try {
const response = await fetch(`/api/canvas/projects/${project.id}`, {
method: "DELETE",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "删除画布失败");
}
setProjects((current) => current.filter((item) => item.id !== project.id));
toast.success("画布已删除");
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除画布失败");
} finally {
setDeletingProjectId(null);
}
}
return (
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
<Toaster richColors position="top-center" />
<div className="mx-auto max-w-6xl space-y-6">
<header className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white p-4">
<div>
<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) => (
<div
key={project.id}
className="group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<Link
className="min-w-0 flex-1"
href={`/canvas/${project.id}`}
>
<div className="truncate text-sm font-medium">{project.title}</div>
<div className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-zinc-500">
{project.description || "暂无说明"}
</div>
<div className="mt-3 text-xs text-zinc-500">
{project.nodeCount} · {" "}
{new Date(project.updatedAt).toLocaleString()}
</div>
</Link>
<div className="flex shrink-0 gap-1 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={() =>
setEditingProject({
id: project.id,
title: project.title,
description: project.description,
})
}
title="编辑画布信息"
>
<Pencil className="size-4" />
</button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-red-50 hover:text-red-600 disabled:pointer-events-none disabled:opacity-40"
disabled={deletingProjectId === project.id}
title="删除画布"
>
<Trash2 className="size-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{project.title}</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => void deleteProject(project)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
))}
{!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>
<ProjectEditDialog
project={editingProject}
isSaving={isSavingEdit}
onChange={setEditingProject}
onClose={() => {
if (!isSavingEdit) setEditingProject(null);
}}
onSave={() => void saveProjectEdit()}
/>
</div>
);
}
function ProjectEditDialog({
project,
isSaving,
onChange,
onClose,
onSave,
}: {
project: EditingProjectState;
isSaving: boolean;
onChange: (project: EditingProjectState) => void;
onClose: () => void;
onSave: () => void;
}) {
if (!project) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/24 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="w-full max-w-lg rounded-xl bg-white p-5 shadow-[0_24px_80px_rgba(24,24,27,.22)] ring-1 ring-zinc-200">
<div className="flex items-center justify-between gap-4">
<h2 className="text-base font-semibold text-zinc-950"></h2>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
disabled={isSaving}
onClick={onClose}
title="关闭"
>
<X className="size-4" />
</button>
</div>
<label className="mt-5 block text-sm font-medium text-zinc-700">
<input
className="mt-2 w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-zinc-400"
disabled={isSaving}
maxLength={80}
value={project.title}
onChange={(event) =>
onChange({ ...project, title: event.target.value })
}
/>
</label>
<label className="mt-4 block text-sm font-medium text-zinc-700">
<textarea
className="mt-2 h-28 w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 outline-none transition focus:border-zinc-400"
disabled={isSaving}
maxLength={300}
placeholder="给这个画布补充用途、阶段或备注"
value={project.description}
onChange={(event) =>
onChange({ ...project, description: event.target.value })
}
/>
</label>
<div className="mt-5 flex justify-end gap-2">
<Button variant="outline" disabled={isSaving} onClick={onClose}>
</Button>
<Button disabled={isSaving || !project.title.trim()} onClick={onSave}>
{isSaving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
);
}
+54 -20
View File
@@ -13,12 +13,12 @@ import {
Keyboard, Keyboard,
Loader2, Loader2,
Menu, Menu,
MessageSquarePlus,
Minus, Minus,
Plus, Plus,
Redo2, Redo2,
BookOpenText, BookOpenText,
Save, Save,
Settings,
Sparkles, Sparkles,
SquareDashedMousePointer, SquareDashedMousePointer,
LayoutGrid, LayoutGrid,
@@ -31,9 +31,14 @@ import {
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import type {CanvasBackgroundMode, CanvasNodeType} from "@/lib/canvas/types"; import type {CanvasBackgroundMode, CanvasNodeType} from "@/lib/canvas/types";
type CanvasSaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
type CanvasToolbarProps = { type CanvasToolbarProps = {
title: string; title: string;
isDirty: boolean;
isSaving: boolean; isSaving: boolean;
saveStatus: CanvasSaveStatus;
lastSaveError: string | null;
isGenerating: boolean; isGenerating: boolean;
lastSavedAt: Date | null; lastSavedAt: Date | null;
backgroundMode: CanvasBackgroundMode; backgroundMode: CanvasBackgroundMode;
@@ -48,6 +53,7 @@ type CanvasToolbarProps = {
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
onGenerate: () => void; onGenerate: () => void;
onOpenPromptComposer: () => void;
onSave: () => void; onSave: () => void;
onUploadMaterial: () => void; onUploadMaterial: () => void;
onCopyJson: () => void; onCopyJson: () => void;
@@ -65,12 +71,14 @@ type CanvasToolbarProps = {
onToggleLibrary: () => void; onToggleLibrary: () => void;
onToggleSelectionMode: () => void; onToggleSelectionMode: () => void;
onShowShortcuts: () => void; onShowShortcuts: () => void;
onOpenSettings: () => void;
}; };
export function CanvasToolbar({ export function CanvasToolbar({
title, title,
isDirty,
isSaving, isSaving,
saveStatus,
lastSaveError,
isGenerating, isGenerating,
lastSavedAt, lastSavedAt,
backgroundMode, backgroundMode,
@@ -85,6 +93,7 @@ export function CanvasToolbar({
onUndo, onUndo,
onRedo, onRedo,
onGenerate, onGenerate,
onOpenPromptComposer,
onSave, onSave,
onUploadMaterial, onUploadMaterial,
onCopyJson, onCopyJson,
@@ -102,7 +111,6 @@ export function CanvasToolbar({
onToggleLibrary, onToggleLibrary,
onToggleSelectionMode, onToggleSelectionMode,
onShowShortcuts, onShowShortcuts,
onOpenSettings,
}: CanvasToolbarProps) { }: CanvasToolbarProps) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@@ -189,19 +197,14 @@ export function CanvasToolbar({
<div <div
className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl"> className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<SaveStatusButton <SaveStatusButton
isDirty={isDirty}
isGenerating={isGenerating} isGenerating={isGenerating}
isSaving={isSaving} isSaving={isSaving}
saveStatus={saveStatus}
lastSaveError={lastSaveError}
lastSavedAt={lastSavedAt} lastSavedAt={lastSavedAt}
onSave={onSave} onSave={onSave}
/> />
<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={onOpenSettings}
title="设置"
>
<Settings className="size-4"/>
</button>
<button <button
type="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" className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
@@ -315,6 +318,12 @@ export function CanvasToolbar({
onClick={onRedo} onClick={onRedo}
/> />
<DockDivider/> <DockDivider/>
<DockButton
hideLabel={hideDockLabels}
icon={<MessageSquarePlus className="size-4"/>}
label="对话生成"
onClick={onOpenPromptComposer}
/>
<DockButton <DockButton
hideLabel={hideDockLabels} hideLabel={hideDockLabels}
icon={<Type className="size-4"/>} icon={<Type className="size-4"/>}
@@ -427,38 +436,63 @@ function MenuDivider() {
} }
function SaveStatusButton({ function SaveStatusButton({
isDirty,
isSaving, isSaving,
saveStatus,
lastSaveError,
isGenerating, isGenerating,
lastSavedAt, lastSavedAt,
onSave, onSave,
}: { }: {
isDirty: boolean;
isSaving: boolean; isSaving: boolean;
saveStatus: CanvasSaveStatus;
lastSaveError: string | null;
isGenerating: boolean; isGenerating: boolean;
lastSavedAt: Date | null; lastSavedAt: Date | null;
onSave: () => void; onSave: () => void;
}) { }) {
const label = isGenerating ? "生成中" : isSaving ? "保存中" : "保存"; const savedAtText = lastSavedAt
const savedTime = lastSavedAt ? `已同步 ${lastSavedAt.toLocaleTimeString("zh-CN", {
? lastSavedAt.toLocaleTimeString("zh-CN", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
hour12: false, hour12: false,
}) })}`
: "--:--"; : "尚未保存";
const title = isSaving ? "正在保存画布" : `保存画布,上次保存 ${savedTime}`; const label = saveStatus === "saving" || isSaving ? "保存中" : "保存";
const statusText =
saveStatus === "saving" || isSaving
? "保存中"
: saveStatus === "error"
? "保存失败"
: isDirty || saveStatus === "dirty"
? isGenerating
? "生成中 · 未保存"
: "未保存"
: savedAtText;
const title =
saveStatus === "error"
? lastSaveError || "保存失败,点击重试"
: saveStatus === "saving" || isSaving
? "正在保存画布"
: isDirty || saveStatus === "dirty"
? "保存当前画布更改"
: `画布${savedAtText}`;
return ( return (
<button <button
type="button" type="button"
className="flex h-8 items-center gap-1.5 rounded-full px-2 text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950 disabled:cursor-wait disabled:opacity-70" className="flex h-8 items-center gap-1.5 rounded-full px-2 text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950 disabled:cursor-wait disabled:opacity-70"
disabled={isSaving} disabled={isSaving || (!isDirty && saveStatus !== "error")}
onClick={onSave} onClick={onSave}
title={title} title={title}
> >
{isSaving ? <Loader2 className="size-4 animate-spin"/> : <Save className="size-4"/>} {isSaving ? <Loader2 className="size-4 animate-spin"/> : <Save className="size-4"/>}
<span className="flex flex-col items-start leading-none"> <span className="flex flex-col items-start leading-none">
<span className="text-[11px] font-medium">{label}</span> <span className="text-[11px] font-medium">{label}</span>
<span className="mt-0.5 text-[9px] font-medium text-zinc-400">{savedTime}</span> <span className={`mt-0.5 text-[9px] font-medium ${saveStatus === "error" ? "text-red-500" : "text-zinc-400"}`}>
{statusText}
</span>
</span> </span>
</button> </button>
); );
-623
View File
@@ -1,623 +0,0 @@
"use client";
import {
Download,
ImageIcon,
Info,
Loader2,
Minimize2,
Minus,
Pencil,
Plus,
Save,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { HistoryItem } from "@/lib/image-workflow";
type AssetFilter = "all" | "direct" | "canvas" | "generate" | "edit";
const filters: Array<{ id: AssetFilter; label: string }> = [
{ id: "all", label: "全部" },
{ id: "direct", label: "直接模式" },
{ id: "canvas", label: "无限画布" },
{ id: "generate", label: "文生图" },
{ id: "edit", label: "图像编辑" },
];
export function AssetManager() {
const [items, setItems] = useState<HistoryItem[]>([]);
const [filter, setFilter] = useState<AssetFilter>("all");
const [loading, setLoading] = useState(false);
const [savingIds, setSavingIds] = useState<string[]>([]);
const [deletingIds, setDeletingIds] = useState<string[]>([]);
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [previewItem, setPreviewItem] = useState<HistoryItem | null>(null);
useEffect(() => {
void loadAssets();
}, []);
const visibleItems = useMemo(
() =>
items.filter((item) => {
if (filter === "all") return true;
if (filter === "direct" || filter === "canvas") {
return (item.source ?? "direct") === filter;
}
return item.mode === filter;
}),
[filter, items],
);
const selectedItem =
items.find((item) => item.id === selectedItemId) ?? null;
async function loadAssets() {
setLoading(true);
try {
const response = await fetch("/api/history?source=all&limit=500", {
cache: "no-store",
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "读取资产失败");
}
const nextItems = Array.isArray(payload.items) ? payload.items : [];
setItems(nextItems);
setDraftNotes(
Object.fromEntries(
nextItems.map((item: HistoryItem) => [item.id, item.note ?? ""]),
),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "读取资产失败");
} finally {
setLoading(false);
}
}
async function saveNote(item: HistoryItem) {
setSavingIds((current) => [...current, item.id]);
try {
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: draftNotes[item.id] ?? "" }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "保存备注失败");
}
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, note: payload.note ?? "" } : entry,
),
);
toast.success("备注已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存备注失败");
} finally {
setSavingIds((current) => current.filter((id) => id !== item.id));
}
}
async function deleteAsset(item: HistoryItem) {
setDeletingIds((current) => [...current, item.id]);
try {
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
method: "DELETE",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "删除素材失败");
}
setItems((current) => current.filter((entry) => entry.id !== item.id));
setSelectedItemId((current) => (current === item.id ? null : current));
toast.success("素材已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除素材失败");
} finally {
setDeletingIds((current) => current.filter((id) => id !== item.id));
}
}
return (
<div
className={
selectedItem
? "grid h-full min-h-0 overflow-hidden lg:grid-cols-[minmax(0,1fr)_360px]"
: "grid h-full min-h-0 overflow-hidden"
}
>
<div className="min-h-0 overflow-auto p-4 lg:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{filters.map((item) => (
<Button
key={item.id}
size="sm"
type="button"
variant={filter === item.id ? "default" : "outline"}
onClick={() => setFilter(item.id)}
>
{item.label}
</Button>
))}
</div>
<Button size="sm" variant="outline" onClick={() => void loadAssets()}>
{loading ? <Loader2 className="animate-spin" /> : <ImageIcon />}
</Button>
</div>
{loading ? (
<div className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
...
</div>
) : visibleItems.length ? (
<div className="columns-1 gap-4 sm:columns-2 xl:columns-3 2xl:columns-4">
{visibleItems.map((item) => {
const isDeleting = deletingIds.includes(item.id);
return (
<article
key={item.id}
className="group mb-4 break-inside-avoid overflow-visible rounded-md border border-zinc-200 bg-white shadow-xs"
>
<div className="relative bg-zinc-100">
<AssetToolbar
item={item}
onDelete={() => void deleteAsset(item)}
onEdit={() => setSelectedItemId(item.id)}
onInfo={() => setSelectedItemId(item.id)}
/>
<div className="overflow-hidden rounded-t-md">
<img
alt={item.prompt || "生成图片"}
className="w-full cursor-zoom-in object-cover transition-transform duration-300 ease-out group-hover:scale-[1.035]"
src={item.imageUrl}
onClick={() => setPreviewItem(item)}
/>
</div>
{isDeleting ? (
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-white/70">
<Loader2 className="animate-spin" />
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 bg-linear-to-t from-zinc-950/45 via-zinc-950/18 to-transparent px-3 pb-3 pt-14 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="line-clamp-3 text-sm font-medium leading-5 drop-shadow">
{item.prompt || "无提示词"}
</div>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
</div>
)}
</div>
{selectedItem ? (
<AssetInfoPanel
draftNote={draftNotes[selectedItem.id] ?? ""}
item={selectedItem}
isSaving={savingIds.includes(selectedItem.id)}
onClose={() => setSelectedItemId(null)}
onDelete={(item) => void deleteAsset(item)}
onNoteChange={(item, note) =>
setDraftNotes((current) => ({
...current,
[item.id]: note,
}))
}
onSaveNote={(item) => void saveNote(item)}
/>
) : null}
{previewItem ? (
<ImageZoomDialog
image={{
title: previewItem.prompt || "生成图片",
url: previewItem.imageUrl,
}}
onClose={() => setPreviewItem(null)}
/>
) : null}
</div>
);
}
function AssetToolbar({
item,
onDelete,
onEdit,
onInfo,
}: {
item: HistoryItem;
onDelete: () => void;
onEdit: () => void;
onInfo: () => void;
}) {
return (
<div className="absolute left-1/2 top-0 z-20 flex -translate-x-1/2 -translate-y-[calc(100%+10px)] items-center gap-1 rounded-full border border-zinc-200 bg-white/95 px-3 py-2 opacity-0 shadow-[0_14px_38px_rgba(24,24,27,.16)] backdrop-blur-xl transition-opacity group-hover:opacity-100">
<AssetToolbarButton
icon={<Info className="size-4" />}
label="信息"
onClick={onInfo}
/>
<AssetToolbarButton
danger
icon={<Trash2 className="size-4" />}
label="删除"
onClick={onDelete}
/>
<AssetToolbarButton
icon={<Pencil className="size-4" />}
label="编辑"
onClick={onEdit}
/>
<AssetToolbarButton
icon={<Download className="size-4" />}
label="下载"
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
/>
</div>
);
}
function AssetToolbarButton({
danger = false,
icon,
label,
onClick,
}: {
danger?: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={
danger
? "flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-sm font-medium text-zinc-700 transition hover:bg-red-50 hover:text-red-600"
: "flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950"
}
title={label}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{icon}
<span className="whitespace-nowrap">{label}</span>
</button>
);
}
function AssetInfoPanel({
draftNote,
isSaving,
item,
onClose,
onDelete,
onNoteChange,
onSaveNote,
}: {
draftNote: string;
isSaving: boolean;
item: HistoryItem | null;
onClose: () => void;
onDelete: (item: HistoryItem) => void;
onNoteChange: (item: HistoryItem, note: string) => void;
onSaveNote: (item: HistoryItem) => void;
}) {
return (
<aside className="hidden min-h-0 border-l border-zinc-200 bg-white lg:block">
{item ? (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 px-4">
<div>
<div className="text-sm font-semibold text-zinc-950"></div>
<div className="text-xs text-zinc-500">{formatDate(item.createdAt)}</div>
</div>
<Button size="icon" type="button" variant="ghost" onClick={onClose}>
<X />
</Button>
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto p-4">
<div className="overflow-hidden rounded-md bg-zinc-100">
<img
alt={item.prompt || "生成图片"}
className="max-h-[520px] w-full object-contain"
src={item.imageUrl}
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Badge>{(item.source ?? "direct") === "canvas" ? "无限画布" : "直接模式"}</Badge>
<Badge>{item.mode === "edit" ? "图像编辑" : "文生图"}</Badge>
<Badge>{item.outputFormat.toUpperCase()}</Badge>
</div>
<div className="mt-4 space-y-4">
<InfoBlock label="提示词">
<p className="text-sm leading-6 text-zinc-950">
{item.prompt || "无提示词"}
</p>
</InfoBlock>
<div className="grid grid-cols-2 gap-2 text-sm">
<InfoCell label="模型" value={item.model} />
<InfoCell label="尺寸" value={item.size} />
<InfoCell label="质量" value={item.quality} />
<InfoCell label="格式" value={item.outputFormat} />
</div>
<InfoBlock label="备注">
<Textarea
className="min-h-28"
placeholder="添加备注"
value={draftNote}
onChange={(event) => onNoteChange(item, event.target.value)}
/>
<div className="mt-2 flex gap-2">
<Button
className="flex-1"
disabled={isSaving}
size="sm"
type="button"
variant="outline"
onClick={() => onSaveNote(item)}
>
{isSaving ? <Loader2 className="animate-spin" /> : <Save />}
</Button>
<Button
size="sm"
type="button"
variant="ghost"
onClick={() => onDelete(item)}
>
<Trash2 />
</Button>
</div>
</InfoBlock>
<Button
className="w-full"
type="button"
variant="outline"
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
>
<Download />
</Button>
</div>
</div>
</div>
) : null}
</aside>
);
}
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]"
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={
dragState
? "flex min-h-0 flex-1 cursor-grabbing overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: "flex min-h-0 flex-1 cursor-grab overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
}
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 InfoBlock({
children,
label,
}: {
children: React.ReactNode;
label: string;
}) {
return (
<div>
<div className="mb-2 text-xs font-medium text-zinc-500">{label}</div>
{children}
</div>
);
}
function InfoCell({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md bg-zinc-100 px-3 py-2">
<div className="text-xs text-zinc-500">{label}</div>
<div className="mt-1 truncate font-medium text-zinc-900" title={value}>
{value}
</div>
</div>
);
}
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 getAssetDownloadName(item: HistoryItem) {
const safePrompt = (item.prompt || "asset-image")
.slice(0, 32)
.replace(/[\\/:*?"<>|]+/g, "-");
return `${safePrompt || "asset-image"}.${item.outputFormat || "png"}`;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
File diff suppressed because it is too large Load Diff
-191
View File
@@ -1,191 +0,0 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
Aperture,
Images,
ImageIcon,
PanelLeft,
PanelLeftClose,
PanelLeftOpen,
RefreshCcw,
Settings2,
Sparkles,
Workflow,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { AssetManager } from "@/components/direct/asset-manager";
import { CanvasProjectsClient } from "@/components/canvas/canvas-projects-client";
import { DirectStudio } from "@/components/direct/direct-studio";
export type ActiveView = "studio" | "history" | "assets" | "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: "查看直接模式生成的图片与任务参数",
},
assets: {
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 === "assets" ? "secondary" : "ghost"}
title="资产管理"
onClick={() => handleViewChange("assets")}
>
<Images />
<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 />
) : routeView === "assets" ? (
<AssetManager />
) : (
<DirectStudio activeView={routeView} />
)}
</div>
</section>
</div>
</main>
);
}
+52 -3
View File
@@ -73,7 +73,7 @@ export function resolveCanvasGenerationInput(
? formatConnectedPrompts(connectedPrompts) ? formatConnectedPrompts(connectedPrompts)
: (resolvedMentions?.prompt ?? ""); : (resolvedMentions?.prompt ?? "");
const negativePrompt = resolveNegativePrompt( const negativePrompt = resolveNegativePrompt(
promptNode, promptNode ?? undefined,
resolvedMentions?.referencedNodeIds ? scopedNodes : nodes, resolvedMentions?.referencedNodeIds ? scopedNodes : nodes,
scopedConnections, scopedConnections,
); );
@@ -83,8 +83,16 @@ export function resolveCanvasGenerationInput(
} }
const configNode = const configNode =
scopedNodes.find((node) => node.type === "config") ?? findConnectedConfigNode(scopedNodes, scopedConnections, [
nodes.find((node) => node.type === "config"); selectedNode?.id,
promptNode.id,
connectedPromptNode?.id,
]) ??
findConnectedConfigNode(scopedNodes, scopedConnections, [
...connectedPrompts.map((source) => source.nodeId),
...imageNodesFromConnections(scopedNodes, scopedConnections).map((node) => node.id),
]);
const selectedImageConfig = getImageGenerationConfig(selectedNode ?? undefined);
const imageNodes = collectReferencedImageNodes( const imageNodes = collectReferencedImageNodes(
scopedNodes, scopedNodes,
resolvedMentions?.referencedNodeIds ?? [], resolvedMentions?.referencedNodeIds ?? [],
@@ -100,11 +108,52 @@ export function resolveCanvasGenerationInput(
config: { config: {
...defaultCanvasConfig, ...defaultCanvasConfig,
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}), ...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
...selectedImageConfig,
...getImageGenerationConfig(promptNode), ...getImageGenerationConfig(promptNode),
}, },
}; };
} }
function findConnectedConfigNode(
nodes: CanvasNode[],
connections: CanvasConnection[],
targetIds: Array<string | undefined | null>,
) {
const targetIdSet = new Set(targetIds.filter(isString));
if (!targetIdSet.size) return undefined;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
for (const connection of connections) {
const from = nodeById.get(connection.fromNodeId);
const to = nodeById.get(connection.toNodeId);
if (from?.type === "config" && targetIdSet.has(to?.id ?? "")) {
return from;
}
if (to?.type === "config" && targetIdSet.has(from?.id ?? "")) {
return to;
}
}
return undefined;
}
function imageNodesFromConnections(
nodes: CanvasNode[],
connections: CanvasConnection[],
) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
return connections
.flatMap((connection) => [
nodeById.get(connection.fromNodeId),
nodeById.get(connection.toNodeId),
])
.filter(isImageWithUrl);
}
function isString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function resolveNegativePrompt( function resolveNegativePrompt(
promptNode: CanvasNode | undefined, promptNode: CanvasNode | undefined,
nodes: CanvasNode[], nodes: CanvasNode[],
+3 -1
View File
@@ -6,6 +6,7 @@ import type {
CanvasPosition, CanvasPosition,
} from "@/lib/canvas/types"; } from "@/lib/canvas/types";
import type { HistoryItem } from "@/lib/image-workflow"; import type { HistoryItem } from "@/lib/image-workflow";
import { defaultCanvasSettings } from "@/lib/canvas/settings";
export const DEFAULT_NODE_WIDTH = 280; export const DEFAULT_NODE_WIDTH = 280;
export const DEFAULT_NODE_HEIGHT = 170; export const DEFAULT_NODE_HEIGHT = 170;
@@ -14,7 +15,8 @@ export const DEFAULT_IMAGE_HEIGHT = 240;
export const defaultCanvasConfig: CanvasConfigNodeMetadata = { export const defaultCanvasConfig: CanvasConfigNodeMetadata = {
channelId: "", channelId: "",
model: "gpt-image-2-2k", model: defaultCanvasSettings.defaultModels.image,
useDefaultModel: true,
size: "1024x1024", size: "1024x1024",
quality: "high", quality: "high",
outputFormat: "png", outputFormat: "png",
+2
View File
@@ -129,6 +129,7 @@ export function resolvePromptMentions(
mentions: PromptMentionSource[], mentions: PromptMentionSource[],
) { ) {
const explicitlyReferencedNodeIds: string[] = []; const explicitlyReferencedNodeIds: string[] = [];
const usedMentions: PromptMentionSource[] = [];
let resolvedPrompt = prompt; let resolvedPrompt = prompt;
for (const mention of mentions) { for (const mention of mentions) {
@@ -140,6 +141,7 @@ export function resolvePromptMentions(
const usedAlias = resolvedPrompt.includes(mention.alias); const usedAlias = resolvedPrompt.includes(mention.alias);
if (!usedMarkdownToken && !usedAlias) continue; if (!usedMarkdownToken && !usedAlias) continue;
explicitlyReferencedNodeIds.push(mention.nodeId); explicitlyReferencedNodeIds.push(mention.nodeId);
usedMentions.push(mention);
resolvedPrompt = resolvedPrompt.split(mention.token).join(replacement); resolvedPrompt = resolvedPrompt.split(mention.token).join(replacement);
resolvedPrompt = resolvedPrompt.split(mention.alias).join(replacement); resolvedPrompt = resolvedPrompt.split(mention.alias).join(replacement);
} }
+90 -74
View File
@@ -1,9 +1,11 @@
export type CanvasModelChannel = { export type CanvasModelChannel = {
id: string; id: string;
name: string; name: string;
kind: CanvasModelKind;
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
models: string[]; models: string[];
defaultModel: string;
}; };
export type CanvasModelKind = "image" | "video" | "text" | "audio"; export type CanvasModelKind = "image" | "video" | "text" | "audio";
@@ -25,9 +27,11 @@ export type CanvasSettings = {
export const defaultCanvasChannel: CanvasModelChannel = { export const defaultCanvasChannel: CanvasModelChannel = {
id: "default", id: "default",
name: "默认渠道", name: "默认渠道",
kind: "image",
baseUrl: "https://api.openai.com", baseUrl: "https://api.openai.com",
apiKey: "", apiKey: "",
models: ["gpt-image-2"], models: ["gpt-image-2-2k"],
defaultModel: "gpt-image-2-2k",
}; };
export const defaultCanvasSettings: CanvasSettings = { export const defaultCanvasSettings: CanvasSettings = {
@@ -71,6 +75,7 @@ export function normalizeCanvasSettings(value: unknown): CanvasSettings {
? candidate.baseUrl ? candidate.baseUrl
: defaultCanvasChannel.baseUrl, : defaultCanvasChannel.baseUrl,
models: [legacyModel], models: [legacyModel],
defaultModel: legacyModel,
}; };
return { return {
hideToolbarText: Boolean(candidate.hideToolbarText), hideToolbarText: Boolean(candidate.hideToolbarText),
@@ -92,12 +97,26 @@ export function normalizeCanvasSettings(value: unknown): CanvasSettings {
channels: [legacyChannel], channels: [legacyChannel],
}; };
} }
const rawChannels = candidate.channels;
const channels = candidate.channels const candidateSelectedModel =
typeof candidate.selectedModel === "string" && candidate.selectedModel.trim()
? candidate.selectedModel.trim()
: "";
const candidateDefaultModels =
candidate.defaultModels && typeof candidate.defaultModels === "object"
? (candidate.defaultModels as Partial<CanvasDefaultModels>)
: {};
const preferredImageModel =
(typeof candidateDefaultModels.image === "string" &&
candidateDefaultModels.image.trim()) ||
candidateSelectedModel;
const channels = rawChannels
.map((channel) => normalizeCanvasChannel(channel)) .map((channel) => normalizeCanvasChannel(channel))
.filter((channel): channel is CanvasModelChannel => Boolean(channel)); .filter((channel): channel is CanvasModelChannel => Boolean(channel));
const normalizedChannels = channels.length ? channels : [defaultCanvasChannel]; const normalizedChannels = channels.length ? channels : [defaultCanvasChannel];
const selectedChannelId = let selectedChannelId =
typeof candidate.selectedChannelId === "string" && typeof candidate.selectedChannelId === "string" &&
normalizedChannels.some((channel) => channel.id === candidate.selectedChannelId) normalizedChannels.some((channel) => channel.id === candidate.selectedChannelId)
? candidate.selectedChannelId ? candidate.selectedChannelId
@@ -105,89 +124,74 @@ export function normalizeCanvasSettings(value: unknown): CanvasSettings {
const selectedChannel = const selectedChannel =
normalizedChannels.find((channel) => channel.id === selectedChannelId) ?? normalizedChannels.find((channel) => channel.id === selectedChannelId) ??
normalizedChannels[0]; normalizedChannels[0];
const selectedModel = if (
typeof candidate.selectedModel === "string" && preferredImageModel &&
candidate.selectedModel.trim() && selectedChannel.kind === "image" &&
selectedChannel.models.includes(candidate.selectedModel) !selectedChannel.models.includes(preferredImageModel)
? candidate.selectedModel ) {
: selectedChannel.models[0] ?? defaultCanvasSettings.selectedModel; selectedChannel.models = [preferredImageModel, ...selectedChannel.models];
const modelPreferences = normalizeModelPreferences( }
candidate.modelPreferences, const kinds: CanvasModelKind[] = ["image", "video", "text", "audio"];
selectedModel, const modelPreferences = Object.fromEntries(
); kinds.map((kind) => [
const defaultModels = normalizeDefaultModels( kind,
candidate.defaultModels, Array.from(
modelPreferences, new Set(
selectedModel, normalizedChannels
.filter((channel) => channel.kind === kind)
.flatMap((channel) => channel.models),
),
),
]),
) as CanvasModelPreferences;
const defaultModels = Object.fromEntries(
kinds.map((kind) => {
const hasChannelDefault = rawChannels.some((value) => {
if (!value || typeof value !== "object") return false;
const channel = value as Partial<CanvasModelChannel>;
const channelKind = isCanvasModelKind(channel.kind) ? channel.kind : "image";
return channelKind === kind && typeof channel.defaultModel === "string";
});
const legacyDefault = hasChannelDefault
? ""
: candidateDefaultModels[kind]?.trim() || "";
const channelsForKind = normalizedChannels.filter(
(channel) => channel.kind === kind,
); );
const defaultChannel =
channelsForKind.find(
(channel) => legacyDefault && channel.models.includes(legacyDefault),
) ?? channelsForKind.find((channel) => channel.defaultModel) ?? channelsForKind[0];
const defaultModel =
(defaultChannel?.models.includes(legacyDefault) ? legacyDefault : "") ||
(defaultChannel?.models.includes(defaultChannel.defaultModel)
? defaultChannel.defaultModel
: "") ||
defaultChannel?.models[0] ||
"";
if (defaultChannel) defaultChannel.defaultModel = defaultModel;
return [kind, defaultModel];
}),
) as CanvasDefaultModels;
const selectedImageChannel =
normalizedChannels.find(
(channel) =>
channel.kind === "image" && channel.id === selectedChannelId,
) ?? normalizedChannels.find((channel) => channel.kind === "image");
if (selectedImageChannel) selectedChannelId = selectedImageChannel.id;
const selectedModel = selectedImageChannel?.defaultModel || defaultModels.image;
return { return {
hideToolbarText: Boolean(candidate.hideToolbarText), hideToolbarText: Boolean(candidate.hideToolbarText),
showImageInfoBadge: Boolean(candidate.showImageInfoBadge), showImageInfoBadge: Boolean(candidate.showImageInfoBadge),
selectedChannelId, selectedChannelId,
selectedModel: defaultModels.image || selectedModel, selectedModel: selectedModel || defaultModels.image,
modelPreferences, modelPreferences,
defaultModels, defaultModels,
channels: normalizedChannels, channels: normalizedChannels,
}; };
} }
function normalizeModelPreferences(
value: unknown,
selectedModel: string,
): CanvasModelPreferences {
const candidate =
value && typeof value === "object"
? (value as Partial<CanvasModelPreferences>)
: {};
return {
image: normalizeModelList(candidate.image, selectedModel ? [selectedModel] : []),
video: normalizeModelList(candidate.video, []),
text: normalizeModelList(candidate.text, []),
audio: normalizeModelList(candidate.audio, []),
};
}
function normalizeDefaultModels(
value: unknown,
preferences: CanvasModelPreferences,
selectedModel: string,
): CanvasDefaultModels {
const candidate =
value && typeof value === "object"
? (value as Partial<CanvasDefaultModels>)
: {};
return {
image: normalizeDefaultModel(candidate.image, preferences.image, selectedModel),
video: normalizeDefaultModel(candidate.video, preferences.video, ""),
text: normalizeDefaultModel(candidate.text, preferences.text, ""),
audio: normalizeDefaultModel(candidate.audio, preferences.audio, ""),
};
}
function normalizeDefaultModel(
value: unknown,
models: string[],
fallback: string,
) {
return typeof value === "string" && models.includes(value)
? value
: models[0] ?? fallback;
}
function normalizeModelList(value: unknown, fallback: string[]) {
const list = Array.isArray(value) ? value : fallback;
return Array.from(
new Set(
list.filter((model): model is string =>
typeof model === "string" && Boolean(model.trim()),
),
),
);
}
function normalizeCanvasChannel(value: unknown): CanvasModelChannel | null { function normalizeCanvasChannel(value: unknown): CanvasModelChannel | null {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const candidate = value as Partial<CanvasModelChannel>; const candidate = value as Partial<CanvasModelChannel>;
@@ -201,15 +205,27 @@ function normalizeCanvasChannel(value: unknown): CanvasModelChannel | null {
? candidate.baseUrl.trim() ? candidate.baseUrl.trim()
: ""; : "";
const apiKey = typeof candidate.apiKey === "string" ? candidate.apiKey : ""; const apiKey = typeof candidate.apiKey === "string" ? candidate.apiKey : "";
const kind = isCanvasModelKind(candidate.kind) ? candidate.kind : "image";
const models = Array.isArray(candidate.models) const models = Array.isArray(candidate.models)
? candidate.models.filter((model): model is string => typeof model === "string" && Boolean(model.trim())) ? candidate.models.filter((model): model is string => typeof model === "string" && Boolean(model.trim()))
: []; : [];
const defaultModel =
typeof candidate.defaultModel === "string" &&
models.includes(candidate.defaultModel)
? candidate.defaultModel
: models[0] ?? "";
return { return {
id, id,
name, name,
kind,
baseUrl, baseUrl,
apiKey, apiKey,
models, models,
defaultModel,
}; };
} }
function isCanvasModelKind(value: unknown): value is CanvasModelKind {
return ["image", "video", "text", "audio"].includes(String(value));
}
+4
View File
@@ -24,6 +24,7 @@ export type CanvasPromptNodeMetadata = {
export type CanvasConfigNodeMetadata = { export type CanvasConfigNodeMetadata = {
channelId?: string; channelId?: string;
model: string; model: string;
useDefaultModel?: boolean;
size: string; size: string;
quality: string; quality: string;
outputFormat: string; outputFormat: string;
@@ -263,6 +264,9 @@ function normalizeConfigMetadata(value: unknown): CanvasConfigNodeMetadata | nul
return { return {
...(typeof candidate.channelId === "string" ? { channelId: candidate.channelId } : {}), ...(typeof candidate.channelId === "string" ? { channelId: candidate.channelId } : {}),
model: candidate.model, model: candidate.model,
...(typeof candidate.useDefaultModel === "boolean"
? { useDefaultModel: candidate.useDefaultModel }
: {}),
size: candidate.size, size: candidate.size,
quality: candidate.quality, quality: candidate.quality,
outputFormat: candidate.outputFormat, outputFormat: candidate.outputFormat,
+6 -2
View File
@@ -202,9 +202,13 @@ export function buildGenerationError(
getStringField(payload, "message") || getStringField(payload, "message") ||
response.statusText || response.statusText ||
"生成失败"; "生成失败";
const code = getStringField(payload, "code");
return { return {
title: `生成失败(HTTP ${response.status}`, title:
code === "PROHIBITED_PROMPT"
? "提示词包含违禁词"
: `生成失败(HTTP ${response.status}`,
message, message,
status: response.status, status: response.status,
requestId, requestId,
@@ -214,7 +218,7 @@ export function buildGenerationError(
Math.round(performance.now() - requestStartedAt), Math.round(performance.now() - requestStartedAt),
upstreamStatus: getNumberField(payload, "upstreamStatus"), upstreamStatus: getNumberField(payload, "upstreamStatus"),
upstreamRequestId: getStringField(payload, "upstreamRequestId"), upstreamRequestId: getStringField(payload, "upstreamRequestId"),
code: getStringField(payload, "code"), code,
type: getStringField(payload, "type"), type: getStringField(payload, "type"),
details: payload.details, details: payload.details,
rawBody: getStringField(payload, "rawBody"), rawBody: getStringField(payload, "rawBody"),
+237
View File
@@ -0,0 +1,237 @@
const prohibitedWords = `裸聊
自杀
杀人
贩毒
枪支
炸药
分尸
色情
淫秽
裸体
做爱
性交
口交
抽插
肛交
射精
迷奸
幼女
援交
卖淫
裸照
nude
naked
porn
xxx
sex(ual)
erotic
incest
underage
bestiality
sexual
发情
高潮
阴道
阴茎
乳房
屁股
淫水
催情
乱伦
捆绑
虐待
兽交
轮奸
鸡巴
操你
干你
骚货
贱货
荡妇
婊子
约炮
直播做爱
成人影片
成人视频
成人电影
皇色
簧色
嗨粉
吸白
溜冰
啪啪啪
18禁
工口
里番
nsfw
fuck
shit
dick
pussy
bitch
whore
slut
molest
hentai
bondage
bdsm
sperm
ejaculate
碎尸
肢解
吃人
活埋
凌迟
割喉
斩首
焚烧
torture
murder
decapitate
毒品
冰毒
海洛因
大麻
可卡因
制毒
吸毒
赌博
赌场
开赌
诈骗
骗钱
钓鱼
盗号
黑客
木马
病毒
入侵
破解
盗版
steal credentials
dump passwords
extract tokens
exfiltrate
ransomware
trojan
backdoor
botnet
keylogger
infostealer
免杀
绕过杀软
绕过edr
bypass av
bypass defender
bypass edr
reverse shell
bind shell
meterpreter
web shell
反弹shell
提权
privilege escalation
privesc
root exploit
persistence
registry run key
exploit payload
shellcode
rop chain
buffer overflow
zero-day
0day
漏洞利用
攻击载荷
proof of concept
注册机
keygen
crack license
license bypass
绕过授权
破解授权
序列号生成
数据窃取
data exfiltration
steal data
数据外泄
lsass dump
credential dumping
hashdump
sam dump
reverse engineering
ida pro
ghidra
x64dbg
ollydbg
frida
frida hook
反编译
脱壳
逆向
容器逃逸
container escape
docker breakout
kubernetes escape
sandbox escape
zero-click
zero click
supply chain attack
依赖投毒
malicious package
trojanized package
ddos
拒绝服务攻击
dos attack
cryptojacking
非法挖矿
挖矿劫持
process injection
dll injection
fileless malware
log tampering
anti-forensics
command injection
sql injection
xss payload
path traversal
ssrf
xxe
lateral movement
横向移动
token theft
jwt hijacking
api key leak
cloud credential
aws key leak
azure token
hardware implant
usb rubber ducky
恶意usb
firmware backdoor
bios rootkit
kernel exploit
内核提权
phishing
钓鱼页面
credential harvesting
fake login
渗透`
.split("\n")
.map(normalizeForMatching)
.filter(Boolean);
export function findProhibitedWords(...prompts: Array<string | undefined>) {
const normalizedPrompt = prompts
.filter((prompt): prompt is string => Boolean(prompt))
.map(normalizeForMatching)
.join("\n");
if (!normalizedPrompt) return [];
return prohibitedWords.filter((word) => normalizedPrompt.includes(word));
}
function normalizeForMatching(value: string) {
return value.normalize("NFKC").toLocaleLowerCase("en-US").trim();
}
+88 -5
View File
@@ -11,10 +11,12 @@ import {
persistBase64Image, persistBase64Image,
readPersistedImageFile, readPersistedImageFile,
} from "@/lib/server/storage/image-file-storage"; } from "@/lib/server/storage/image-file-storage";
import { findProhibitedWords } from "@/lib/server/prompt-moderation";
const identityGuard = 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."; "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 defaultOpenAITimeoutMs = 600_000;
const openAITimeoutMs = getOpenAITimeoutMs();
const openAIMaxRetries = 0; const openAIMaxRetries = 0;
const jobRetentionMs = 60 * 60 * 1000; const jobRetentionMs = 60 * 60 * 1000;
@@ -42,6 +44,8 @@ type ImageJobInput = {
quality: Quality; quality: Quality;
outputFormat: OutputFormat; outputFormat: OutputFormat;
preserveIdentity: boolean; preserveIdentity: boolean;
channelId?: string;
channelName?: string;
channelBaseUrl?: string; channelBaseUrl?: string;
channelApiKey?: string; channelApiKey?: string;
images: UploadedFile[]; images: UploadedFile[];
@@ -120,8 +124,34 @@ export async function submitImageJobRequest(request: Request) {
phase = "parse-form"; phase = "parse-form";
const formData = await request.formData(); const formData = await request.formData();
const input = await parseImageJobInput(formData); const input = await parseImageJobInput(formData);
phase = "validate-prompt";
const matchedProhibitedWords = findProhibitedWords(
input.prompt,
input.negativePrompt,
);
if (matchedProhibitedWords.length > 0) {
log("error", "prohibited words found in prompt", {
matchedWordCount: matchedProhibitedWords.length,
});
return jsonWithRequestId(
{
error: `提示词中存在违禁词:${matchedProhibitedWords.join("、")},请修改后重试。`,
code: "PROHIBITED_PROMPT",
details: { prohibitedWords: matchedProhibitedWords },
requestId,
phase,
durationMs: elapsedMs(startedAt),
},
400,
requestId,
);
}
const apiKey = input.channelApiKey || process.env.OPENAI_API_KEY; const apiKey = input.channelApiKey || process.env.OPENAI_API_KEY;
const baseURL = input.channelBaseUrl || process.env.OPENAI_BASE_URL; const baseURL = normalizeOpenAIBaseUrl(
input.channelBaseUrl || process.env.OPENAI_BASE_URL || "",
);
if (!apiKey) { if (!apiKey) {
phase = "validate-env"; phase = "validate-env";
@@ -145,6 +175,8 @@ export async function submitImageJobRequest(request: Request) {
quality: input.quality, quality: input.quality,
outputFormat: input.outputFormat, outputFormat: input.outputFormat,
preserveIdentity: input.preserveIdentity, preserveIdentity: input.preserveIdentity,
channelId: input.channelId,
channelName: input.channelName,
promptChars: input.prompt.length, promptChars: input.prompt.length,
promptPreview: preview(input.prompt), promptPreview: preview(input.prompt),
negativePromptChars: input.negativePrompt?.length ?? 0, negativePromptChars: input.negativePrompt?.length ?? 0,
@@ -601,13 +633,15 @@ async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
mode: getString(formData, "mode", "generate") as ImageMode, mode: getString(formData, "mode", "generate") as ImageMode,
prompt: getString(formData, "prompt"), prompt: getString(formData, "prompt"),
negativePrompt: getString(formData, "negativePrompt").trim() || undefined, negativePrompt: getString(formData, "negativePrompt").trim() || undefined,
model: getString(formData, "model", "gpt-image-2-2k"), model: getString(formData, "model"),
size: getString(formData, "size", "auto"), size: getString(formData, "size", "auto"),
quality: normalizeQuality(getString(formData, "quality", "high")), quality: normalizeQuality(getString(formData, "quality", "high")),
outputFormat: normalizeOutputFormat( outputFormat: normalizeOutputFormat(
getString(formData, "outputFormat", "png"), getString(formData, "outputFormat", "png"),
), ),
preserveIdentity: getString(formData, "preserveIdentity", "true") === "true", preserveIdentity: getString(formData, "preserveIdentity", "true") === "true",
channelId: getString(formData, "channelId") || undefined,
channelName: getString(formData, "channelName") || undefined,
channelBaseUrl: normalizeOptionalBaseUrl(getString(formData, "channelBaseUrl")), channelBaseUrl: normalizeOptionalBaseUrl(getString(formData, "channelBaseUrl")),
channelApiKey: getString(formData, "channelApiKey"), channelApiKey: getString(formData, "channelApiKey"),
images: [...uploadedImages, ...referencedImages], images: [...uploadedImages, ...referencedImages],
@@ -624,6 +658,10 @@ function validateImageJobInput(input: ImageJobInput) {
return "Prompt is required"; return "Prompt is required";
} }
if (!input.model.trim()) {
return "Model is required";
}
if (input.mode === "edit" && input.images.length === 0) { if (input.mode === "edit" && input.images.length === 0) {
return "Edit mode requires an uploaded image"; return "Edit mode requires an uploaded image";
} }
@@ -822,6 +860,23 @@ function normalizeOptionalBaseUrl(value: string) {
return trimmed ? trimmed.replace(/\/+$/, "") : undefined; return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
} }
function normalizeOpenAIBaseUrl(value: string) {
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return undefined;
try {
const url = new URL(trimmed);
const pathname = url.pathname.replace(/\/+$/, "");
if (pathname === "" || pathname === "/") {
url.pathname = "/v1";
return url.toString().replace(/\/+$/, "");
}
return trimmed;
} catch {
return trimmed;
}
}
function normalizeQuality(value: string): Quality { function normalizeQuality(value: string): Quality {
return ["low", "medium", "high", "auto"].includes(value) return ["low", "medium", "high", "auto"].includes(value)
? (value as Quality) ? (value as Quality)
@@ -914,9 +969,14 @@ function serializeError(error: unknown) {
error?: unknown; error?: unknown;
}; };
const message = normalizeErrorMessage(
error.message || "Unknown image generation error",
record.status,
);
return { return {
name: error.name, name: error.name,
message: error.message || "Unknown image generation error", message,
stack: error.stack, stack: error.stack,
status: record.status, status: record.status,
code: record.code, code: record.code,
@@ -929,11 +989,34 @@ function serializeError(error: unknown) {
} }
return { return {
message: "Unknown image generation error", message: normalizeErrorMessage("Unknown image generation error"),
detail: safeJson(error), detail: safeJson(error),
}; };
} }
function normalizeErrorMessage(message: string, status?: number) {
if (status === 524) {
return `${message}\n\n提示:本服务允许等待 ${Math.round(openAITimeoutMs / 1000)} 秒,但上游网关提前返回了 524。请提高渠道 Base URL 前置网关的 proxy/read timeout,或改用允许更长图片生成请求的上游地址。`;
}
if (
message.includes("'utf-8' codec can't decode byte 0x89") ||
message.includes("invalid start byte")
) {
return `${message}\n\n提示:上游接口没有按 OpenAI 图片编辑 multipart 格式解析上传的图片。当前画布因为连接了参考图片会走图像编辑模式,请确认该渠道支持 /v1/images/edits;如果只想文生图,请断开参考图片连接。`;
}
return message;
}
function getOpenAITimeoutMs() {
const configured = Number(process.env.OPENAI_IMAGE_TIMEOUT_MS);
if (!Number.isFinite(configured) || configured <= 0) {
return defaultOpenAITimeoutMs;
}
return Math.min(Math.max(Math.round(configured), 30_000), 1_800_000);
}
function safeJson(value: unknown) { function safeJson(value: unknown) {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;