Download apps/web/src/components/generate/GlobalGenerationProgress.tsx from Atmint/cpns: direct link, hf CLI and curl.
- Browser
- Download file 10.5 kB
-
https://huggingface.co/spaces/Atmint/cpns/resolve/main/apps/web/src/components/generate/GlobalGenerationProgress.tsx
- Command line
-
hf download hf://spaces/Atmint/cpns/apps/web/src/components/generate/GlobalGenerationProgress.tsx
-
curl -L -o GlobalGenerationProgress.tsx https://huggingface.co/spaces/Atmint/cpns/resolve/main/apps/web/src/components/generate/GlobalGenerationProgress.tsx
10.5 kB
| import { useState, useRef, useEffect } from "react"; | |
| import { useRouterState, Link } from "@tanstack/react-router"; | |
| import { useMutation } from "@tanstack/react-query"; | |
| import { MaterialIcon } from "@/components/ui/MaterialIcon"; | |
| import { useGenerationJobs, type ActiveJob } from "@/hooks/use-generation-jobs"; | |
| import { isTerminal } from "@/hooks/use-job-shared"; | |
| import { trpc, queryClient } from "@/utils/trpc"; | |
| interface LogEntry { | |
| step: string; | |
| message: string; | |
| status: "running" | "done" | "error"; | |
| timestamp: string; | |
| details?: string; | |
| } | |
| const STEP_LABELS: Record<string, string> = { | |
| generate_passage: "Menulis Bacaan", | |
| validate_passage: "Memvalidasi Bacaan", | |
| generate_questions: "Membuat Soal", | |
| self_validate: "Self-Check Kualitas", | |
| save: "Menyimpan Hasil", | |
| cancel: "Dibatalkan", | |
| error: "Error", | |
| }; | |
| function LogStatusIcon({ status }: { status: string }) { | |
| if (status === "done") | |
| return ( | |
| <MaterialIcon | |
| name="check_circle" | |
| className="text-[var(--matcha-400)] text-xs shrink-0" | |
| /> | |
| ); | |
| if (status === "error") | |
| return ( | |
| <MaterialIcon | |
| name="error" | |
| className="text-[var(--pomegranate-400)] text-xs shrink-0" | |
| /> | |
| ); | |
| return ( | |
| <MaterialIcon | |
| name="sync" | |
| className="text-[var(--matcha-400)] text-xs shrink-0 animate-spin" | |
| /> | |
| ); | |
| } | |
| function TerminalLog({ logs, isRunning }: { logs: LogEntry[]; isRunning: boolean }) { | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const [expandedIdx, setExpandedIdx] = useState<number | null>(null); | |
| useEffect(() => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| } | |
| }, [logs]); | |
| if (!logs?.length) { | |
| return ( | |
| <div className="mt-2 rounded-lg bg-black/40 p-2 border border-white/5"> | |
| <div className="flex items-center gap-2 text-white/40 text-[10px] font-mono"> | |
| <MaterialIcon name="psychology" className="text-[10px] animate-pulse" /> | |
| AI sedang mempersiapkan... | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div | |
| ref={scrollRef} | |
| className="mt-2 max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 font-mono text-[10px] leading-relaxed border border-white/5" | |
| > | |
| {logs.map((log, i) => { | |
| const isExpanded = expandedIdx === i; | |
| const hasDetails = !!log.details && log.details.length > 0; | |
| const isLast = i === logs.length - 1; | |
| const isCurrentRunning = isLast && log.status === "running" && isRunning; | |
| return ( | |
| <div key={i} className="flex flex-col"> | |
| <div | |
| className={`flex items-start gap-1.5 py-0.5 ${hasDetails ? "cursor-pointer hover:bg-white/5 rounded px-1 -mx-1 transition-colors" : ""}`} | |
| onClick={() => hasDetails && setExpandedIdx(isExpanded ? null : i)} | |
| > | |
| <LogStatusIcon status={log.status} /> | |
| <span className="text-white/30 shrink-0"> | |
| {new Date(log.timestamp).toLocaleTimeString("id-ID", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| })} | |
| </span> | |
| <span | |
| className={`font-semibold truncate ${ | |
| log.status === "error" | |
| ? "text-[var(--pomegranate-400)]" | |
| : log.status === "done" | |
| ? "text-[var(--matcha-400)]" | |
| : "text-white/80" | |
| }`} | |
| > | |
| {STEP_LABELS[log.step] ?? log.step} | |
| </span> | |
| {isCurrentRunning && ( | |
| <span className="w-1 h-1 bg-[var(--matcha-400)] rounded-full animate-pulse shrink-0 mt-1" /> | |
| )} | |
| </div> | |
| {isExpanded && hasDetails && ( | |
| <div className="ml-5 mt-0.5 mb-1 p-2 rounded bg-black/60 border border-white/10 text-white/70 whitespace-pre-wrap text-[9px] leading-relaxed max-h-24 overflow-y-auto"> | |
| {log.details} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| function SingleJobCard({ | |
| job, | |
| onCancel, | |
| cancelPending, | |
| }: { | |
| job: ActiveJob; | |
| onCancel: (jobId: string) => void; | |
| cancelPending: boolean; | |
| }) { | |
| const [minimized, setMinimized] = useState(false); | |
| const progress = job.progress ?? 0; | |
| const message = job.progressMessage ?? "Sedang berjalan..."; | |
| const logs = (job.logs ?? []) as LogEntry[]; | |
| const isAgentic = job.mode === "agentic"; | |
| if (minimized) { | |
| return ( | |
| <button | |
| onClick={() => setMinimized(false)} | |
| className="flex items-center gap-2 py-1.5 px-3 bg-[var(--clay-black)]/90 rounded-lg text-white text-xs font-semibold border border-[var(--matcha-400)]/30 hover:border-[var(--matcha-400)]/60 transition-all w-full" | |
| > | |
| <MaterialIcon | |
| name="psychology" | |
| className="text-[var(--matcha-400)] animate-pulse text-xs" | |
| /> | |
| <span className="truncate flex-1 text-left">{message}</span> | |
| <span className="tabular-nums text-[var(--matcha-400)]">{progress}%</span> | |
| </button> | |
| ); | |
| } | |
| return ( | |
| <div className="rounded-xl bg-[var(--clay-black)] border border-white/10 p-3 transition-all"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <div className="flex items-center gap-2 min-w-0"> | |
| <MaterialIcon | |
| name="psychology" | |
| className="text-[var(--matcha-400)] animate-pulse text-sm shrink-0" | |
| /> | |
| <div className="min-w-0"> | |
| <span className="text-xs font-semibold block truncate">{message}</span> | |
| {isAgentic && ( | |
| <span className="text-[10px] text-[var(--matcha-400)]">Agentic</span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1 shrink-0"> | |
| <button | |
| onClick={() => setMinimized(true)} | |
| className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-white/10 text-white/50 hover:text-white transition-colors" | |
| > | |
| <MaterialIcon name="expand_less" className="text-xs" /> | |
| </button> | |
| <button | |
| onClick={() => onCancel(job.id)} | |
| disabled={cancelPending} | |
| className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/20 text-white/50 hover:text-[var(--pomegranate-400)] transition-colors disabled:opacity-30" | |
| > | |
| <MaterialIcon name="close" className="text-xs" /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mb-2"> | |
| <div className="flex justify-between items-center mb-1"> | |
| <span className="tabular-nums text-xs font-bold text-[var(--matcha-400)]"> | |
| {progress}% | |
| </span> | |
| </div> | |
| <div className="w-full h-1.5 bg-white/20 rounded-full overflow-hidden"> | |
| <div | |
| className="h-full bg-gradient-to-r from-[var(--matcha-600)] to-[var(--matcha-400)] transition-all duration-500 rounded-full" | |
| style={{ width: `${progress}%` }} | |
| /> | |
| </div> | |
| </div> | |
| {isAgentic && <TerminalLog logs={logs} isRunning={!isTerminal(job.status)} />} | |
| </div> | |
| ); | |
| } | |
| export function GlobalGenerationProgress() { | |
| const { activeJobs, isGenerating, removeJob, setError } = useGenerationJobs(); | |
| const [collapsed, setCollapsed] = useState(false); | |
| const matches = useRouterState({ select: (s) => s.matches }); | |
| const isGeneratePage = matches.some((m) => m.routeId === "/generate"); | |
| const isTakePage = matches.some((m) => m.routeId === "/package/$id/take"); | |
| const cancelMutation = useMutation({ | |
| ...trpc.ai.cancelJob.mutationOptions(), | |
| onSuccess: async (_data, variables) => { | |
| removeJob(variables.jobId); | |
| setError(null); | |
| await queryClient.invalidateQueries({ | |
| queryKey: trpc.ai.myJobs.queryKey(), | |
| }); | |
| }, | |
| }); | |
| if (!isGenerating || isTakePage) return null; | |
| const handleCancel = (jobId: string) => { | |
| cancelMutation.mutate({ jobId }); | |
| }; | |
| // Collapsed floating pill showing count | |
| if (collapsed) { | |
| return ( | |
| <button | |
| onClick={() => setCollapsed(false)} | |
| className="fixed bottom-8 right-6 z-50 h-11 pl-4 pr-5 bg-[var(--clay-black)] rounded-full flex items-center gap-2.5 text-white text-sm font-semibold shadow-2xl border border-[var(--matcha-400)]/30 hover:border-[var(--matcha-400)]/60 transition-all" | |
| > | |
| <MaterialIcon | |
| name="psychology" | |
| className="text-[var(--matcha-400)] animate-pulse text-sm" | |
| /> | |
| <span>{activeJobs.length} active</span> | |
| </button> | |
| ); | |
| } | |
| return ( | |
| <div | |
| className={`fixed bottom-8 right-6 z-50 rounded-2xl shadow-2xl border p-4 text-white transition-all ${ | |
| isGeneratePage | |
| ? "w-80 sm:w-[26rem] ring-2 ring-[var(--matcha-400)]/40 border-[var(--matcha-400)]/30 bg-[var(--clay-black)]" | |
| : "w-72 sm:w-80 border-white/10 bg-[var(--clay-black)]" | |
| }`} | |
| > | |
| <div className="flex items-center justify-between mb-3"> | |
| <div className="flex items-center gap-2"> | |
| <MaterialIcon | |
| name="psychology" | |
| className="text-[var(--matcha-400)] animate-pulse text-base" | |
| /> | |
| <span className="text-sm font-semibold"> | |
| AI Generation{activeJobs.length > 1 ? ` (${activeJobs.length})` : ""} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Link | |
| to="/generate" | |
| className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/60 hover:text-white transition-colors" | |
| title="Buka halaman generate" | |
| > | |
| <MaterialIcon name="open_in_new" className="text-sm" /> | |
| </Link> | |
| <button | |
| onClick={() => setCollapsed(true)} | |
| className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/60 hover:text-white transition-colors" | |
| title="Minimize" | |
| > | |
| <MaterialIcon name="expand_more" className="text-sm" /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-2 max-h-[60vh] overflow-y-auto"> | |
| {activeJobs.map((job) => ( | |
| <SingleJobCard | |
| key={job.id} | |
| job={job} | |
| onCancel={handleCancel} | |
| cancelPending={cancelMutation.isPending} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |