sing-ui/components/log-viewer.tsx

130 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const LEVEL_CLASS: Record<string, string> = {
trace: "text-zinc-600",
debug: "text-zinc-500",
info: "text-sky-400",
warn: "text-amber-400",
warning: "text-amber-400",
error: "text-red-400",
fatal: "text-red-500",
};
function parseLine(line: string): { text: string; className: string } {
const t = line.trim();
if (t.startsWith("{")) {
try {
const o = JSON.parse(t) as Record<string, unknown>;
if (o && typeof o === "object" && typeof o.lvl === "string") {
return {
text: typeof o.msg === "string" ? o.msg : t,
className: LEVEL_CLASS[o.lvl.toLowerCase()] ?? "text-zinc-300",
};
}
} catch {
// 不是合法 JSON原样显示
}
}
return { text: line, className: "text-zinc-300" };
}
interface LogViewerProps {
/** SSE 模式:订阅 /api/runs/[runId]/events。runId 变化时请通过 key 重挂载 */
runId?: number;
/** 静态模式:直接展示完整日志 */
log?: string;
live?: boolean;
className?: string;
emptyText?: string;
/** SSE 收到 done 事件时回调 */
onDone?: () => void;
}
export function LogViewer({
runId,
log,
live,
className,
emptyText = "暂无日志",
onDone,
}: LogViewerProps) {
const isLive = live && runId != null;
const [sseLines, setSseLines] = useState<string[]>([]);
const boxRef = useRef<HTMLDivElement>(null);
const stickRef = useRef(true);
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
useEffect(() => {
if (!isLive) return;
const es = new EventSource(`/api/runs/${runId}/events`);
es.onmessage = (e) => {
try {
const d = JSON.parse(e.data) as { line?: unknown };
setSseLines((prev) => [
...prev,
typeof d.line === "string" ? d.line : String(e.data),
]);
} catch {
setSseLines((prev) => [...prev, String(e.data)]);
}
};
es.addEventListener("done", () => {
es.close();
onDoneRef.current?.();
});
es.onerror = () => es.close();
return () => es.close();
}, [isLive, runId]);
const lines = useMemo(() => {
if (isLive) return sseLines;
if (log == null || log === "") return [];
return log.replace(/\n+$/, "").split("\n");
}, [isLive, sseLines, log]);
useEffect(() => {
const el = boxRef.current;
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
}, [lines]);
return (
<div
ref={boxRef}
onScroll={() => {
const el = boxRef.current;
if (!el) return;
// 用户上翻时暂停自动滚动,回到底部附近时恢复
stickRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className={cn(
"h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 p-3 font-mono text-xs leading-5",
className
)}
>
{lines.length === 0 ? (
<div className="text-zinc-500">{emptyText}</div>
) : (
lines.map((line, i) => {
const { text, className: lineCls } = parseLine(line);
return (
<div
key={i}
className={cn("whitespace-pre-wrap break-all", lineCls)}
>
{text || " "}
</div>
);
})
)}
</div>
);
}