"use client"; import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, ImagePlus, LoaderCircle, PenLine, Plus, SlidersHorizontal, Sparkles, Trash2, Upload } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { App, AutoComplete, Button, Checkbox, Drawer, Empty, Image, Input, InputNumber, Modal, Select, Tag, Typography } from "antd"; import localforage from "localforage"; import { ModelPicker } from "@/components/model-picker"; import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { nanoid } from "nanoid"; import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; import { requestEdit, requestGeneration } from "@/services/api/image"; import { uploadImage } from "@/services/image-storage"; import { useAssetStore } from "@/stores/use-asset-store"; import type { ReferenceImage } from "@/types/image"; type GeneratedImage = { id: string; dataUrl: string; durationMs: number; width: number; height: number; bytes: number; }; type GenerationResult = { id: string; status: "pending" | "success" | "failed"; image?: GeneratedImage; error?: string; }; type GenerationLog = { id: string; createdAt: number; title: string; time: string; model: string; durationMs: number; successCount: number; failCount: number; imageCount: number; size: string; quality: string; status: "成功" | "失败"; thumbnails: string[]; }; type UpdateAiConfig = (key: K, value: AiConfig[K]) => void; const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ({ label: value, value })); const qualityOptions = ["auto", "low", "medium", "high"].map((value) => ({ label: value, value })); const LOG_STORE_KEY = "infinite-canvas:image_generation_logs"; const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`; const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" }); export default function ImagePage() { const { message } = App.useApp(); const fileInputRef = useRef(null); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); const [prompt, setPrompt] = useState(""); const [references, setReferences] = useState([]); const [results, setResults] = useState([]); const [logs, setLogs] = useState([]); const [running, setRunning] = useState(false); const [logsOpen, setLogsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [promptDialogOpen, setPromptDialogOpen] = useState(false); const [assetPickerOpen, setAssetPickerOpen] = useState(false); const [startedAt, setStartedAt] = useState(0); const [elapsedMs, setElapsedMs] = useState(0); const [selectedLogIds, setSelectedLogIds] = useState([]); const [previewLog, setPreviewLog] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const model = effectiveConfig.imageModel || effectiveConfig.model; const canGenerate = Boolean(prompt.trim()); const generationCount = Math.max(1, Math.min(10, Number(config.count) || 1)); useEffect(() => { if (!running || !startedAt) return; const timer = window.setInterval(() => setElapsedMs(performance.now() - startedAt), 1000); return () => window.clearInterval(timer); }, [running, startedAt]); useEffect(() => { void refreshLogs(); }, []); const addReferences = async (files?: FileList | null) => { const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")); const nextReferences = await Promise.all( imageFiles.map(async (file) => { const image = await uploadImage(file); return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; }), ); setReferences((value) => [...value, ...nextReferences]); }; const addReferencesFromClipboard = async () => { try { const items = await navigator.clipboard.read(); const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type)))); if (!blobs.length) { message.error("剪切板里没有可读取的图片"); return; } const nextReferences = await Promise.all( blobs.map(async (blob, index) => { const image = await uploadImage(blob); return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; }), ); setReferences((value) => [...value, ...nextReferences]); message.success(`已读取 ${nextReferences.length} 张参考图`); } catch { message.error("剪切板里没有可读取的图片"); } }; const generate = async () => { const text = prompt.trim(); if (!text) { message.error("请输入生图提示词"); return; } if (!isAiConfigReady(effectiveConfig, model)) { message.warning("请先完成配置"); openConfigDialog(true); return; } const snapshot = buildRequestSnapshot(); if (!snapshot) return; setElapsedMs(0); setRunning(true); setPreviewLog(null); setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" }))); const batchStartedAt = performance.now(); setStartedAt(batchStartedAt); const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot)); const result = await Promise.allSettled(tasks); const successImages = result.filter((item): item is PromiseFulfilledResult => item.status === "fulfilled").map((item) => item.value); const successCount = successImages.length; const failCount = generationCount - successCount; const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected"); try { saveLog( buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", thumbnails: successImages.map((image) => image.dataUrl), }), ); successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败"); } finally { setRunning(false); } }; const downloadImage = (image: GeneratedImage, index: number) => { const link = document.createElement("a"); link.href = image.dataUrl; link.download = `image-${index + 1}.png`; link.click(); }; const addResultToReferences = async (image: GeneratedImage, index: number) => { const stored = await uploadImage(image.dataUrl); setReferences((value) => [...value, { id: nanoid(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); message.success("已加入参考图"); }; const saveResultToAssets = async (image: GeneratedImage, index: number) => { const stored = await uploadImage(image.dataUrl); addAsset({ kind: "image", title: `生成结果 ${index + 1}`, coverUrl: stored.url, tags: [], source: "生图工作台", data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType }, metadata: { source: "image-page", prompt }, }); message.success("已加入我的素材"); }; const insertPickedAsset = async (payload: InsertAssetPayload) => { if (payload.kind === "text") { setPrompt(payload.content); } else { const stored = await uploadImage(payload.dataUrl); setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); } setAssetPickerOpen(false); }; const createSession = () => { setPrompt(""); setReferences([]); setResults([]); setElapsedMs(0); setStartedAt(0); setSelectedLogIds([]); setPreviewLog(null); }; const deleteSelectedLogs = () => { void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs); if (previewLog && selectedLogIds.includes(previewLog.id)) { setPreviewLog(null); setResults([]); } setSelectedLogIds([]); setDeleteConfirmOpen(false); }; const saveLog = (log: GenerationLog) => { void logStore.setItem(log.id, log).then(refreshLogs); }; const refreshLogs = async () => setLogs(await readStoredLogs()); const previewGenerationLog = async (log: GenerationLog) => { setPreviewLog(log); setLogsOpen(false); const images = await Promise.all( log.thumbnails.map(async (dataUrl, index) => { const meta = await readImageMeta(dataUrl); return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) }; }), ); setResults(images.map((image) => ({ id: image.id, status: "success", image }))); }; const buildRequestSnapshot = () => { const text = prompt.trim(); if (!text) { message.error("请输入生图提示词"); return null; } if (!isAiConfigReady(effectiveConfig, model)) { message.warning("请先完成配置"); openConfigDialog(true); return null; } return { text, config: { ...effectiveConfig, model, count: "1" }, references: [...references] }; }; const runGenerationSlot = async (index: number, snapshot: { text: string; config: AiConfig; references: ReferenceImage[] }) => { const itemStartedAt = performance.now(); try { const result = snapshot.references.length ? await requestEdit(snapshot.config, snapshot.text, snapshot.references) : await requestGeneration(snapshot.config, snapshot.text); const image = result[0]; if (!image) throw new Error("接口没有返回图片"); const meta = await readImageMeta(image.dataUrl); const nextImage = { id: image.id, dataUrl: image.dataUrl, durationMs: performance.now() - itemStartedAt, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(image.dataUrl) }; setResults((value) => updateResultAt(value, index, { status: "success", image: nextImage })); return nextImage; } catch (error) { setResults((value) => updateResultAt(value, index, { status: "failed", error: error instanceof Error ? error.message : "生成失败" })); throw error; } }; const retryResult = (index: number) => { const snapshot = buildRequestSnapshot(); if (!snapshot) return; setPreviewLog(null); setResults((value) => updateResultAt(value, index, { status: "pending", error: undefined, image: undefined })); void runGenerationSlot(index, snapshot).catch(() => {}); }; return (

生图工作台

提示词
setPrompt(event.target.value)} rows={7} placeholder="描述画面主体、风格、构图、光线和用途" />
参考图
{ if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return; event.preventDefault(); event.currentTarget.scrollLeft += event.deltaY; }} > {references.map((item) => (
{item.name}
))} {!references.length ?
暂无参考图
: null}
{model} · {effectiveConfig.size} · {effectiveConfig.quality}

生成结果

{running ? 等待 {formatDuration(elapsedMs)} : null}
{results.length ? (
{results.map((result, index) => result.status === "success" && result.image ? ( ) : result.status === "failed" ? ( retryResult(index)} /> ) : ( ), )}
) : (
)}
{ void addReferences(event.target.files); event.target.value = ""; }} /> setLogsOpen(false)}> setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} /> setSettingsOpen(false)}>
void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} /> setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除选中的 {selectedLogIds.length} 条生成记录吗?
); } function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) { return ( <>