333 lines
14 KiB
TypeScript
333 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
import {
|
|
ArrowDownToLine,
|
|
CheckCircle2,
|
|
CircleAlert,
|
|
Film,
|
|
ImagePlus,
|
|
Link2,
|
|
Loader2,
|
|
Play,
|
|
RotateCcw,
|
|
TerminalSquare,
|
|
Upload,
|
|
} from "lucide-react"
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
|
|
|
|
type AgentRunLog = {
|
|
ts: number
|
|
level: "info" | "warn" | "error"
|
|
message: string
|
|
}
|
|
|
|
type AgentRun = {
|
|
id: string
|
|
job_id: string
|
|
status: "draft" | "queued" | "executing" | "reviewing" | "completed" | "failed"
|
|
stage: string
|
|
progress: number
|
|
logs: AgentRunLog[]
|
|
video_ids: string[]
|
|
final_video_url: string
|
|
contact_sheet_url: string
|
|
error: string
|
|
created_at: number
|
|
updated_at: number
|
|
}
|
|
|
|
const STAGES = [
|
|
{ key: "download", label: "下载" },
|
|
{ key: "assets", label: "素材" },
|
|
{ key: "analyze", label: "拆解" },
|
|
{ key: "plan", label: "规划" },
|
|
{ key: "execute", label: "生成" },
|
|
{ key: "review", label: "审片" },
|
|
{ key: "compose", label: "合成" },
|
|
{ key: "final", label: "成片" },
|
|
]
|
|
|
|
function formatClock(ts: number) {
|
|
if (!ts) return "--:--:--"
|
|
return new Date(ts * 1000).toLocaleTimeString("zh-CN", { hour12: false })
|
|
}
|
|
|
|
function runVideoUrl(run: AgentRun | null) {
|
|
if (!run?.final_video_url) return ""
|
|
return `${API_BASE}${run.final_video_url}`
|
|
}
|
|
|
|
function runContactUrl(run: AgentRun | null) {
|
|
if (!run?.contact_sheet_url) return ""
|
|
return `${API_BASE}${run.contact_sheet_url}`
|
|
}
|
|
|
|
export default function AgentPage() {
|
|
const [url, setUrl] = useState("")
|
|
const [files, setFiles] = useState<File[]>([])
|
|
const [run, setRun] = useState<AgentRun | null>(null)
|
|
const [recent, setRecent] = useState<AgentRun[]>([])
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState("")
|
|
const terminalRef = useRef<HTMLDivElement>(null)
|
|
|
|
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
|
|
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
|
|
|
|
useEffect(() => {
|
|
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
.then((items: AgentRun[]) => {
|
|
setRecent(items)
|
|
const latest = items.find((item) => item.status === "executing" || item.status === "reviewing" || item.status === "completed")
|
|
if (latest) setRun(latest)
|
|
})
|
|
.catch(() => undefined)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!run || run.status === "completed" || run.status === "failed") return
|
|
const timer = window.setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/agent-runs/${run.id}`, { cache: "no-store" })
|
|
if (!res.ok) return
|
|
const next = await res.json()
|
|
setRun(next)
|
|
} catch {
|
|
/* keep current state */
|
|
}
|
|
}, 2000)
|
|
return () => window.clearInterval(timer)
|
|
}, [run?.id, run?.status])
|
|
|
|
useEffect(() => {
|
|
const el = terminalRef.current
|
|
if (el) el.scrollTop = el.scrollHeight
|
|
}, [run?.logs.length])
|
|
|
|
async function submit() {
|
|
setError("")
|
|
if (!url.trim()) {
|
|
setError("需要 TikTok 链接")
|
|
return
|
|
}
|
|
setSubmitting(true)
|
|
try {
|
|
const form = new FormData()
|
|
form.append("tk_url", url.trim())
|
|
files.slice(0, 6).forEach((file) => form.append("product_files", file))
|
|
const res = await fetch(`${API_BASE}/agent-runs`, { method: "POST", body: form })
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "")
|
|
throw new Error(text.slice(0, 260) || `HTTP ${res.status}`)
|
|
}
|
|
const created = await res.json()
|
|
setRun(created)
|
|
setRecent((prev) => [created, ...prev.filter((item) => item.id !== created.id)].slice(0, 8))
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const activeStageIndex = Math.max(0, STAGES.findIndex((item) => item.key === run?.stage))
|
|
const canStart = !!url.trim() && !submitting
|
|
const videoSrc = runVideoUrl(run)
|
|
const contactSrc = runContactUrl(run)
|
|
|
|
return (
|
|
<main className="min-h-screen bg-[#f3f4f7] text-[#111318]">
|
|
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col gap-5 px-5 py-5">
|
|
<header className="flex items-center justify-between rounded-[28px] border border-black/5 bg-white/80 px-5 py-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
|
<div>
|
|
<div className="text-[12px] font-semibold uppercase tracking-[0.18em] text-[#7b8190]">SKG Agent Cut</div>
|
|
<h1 className="mt-1 text-[26px] font-semibold tracking-0 text-[#111318]">一分钟二创出片终端</h1>
|
|
</div>
|
|
<div className="hidden items-center gap-2 rounded-full bg-[#111318] px-3 py-2 text-[12px] font-medium text-white md:flex">
|
|
<TerminalSquare className="h-4 w-4 text-[#81d4ff]" />
|
|
{run ? `${run.status} · ${run.progress}%` : "standby"}
|
|
</div>
|
|
</header>
|
|
|
|
<section className="grid min-h-[calc(100vh-128px)] grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(520px,1fr)_420px]">
|
|
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
|
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
|
<label className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
|
<Link2 className="h-4 w-4 text-[#0a84ff]" />
|
|
TikTok 链接
|
|
</label>
|
|
<textarea
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://www.tiktok.com/@..."
|
|
className="h-28 w-full resize-none rounded-[18px] border border-[#d9dee8] bg-white px-4 py-3 text-[14px] leading-relaxed text-[#111318] outline-none transition focus:border-[#0a84ff] focus:ring-4 focus:ring-[#0a84ff]/10"
|
|
/>
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
|
<label className="mb-3 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
|
<ImagePlus className="h-4 w-4 text-[#34c759]" />
|
|
产品图
|
|
</label>
|
|
<label className="flex h-32 cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7ceda] bg-white text-center transition hover:border-[#0a84ff] hover:bg-[#f7fbff]">
|
|
<Upload className="mb-2 h-6 w-6 text-[#7b8190]" />
|
|
<span className="text-[13px] font-medium text-[#2b3038]">上传产品图</span>
|
|
<span className="mt-1 text-[12px] text-[#7b8190]">最多 6 张</span>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const next = Array.from(e.target.files ?? []).slice(0, 6)
|
|
setFiles(next)
|
|
}}
|
|
/>
|
|
</label>
|
|
{previews.length > 0 && (
|
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
{previews.map((item) => (
|
|
<div key={`${item.file.name}-${item.file.size}`} className="aspect-square overflow-hidden rounded-[14px] border border-black/5 bg-white">
|
|
<img src={item.url} alt={item.file.name} className="h-full w-full object-contain" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded-[18px] border border-[#ff453a]/20 bg-[#ff453a]/10 px-4 py-3 text-[13px] text-[#9f1d17]">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
disabled={!canStart}
|
|
onClick={submit}
|
|
className="flex h-14 items-center justify-center gap-2 rounded-[20px] bg-[#111318] text-[15px] font-semibold text-white shadow-[0_16px_40px_rgba(17,19,24,0.18)] transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#b8bec8]"
|
|
>
|
|
{submitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Play className="h-5 w-5" />}
|
|
开始出片
|
|
</button>
|
|
|
|
<div className="mt-auto rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-3">
|
|
<div className="mb-2 text-[12px] font-semibold text-[#7b8190]">最近任务</div>
|
|
<div className="space-y-2">
|
|
{recent.slice(0, 4).map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setRun(item)}
|
|
className="flex w-full items-center justify-between rounded-[16px] bg-white px-3 py-2 text-left text-[12px] text-[#2b3038] transition hover:bg-[#f1f5fb]"
|
|
>
|
|
<span className="font-medium">{item.id}</span>
|
|
<span className="text-[#7b8190]">{item.status}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="flex min-h-[680px] flex-col rounded-[30px] border border-black/5 bg-[#111318] p-4 shadow-[0_24px_80px_rgba(20,25,38,0.16)]">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-[16px] bg-white/8">
|
|
<TerminalSquare className="h-5 w-5 text-[#81d4ff]" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-[16px] font-semibold text-white">Agent Terminal</h2>
|
|
<p className="text-[12px] text-white/45">{run ? `run ${run.id} · job ${run.job_id}` : "waiting for input"}</p>
|
|
</div>
|
|
</div>
|
|
{run?.status === "failed" ? (
|
|
<CircleAlert className="h-5 w-5 text-[#ff453a]" />
|
|
) : run?.status === "completed" ? (
|
|
<CheckCircle2 className="h-5 w-5 text-[#34c759]" />
|
|
) : (
|
|
<Loader2 className={`h-5 w-5 text-[#81d4ff] ${run ? "animate-spin" : ""}`} />
|
|
)}
|
|
</div>
|
|
|
|
<div className="mb-4 grid grid-cols-4 gap-2 lg:grid-cols-8">
|
|
{STAGES.map((stage, index) => {
|
|
const active = index <= activeStageIndex || run?.status === "completed"
|
|
return (
|
|
<div key={stage.key} className={`rounded-[14px] px-3 py-2 text-[12px] ${active ? "bg-white text-[#111318]" : "bg-white/6 text-white/40"}`}>
|
|
{stage.label}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/8">
|
|
<div className="h-full rounded-full bg-[#34c759] transition-all duration-700" style={{ width: `${run?.progress ?? 0}%` }} />
|
|
</div>
|
|
|
|
<div ref={terminalRef} className="min-h-0 flex-1 overflow-auto rounded-[22px] border border-white/8 bg-black px-4 py-4 font-mono text-[12px] leading-relaxed text-[#d8f3dc]">
|
|
{!run && <div className="text-white/35">$ idle</div>}
|
|
{run?.logs.map((log, index) => (
|
|
<div key={`${log.ts}-${index}`} className={log.level === "error" ? "text-[#ff8a80]" : log.level === "warn" ? "text-[#ffd166]" : "text-[#d8f3dc]"}>
|
|
<span className="text-white/30">[{formatClock(log.ts)}]</span> {log.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-[12px] font-semibold uppercase tracking-[0.16em] text-[#7b8190]">Final</div>
|
|
<h2 className="mt-1 text-[18px] font-semibold text-[#111318]">成片播放器</h2>
|
|
</div>
|
|
<Film className="h-5 w-5 text-[#ff9f0a]" />
|
|
</div>
|
|
|
|
<div className="aspect-[9/16] overflow-hidden rounded-[26px] border border-black/8 bg-[#111318]">
|
|
{videoSrc ? (
|
|
<video key={videoSrc} src={videoSrc} controls playsInline className="h-full w-full bg-black object-contain" />
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#7b8190]">
|
|
<Film className="h-8 w-8" />
|
|
<span className="text-[13px]">等待成片</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{contactSrc && (
|
|
<div className="overflow-hidden rounded-[18px] border border-black/8 bg-white">
|
|
<img src={contactSrc} alt="final contact sheet" className="w-full object-cover" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<a
|
|
href={videoSrc || undefined}
|
|
download
|
|
className={`flex h-11 items-center justify-center gap-2 rounded-[16px] text-[13px] font-semibold ${videoSrc ? "bg-[#0a84ff] text-white" : "pointer-events-none bg-[#dfe3ea] text-[#8d94a1]"}`}
|
|
>
|
|
<ArrowDownToLine className="h-4 w-4" />
|
|
下载
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setRun(null)
|
|
setError("")
|
|
}}
|
|
className="flex h-11 items-center justify-center gap-2 rounded-[16px] bg-[#eef1f6] text-[13px] font-semibold text-[#2b3038] transition hover:bg-[#e3e7ef]"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
重来
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|