chatgpt2api / web /src /app /image /page.tsx
li2895's picture
Update to v1.7.0 with HuggingFace adaptation
c47ec10
Raw
History Blame Contribute Delete
63.5 kB
"use client";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, History, LoaderCircle, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { ImageComposer } from "@/app/image/components/image-composer";
import { ImageResults, type ImageLightboxItem } from "@/app/image/components/image-results";
import { ImageSidebar } from "@/app/image/components/image-sidebar";
import { ImageLightbox } from "@/components/image-lightbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
createImageEditTask,
createImageGenerationTask,
fetchAccounts,
fetchModels,
fetchImageTasks,
resumeImagePoll,
type Account,
type ImageModel,
type Model,
type ImageTask,
} from "@/lib/api";
import { useAuthGuard } from "@/lib/use-auth-guard";
import { useSettingsStore } from "@/app/settings/store";
import {
clearImageConversations,
deleteImageConversation,
getImageConversationStats,
listImageConversations,
renameImageConversation,
saveImageConversation,
saveImageConversations,
type ImageConversation,
type ImageConversationMode,
type ImageTurn,
type ImageTurnStatus,
type StoredImage,
type StoredReferenceImage,
} from "@/store/image-conversations";
const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
const IMAGE_RATIO_STORAGE_KEY = "chatgpt2api:image_last_ratio";
const IMAGE_TIER_STORAGE_KEY = "chatgpt2api:image_last_tier";
const IMAGE_QUALITY_STORAGE_KEY = "chatgpt2api:image_last_quality";
const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
const SCROLL_POSITIONS_STORAGE_KEY = "chatgpt2api:image_scroll_positions";
const SCROLL_TO_LATEST_THRESHOLD = 160;
function loadScrollPositions(): Map<string, number> {
if (typeof window === "undefined") return new Map();
try {
const raw = window.sessionStorage.getItem(SCROLL_POSITIONS_STORAGE_KEY);
if (!raw) return new Map();
const parsed = JSON.parse(raw) as Record<string, number>;
return new Map(Object.entries(parsed));
} catch {
return new Map();
}
}
function saveScrollPositions(positions: Map<string, number>) {
if (typeof window === "undefined") return;
try {
const obj: Record<string, number> = {};
positions.forEach((value, key) => { obj[key] = value; });
window.sessionStorage.setItem(SCROLL_POSITIONS_STORAGE_KEY, JSON.stringify(obj));
} catch {
// sessionStorage may be full or unavailable
}
}
function clampImageCount(value: string) {
return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
}
function parseImageSize(size: string) {
const match = size.match(/^(\d+)x(\d+)$/);
return match ? { width: match[1], height: match[2] } : { width: "1024", height: "1024" };
}
const activeConversationQueueIds = new Set<string>();
let pollAbortController: AbortController | null = null;
function getResultsDistanceFromBottom(element: HTMLElement) {
return element.scrollHeight - element.scrollTop - element.clientHeight;
}
function buildConversationTitle(prompt: string) {
const trimmed = prompt.trim();
if (trimmed.length <= 12) {
return trimmed;
}
return `${trimmed.slice(0, 12)}...`;
}
function formatConversationTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function formatAvailableQuota(accounts: Account[]) {
const availableAccounts = accounts.filter((account) => account.status !== "禁用");
return String(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
}
function createId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取参考图失败"));
reader.readAsDataURL(file);
});
}
function dataUrlToFile(dataUrl: string, fileName: string, mimeType?: string) {
const [header, content] = dataUrl.split(",", 2);
const matchedMimeType = header.match(/data:(.*?);base64/)?.[1];
const binary = atob(content || "");
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mimeType || matchedMimeType || "image/png" });
}
function filterImageModels(items: Model[]): ImageModel[] {
return items
.map((item) => String(item.id || "").trim())
.filter((id, index, list) => id.toLowerCase().includes("image") && list.indexOf(id) === index);
}
function normalizeStoredImageModel(value: string | null, availableModels: ImageModel[]): ImageModel {
const normalized = String(value || "").trim();
if (normalized && availableModels.includes(normalized)) {
return normalized;
}
return availableModels[0] || "gpt-image-2";
}
function buildReferenceImageFromResult(image: StoredImage, fileName: string): StoredReferenceImage | null {
if (!image.b64_json) {
return null;
}
return {
name: fileName,
type: "image/png",
dataUrl: `data:image/png;base64,${image.b64_json}`,
};
}
async function fetchImageAsFile(url: string, fileName: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error("读取结果图失败");
}
const blob = await response.blob();
return new File([blob], fileName, { type: blob.type || "image/png" });
}
async function buildReferenceImageFromStoredImage(image: StoredImage, fileName: string) {
const direct = buildReferenceImageFromResult(image, fileName);
if (direct) {
return {
referenceImage: direct,
file: dataUrlToFile(direct.dataUrl, direct.name, direct.type),
};
}
if (!image.url) {
return null;
}
const file = await fetchImageAsFile(image.url, fileName);
return {
referenceImage: {
name: file.name,
type: file.type || "image/png",
dataUrl: await readFileAsDataUrl(file),
},
file,
};
}
function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage {
if (task.status === "success") {
const first = task.data?.[0];
if (!first?.b64_json && !first?.url) {
return {
...image,
taskId: task.id,
status: "error",
taskStatus: undefined,
progress: undefined,
error: "未返回图片数据",
};
}
return {
...image,
taskId: task.id,
status: "success",
taskStatus: undefined,
progress: undefined,
b64_json: first.b64_json,
url: first.url,
revised_prompt: first.revised_prompt,
error: undefined,
durationMs: task.duration_ms,
};
}
if (task.status === "error") {
return {
...image,
taskId: task.id,
status: "error",
taskStatus: undefined,
progress: undefined,
error: task.error || "生成失败",
durationMs: task.duration_ms,
};
}
const newTaskStatus = task.status === "queued" ? "queued" : task.status === "running" ? "running" : image.taskStatus;
const shouldSetStartTime = newTaskStatus === "running" && !image.startTime;
const startTime = shouldSetStartTime ? Date.now() : image.startTime;
// elapsedSecs 仅使用后端返回的值,确保计时从 image_stream_resolve_start 开始
const elapsedSecs =
newTaskStatus === "running" && typeof task.elapsed_secs === "number"
? task.elapsed_secs
: undefined;
return {
...image,
taskId: task.id,
status: "loading",
taskStatus: newTaskStatus,
progress: task.progress || image.progress,
error: undefined,
startTime,
elapsedSecs,
elapsedUpdatedAt: elapsedSecs != null ? Date.now() : undefined,
};
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function pickFallbackConversationId(conversations: ImageConversation[]) {
const activeConversation = conversations.find((conversation) =>
conversation.turns.some((turn) => turn.status === "queued" || turn.status === "generating"),
);
return activeConversation?.id ?? conversations[0]?.id ?? null;
}
function sortImageConversations(conversations: ImageConversation[]) {
return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
function deriveTurnStatus(turn: ImageTurn): Pick<ImageTurn, "status" | "error"> {
const loadingCount = turn.images.filter((image) => image.status === "loading").length;
const failedCount = turn.images.filter((image) => image.status === "error").length;
const successCount = turn.images.filter((image) => image.status === "success").length;
if (loadingCount > 0) {
// 如果任何图片的 taskStatus 为 running,则状态为 generating
const hasRunning = turn.images.some((image) => image.taskStatus === "running");
if (hasRunning) {
return { status: "generating", error: undefined };
}
return { status: turn.status === "queued" ? "queued" : "generating", error: undefined };
}
if (failedCount > 0) {
return { status: "error", error: `其中 ${failedCount} 张未成功生成` };
}
if (successCount > 0) {
return { status: "success", error: undefined };
}
// 所有图片都被忽略(images 为空),视为完成
return { status: "success", error: undefined };
}
function finalizeIdleQueuedTurn(turn: ImageTurn): ImageTurn {
if (
(turn.status !== "queued" && turn.status !== "generating") ||
turn.images.some((image) => image.status === "loading")
) {
return turn;
}
const derived = deriveTurnStatus(turn);
if (derived.status === turn.status && derived.error === turn.error) {
return turn;
}
return {
...turn,
...derived,
};
}
async function syncConversationImageTasks(items: ImageConversation[]) {
const taskIds = Array.from(
new Set(
items.flatMap((conversation) =>
conversation.turns.flatMap((turn) =>
turn.resultsDeleted
? []
: turn.images.flatMap((image) =>
(image.status === "loading" || (image.status === "error" && image.taskId))
? [image.taskId!]
: [],
),
),
),
),
);
if (taskIds.length === 0) {
return items;
}
let taskList: Awaited<ReturnType<typeof fetchImageTasks>>;
try {
taskList = await fetchImageTasks(taskIds);
} catch {
return items;
}
const taskMap = new Map(taskList.items.map((task) => [task.id, task]));
let changed = false;
const normalized = items.map((conversation) => {
const turns = conversation.turns.map((turn) => {
let turnChanged = false;
const images = turn.images.map((image) => {
if (!image.taskId) {
return image;
}
if (image.status !== "loading" && image.status !== "error") {
return image;
}
const task = taskMap.get(image.taskId);
if (!task) {
return image;
}
const nextImage = taskDataToStoredImage(image, task);
if (nextImage !== image) {
turnChanged = true;
}
return nextImage;
});
if (!turnChanged) {
return turn;
}
changed = true;
const derived = deriveTurnStatus({ ...turn, images });
return {
...turn,
...derived,
images,
};
});
if (turns === conversation.turns || !turns.some((turn, index) => turn !== conversation.turns[index])) {
return conversation;
}
return {
...conversation,
turns,
updatedAt: new Date().toISOString(),
};
});
if (changed) {
await saveImageConversations(normalized);
}
return normalized;
}
async function recoverConversationHistory(items: ImageConversation[]) {
let changed = false;
const normalized = items.map((conversation) => {
const turns = conversation.turns.map((turn) => {
if (turn.status !== "queued" && turn.status !== "generating" && turn.status !== "error") {
return turn;
}
let turnChanged = false;
const images = turn.images.map((image) => {
if (image.status !== "loading" || image.taskId) {
return image;
}
turnChanged = true;
return {
...image,
status: "error" as const,
error: "页面刷新或任务中断,未找到可恢复的任务 ID",
};
});
const candidateTurn = turnChanged ? { ...turn, images } : turn;
const nextTurn = finalizeIdleQueuedTurn(candidateTurn);
const derived = turnChanged ? deriveTurnStatus(nextTurn) : { status: nextTurn.status, error: nextTurn.error };
if (!turnChanged && nextTurn === turn && derived.status === turn.status && derived.error === turn.error) {
return turn;
}
changed = true;
return {
...nextTurn,
...derived,
};
});
if (!turns.some((turn, index) => turn !== conversation.turns[index])) {
return conversation;
}
return {
...conversation,
turns,
updatedAt: new Date().toISOString(),
};
});
if (changed) {
await saveImageConversations(normalized);
}
return syncConversationImageTasks(normalized);
}
function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
const didLoadQuotaRef = useRef(false);
const conversationsRef = useRef<ImageConversation[]>([]);
const loadCancelledRef = useRef(false);
const resultsViewportRef = useRef<HTMLDivElement>(null);
const lastConversationIdRef = useRef<string | null>(null);
const shouldStickToBottomRef = useRef(true);
const scrollRafRef = useRef<number | null>(null);
const scrollSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const scrollPositionsRef = useRef<Map<string, number>>(loadScrollPositions());
const isRestoringScrollRef = useRef(false);
const scrollRestoreGenerationRef = useRef(0);
const config = useSettingsStore((state) => state.config);
const imageTimeoutRetrySecs = Number(config?.image_timeout_retry_secs || 30);
const [imagePrompt, setImagePrompt] = useState("");
const [imageCount, setImageCount] = useState("3");
const [imageRatio, setImageRatio] = useState("auto");
const [imageTier, setImageTier] = useState("1k");
const [imageWidth, setImageWidth] = useState("1024");
const [imageHeight, setImageHeight] = useState("1024");
const [imageQuality, setImageQuality] = useState("auto");
const [imageModel, setImageModel] = useState<ImageModel>("gpt-image-2");
const [imageModels, setImageModels] = useState<ImageModel[]>(["gpt-image-2"]);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [referenceImageFiles, setReferenceImageFiles] = useState<File[]>([]);
const [referenceImages, setReferenceImages] = useState<StoredReferenceImage[]>([]);
const [conversations, setConversations] = useState<ImageConversation[]>([]);
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [availableQuota, setAvailableQuota] = useState("加载中...");
const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const scrollToLatestBtnRef = useRef<HTMLButtonElement>(null);
const [deleteConfirm, setDeleteConfirm] = useState<
| { type: "one"; id: string }
| { type: "prompt"; conversationId: string; turnId: string }
| { type: "results"; conversationId: string; turnId: string }
| { type: "all" }
| null
>(null);
const [timeoutRetry, setTimeoutRetry] = useState<{
conversationId: string;
taskId: string;
taskError: string;
} | null>(null);
const parsedCount = useMemo(() => Number(clampImageCount(imageCount)), [imageCount]);
const selectedConversation = useMemo(
() => conversations.find((item) => item.id === selectedConversationId) ?? null,
[conversations, selectedConversationId],
);
const activeTaskCount = useMemo(
() =>
conversations.reduce((sum, conversation) => {
const stats = getImageConversationStats(conversation);
return sum + stats.queued + stats.running;
}, 0),
[conversations],
);
const deleteConfirmTitle =
deleteConfirm?.type === "all"
? "清空历史记录"
: deleteConfirm?.type === "prompt"
? "删除提示词记录"
: deleteConfirm?.type === "results"
? "删除生成结果"
: deleteConfirm?.type === "one"
? "删除对话"
: "";
const deleteConfirmDescription =
deleteConfirm?.type === "all"
? "确认删除全部图片历史记录吗?删除后无法恢复。"
: deleteConfirm?.type === "prompt"
? "确认删除这条提示词记录吗?对应生成结果会保留。"
: deleteConfirm?.type === "results"
? "确认删除这条生成结果吗?对应提示词记录会保留。"
: deleteConfirm?.type === "one"
? "确认删除这条图片对话吗?删除后无法恢复。"
: "";
useEffect(() => {
conversationsRef.current = conversations;
}, [conversations]);
const scrollResultsToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
const element = resultsViewportRef.current;
if (!element) {
return;
}
shouldStickToBottomRef.current = true;
const btn = scrollToLatestBtnRef.current;
if (btn) btn.style.display = "none";
element.scrollTo({
top: element.scrollHeight,
behavior,
});
}, []);
const handleResultsScroll = useCallback(() => {
if (scrollRafRef.current !== null) {
return;
}
scrollRafRef.current = window.requestAnimationFrame(() => {
scrollRafRef.current = null;
const element = resultsViewportRef.current;
if (!element) {
return;
}
// 恢复滚动位置期间不处理滚动事件
if (isRestoringScrollRef.current) {
return;
}
// 保存当前会话的滚动位置(debounce 300ms 写入 sessionStorage)
const convId = lastConversationIdRef.current;
if (convId) {
scrollPositionsRef.current.set(convId, element.scrollTop);
if (scrollSaveTimerRef.current) clearTimeout(scrollSaveTimerRef.current);
scrollSaveTimerRef.current = setTimeout(() => {
scrollSaveTimerRef.current = null;
saveScrollPositions(scrollPositionsRef.current);
}, 300);
}
const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
shouldStickToBottomRef.current = !isAwayFromLatest;
// 直接操作 DOM 控制按钮显隐,避免 setState 触发全组件重渲染
const btn = scrollToLatestBtnRef.current;
if (btn) {
if (isAwayFromLatest) {
btn.style.display = "";
} else {
btn.style.display = "none";
}
}
});
}, []);
useEffect(() => {
return () => {
if (scrollRafRef.current !== null) {
window.cancelAnimationFrame(scrollRafRef.current);
}
if (scrollSaveTimerRef.current !== null) {
clearTimeout(scrollSaveTimerRef.current);
saveScrollPositions(scrollPositionsRef.current);
}
};
}, []);
const loadHistory = useCallback(async () => {
try {
const storedRatio =
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_RATIO_STORAGE_KEY) : null;
const storedTier =
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_TIER_STORAGE_KEY) : null;
const storedQuality =
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_QUALITY_STORAGE_KEY) : null;
const storedCount =
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
setImageRatio(storedRatio || "1:1");
setImageTier(storedTier || "1k");
setImageWidth("1024");
setImageHeight("1024");
setImageQuality(storedQuality || "auto");
setImageCount(storedCount ? clampImageCount(storedCount) : "1");
const items = await listImageConversations();
const normalizedItems = await recoverConversationHistory(items);
if (loadCancelledRef.current) {
return;
}
conversationsRef.current = normalizedItems;
setConversations(normalizedItems);
const storedConversationId =
typeof window !== "undefined" ? window.localStorage.getItem(ACTIVE_CONVERSATION_STORAGE_KEY) : null;
const nextSelectedConversationId =
(storedConversationId && normalizedItems.some((conversation) => conversation.id === storedConversationId)
? storedConversationId
: null) ?? pickFallbackConversationId(normalizedItems);
setSelectedConversationId(nextSelectedConversationId);
} catch (error) {
const message = error instanceof Error ? error.message : "读取会话记录失败";
toast.error(message);
} finally {
if (!loadCancelledRef.current) {
setIsLoadingHistory(false);
}
}
}, [
setImageRatio,
setImageTier,
setImageWidth,
setImageHeight,
setImageQuality,
setImageCount,
setConversations,
setSelectedConversationId,
setIsLoadingHistory,
]);
// Handle bfcache (back/forward cache) — re-sync task status on page restore
useEffect(() => {
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
void loadHistory();
}
};
window.addEventListener("pageshow", handlePageShow);
return () => window.removeEventListener("pageshow", handlePageShow);
}, [loadHistory]);
useEffect(() => {
loadCancelledRef.current = false;
void loadHistory();
return () => {
loadCancelledRef.current = true;
// 组件卸载时保存当前滚动位置到 sessionStorage
const element = resultsViewportRef.current;
const convId = lastConversationIdRef.current;
if (element && convId) {
scrollPositionsRef.current.set(convId, element.scrollTop);
saveScrollPositions(scrollPositionsRef.current);
}
activeConversationQueueIds.clear();
if (pollAbortController) {
pollAbortController.abort();
pollAbortController = null;
}
};
}, [loadHistory]);
useEffect(() => {
let cancelled = false;
const loadImageModels = async () => {
try {
const data = await fetchModels();
const available = filterImageModels(Array.isArray(data.data) ? data.data : []);
if (cancelled || available.length === 0) {
return;
}
setImageModels(available);
const storedModel = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_MODEL_STORAGE_KEY) : null;
setImageModel((current) => {
if (available.includes(current)) {
return current;
}
return normalizeStoredImageModel(storedModel, available);
});
} catch {
if (!cancelled) {
setImageModels(["gpt-image-2"]);
}
}
};
void loadImageModels();
return () => {
cancelled = true;
};
}, []);
const loadQuota = useCallback(async () => {
if (!isAdmin) {
setAvailableQuota("--");
return;
}
try {
const data = await fetchAccounts();
setAvailableQuota(formatAvailableQuota(data.items));
} catch {
setAvailableQuota((prev) => (prev === "加载中..." ? "--" : prev));
}
}, [isAdmin]);
useEffect(() => {
if (didLoadQuotaRef.current) {
return;
}
didLoadQuotaRef.current = true;
const handleFocus = () => {
void loadQuota();
};
void loadQuota();
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [isAdmin, loadQuota]);
// 切换会话时保存旧会话滚动位置,并隐藏容器防止闪烁
useLayoutEffect(() => {
if (!selectedConversation) {
lastConversationIdRef.current = null;
shouldStickToBottomRef.current = true;
const btn = scrollToLatestBtnRef.current;
if (btn) btn.style.display = "none";
return;
}
const element = resultsViewportRef.current;
if (!element) {
return;
}
const didSwitchConversation = lastConversationIdRef.current !== selectedConversation.id;
if (didSwitchConversation) {
// 递增 generation,使之前未完成的 rAF 回调失效
scrollRestoreGenerationRef.current += 1;
// 先保存旧会话的滚动位置(lastConversationIdRef 还是旧值)
const oldConvId = lastConversationIdRef.current;
if (oldConvId) {
scrollPositionsRef.current.set(oldConvId, element.scrollTop);
saveScrollPositions(scrollPositionsRef.current);
}
// 更新为新会话 ID
lastConversationIdRef.current = selectedConversation.id;
// 如果有保存的滚动位置,隐藏容器防止用户看到 scrollTop=0 的内容
const savedScrollTop = scrollPositionsRef.current.get(selectedConversation.id);
if (savedScrollTop != null && savedScrollTop > 0) {
element.style.visibility = "hidden";
isRestoringScrollRef.current = true;
}
}
}, [selectedConversation?.id]);
// 恢复滚动位置或跟随最新内容
useEffect(() => {
if (!selectedConversation) {
return;
}
const element = resultsViewportRef.current;
if (!element) {
return;
}
const savedScrollTop = scrollPositionsRef.current.get(selectedConversation.id);
if (savedScrollTop != null && savedScrollTop > 0) {
// 捕获当前 generation,用于检测是否已被新的切换取代
const generation = scrollRestoreGenerationRef.current;
// 容器已在 useLayoutEffect 中设为 visibility:hidden,用户看不到滚动过程
requestAnimationFrame(() => {
// 如果 generation 已变,说明用户又切换了,放弃本次恢复
if (scrollRestoreGenerationRef.current !== generation) return;
element.scrollTop = savedScrollTop;
// 再等一帧确保 scrollTop 生效后再显示容器
requestAnimationFrame(() => {
// 再次检查 generation
if (scrollRestoreGenerationRef.current !== generation) return;
const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
shouldStickToBottomRef.current = !isAwayFromLatest;
const btn = scrollToLatestBtnRef.current;
if (btn) btn.style.display = isAwayFromLatest ? "" : "none";
// 显示容器 — 用户直接看到正确位置的内容
element.style.visibility = "";
isRestoringScrollRef.current = false;
});
});
// 恢复后清除保存的位置,下次内容更新时走正常的 shouldFollowLatest 逻辑
scrollPositionsRef.current.delete(selectedConversation.id);
return;
}
// 无保存位置,按正常逻辑处理
const shouldFollowLatest =
shouldStickToBottomRef.current ||
getResultsDistanceFromBottom(element) <= SCROLL_TO_LATEST_THRESHOLD;
if (shouldFollowLatest) {
requestAnimationFrame(() => scrollResultsToLatest("smooth"));
return;
}
const btn = scrollToLatestBtnRef.current;
if (btn) btn.style.display = "";
}, [selectedConversation?.id, selectedConversation?.updatedAt, selectedConversation?.turns.length, scrollResultsToLatest]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
if (selectedConversationId) {
window.localStorage.setItem(ACTIVE_CONVERSATION_STORAGE_KEY, selectedConversationId);
} else {
window.localStorage.removeItem(ACTIVE_CONVERSATION_STORAGE_KEY);
}
}, [selectedConversationId]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(IMAGE_RATIO_STORAGE_KEY, imageRatio);
window.localStorage.setItem(IMAGE_TIER_STORAGE_KEY, imageTier);
window.localStorage.setItem(IMAGE_QUALITY_STORAGE_KEY, imageQuality);
window.localStorage.setItem(IMAGE_MODEL_STORAGE_KEY, imageModel);
}, [imageRatio, imageTier, imageQuality, imageModel]);
useEffect(() => {
if (typeof window !== "undefined" && parsedCount > 0) {
window.localStorage.setItem(IMAGE_COUNT_STORAGE_KEY, String(parsedCount));
}
}, [parsedCount]);
useEffect(() => {
if (selectedConversationId && !conversations.some((conversation) => conversation.id === selectedConversationId)) {
setSelectedConversationId(pickFallbackConversationId(conversations));
}
}, [conversations, selectedConversationId]);
const persistConversation = async (conversation: ImageConversation) => {
const nextConversations = sortImageConversations([
conversation,
...conversationsRef.current.filter((item) => item.id !== conversation.id),
]);
conversationsRef.current = nextConversations;
setConversations(nextConversations);
await saveImageConversation(conversation);
};
const updateConversation = useCallback(
async (
conversationId: string,
updater: (current: ImageConversation | null) => ImageConversation,
options: { persist?: boolean } = {},
) => {
const current = conversationsRef.current.find((item) => item.id === conversationId) ?? null;
const nextConversation = updater(current);
const nextConversations = sortImageConversations([
nextConversation,
...conversationsRef.current.filter((item) => item.id !== conversationId),
]);
conversationsRef.current = nextConversations;
setConversations(nextConversations);
if (options.persist !== false) {
await saveImageConversation(nextConversation);
}
},
[],
);
const clearComposerInputs = useCallback(() => {
setImagePrompt("");
setReferenceImageFiles([]);
setReferenceImages([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const resetComposer = useCallback(() => {
clearComposerInputs();
}, [clearComposerInputs]);
const handleCreateDraft = () => {
shouldStickToBottomRef.current = true;
const btn = scrollToLatestBtnRef.current;
if (btn) btn.style.display = "none";
setSelectedConversationId(null);
resetComposer();
textareaRef.current?.focus();
};
const handleDeleteConversation = async (id: string) => {
const nextConversations = conversations.filter((item) => item.id !== id);
conversationsRef.current = nextConversations;
setConversations(nextConversations);
if (selectedConversationId === id) {
setSelectedConversationId(pickFallbackConversationId(nextConversations));
resetComposer();
}
try {
await deleteImageConversation(id);
} catch (error) {
const message = error instanceof Error ? error.message : "删除会话失败";
toast.error(message);
const items = await listImageConversations();
conversationsRef.current = items;
setConversations(items);
}
};
const handleDeleteTurnPart = async (conversationId: string, turnId: string, part: "prompt" | "results") => {
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
if (!conversation) {
return;
}
const turns = conversation.turns
.map((turn) => {
if (turn.id !== turnId) {
return turn;
}
const images =
part === "results"
? turn.images.map((image) => ({ id: image.id, status: "error" as const, error: "生成结果已删除" }))
: turn.images;
const derived =
part === "results"
? deriveTurnStatus({
...turn,
images,
})
: { status: turn.status, error: turn.error };
const nextTurn = {
...turn,
prompt: part === "prompt" ? "" : turn.prompt,
promptDeleted: part === "prompt" ? true : turn.promptDeleted,
resultsDeleted: part === "results" ? true : turn.resultsDeleted,
...derived,
images,
};
return nextTurn.promptDeleted && nextTurn.resultsDeleted ? null : nextTurn;
})
.filter((turn): turn is ImageTurn => Boolean(turn));
if (turns.length === 0) {
await handleDeleteConversation(conversationId);
return;
}
const nextConversation = {
...conversation,
updatedAt: new Date().toISOString(),
turns,
};
await persistConversation(nextConversation);
};
const handleClearHistory = async () => {
try {
await clearImageConversations();
conversationsRef.current = [];
setConversations([]);
setSelectedConversationId(null);
resetComposer();
toast.success("已清空历史记录");
} catch (error) {
const message = error instanceof Error ? error.message : "清空历史记录失败";
toast.error(message);
}
};
const handleRenameConversation = async (id: string, title: string) => {
const nextConversations = conversations.map((item) =>
item.id === id ? { ...item, title, updatedAt: new Date().toISOString() } : item,
);
conversationsRef.current = sortImageConversations(nextConversations);
setConversations(conversationsRef.current);
try {
await renameImageConversation(id, title);
} catch (error) {
const message = error instanceof Error ? error.message : "重命名失败";
toast.error(message);
}
};
const openDeleteConversationConfirm = (id: string) => {
setIsHistoryOpen(false);
setDeleteConfirm({ type: "one", id });
};
const openDeletePromptConfirm = (conversationId: string, turnId: string) => {
setDeleteConfirm({ type: "prompt", conversationId, turnId });
};
const openDeleteResultsConfirm = (conversationId: string, turnId: string) => {
setDeleteConfirm({ type: "results", conversationId, turnId });
};
const openClearHistoryConfirm = () => {
setIsHistoryOpen(false);
setDeleteConfirm({ type: "all" });
};
const handleConfirmDelete = async () => {
const target = deleteConfirm;
setDeleteConfirm(null);
if (!target) {
return;
}
if (target.type === "all") {
await handleClearHistory();
return;
}
if (target.type === "prompt" || target.type === "results") {
await handleDeleteTurnPart(target.conversationId, target.turnId, target.type);
return;
}
await handleDeleteConversation(target.id);
};
const appendReferenceImages = useCallback(async (files: File[]) => {
if (files.length === 0) {
return;
}
try {
const previews = await Promise.all(
files.map(async (file) => ({
name: file.name,
type: file.type || "image/png",
dataUrl: await readFileAsDataUrl(file),
})),
);
setReferenceImageFiles((prev) => [...prev, ...files]);
setReferenceImages((prev) => [...prev, ...previews]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
} catch (error) {
const message = error instanceof Error ? error.message : "读取参考图失败";
toast.error(message);
}
}, []);
const handleReferenceImageChange = useCallback(
async (files: File[]) => {
if (files.length === 0) {
return;
}
await appendReferenceImages(files);
},
[appendReferenceImages],
);
const handleRemoveReferenceImage = useCallback((index: number) => {
setReferenceImageFiles((prev) => {
const next = prev.filter((_, currentIndex) => currentIndex !== index);
if (next.length === 0 && fileInputRef.current) {
fileInputRef.current.value = "";
}
return next;
});
setReferenceImages((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
}, []);
const handleContinueEdit = useCallback(
async (conversationId: string, image: StoredImage | StoredReferenceImage) => {
try {
const nextReference =
"dataUrl" in image
? {
referenceImage: image,
file: dataUrlToFile(image.dataUrl, image.name, image.type),
}
: await buildReferenceImageFromStoredImage(image, `conversation-${conversationId}-${Date.now()}.png`);
if (!nextReference) {
return;
}
setSelectedConversationId(conversationId);
setReferenceImages((prev) => [...prev, nextReference.referenceImage]);
setReferenceImageFiles((prev) => [...prev, nextReference.file]);
setImagePrompt("");
textareaRef.current?.focus();
toast.success("已加入当前参考图,继续输入描述即可编辑");
} catch (error) {
const message = error instanceof Error ? error.message : "读取结果图失败";
toast.error(message);
}
},
[],
);
const handleReuseTurnConfig = useCallback(async (conversationId: string, turnId: string) => {
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
const turn = conversation?.turns.find((item) => item.id === turnId);
if (!conversation || !turn || !turn.prompt.trim()) {
return;
}
setSelectedConversationId(conversationId);
setImagePrompt(turn.prompt);
setImageCount(String(Math.max(1, turn.count || turn.images.length || 1)));
setImageRatio(turn.ratio);
setImageTier(turn.tier);
const parsedSize = parseImageSize(turn.size);
setImageWidth(parsedSize.width);
setImageHeight(parsedSize.height);
setImageQuality(turn.quality);
setImageModel(turn.model);
setReferenceImages(turn.referenceImages);
setReferenceImageFiles(
turn.referenceImages.map((image) => dataUrlToFile(image.dataUrl, image.name, image.type)),
);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
textareaRef.current?.focus();
toast.success("已复用这条提示词配置");
}, []);
const openLightbox = useCallback((images: ImageLightboxItem[], index: number) => {
if (images.length === 0) {
return;
}
setLightboxImages(images);
setLightboxIndex(Math.max(0, Math.min(index, images.length - 1)));
setLightboxOpen(true);
}, []);
const createLoadingImages = (turnId: string, count: number) =>
Array.from({ length: count }, (_, index) => {
const imageId = `${turnId}-${index}`;
return {
id: imageId,
taskId: imageId,
status: "loading" as const,
};
});
/* eslint-disable react-hooks/preserve-manual-memoization */
const runConversationQueue = useCallback(
async (conversationId: string) => {
if (activeConversationQueueIds.has(conversationId)) {
return;
}
const snapshot = conversationsRef.current.find((conversation) => conversation.id === conversationId);
const activeTurn = snapshot?.turns.find(
(turn) =>
(turn.status === "queued" || turn.status === "generating") &&
turn.images.some((image) => image.status === "loading"),
);
if (!snapshot || !activeTurn) {
return;
}
activeConversationQueueIds.add(conversationId);
const applyTasks = async (tasks: ImageTask[]) => {
const taskMap = new Map(tasks.map((task) => [task.id, task]));
await updateConversation(conversationId, (current) => {
const conversation = current ?? snapshot;
const turns = conversation.turns.map((turn) => {
if (turn.id !== activeTurn.id) {
return turn;
}
const images = turn.images.map((image) => {
const taskId = image.taskId || image.id;
const task = taskMap.get(taskId);
return task ? taskDataToStoredImage({ ...image, taskId }, task) : image;
});
const derived = deriveTurnStatus({ ...turn, images });
return {
...turn,
...derived,
images,
};
});
return {
...conversation,
updatedAt: new Date().toISOString(),
turns,
};
});
};
try {
const referenceFiles = activeTurn.referenceImages.map((image, index) =>
dataUrlToFile(image.dataUrl, image.name || `${activeTurn.id}-${index + 1}.png`, image.type),
);
if (activeTurn.mode === "edit" && referenceFiles.length === 0) {
throw new Error("未找到可用于继续编辑的参考图");
}
const pendingImages = activeTurn.images.filter((image) => image.status === "loading");
const submitted = await Promise.all(
pendingImages.map((image) => {
const taskId = image.taskId || image.id;
return activeTurn.mode === "edit"
? createImageEditTask(taskId, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
: createImageGenerationTask(taskId, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality);
}),
);
await applyTasks(submitted);
let consecutiveErrors = 0;
const retryingTaskIdsRef = new Set<string>();
while (true) {
const latestConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
const latestTurn = latestConversation?.turns.find((turn) => turn.id === activeTurn.id);
const loadingTaskIds =
latestTurn?.images.flatMap((image) =>
image.status === "loading" && image.taskId ? [image.taskId] : [],
) || [];
if (loadingTaskIds.length === 0) {
break;
}
await sleep(2000);
try {
const taskList = await fetchImageTasks(loadingTaskIds);
consecutiveErrors = 0;
if (taskList.items.length > 0) {
// 检测是否有超时错误且需要显示重试按钮
const timeoutTask = taskList.items.find(
(task) =>
task.status === "error" &&
task.error?.includes("超时") &&
task.conversation_id &&
!retryingTaskIdsRef.has(task.id),
);
if (timeoutTask && timeoutTask.conversation_id) {
retryingTaskIdsRef.add(timeoutTask.id);
setTimeoutRetry({
conversationId: timeoutTask.conversation_id,
taskId: timeoutTask.id,
taskError: timeoutTask.error || "生图超时",
});
// 应用超时错误到对应图片,显示继续等待按钮
await applyTasks([timeoutTask]);
} else {
await applyTasks(taskList.items);
}
}
if (taskList.missing_ids.length > 0 && latestTurn) {
const missingImages = latestTurn.images.filter(
(image) => image.status === "loading" && image.taskId && taskList.missing_ids.includes(image.taskId),
);
const resubmitted = await Promise.all(
missingImages.map((image) =>
activeTurn.mode === "edit"
? createImageEditTask(image.taskId || image.id, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
: createImageGenerationTask(image.taskId || image.id, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality),
),
);
if (resubmitted.length > 0) {
await applyTasks(resubmitted);
}
}
} catch (pollError) {
consecutiveErrors += 1;
if (consecutiveErrors >= 10) {
throw pollError;
}
}
}
await loadQuota();
} catch (error) {
const message = error instanceof Error ? error.message : "生成图片失败";
await updateConversation(conversationId, (current) => {
const conversation = current ?? snapshot;
return {
...conversation,
updatedAt: new Date().toISOString(),
turns: conversation.turns.map((turn) =>
turn.id === activeTurn.id
? {
...turn,
status: "error",
error: message,
images: turn.images.map((image) =>
image.status === "loading" ? { ...image, status: "error", error: message } : image,
),
}
: turn,
),
};
});
toast.error(message);
} finally {
activeConversationQueueIds.delete(conversationId);
for (const conversation of conversationsRef.current) {
if (
!activeConversationQueueIds.has(conversation.id) &&
conversation.turns.some(
(turn) =>
(turn.status === "queued" || turn.status === "generating") &&
turn.images.some((image) => image.status === "loading"),
)
) {
void runConversationQueue(conversation.id);
}
}
}
},
[loadQuota, updateConversation],
);
/* eslint-enable react-hooks/preserve-manual-memoization */
const handleRegenerateTurn = useCallback(
async (conversationId: string, turnId: string) => {
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
const sourceTurn = conversation?.turns.find((turn) => turn.id === turnId);
if (!conversation || !sourceTurn || !sourceTurn.prompt.trim()) {
return;
}
const now = new Date().toISOString();
const nextTurnId = createId();
const count = Math.max(1, sourceTurn.count || sourceTurn.images.length || 1);
const nextTurn: ImageTurn = {
id: nextTurnId,
prompt: sourceTurn.prompt,
model: sourceTurn.model,
mode: sourceTurn.mode,
referenceImages: sourceTurn.referenceImages,
count,
size: sourceTurn.size,
ratio: sourceTurn.ratio,
tier: sourceTurn.tier,
quality: sourceTurn.quality,
images: createLoadingImages(nextTurnId, count),
createdAt: now,
status: "queued",
};
const nextConversation = {
...conversation,
updatedAt: now,
turns: [...conversation.turns, nextTurn],
};
setSelectedConversationId(conversationId);
await persistConversation(nextConversation);
void runConversationQueue(conversationId);
toast.success("已加入重新生成队列");
},
[runConversationQueue],
);
const handleRetryImage = useCallback(
async (conversationId: string, turnId: string, imageId: string) => {
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
if (!conversation) {
return;
}
const now = new Date().toISOString();
const retryImageId = `${turnId}-${createId()}`;
const nextConversation = {
...conversation,
updatedAt: now,
turns: conversation.turns.map((turn) => {
if (turn.id !== turnId) {
return turn;
}
if (!turn.prompt.trim()) {
return turn;
}
const images = turn.images.map((image) =>
image.id === imageId
? {
id: retryImageId,
taskId: retryImageId,
status: "loading" as const,
}
: image,
);
const derived = deriveTurnStatus({ ...turn, status: "queued", images });
return {
...turn,
...derived,
images,
};
}),
};
setSelectedConversationId(conversationId);
await persistConversation(nextConversation);
void runConversationQueue(conversationId);
},
[runConversationQueue],
);
const handleTimeoutRetryContinue = useCallback(async () => {
if (!timeoutRetry) return;
const { conversationId, taskId } = timeoutRetry;
try {
await resumeImagePoll(taskId, imageTimeoutRetrySecs);
// 将对应图片的状态重置为 loading,并清除错误
void updateConversation(conversationId, (current) => {
const conversation = current ?? conversationsRef.current.find((c) => c.id === conversationId);
if (!conversation) return current!;
return {
...conversation,
updatedAt: new Date().toISOString(),
turns: conversation.turns.map((turn) => {
const hasLoading = turn.images.some((image) => image.taskId === taskId);
if (!hasLoading) return turn;
return {
...turn,
status: "generating" as const,
error: undefined,
images: turn.images.map((image) =>
image.taskId === taskId
? { ...image, status: "loading" as const, error: undefined, taskStatus: "running" as const, startTime: image.startTime || Date.now() }
: image
),
};
}),
};
});
// 清除重试状态
setTimeoutRetry(null);
toast.info(`已继续等待 ${imageTimeoutRetrySecs} 秒`);
} catch (err) {
const msg = err instanceof Error ? err.message : "续轮询失败";
toast.error(msg);
setTimeoutRetry(null);
}
}, [timeoutRetry, updateConversation, imageTimeoutRetrySecs]);
const handleTimeoutRetryCancel = useCallback(() => {
if (!timeoutRetry) return;
const { conversationId: convId, taskId, taskError } = timeoutRetry;
// 将超时错误应用到对应图片
void updateConversation(convId, (current) => {
const conversation = current ?? conversationsRef.current.find((c) => c.id === convId);
if (!conversation) return current!;
return {
...conversation,
updatedAt: new Date().toISOString(),
turns: conversation.turns.map((turn) => {
const hasLoading = turn.images.some((image) => image.status === "loading" && image.taskId === taskId);
if (!hasLoading) return turn;
const images = turn.images.map((image) =>
image.taskId === taskId ? { ...image, status: "error" as const, error: taskError } : image,
);
const derived = deriveTurnStatus({ ...turn, images });
return {
...turn,
...derived,
images,
};
}),
};
});
setTimeoutRetry(null);
toast.error(taskError);
}, [timeoutRetry, updateConversation]);
const handleDismissErrors = useCallback(
async (conversationId: string, turnId: string) => {
await updateConversation(conversationId, (current) => {
const conversation = current ?? conversationsRef.current.find((c) => c.id === conversationId);
if (!conversation) return current!;
return {
...conversation,
updatedAt: new Date().toISOString(),
turns: conversation.turns.map((turn) => {
if (turn.id !== turnId) return turn;
const successImages = turn.images.filter((image) => image.status !== "error");
const derived = deriveTurnStatus({ ...turn, images: successImages });
return {
...turn,
...derived,
count: successImages.length,
images: successImages,
};
}),
};
});
},
[updateConversation],
);
useEffect(() => {
for (const conversation of conversations) {
if (
!activeConversationQueueIds.has(conversation.id) &&
conversation.turns.some(
(turn) =>
!turn.resultsDeleted &&
(turn.status === "queued" || turn.status === "generating") &&
turn.images.some((image) => image.status === "loading"),
)
) {
void runConversationQueue(conversation.id);
}
}
}, [conversations, runConversationQueue]);
const handleSubmit = async () => {
const prompt = imagePrompt.trim();
if (!prompt) {
toast.error("请输入提示词");
return;
}
const effectiveImageMode: ImageConversationMode = referenceImageFiles.length > 0 ? "edit" : "generate";
const targetConversation = selectedConversationId
? conversationsRef.current.find((conversation) => conversation.id === selectedConversationId) ?? null
: null;
const now = new Date().toISOString();
const conversationId = targetConversation?.id ?? createId();
const turnId = createId();
const imageSize = `${imageWidth || 1024}x${imageHeight || 1024}`;
const draftTurn: ImageTurn = {
id: turnId,
prompt,
model: imageModel,
mode: effectiveImageMode,
referenceImages: effectiveImageMode === "edit" ? referenceImages : [],
count: parsedCount,
size: imageSize,
ratio: imageRatio,
tier: imageTier,
quality: imageQuality,
images: createLoadingImages(turnId, parsedCount),
createdAt: now,
status: "queued",
};
const baseConversation: ImageConversation = targetConversation
? {
...targetConversation,
updatedAt: now,
turns: [...targetConversation.turns, draftTurn],
}
: {
id: conversationId,
title: buildConversationTitle(prompt),
createdAt: now,
updatedAt: now,
turns: [draftTurn],
};
shouldStickToBottomRef.current = true;
const btn = scrollToLatestBtnRef.current;
if (btn) btn.style.display = "none";
setSelectedConversationId(conversationId);
clearComposerInputs();
await persistConversation(baseConversation);
void runConversationQueue(conversationId);
const targetStats = getImageConversationStats(baseConversation);
if (targetStats.running > 0 || targetStats.queued > 1) {
toast.success("已加入当前对话队列");
} else if (!targetConversation) {
toast.success("已创建新对话并开始处理");
} else {
toast.success("已发送到当前对话");
}
};
return (
<>
<section className="mx-auto grid h-[calc(100dvh-6.5rem)] min-h-0 w-full max-w-[1380px] grid-cols-1 gap-2 overflow-hidden px-0 pb-[calc(env(safe-area-inset-bottom)+0.5rem)] sm:h-[calc(100dvh-5.25rem)] sm:gap-3 sm:px-3 sm:pb-6 lg:grid-cols-[240px_minmax(0,1fr)]">
<div className="hidden h-full min-h-0 border-r border-stone-200/70 pr-3 lg:block">
<ImageSidebar
conversations={conversations}
isLoadingHistory={isLoadingHistory}
selectedConversationId={selectedConversationId}
onCreateDraft={handleCreateDraft}
onClearHistory={openClearHistoryConfirm}
onSelectConversation={setSelectedConversationId}
onDeleteConversation={openDeleteConversationConfirm}
onRenameConversation={handleRenameConversation}
formatConversationTime={formatConversationTime}
/>
</div>
<Dialog open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DialogContent className="flex h-[min(82dvh,760px)] w-[92vw] max-w-[460px] flex-col overflow-hidden rounded-[32px] border-white/80 bg-white p-0 shadow-[0_32px_110px_-38px_rgba(15,23,42,0.45)] sm:rounded-[36px]">
<DialogHeader className="px-6 pt-7 pb-4 sm:px-8">
<DialogTitle className="flex items-center gap-2 text-xl font-bold tracking-tight">
<History className="size-5" />
历史记录
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-8 sm:px-8">
<ImageSidebar
conversations={conversations}
isLoadingHistory={isLoadingHistory}
selectedConversationId={selectedConversationId}
onCreateDraft={() => {
handleCreateDraft();
setIsHistoryOpen(false);
}}
onClearHistory={openClearHistoryConfirm}
onSelectConversation={(id) => {
setSelectedConversationId(id);
setIsHistoryOpen(false);
}}
onDeleteConversation={openDeleteConversationConfirm}
onRenameConversation={handleRenameConversation}
formatConversationTime={formatConversationTime}
hideActionButtons
/>
</div>
</DialogContent>
</Dialog>
<div className="flex min-h-0 flex-col gap-2 sm:gap-4">
<div className="flex items-center justify-between gap-2 px-1 lg:hidden">
<Button
variant="outline"
className="h-10 flex-1 rounded-2xl border-stone-200 bg-white/90 text-stone-700 shadow-sm"
onClick={() => setIsHistoryOpen(true)}
>
<History className="mr-2 size-4" />
历史记录 ({conversations.length})
</Button>
<Button
className="h-10 rounded-2xl bg-stone-950 text-white shadow-sm"
onClick={handleCreateDraft}
>
<Plus className="size-4" />
新建
</Button>
<Button
variant="outline"
className="h-10 rounded-2xl border-stone-200 bg-white/85 px-3 text-stone-600 shadow-sm"
onClick={openClearHistoryConfirm}
disabled={conversations.length === 0}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="relative min-h-0 flex-1">
<div
ref={resultsViewportRef}
onScroll={handleResultsScroll}
className="hide-scrollbar h-full overscroll-contain overflow-y-auto px-1 py-2 sm:px-4 sm:py-4"
style={{ contain: "layout style paint" }}
>
<ImageResults
selectedConversation={selectedConversation}
onOpenLightbox={openLightbox}
onContinueEdit={handleContinueEdit}
onDeletePrompt={openDeletePromptConfirm}
onDeleteResults={openDeleteResultsConfirm}
onReuseTurnConfig={handleReuseTurnConfig}
onRegenerateTurn={handleRegenerateTurn}
onRetryImage={handleRetryImage}
onTimeoutRetryContinue={handleTimeoutRetryContinue}
onDismissErrors={handleDismissErrors}
formatConversationTime={formatConversationTime}
/>
</div>
<button
ref={scrollToLatestBtnRef}
type="button"
aria-label="滚动到最新消息"
title="滚动到最新消息"
onClick={() => scrollResultsToLatest("smooth")}
className="absolute bottom-4 left-1/2 z-20 inline-flex size-11 -translate-x-1/2 items-center justify-center rounded-full border border-stone-200 bg-white/95 text-stone-700 shadow-lg shadow-stone-200/60 backdrop-blur transition hover:-translate-y-0.5 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/10 dark:bg-stone-800/95 dark:text-stone-100 dark:shadow-black/40 dark:hover:bg-stone-700"
style={{ display: "none" }}
>
<ArrowDown className="size-5" />
</button>
</div>
<ImageComposer
prompt={imagePrompt}
imageCount={imageCount}
imageRatio={imageRatio}
imageTier={imageTier}
imageWidth={imageWidth}
imageHeight={imageHeight}
imageQuality={imageQuality}
imageModel={imageModel}
imageModels={imageModels}
availableQuota={availableQuota}
activeTaskCount={activeTaskCount}
referenceImages={referenceImages}
textareaRef={textareaRef}
fileInputRef={fileInputRef}
onPromptChange={setImagePrompt}
onImageCountChange={(value) => setImageCount(value ? clampImageCount(value) : "")}
onImageRatioChange={setImageRatio}
onImageTierChange={setImageTier}
onImageWidthChange={setImageWidth}
onImageHeightChange={setImageHeight}
onImageQualityChange={setImageQuality}
onImageModelChange={setImageModel}
onSubmit={handleSubmit}
onPickReferenceImage={() => fileInputRef.current?.click()}
onReferenceImageChange={handleReferenceImageChange}
onRemoveReferenceImage={handleRemoveReferenceImage}
/>
</div>
</section>
<ImageLightbox
images={lightboxImages}
currentIndex={lightboxIndex}
open={lightboxOpen}
onOpenChange={setLightboxOpen}
onIndexChange={setLightboxIndex}
/>
{deleteConfirm ? (
<Dialog open onOpenChange={(open) => (!open ? setDeleteConfirm(null) : null)}>
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
<DialogHeader className="gap-2">
<DialogTitle>{deleteConfirmTitle}</DialogTitle>
<DialogDescription className="text-sm leading-6">
{deleteConfirmDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
取消
</Button>
<Button className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => void handleConfirmDelete()}>
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</>
);
}
export default function ImagePage() {
const { isCheckingAuth, session } = useAuthGuard();
if (isCheckingAuth || !session) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<LoaderCircle className="size-5 animate-spin text-stone-400" />
</div>
);
}
return <ImagePageContent isAdmin={session.role === "admin"} />;
}