From 520fa0aa8918b12188d7ca4a2a7950e8b91ccc7b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 00:07:50 +0800 Subject: [PATCH] Implement canvas autosave and manager updates --- .env.example | 1 + src/app/(manager)/assets/page.tsx | 453 ++++++ src/app/(manager)/canvas/page.tsx | 320 ++++ src/app/(manager)/layout.tsx | 164 +++ src/app/(manager)/page.tsx | 1026 +++++++++++++ src/app/(manager)/settings/page.tsx | 269 ++++ src/app/api/canvas/models/route.ts | 17 +- src/app/api/images/events/route.ts | 2 +- src/app/api/images/route.ts | 2 +- src/app/canvas/page.tsx | 5 - src/app/page.tsx | 20 - .../canvas/canvas-editor-client.tsx | 647 ++++++--- .../canvas/canvas-node-inspector.tsx | 43 +- .../canvas/canvas-projects-client.tsx | 320 ---- src/components/canvas/canvas-toolbar.tsx | 76 +- src/components/direct/asset-manager.tsx | 623 -------- src/components/direct/direct-studio.tsx | 1290 ----------------- src/components/direct/home-shell.tsx | 191 --- src/lib/canvas/generation.ts | 55 +- src/lib/canvas/node-factory.ts | 4 +- src/lib/canvas/prompt-mentions.ts | 2 + src/lib/canvas/settings.ts | 166 ++- src/lib/canvas/types.ts | 4 + src/lib/image-workflow.ts | 8 +- src/lib/server/prompt-moderation.ts | 237 +++ src/lib/server/services/image-job-service.ts | 93 +- 26 files changed, 3266 insertions(+), 2772 deletions(-) create mode 100644 src/app/(manager)/assets/page.tsx create mode 100644 src/app/(manager)/canvas/page.tsx create mode 100644 src/app/(manager)/layout.tsx create mode 100644 src/app/(manager)/page.tsx create mode 100644 src/app/(manager)/settings/page.tsx delete mode 100644 src/app/canvas/page.tsx delete mode 100644 src/app/page.tsx delete mode 100644 src/components/canvas/canvas-projects-client.tsx delete mode 100644 src/components/direct/asset-manager.tsx delete mode 100644 src/components/direct/direct-studio.tsx delete mode 100644 src/components/direct/home-shell.tsx create mode 100644 src/lib/server/prompt-moderation.ts diff --git a/.env.example b/.env.example index 433ceb8..cbb752e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ OPENAI_API_KEY=sk-your-key OPENAI_BASE_URL=https://api.kkrich.ltd/v1 +OPENAI_IMAGE_TIMEOUT_MS=600000 diff --git a/src/app/(manager)/assets/page.tsx b/src/app/(manager)/assets/page.tsx new file mode 100644 index 0000000..8eb42d7 --- /dev/null +++ b/src/app/(manager)/assets/page.tsx @@ -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([]); + const [filter, setFilter] = useState("all"); + const [loading, setLoading] = useState(false); + const [savingIds, setSavingIds] = useState([]); + const [deletingIds, setDeletingIds] = useState([]); + const [draftNotes, setDraftNotes] = useState>({}); + const [selectedItemId, setSelectedItemId] = useState(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 ( +
+
+
+
+ {filters.map((item) => ( + + ))} +
+ +
+ + {loading ? ( +
+ 正在加载资产... +
+ ) : visibleItems.length ? ( +
+ {visibleItems.map((item) => { + const isDeleting = deletingIds.includes(item.id); + return ( +
+
+ + {isDeleting ? ( +
+ +
+ ) : null} +
+
+ {item.prompt || "无提示词"} +
+
+
+
+ ); + })} +
+ ) : ( +
+ 当前分类没有资产 +
+ )} +
+ {selectedItem ? ( + 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} +
+ ); +} + +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 ( +
+
+
+ +
+ + +
+ {item.prompt +
+ +