dashboard / frontend /src /sft_diff /SftDiffApp.tsx
timchen0618
SFT Diff: add qwen template dropdown, 830 rows (both templates)
a45ef56
import { useEffect, useState, useMemo } from "react";
// ── Types ─────────────────────────────────────────────────────────────────
type Template = "gpt-oss" | "qwen";
interface Message {
role: "user" | "assistant";
content: string;
}
interface Row {
query_id: string;
excerpt: string;
messages_gpt: Message[] | null;
messages_qwen: Message[] | null;
}
// ── Color palette ─────────────────────────────────────────────────────────
const COLORS = {
user: { bg: "bg-amber-950/40", border: "border-amber-600", label: "text-amber-400", tag: "USER MESSAGE" },
reasoning: { bg: "bg-purple-950/40", border: "border-purple-600", label: "text-purple-400", tag: "REASONING" },
tool_call: { bg: "bg-blue-950/40", border: "border-blue-600", label: "text-blue-400", tag: "TOOL CALL" },
tool_resp: { bg: "bg-gray-800/60", border: "border-gray-600", label: "text-gray-400", tag: "TOOL RESPONSE" },
};
// ── Block component ───────────────────────────────────────────────────────
function Block({ kind, label, children }: {
kind: keyof typeof COLORS;
label?: string;
children: React.ReactNode;
}) {
const c = COLORS[kind];
return (
<div className={`border-l-2 ${c.border} ${c.bg} pl-3 py-2 rounded-r mb-2`}>
<div className={`text-[9px] font-bold uppercase tracking-widest mb-1.5 ${c.label}`}>
{label ?? c.tag}
</div>
{children}
</div>
);
}
// ── Left panel: parse excerpt (newline-separated JSON objects) ────────────
interface ExcerptItem {
type: "reasoning" | "function_call" | "function_call_output" | string;
[key: string]: unknown;
}
function parseExcerptItems(excerpt: string): ExcerptItem[] {
return excerpt
.split(/\n\n/)
.map(s => s.trim())
.filter(Boolean)
.flatMap(chunk => {
try {
const obj = JSON.parse(chunk);
return typeof obj === "object" && obj !== null && "type" in obj ? [obj as ExcerptItem] : [];
} catch {
return [];
}
});
}
function ExcerptPanel({ excerpt, userContent }: { excerpt: string; userContent: string }) {
const items = useMemo(() => parseExcerptItems(excerpt), [excerpt]);
return (
<div className="space-y-0">
<Block kind="user">
<pre className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed">
{userContent}
</pre>
</Block>
{items.map((item, i) => {
const raw = JSON.stringify(item, null, 2);
if (item.type === "reasoning") {
return (
<Block key={i} kind="reasoning">
<pre className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed">{raw}</pre>
</Block>
);
}
if (item.type === "function_call") {
return (
<Block key={i} kind="tool_call" label={`TOOL CALL${item.name ? `: ${item.name}` : ""}`}>
<pre className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed">{raw}</pre>
</Block>
);
}
if (item.type === "function_call_output") {
return (
<Block key={i} kind="tool_resp">
<pre className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed">{raw}</pre>
</Block>
);
}
return (
<div key={i} className="border-l-2 border-gray-700 pl-3 py-2 mb-2">
<pre className="text-xs text-gray-400 whitespace-pre-wrap font-mono leading-relaxed">{raw}</pre>
</div>
);
})}
</div>
);
}
// ── Right panel: split assistant content into typed segments ──────────────
type SegKind = "reasoning" | "tool_call";
/**
* For gpt-oss: split at <tool_call>...</tool_call>.
* Segments outside tags are reasoning; tags themselves are tool_call.
*/
function splitGptContent(content: string): { kind: SegKind; text: string }[] {
const segs: { kind: SegKind; text: string }[] = [];
const re = /(<tool_call>[\s\S]*?<\/tool_call>)/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const pre = content.slice(last, m.index).trim();
if (pre) segs.push({ kind: "reasoning", text: pre });
segs.push({ kind: "tool_call", text: m[1] });
last = m.index + m[1].length;
}
const tail = content.slice(last).trim();
if (tail) segs.push({ kind: "reasoning", text: tail });
return segs;
}
/**
* For qwen: reasoning is inside <think>...</think>, tool calls in <tool_call>...</tool_call>.
* Both may appear in one assistant message; extract all in order.
*/
function splitQwenContent(content: string): { kind: SegKind; text: string }[] {
const segs: { kind: SegKind; text: string }[] = [];
const re = /(<think>[\s\S]*?<\/think>|<tool_call>[\s\S]*?<\/tool_call>)/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const gap = content.slice(last, m.index).trim();
if (gap) segs.push({ kind: "reasoning", text: gap });
if (m[1].startsWith("<think>")) {
segs.push({ kind: "reasoning", text: m[1] });
} else {
segs.push({ kind: "tool_call", text: m[1] });
}
last = m.index + m[1].length;
}
const tail = content.slice(last).trim();
if (tail) segs.push({ kind: "reasoning", text: tail });
return segs;
}
function AssistantBlock({ content, template }: { content: string; template: Template }) {
const segs = template === "qwen" ? splitQwenContent(content) : splitGptContent(content);
const hasToolCall = segs.some(s => s.kind === "tool_call");
const outerColor = COLORS.reasoning;
return (
<div className={`border-l-2 ${outerColor.border} ${outerColor.bg} pl-3 py-2 rounded-r mb-2`}>
<div className={`text-[9px] font-bold uppercase tracking-widest mb-1.5 ${outerColor.label}`}>
{hasToolCall ? "REASONING + TOOL CALL" : "REASONING"}
</div>
{segs.map((seg, j) => {
if (seg.kind === "tool_call") {
const tc = COLORS.tool_call;
return (
<div key={j} className={`border-l-2 ${tc.border} ${tc.bg} pl-2 py-1.5 rounded-r mb-1.5`}>
<div className={`text-[9px] font-bold uppercase tracking-widest mb-1 ${tc.label}`}>TOOL CALL</div>
<pre className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed">{seg.text}</pre>
</div>
);
}
return (
<pre key={j} className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed mb-1.5">
{seg.text}
</pre>
);
})}
</div>
);
}
function MessagesPanel({ messages, template }: { messages: Message[]; template: Template }) {
return (
<div className="space-y-0">
{messages.map((msg, i) => {
if (msg.role === "user" && i === 0) {
return (
<Block key={i} kind="user">
<pre className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed">{msg.content}</pre>
</Block>
);
}
if (msg.role === "user") {
return (
<Block key={i} kind="tool_resp">
<pre className="text-xs text-gray-200 whitespace-pre-wrap font-mono leading-relaxed">{msg.content}</pre>
</Block>
);
}
return <AssistantBlock key={i} content={msg.content} template={template} />;
})}
</div>
);
}
// ── Legend ────────────────────────────────────────────────────────────────
function Legend() {
return (
<div className="flex items-center gap-4 px-4 py-1.5 bg-gray-900/80 border-b border-gray-800 shrink-0 flex-wrap">
<span className="text-[9px] font-bold uppercase tracking-widest text-gray-500 mr-1">Legend</span>
{(Object.entries(COLORS) as [keyof typeof COLORS, typeof COLORS[keyof typeof COLORS]][]).map(([k, c]) => (
<span key={k} className={`flex items-center gap-1.5 text-[10px] ${c.label}`}>
<span className={`inline-block w-2.5 h-2.5 rounded-sm border ${c.border} ${c.bg}`} />
{c.tag}
</span>
))}
</div>
);
}
// ── Template dropdown ─────────────────────────────────────────────────────
function TemplateDropdown({ value, onChange }: { value: Template; onChange: (t: Template) => void }) {
return (
<select
value={value}
onChange={e => onChange(e.target.value as Template)}
className="text-xs bg-gray-800 border border-gray-600 text-gray-200 rounded px-2 py-1 ml-2 cursor-pointer"
>
<option value="gpt-oss">gpt-oss</option>
<option value="qwen">qwen</option>
</select>
);
}
// ── Main component ────────────────────────────────────────────────────────
export default function SftDiffApp() {
const [data, setData] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedIdx, setSelectedIdx] = useState(0);
const [search, setSearch] = useState("");
const [template, setTemplate] = useState<Template>("gpt-oss");
useEffect(() => {
setLoading(true);
fetch("/api/sft-diff/")
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); })
.then((d: { rows: Row[] }) => { setData(d.rows); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, []);
const filtered = useMemo(() => {
if (!search.trim()) return data;
const q = search.toLowerCase();
return data.filter(r =>
r.query_id.toLowerCase().includes(q) ||
(r.messages_gpt?.[0]?.content ?? r.messages_qwen?.[0]?.content ?? "").toLowerCase().includes(q)
);
}, [data, search]);
const current = filtered[selectedIdx] ?? null;
const activeMessages: Message[] | null = current
? (template === "qwen" ? current.messages_qwen : current.messages_gpt)
: null;
// User content for the left panel β€” prefer gpt-oss, fall back to qwen (same content)
const userContent = current?.messages_gpt?.[0]?.content ?? current?.messages_qwen?.[0]?.content ?? "";
if (loading) return <div className="h-full flex items-center justify-center text-gray-400">Loading…</div>;
if (error) return <div className="h-full flex items-center justify-center text-red-400">Error: {error}</div>;
return (
<div className="h-full flex overflow-hidden bg-gray-950 text-gray-100">
{/* ── Sidebar ──────────────────────────────────────────────── */}
<div className="w-60 shrink-0 flex flex-col border-r border-gray-800 bg-gray-900">
<div className="px-2 py-1.5 border-b border-gray-800">
<input
type="text"
placeholder="Search query ID or question…"
value={search}
onChange={e => { setSearch(e.target.value); setSelectedIdx(0); }}
className="w-full bg-gray-800 border border-gray-700 text-gray-200 text-xs rounded px-2 py-1.5 placeholder-gray-600"
/>
<div className="text-[10px] text-gray-600 mt-1">{filtered.length} / {data.length}</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.map((row, i) => {
const content = row.messages_gpt?.[0]?.content ?? row.messages_qwen?.[0]?.content ?? "";
const qMatch = content.match(/Question:\s*([\s\S]{0,120})/);
const preview = qMatch ? qMatch[1].trim().replace(/\n/g, " ") : content.slice(0, 80);
return (
<button key={row.query_id + i} onClick={() => setSelectedIdx(i)}
className={`w-full text-left px-3 py-2 border-b border-gray-800/50 text-xs transition-colors ${
selectedIdx === i
? "bg-blue-900/40 text-blue-200 border-l-2 border-l-blue-500"
: "text-gray-400 hover:bg-gray-800"
}`}
>
<div className="font-medium text-gray-200 mb-0.5">#{row.query_id}</div>
<div className="text-[10px] text-gray-500 leading-snug line-clamp-2">{preview}</div>
</button>
);
})}
</div>
</div>
{/* ── Main area ────────────────────────────────────────────── */}
{current ? (
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Legend />
<div className="flex-1 flex overflow-hidden min-w-0">
{/* Left: original excerpt */}
<div className="flex-1 flex flex-col min-w-0 border-r border-gray-800 overflow-hidden">
<div className="px-3 py-1.5 bg-gray-900/60 border-b border-gray-800 shrink-0">
<span className="text-[11px] font-semibold text-amber-400 uppercase tracking-widest">Original</span>
<span className="text-[10px] text-gray-500 ml-2">excerpt field β€” raw JSON items</span>
</div>
<div className="flex-1 overflow-y-auto p-3">
<ExcerptPanel excerpt={current.excerpt} userContent={userContent} />
</div>
</div>
{/* Right: converted messages */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="px-3 py-1.5 bg-gray-900/60 border-b border-gray-800 shrink-0 flex items-center">
<span className="text-[11px] font-semibold text-sky-400 uppercase tracking-widest">Converted</span>
<TemplateDropdown value={template} onChange={setTemplate} />
{activeMessages && (
<span className="text-[10px] text-gray-500 ml-2">{activeMessages.length} messages</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-3">
{activeMessages ? (
<MessagesPanel messages={activeMessages} template={template} />
) : (
<div className="text-gray-500 text-xs italic mt-4">
Not available for {template} template.
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">No record selected.</div>
)}
</div>
);
}