"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { AlertCircle, CheckCircle2, Clock3, FileArchive, FileText, History, ImagePlus, LoaderCircle, Pencil, Play, Plus, RefreshCw, Trash2, XCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { httpRequest } from "@/lib/request"; import { cn } from "@/lib/utils"; import { listDeletedEditableFileIds, listEditableFileDrafts, saveDeletedEditableFileIds, saveEditableFileDrafts, type EditableFileDraft, } from "@/store/editable-file-history"; import type { EditableFileTask } from "./types"; type Props = { title: string; kind: "ppt" | "psd"; endpoint: string; defaultPrompt: string; imageRequired?: boolean; }; const MAX_HISTORY = 20; const DRAFT_ID = "__draft__"; const taskIdOf = (task: EditableFileTask | null | undefined) => task?.taskId || task?.id || ""; const isRunning = (task: EditableFileTask | null | undefined) => task?.status === "queued" || task?.status === "running"; const statusText = (status: string) => ({ queued: "排队中", running: "生成中", success: "已完成", error: "失败" }[status] || status); const statusClass = (status: string) => status === "success" ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/20 dark:text-emerald-300" : status === "error" ? "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/20 dark:text-rose-300" : "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/20 dark:text-amber-300"; const formatElapsed = (seconds: number) => `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`; const titleOfPrompt = (prompt: string, fallback: string) => prompt.trim().replace(/\s+/g, " ").slice(0, 24) || fallback; const readFile = (file: File) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); const mergeTasks = (current: EditableFileTask[], updates: EditableFileTask[]) => { const next = [...current]; for (const update of updates) { const id = taskIdOf(update); if (!id) continue; const index = next.findIndex((item) => taskIdOf(item) === id); const merged = { ...(index >= 0 ? next[index] : {}), ...update, polled_at: Date.now() }; if (index >= 0) next[index] = merged; else next.unshift(merged); } return next.slice(0, MAX_HISTORY); }; const removeTasks = (current: EditableFileTask[], ids: string[]) => { const missing = new Set(ids); return missing.size ? current.filter((task) => !missing.has(taskIdOf(task))) : current; }; const fileNameOf = (url: string) => { try { return decodeURIComponent(new URL(url).pathname.split("/").pop() || ""); } catch { return decodeURIComponent(url.split("/").pop() || ""); } }; function ResultFile({ href, icon, label }: { href?: string; icon: ReactNode; label: string }) { if (!href) return null; return (
{icon}
{label}
{fileNameOf(href)}
); } export function EditableFilePanel({ title, kind, endpoint, defaultPrompt, imageRequired }: Props) { const [prompt, setPrompt] = useState(defaultPrompt); const [images, setImages] = useState([]); const [tasks, setTasks] = useState([]); const [selectedId, setSelectedId] = useState(""); const [submitting, setSubmitting] = useState(false); const [polling, setPolling] = useState(false); const [error, setError] = useState(""); const [now, setNow] = useState(Date.now()); const [drafts, setDrafts] = useState>({}); const [deletedIds, setDeletedIds] = useState>(new Set()); const [renamingId, setRenamingId] = useState(""); const [renamingTitle, setRenamingTitle] = useState(""); const [deleteConfirm, setDeleteConfirm] = useState<{ type: "one"; id: string } | { type: "all"; ids: string[] } | null>(null); const visibleTasks = useMemo(() => tasks.filter((task) => task.kind === kind && !deletedIds.has(taskIdOf(task))).slice(0, MAX_HISTORY), [deletedIds, kind, tasks]); const selectedTask = selectedId === DRAFT_ID ? null : visibleTasks.find((task) => taskIdOf(task) === selectedId) || visibleTasks[0] || null; const running = visibleTasks.some(isRunning); const runningIds = visibleTasks.filter(isRunning).map(taskIdOf).join(","); const elapsedOf = (task: EditableFileTask | null | undefined) => { if (!task) return 0; const base = Math.max(0, Number(task.elapsed_seconds || 0)); return Math.max(0, isRunning(task) && task.polled_at ? base + Math.floor((now - task.polled_at) / 1000) : base); }; const fetchTasks = useCallback(async (ids: string[] = []) => { const taskIds = Array.from(new Set(ids.filter(Boolean))).slice(0, MAX_HISTORY); setPolling(true); try { const path = taskIds.length ? `/v1/editable-file-tasks?ids=${taskIds.map(encodeURIComponent).join(",")}` : "/v1/editable-file-tasks"; const result = await httpRequest<{ items: EditableFileTask[]; missing_ids?: string[] }>(path); const missingIds = result.missing_ids || []; const hidden = await listDeletedEditableFileIds(kind); setTasks((current) => (taskIds.length ? mergeTasks(removeTasks(current, missingIds), result.items || []) : (result.items || [])).filter((task) => !hidden.has(taskIdOf(task)))); setSelectedId((current) => missingIds.includes(current) ? "" : current); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setPolling(false); } }, [kind]); useEffect(() => { void listEditableFileDrafts(kind).then(setDrafts); void listDeletedEditableFileIds(kind).then(setDeletedIds); void fetchTasks(); }, [fetchTasks, kind]); useEffect(() => { if (selectedId === DRAFT_ID) return; const draft = drafts[selectedId]; const task = visibleTasks.find((item) => taskIdOf(item) === selectedId); setPrompt(draft?.prompt || task?.prompt_preview || defaultPrompt); setImages(Array.isArray(draft?.images) ? draft.images : []); }, [defaultPrompt, drafts, selectedId, visibleTasks]); useEffect(() => { if (selectedId === DRAFT_ID) return; if (visibleTasks.length && (!selectedId || !visibleTasks.some((task) => taskIdOf(task) === selectedId))) setSelectedId(taskIdOf(visibleTasks[0])); if (!visibleTasks.length && selectedId) setSelectedId(""); }, [selectedId, visibleTasks]); useEffect(() => { if (!running) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [running]); useEffect(() => { const ids = runningIds.split(",").filter(Boolean); if (!ids.length) return; const timer = window.setInterval(() => void fetchTasks(ids), 5000); return () => window.clearInterval(timer); }, [fetchTasks, runningIds]); const appendFiles = async (files: FileList | null) => { if (!files?.length) return; const values = await Promise.all(Array.from(files).map(readFile)); setImages((current) => [...current, ...values]); }; const persistDrafts = (updater: (current: Record) => Record) => { setDrafts((current) => { const next = updater(current); void saveEditableFileDrafts(kind, next); return next; }); }; const createDraft = () => { setError(""); setSelectedId(DRAFT_ID); setPrompt(defaultPrompt); setImages([]); }; const renameTask = (id: string, title: string) => { const trimmed = title.trim(); if (!id || !trimmed) return; persistDrafts((current) => ({ ...current, [id]: { ...(current[id] || {}), title: trimmed } })); }; const deleteTask = (id: string) => { if (!id) return; const nextDeleted = new Set(deletedIds); nextDeleted.add(id); void saveDeletedEditableFileIds(kind, nextDeleted); setDeletedIds(nextDeleted); setTasks((current) => current.filter((task) => taskIdOf(task) !== id)); persistDrafts((current) => { const next = { ...current }; delete next[id]; return next; }); if (selectedId === id) setSelectedId(""); }; const clearHistory = () => { const ids = deleteConfirm?.type === "all" ? deleteConfirm.ids : tasks.filter((task) => task.kind === kind).map(taskIdOf).filter(Boolean); if (!ids.length) return; const nextDeleted = new Set([...deletedIds, ...ids]); void saveDeletedEditableFileIds(kind, nextDeleted); setDeletedIds(nextDeleted); setTasks((current) => current.filter((task) => task.kind !== kind || !ids.includes(taskIdOf(task)))); persistDrafts((current) => { const next = { ...current }; ids.forEach((id) => delete next[id]); return next; }); setSelectedId(""); }; const confirmDelete = () => { if (!deleteConfirm) return; if (deleteConfirm.type === "one") deleteTask(deleteConfirm.id); else clearHistory(); setDeleteConfirm(null); }; const submit = async () => { setError(""); setSubmitting(true); try { const base64_images = images; if (imageRequired && !base64_images.length) throw new Error("base64_images is empty"); const task = await httpRequest(endpoint, { method: "POST", body: { client_task_id: crypto.randomUUID(), prompt, base64_images } }); const polledAt = Date.now(); const id = taskIdOf(task); const draft = { prompt: prompt.trim(), images: base64_images, title: titleOfPrompt(prompt, title) }; const nextTask = { ...task, prompt_preview: draft.prompt, polled_at: polledAt }; setNow(polledAt); persistDrafts((current) => ({ ...current, [id]: draft })); setTasks((current) => mergeTasks(current.filter((item) => taskIdOf(item) !== taskIdOf(nextTask)), [nextTask])); setSelectedId(id); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; const refreshAll = () => void fetchTasks(); const selectTask = (id: string) => { setRenamingId(""); setSelectedId(id); const draft = drafts[id]; const task = visibleTasks.find((item) => taskIdOf(item) === id); setPrompt(draft?.prompt || task?.prompt_preview || defaultPrompt); setImages(Array.isArray(draft?.images) ? draft.images : []); }; const startRename = (id: string) => { setRenamingId(id); setRenamingTitle(drafts[id]?.title || titleOfPrompt(drafts[id]?.prompt || "", `${kind.toUpperCase()} 任务`)); }; const commitRename = () => { renameTask(renamingId, renamingTitle); setRenamingId(""); setRenamingTitle(""); }; return ( <>

{title}