2api
feat: configurable stream stall timeout + per-provider UI
88c4c60
Raw
History Blame Contribute Delete
12 kB
"use client";
import { useState } from "react";
import { Card, Button } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
// 7 steps matching requestLogger files exactly
const STEPS = [
{ id: 1, label: "Client Request", file: "1_req_client.json", lang: "json", desc: "Raw request from client" },
{ id: 2, label: "Source Body", file: "2_req_source.json", lang: "json", desc: "After initial conversion" },
{ id: 3, label: "OpenAI Intermediate", file: "3_req_openai.json", lang: "json", desc: "source → openai" },
{ id: 4, label: "Target Request", file: "4_req_target.json", lang: "json", desc: "openai → target + URL + headers" },
{ id: 5, label: "Provider Response", file: "5_res_provider.txt", lang: "text", desc: "Raw SSE from provider" },
{ id: 6, label: "OpenAI Response", file: "6_res_openai.txt", lang: "text", desc: "target → openai (response)" },
{ id: 7, label: "Client Response", file: "7_res_client.txt", lang: "text", desc: "Final response to client" },
];
const EDITOR_OPTIONS = {
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
};
export default function TranslatorPage() {
const [contents, setContents] = useState({});
const [expanded, setExpanded] = useState({ 1: true });
const [loading, setLoading] = useState({});
// Detected from step 1: { provider, model, sourceFormat, targetFormat }
const [meta, setMeta] = useState(null);
const setLoad = (key, val) => setLoading(prev => ({ ...prev, [key]: val }));
const setContent = (id, val) => setContents(prev => ({ ...prev, [id]: val }));
const toggle = (id) => setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
const openNext = (nextId) => setExpanded(prev => {
const next = {};
STEPS.forEach(s => { next[s.id] = false; });
next[nextId] = true;
return next;
});
// Load file from logs/translator/
const handleLoad = async (stepId) => {
const step = STEPS.find(s => s.id === stepId);
setLoad(`load-${stepId}`, true);
try {
const res = await fetch(`/api/translator/load?file=${step.file}`);
const data = await res.json();
if (data.success) {
setContent(stepId, data.content);
if (stepId === 1) await detectMeta(data.content);
} else {
alert(data.error || "File not found");
}
} catch (e) {
alert(e.message);
}
setLoad(`load-${stepId}`, false);
};
// Step 1: detect provider/format from model field
const detectMeta = async (rawContent) => {
try {
const body = typeof rawContent === "string" ? JSON.parse(rawContent) : rawContent;
const res = await fetch("/api/translator/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 1, body })
});
const data = await res.json();
if (data.success) setMeta(data.result);
} catch { /* ignore */ }
};
const save = (file, content) => fetch("/api/translator/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file, content })
}).catch(() => {});
// Step 1 → Step 3: source → OpenAI intermediate
const handleToOpenAI = async () => {
setLoad("toOpenAI", true);
try {
const raw = contents[1];
const body = JSON.parse(raw);
// Save input: 1_req_client.json + 2_req_source.json (body only)
save("1_req_client.json", raw);
save("2_req_source.json", JSON.stringify({ timestamp: new Date().toISOString(), headers: {}, body: body.body || body }, null, 2));
const res = await fetch("/api/translator/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 2, body })
});
const data = await res.json();
if (!data.success) { alert(data.error); return; }
const str = JSON.stringify(data.result.body, null, 2);
setContent(3, str);
openNext(3);
} catch (e) { alert(e.message); }
setLoad("toOpenAI", false);
};
// Step 3 → Step 4: OpenAI → target + build URL/headers
const handleToTarget = async () => {
setLoad("toTarget", true);
try {
const raw = contents[3];
const openaiBody = JSON.parse(raw);
// Save input: 3_req_openai.json
save("3_req_openai.json", raw);
const res = await fetch("/api/translator/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 3, body: { ...openaiBody, provider: meta?.provider, model: meta?.model } })
});
const data = await res.json();
if (!data.success) { alert(data.error); return; }
// Embed provider + model so Send works even without meta
const step4Content = { ...data.result, provider: meta?.provider, model: meta?.model };
setContent(4, JSON.stringify(step4Content, null, 2));
openNext(4);
} catch (e) { alert(e.message); }
setLoad("toTarget", false);
};
// Step 4 → Step 5: send to provider via executor
const handleSend = async () => {
setLoad("send", true);
try {
const raw = contents[4];
const step4 = JSON.parse(raw);
// Save input: 4_req_target.json
save("4_req_target.json", raw);
// Read provider/model from step4 content (embedded during build), fallback to meta
const provider = step4.provider || meta?.provider;
const model = step4.model || meta?.model;
if (!provider || !model) {
alert("Missing provider or model. Please run step 1 first to detect them.");
return;
}
const res = await fetch("/api/translator/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, model, body: step4.body || step4 })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
alert(err.error || "Send failed");
return;
}
// Accumulate streaming response
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
full += decoder.decode(value, { stream: true });
}
setContent(5, full);
openNext(5);
// Save to logs/translator/5_res_provider.txt
await fetch("/api/translator/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file: "5_res_provider.txt", content: full })
});
} catch (e) {
alert(e.message);
} finally {
setLoad("send", false);
}
};
const { copy } = useCopyToClipboard();
const handleCopy = async (id) => {
if (!contents[id]) return;
copy(contents[id], `translator-step-${id}`);
};
const handleFormat = (id) => {
try {
const obj = JSON.parse(contents[id]);
setContent(id, JSON.stringify(obj, null, 2));
} catch { /* not JSON, skip */ }
};
// Render action button per step
const getAction = (stepId) => {
if (stepId === 1) return <Button size="sm" icon="arrow_forward" loading={loading["toOpenAI"]} onClick={handleToOpenAI}>→ OpenAI</Button>;
if (stepId === 3) return <Button size="sm" icon="arrow_forward" loading={loading["toTarget"]} onClick={handleToTarget}>→ Target</Button>;
if (stepId === 4) return <Button size="sm" icon="send" loading={loading["send"]} onClick={handleSend}>Send</Button>;
return null;
};
return (
<div className="p-8 space-y-3">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div>
<h1 className="text-2xl font-bold text-text-main">Translator Debug</h1>
<p className="text-sm text-text-muted mt-1">Replay request flow — matches log files</p>
</div>
{meta && (
<div className="flex items-center gap-2 flex-wrap justify-end">
<MetaBadge label="src" value={meta.sourceFormat} color="blue" />
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<MetaBadge label="dst" value={meta.targetFormat} color="orange" />
<MetaBadge label="provider" value={meta.provider} color="green" />
<MetaBadge label="model" value={meta.model} color="purple" />
</div>
)}
</div>
{STEPS.map((step) => {
const action = getAction(step.id);
const isExpanded = !!expanded[step.id];
const content = contents[step.id] || "";
return (
<Card key={step.id}>
<div className="p-4 space-y-3">
{/* Step header */}
<div className="flex items-center justify-between">
<button onClick={() => toggle(step.id)} className="flex items-center gap-2 flex-1 text-left group">
<span className="material-symbols-outlined text-[20px] text-text-muted group-hover:text-primary transition-colors">
{isExpanded ? "expand_more" : "chevron_right"}
</span>
<span className="text-xs font-mono text-text-muted/60 w-4">{step.id}</span>
<h3 className="text-sm font-semibold text-text-main">{step.label}</h3>
<span className="text-xs text-text-muted/60 font-mono">{step.file}</span>
{content && <span className="text-xs text-green-500">({content.length} chars)</span>}
</button>
{!isExpanded && (
<div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" icon="folder_open" loading={loading[`load-${step.id}`]} onClick={() => handleLoad(step.id)} />
{action}
</div>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage={step.lang === "text" ? "plaintext" : "json"}
value={content}
onChange={(v) => {
setContent(step.id, v || "");
if (step.id === 1) detectMeta(v || "");
}}
theme="vs-dark"
options={EDITOR_OPTIONS}
/>
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" icon="folder_open" loading={loading[`load-${step.id}`]} onClick={() => handleLoad(step.id)}>Load</Button>
<Button size="sm" variant="outline" icon="data_object" onClick={() => handleFormat(step.id)}>Format</Button>
<Button size="sm" variant="outline" icon="content_copy" onClick={() => handleCopy(step.id)}>Copy</Button>
{action}
</div>
</>
)}
</div>
</Card>
);
})}
</div>
);
}
function MetaBadge({ label, value, color }) {
const colors = {
blue: "bg-blue-500/10 text-blue-500",
orange: "bg-orange-500/10 text-orange-500",
green: "bg-green-500/10 text-green-500",
purple: "bg-purple-500/10 text-purple-500",
};
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono ${colors[color]}`}>
<span className="text-text-muted/70 font-sans text-[10px]">{label}:</span>{value}
</span>
);
}