mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
Merge F9 (TranslatorPageClient 2-tab shell + integration tests + legacy *Mode removal) into refactor/pages-v3-19
This commit is contained in:
@@ -1,171 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Badge, Card, SegmentedControl } from "@/shared/components";
|
||||
import PlaygroundMode from "./components/PlaygroundMode";
|
||||
import ChatTesterMode from "./components/ChatTesterMode";
|
||||
import TestBenchMode from "./components/TestBenchMode";
|
||||
import LiveMonitorMode from "./components/LiveMonitorMode";
|
||||
import StreamTransformerMode from "./components/StreamTransformerMode";
|
||||
import TranslatorConceptCard from "./components/TranslatorConceptCard";
|
||||
import TranslateTab from "./components/TranslateTab";
|
||||
import MonitorTab from "./components/MonitorTab";
|
||||
import AdvancedSection from "./components/advanced/AdvancedSection";
|
||||
import RawJsonPanel from "./components/advanced/RawJsonPanel";
|
||||
import PipelineView from "./components/advanced/PipelineView";
|
||||
import StreamTransformerAccordion from "./components/advanced/StreamTransformerAccordion";
|
||||
import TestBenchAccordion from "./components/advanced/TestBenchAccordion";
|
||||
import CompressionPreviewAccordion from "./components/advanced/CompressionPreviewAccordion";
|
||||
import { useTranslateDeepLink } from "./hooks/useTranslateDeepLink";
|
||||
import type { AdvancedSlug, TranslatorTab } from "./types";
|
||||
|
||||
export default function TranslatorPageClient() {
|
||||
const t = useTranslations("translator");
|
||||
const [showFeatures, setShowFeatures] = useState(false);
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string) => {
|
||||
try {
|
||||
const translated = t(key);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-text-muted">Loading…</div>}>
|
||||
<TranslatorPageClientInner />
|
||||
</Suspense>
|
||||
);
|
||||
const [mode, setMode] = useState("playground");
|
||||
const modes = [
|
||||
{ value: "playground", label: translateOrFallback("playground", "Playground"), icon: "code" },
|
||||
{
|
||||
value: "chat-tester",
|
||||
label: translateOrFallback("chatTester", "Chat Tester"),
|
||||
icon: "chat",
|
||||
},
|
||||
{
|
||||
value: "test-bench",
|
||||
label: translateOrFallback("testBench", "Test Bench"),
|
||||
icon: "science",
|
||||
},
|
||||
{
|
||||
value: "stream-transformer",
|
||||
label: translateOrFallback("streamTransformer", "Stream Transformer"),
|
||||
icon: "swap_horiz",
|
||||
},
|
||||
{
|
||||
value: "live-monitor",
|
||||
label: translateOrFallback("liveMonitor", "Live Monitor"),
|
||||
icon: "monitoring",
|
||||
},
|
||||
];
|
||||
const modeDescriptions: Record<string, string> = {
|
||||
playground: translateOrFallback(
|
||||
"modeDescriptionPlayground",
|
||||
"Inspect request translation step-by-step between API formats."
|
||||
),
|
||||
"chat-tester": translateOrFallback(
|
||||
"modeDescriptionChatTester",
|
||||
"Send a real prompt through the selected provider and inspect every translation stage."
|
||||
),
|
||||
"test-bench": translateOrFallback(
|
||||
"modeDescriptionTestBench",
|
||||
"Run compatibility scenarios across source formats and target providers."
|
||||
),
|
||||
"stream-transformer": translateOrFallback(
|
||||
"modeDescriptionStreamTransformer",
|
||||
"Transform Chat Completions SSE into Responses API SSE and inspect emitted events."
|
||||
),
|
||||
"live-monitor": translateOrFallback(
|
||||
"modeDescriptionLiveMonitor",
|
||||
"Watch translation events in real time as requests flow through OmniRoute."
|
||||
),
|
||||
}
|
||||
|
||||
function TranslatorPageClientInner() {
|
||||
const t = useTranslations("translator");
|
||||
const [sharedInputContent, setSharedInputContent] = useState("");
|
||||
const { state, setTab, setAdvanced } = useTranslateDeepLink();
|
||||
|
||||
const makeOpenHandler = (slug: AdvancedSlug) => (open: boolean) => {
|
||||
if (open) {
|
||||
setAdvanced(slug);
|
||||
} else if (state.advanced === slug) {
|
||||
setAdvanced(null);
|
||||
}
|
||||
};
|
||||
|
||||
const advancedSlot = (
|
||||
<AdvancedSection forceOpenSlug={state.advanced}>
|
||||
<RawJsonPanel
|
||||
slug="rawjson"
|
||||
forceOpen={state.advanced === "rawjson"}
|
||||
onOpenChange={makeOpenHandler("rawjson")}
|
||||
/>
|
||||
<PipelineView
|
||||
slug="pipeline"
|
||||
forceOpen={state.advanced === "pipeline"}
|
||||
onOpenChange={makeOpenHandler("pipeline")}
|
||||
steps={[]}
|
||||
/>
|
||||
<StreamTransformerAccordion
|
||||
forceOpen={state.advanced === "streamtransform"}
|
||||
onOpenChange={makeOpenHandler("streamtransform")}
|
||||
/>
|
||||
<TestBenchAccordion
|
||||
slug="testbench"
|
||||
forceOpen={state.advanced === "testbench"}
|
||||
onOpenChange={makeOpenHandler("testbench")}
|
||||
/>
|
||||
<CompressionPreviewAccordion
|
||||
forceOpen={state.advanced === "compression"}
|
||||
onOpenChange={makeOpenHandler("compression")}
|
||||
inputContent={sharedInputContent}
|
||||
/>
|
||||
</AdvancedSection>
|
||||
);
|
||||
|
||||
const tabOptions = [
|
||||
{ value: "translate", label: t("tabTranslate"), icon: "translate" },
|
||||
{ value: "monitor", label: t("tabMonitor"), icon: "monitoring" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0">
|
||||
<TranslatorConceptCard />
|
||||
|
||||
<AutoFeaturesCard />
|
||||
|
||||
<div className="flex justify-end min-w-0 overflow-x-auto">
|
||||
<SegmentedControl
|
||||
options={modes}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={tabOptions}
|
||||
value={state.tab}
|
||||
onChange={(v) => setTab(v as TranslatorTab)}
|
||||
size="md"
|
||||
aria-label={t("tabTranslateAriaLabel")}
|
||||
className="min-w-max"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/10 bg-primary/5">
|
||||
<button
|
||||
onClick={() => setShowFeatures((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">
|
||||
auto_fix_high
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("autoFeaturesTitle")}</h3>
|
||||
<Badge variant="primary" size="sm">
|
||||
{t("autoFeaturesCount")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{showFeatures ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
{state.tab === "translate" && (
|
||||
<TranslateTab
|
||||
forceOpenAdvancedSlug={state.advanced}
|
||||
onAdvancedSlugChange={(slug) => setAdvanced(slug)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFeatures && (
|
||||
<div className="grid grid-cols-1 gap-3 px-4 pb-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FeatureChip
|
||||
icon="psychology"
|
||||
title={t("featureReasoningCache")}
|
||||
description={t("featureReasoningCacheDesc")}
|
||||
color="purple"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="schema"
|
||||
title={t("featureSchemaCoercion")}
|
||||
description={t("featureSchemaCoercionDesc")}
|
||||
color="blue"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="swap_vert"
|
||||
title={t("featureRoleNormalization")}
|
||||
description={t("featureRoleNormalizationDesc")}
|
||||
color="amber"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="fingerprint"
|
||||
title={t("featureToolCallIds")}
|
||||
description={t("featureToolCallIdsDesc")}
|
||||
color="emerald"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="add_circle"
|
||||
title={t("featureMissingToolResponse")}
|
||||
description={t("featureMissingToolResponseDesc")}
|
||||
color="cyan"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="tune"
|
||||
title={t("featureThinkingBudget")}
|
||||
description={t("featureThinkingBudgetDesc")}
|
||||
color="orange"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="alt_route"
|
||||
title={t("featureDirectPaths")}
|
||||
description={t("featureDirectPathsDesc")}
|
||||
color="pink"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="photo_size_select_large"
|
||||
title={t("featureImageMapping")}
|
||||
description={t("featureImageMappingDesc")}
|
||||
color="indigo"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{state.tab === "translate" && advancedSlot}
|
||||
|
||||
{/* Mode Content */}
|
||||
{mode === "playground" && <PlaygroundMode />}
|
||||
{mode === "chat-tester" && <ChatTesterMode />}
|
||||
{mode === "test-bench" && <TestBenchMode />}
|
||||
{mode === "stream-transformer" && <StreamTransformerMode />}
|
||||
{mode === "live-monitor" && <LiveMonitorMode />}
|
||||
{state.tab === "monitor" && (
|
||||
<MonitorTab onGoToTranslate={() => setTab("translate")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoFeaturesCard() {
|
||||
const t = useTranslations("translator");
|
||||
const [showFeatures, setShowFeatures] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/10 bg-primary/5">
|
||||
<button
|
||||
onClick={() => setShowFeatures((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">
|
||||
auto_fix_high
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("autoFeaturesTitle")}</h3>
|
||||
<Badge variant="primary" size="sm">
|
||||
{t("autoFeaturesCount")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{showFeatures ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showFeatures && (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-3 px-4 pb-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||
data-testid="auto-features-grid"
|
||||
>
|
||||
<FeatureChip
|
||||
icon="psychology"
|
||||
title={t("featureReasoningCache")}
|
||||
description={t("featureReasoningCacheDesc")}
|
||||
color="purple"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="schema"
|
||||
title={t("featureSchemaCoercion")}
|
||||
description={t("featureSchemaCoercionDesc")}
|
||||
color="blue"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="swap_vert"
|
||||
title={t("featureRoleNormalization")}
|
||||
description={t("featureRoleNormalizationDesc")}
|
||||
color="amber"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="fingerprint"
|
||||
title={t("featureToolCallIds")}
|
||||
description={t("featureToolCallIdsDesc")}
|
||||
color="emerald"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="add_circle"
|
||||
title={t("featureMissingToolResponse")}
|
||||
description={t("featureMissingToolResponseDesc")}
|
||||
color="cyan"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="tune"
|
||||
title={t("featureThinkingBudget")}
|
||||
description={t("featureThinkingBudgetDesc")}
|
||||
color="orange"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="alt_route"
|
||||
title={t("featureDirectPaths")}
|
||||
description={t("featureDirectPathsDesc")}
|
||||
color="pink"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="photo_size_select_large"
|
||||
title={t("featureImageMapping")}
|
||||
description={t("featureImageMappingDesc")}
|
||||
color="indigo"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureChip({
|
||||
icon,
|
||||
title,
|
||||
@@ -213,7 +234,7 @@ function FeatureChip({
|
||||
}[color];
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${colorMap.shell}`}>
|
||||
<div className={`rounded-lg border p-3 ${colorMap.shell}`} data-testid="feature-chip">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[16px] ${colorMap.icon}`}>{icon}</span>
|
||||
<p className="text-xs font-semibold text-text-main">{title}</p>
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../hooks/useAvailableModels";
|
||||
import Editor from "@/shared/components/MonacoEditor";
|
||||
|
||||
/**
|
||||
* Chat Tester Mode:
|
||||
* - Left: Chat interface (send messages as a specific client format)
|
||||
* - Right: {t("pipelineVisualization")} showing each translation step
|
||||
*
|
||||
* How it works:
|
||||
* 1. You type a message and select a "Client Format" (how the request is structured)
|
||||
* 2. The message is built into a request body matching the client format
|
||||
* 3. OmniRoute detects the format, translates it through the pipeline, and sends to the provider
|
||||
* 4. Each pipeline step is shown on the right: Client → Detect → OpenAI → Provider → Response
|
||||
*/
|
||||
|
||||
export default function ChatTesterMode() {
|
||||
const t = useTranslations("translator");
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [clientFormat, setClientFormat] = useState("openai");
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [chatHistory, setChatHistory] = useState([]);
|
||||
const [pipeline, setPipeline] = useState(null);
|
||||
const [expandedStep, setExpandedStep] = useState(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// Pick a smart default model when format changes or models finish loading
|
||||
useEffect(() => {
|
||||
const picked = pickModelForFormat(clientFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [clientFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim() || sending) return;
|
||||
|
||||
const userMessage = message.trim();
|
||||
setMessage("");
|
||||
setSending(true);
|
||||
setChatHistory((prev) => [...prev, { role: "user", content: userMessage }]);
|
||||
|
||||
const steps = [];
|
||||
|
||||
try {
|
||||
// Build the messages array
|
||||
const allMessages = [
|
||||
...chatHistory.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: "user", content: userMessage },
|
||||
];
|
||||
|
||||
// Step 1: Build client request in the chosen format
|
||||
let clientRequest;
|
||||
if (clientFormat === "claude") {
|
||||
clientRequest = {
|
||||
model,
|
||||
max_tokens: 1024,
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
};
|
||||
} else if (clientFormat === "gemini") {
|
||||
clientRequest = {
|
||||
model,
|
||||
contents: allMessages.map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
};
|
||||
} else if (clientFormat === "antigravity") {
|
||||
clientRequest = {
|
||||
request: {
|
||||
contents: allMessages.map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
},
|
||||
model,
|
||||
userAgent: "antigravity",
|
||||
};
|
||||
} else if (clientFormat === "openai-responses") {
|
||||
clientRequest = {
|
||||
model,
|
||||
input: allMessages.map((m) => ({
|
||||
type: "message",
|
||||
role: m.role,
|
||||
content: [{ type: "input_text", text: m.content }],
|
||||
})),
|
||||
stream: true,
|
||||
};
|
||||
} else if (clientFormat === "cursor" || clientFormat === "kiro") {
|
||||
clientRequest = {
|
||||
model,
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
};
|
||||
} else {
|
||||
clientRequest = {
|
||||
model,
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
};
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: 1,
|
||||
name: t("clientRequest"),
|
||||
description: t("clientRequestDescription"),
|
||||
format: clientFormat,
|
||||
content: JSON.stringify(clientRequest, null, 2),
|
||||
status: "done",
|
||||
});
|
||||
|
||||
// Step 2: Detect source format
|
||||
const detectRes = await fetch("/api/translator/detect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: clientRequest }),
|
||||
});
|
||||
const detectData = await detectRes.json();
|
||||
const detectedFormat = detectData.format || clientFormat;
|
||||
|
||||
steps.push({
|
||||
id: 2,
|
||||
name: t("formatDetected"),
|
||||
description: t("formatDetectedDescription"),
|
||||
format: detectedFormat,
|
||||
content: JSON.stringify(
|
||||
{ detectedFormat, clientFormat, match: detectedFormat === clientFormat },
|
||||
null,
|
||||
2
|
||||
),
|
||||
status: "done",
|
||||
});
|
||||
|
||||
// Step 3: Translate to OpenAI intermediate
|
||||
const toOpenaiRes = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat: detectedFormat,
|
||||
targetFormat: "openai",
|
||||
body: clientRequest,
|
||||
}),
|
||||
});
|
||||
const toOpenaiData = await toOpenaiRes.json();
|
||||
|
||||
steps.push({
|
||||
id: 3,
|
||||
name: t("openaiIntermediate"),
|
||||
description: t("openaiIntermediateDescription"),
|
||||
format: "openai",
|
||||
content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2),
|
||||
status: toOpenaiData.success ? "done" : "error",
|
||||
});
|
||||
|
||||
// Step 4: Translate to provider target format
|
||||
const providerTargetRes = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat: "openai",
|
||||
provider,
|
||||
body: toOpenaiData.result,
|
||||
}),
|
||||
});
|
||||
const providerTargetData = await providerTargetRes.json();
|
||||
const targetFmt = providerTargetData.targetFormat || "openai";
|
||||
|
||||
steps.push({
|
||||
id: 4,
|
||||
name: t("providerFormat"),
|
||||
description: t("providerFormatDescription"),
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2),
|
||||
status: providerTargetData.success ? "done" : "error",
|
||||
});
|
||||
|
||||
// Step 5: Send to provider
|
||||
const sendRes = await fetch("/api/translator/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, body: providerTargetData.result || toOpenaiData.result }),
|
||||
});
|
||||
|
||||
if (!sendRes.ok) {
|
||||
const errData = await sendRes.json().catch(() => ({ error: t("requestFailed") }));
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: t("providerResponse"),
|
||||
description: t("providerResponseRawDescription"),
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(errData, null, 2),
|
||||
status: "error",
|
||||
});
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: t("errorMessage", { message: errData.error || t("requestFailed") }),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// Read streaming response
|
||||
const reader = sendRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullResponse = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
fullResponse += decoder.decode(value, { stream: true });
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: t("providerResponse"),
|
||||
description: t("providerResponseSseDescription"),
|
||||
format: targetFmt,
|
||||
content:
|
||||
fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""),
|
||||
status: "done",
|
||||
});
|
||||
|
||||
// Extract assistant text from SSE
|
||||
const assistantText = extractAssistantText(fullResponse);
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: assistantText || t("noTextExtracted") },
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: steps.length + 1,
|
||||
name: t("error"),
|
||||
description: t("unexpectedError"),
|
||||
format: "error",
|
||||
content: JSON.stringify({ error: err.message }, null, 2),
|
||||
status: "error",
|
||||
});
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: t("errorMessage", { message: err.message }) },
|
||||
]);
|
||||
}
|
||||
|
||||
setPipeline(steps);
|
||||
setExpandedStep(steps.length > 0 ? steps[steps.length - 1].id : null);
|
||||
setSending(false);
|
||||
setTimeout(scrollToBottom, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 min-w-0">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("pipelineDebugger")}</p>
|
||||
<p>{t("chatTesterDescription")}</p>
|
||||
<p>
|
||||
<strong className="text-text-main">{t("chatTesterFlow")}</strong>.{" "}
|
||||
{t("clickStepToInspect")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 min-w-0">
|
||||
{/* Left: Chat Interface */}
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
{t("clientFormat")}
|
||||
</label>
|
||||
<Select
|
||||
value={clientFormat}
|
||||
onChange={(e) => setClientFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
{t("provider")}
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
{t("model")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="model-suggestions"
|
||||
placeholder={t("modelPlaceholder")}
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<Card className="min-h-[400px] flex flex-col">
|
||||
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
|
||||
{chatHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
chat
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">{t("sendMessageToSeePipeline")}</p>
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
{t("chatMessageHintPrefix")} <strong>{FORMAT_META[clientFormat]?.label}</strong>{" "}
|
||||
{t("chatMessageHintSuffix")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chatHistory.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/10 text-text-main border border-primary/20"
|
||||
: "bg-bg-subtle text-text-main border border-border"
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
|
||||
{msg.role === "user"
|
||||
? t("youWithFormat", { format: FORMAT_META[clientFormat]?.label })
|
||||
: t("assistant")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder={t("typeMessage")}
|
||||
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Pipeline Visualization */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
account_tree
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("translationPipeline")}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t("clickStepToInspect")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!pipeline ? (
|
||||
<Card>
|
||||
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
account_tree
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">{t("pipelineVisualization")}</p>
|
||||
<p className="text-xs text-center max-w-xs">{t("pipelineVisualizationHint")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pipeline.map((step, i) => {
|
||||
const meta = FORMAT_META[step.format] || {
|
||||
label: step.format,
|
||||
color: "gray",
|
||||
icon: "code",
|
||||
};
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{/* Connector line */}
|
||||
{i > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={
|
||||
step.status === "error"
|
||||
? "border-red-500/30"
|
||||
: isExpanded
|
||||
? "border-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="w-full p-3 flex items-center gap-3 text-left"
|
||||
>
|
||||
{/* Step number */}
|
||||
<div
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
|
||||
step.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: step.status === "done"
|
||||
? `bg-${meta.color}-500/10 text-${meta.color}-500`
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.status === "error" ? "!" : step.id}
|
||||
</div>
|
||||
|
||||
{/* Step info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main">{step.name}</p>
|
||||
{step.description && (
|
||||
<p className="text-[10px] text-text-muted truncate">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
|
||||
{/* Expand icon */}
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{isExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="250px"
|
||||
defaultLanguage="json"
|
||||
value={step.content}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract assistant text from SSE stream */
|
||||
function extractAssistantText(sseText) {
|
||||
let text = "";
|
||||
const lines = sseText.split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (payload === "[DONE]") break;
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
// OpenAI format
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
if (delta?.content) text += delta.content;
|
||||
// Claude format
|
||||
if (parsed.type === "content_block_delta" && parsed.delta?.text) {
|
||||
text += parsed.delta.text;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON, skip */
|
||||
}
|
||||
}
|
||||
return text || sseText.slice(0, 500);
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import { FORMAT_META } from "../exampleTemplates";
|
||||
|
||||
/**
|
||||
* Live Monitor Mode:
|
||||
* Shows recent translation activity from the proxy in real-time.
|
||||
* Polls /api/translator/history for translation events.
|
||||
*/
|
||||
export default function LiveMonitorMode() {
|
||||
const t = useTranslations("translator");
|
||||
const tc = useTranslations("common");
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string, values?: Record<string, unknown>) => {
|
||||
try {
|
||||
const translated = t(key, values);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const intervalRef = useRef(null);
|
||||
const notAvailable = t("notAvailableSymbol");
|
||||
const formatLatency = (value) => t("millisecondsShort", { value });
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/translator/history?limit=50");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEvents(data.events || []);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchHistory, 3000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
|
||||
// Stats
|
||||
const successCount = events.filter((e) => e.status === "success").length;
|
||||
const errorCount = events.filter((e) => e.status === "error").length;
|
||||
const comboCount = events.filter((e) => e.isComboRouted).length;
|
||||
const uniqueEndpoints = new Set(events.map((e) => e.routeEndpoint || e.endpoint).filter(Boolean))
|
||||
.size;
|
||||
const avgLatency =
|
||||
events.length > 0
|
||||
? Math.round(events.reduce((sum, e) => sum + (e.latency || 0), 0) / events.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span
|
||||
className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("realtime")}</p>
|
||||
<p>
|
||||
{t("liveMonitorDescriptionPrefix")}{" "}
|
||||
<strong className="text-text-main">{t("chatTester")}</strong>,{" "}
|
||||
<strong className="text-text-main">{t("testBench")}</strong>
|
||||
{t("liveMonitorDescriptionSuffix")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
icon="translate"
|
||||
label={t("totalTranslations")}
|
||||
value={events.length}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard icon="check_circle" label={t("successful")} value={successCount} color="green" />
|
||||
<StatCard icon="error" label={t("errors")} value={errorCount} color="red" />
|
||||
<StatCard
|
||||
icon="speed"
|
||||
label={t("avgLatency")}
|
||||
value={formatLatency(avgLatency)}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
icon="hub"
|
||||
label={translateOrFallback("comboRouted", "Combo-routed")}
|
||||
value={comboCount}
|
||||
color="amber"
|
||||
/>
|
||||
<StatCard
|
||||
icon="lan"
|
||||
label={translateOrFallback("uniqueEndpoints", "Unique endpoints")}
|
||||
value={uniqueEndpoints}
|
||||
color="cyan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-500/10 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[14px]">memory</span>
|
||||
<p>
|
||||
{t("liveMonitorMemoryNote")}{" "}
|
||||
<span className="text-text-muted">{t("liveMonitorMemoryCapNote")}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${autoRefresh ? "text-green-500 animate-pulse" : "text-text-muted"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{autoRefresh ? "radio_button_checked" : "radio_button_unchecked"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="text-sm text-text-main hover:text-primary transition-colors"
|
||||
>
|
||||
{autoRefresh ? t("liveAutoRefreshing") : t("paused")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchHistory}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
refresh
|
||||
</span>
|
||||
{tc("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events Table */}
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-3">{t("recentTranslations")}</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin mr-2" aria-hidden="true">
|
||||
progress_activity
|
||||
</span>
|
||||
{tc("loading")}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<span
|
||||
className="material-symbols-outlined text-[48px] mb-3 opacity-30"
|
||||
aria-hidden="true"
|
||||
>
|
||||
monitoring
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">{t("noTranslations")}</p>
|
||||
<p className="text-xs text-center max-w-sm">{t("eventsAppearHint")}</p>
|
||||
<div className="mt-3 rounded-lg border border-border/40 bg-bg-subtle/50 px-4 py-3 text-left">
|
||||
<p className="text-[10px] font-semibold text-text-muted">
|
||||
{t("eventSourcesLabel")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1 text-[10px] text-text-muted">
|
||||
<li>{t("eventSourceTranslatorPage")}</li>
|
||||
<li>{t("eventSourceMainPipeline")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3 text-xs">
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("chatTesterTab")}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("testBenchTab")}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("externalApiCalls")}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("ideCliIntegrations")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] mt-3 text-text-muted/70">{t("inMemoryNote")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted border-b border-border">
|
||||
<th className="pb-2 pr-4">{t("time")}</th>
|
||||
<th className="pb-2 pr-4">{translateOrFallback("routeDetails", "Route")}</th>
|
||||
<th className="pb-2 pr-4">{t("source")}</th>
|
||||
<th className="pb-2 pr-4">{t("target")}</th>
|
||||
<th className="pb-2 pr-4">{t("model")}</th>
|
||||
<th className="pb-2 pr-4">{t("status")}</th>
|
||||
<th className="pb-2 text-right">{t("latency")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event, i) => {
|
||||
const srcMeta = FORMAT_META[event.sourceFormat] || {
|
||||
label: event.sourceFormat || "?",
|
||||
color: "gray",
|
||||
};
|
||||
const tgtMeta = FORMAT_META[event.targetFormat] || {
|
||||
label: event.targetFormat || "?",
|
||||
color: "gray",
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={event.id || i}
|
||||
className="border-b border-border/50 hover:bg-bg-subtle/50 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||
{event.timestamp
|
||||
? new Date(event.timestamp).toLocaleTimeString()
|
||||
: notAvailable}
|
||||
</td>
|
||||
<td className="py-2 pr-4 min-w-[220px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="default" size="sm">
|
||||
{event.routeProvider || event.provider || notAvailable}
|
||||
</Badge>
|
||||
{event.routeCombo ? (
|
||||
<Badge variant="primary" size="sm">
|
||||
{translateOrFallback("comboBadge", "Combo")}: {event.routeCombo}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-text-muted">
|
||||
<span>
|
||||
{translateOrFallback("routeEndpointLabel", "Endpoint")}:{" "}
|
||||
{event.routeEndpoint || event.endpoint || notAvailable}
|
||||
</span>
|
||||
{event.routeConnectionShortId ? (
|
||||
<span>
|
||||
{translateOrFallback("routeConnectionLabel", "Conn")}:{" "}
|
||||
<span className="font-mono">{event.routeConnectionShortId}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant="default" size="sm">
|
||||
{srcMeta.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant="primary" size="sm">
|
||||
{tgtMeta.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs font-mono text-text-muted break-all">
|
||||
{event.model || notAvailable}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{event.status === "success" ? (
|
||||
<Badge variant="success" size="sm" dot>
|
||||
{t("ok")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="error" size="sm" dot>
|
||||
{event.statusCode || t("errorShort")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs text-text-muted">
|
||||
{event.latency ? formatLatency(event.latency) : notAvailable}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, color }) {
|
||||
const colorMap = {
|
||||
blue: { shell: "bg-blue-500/10", icon: "text-blue-500" },
|
||||
green: { shell: "bg-green-500/10", icon: "text-green-500" },
|
||||
red: { shell: "bg-red-500/10", icon: "text-red-500" },
|
||||
purple: { shell: "bg-purple-500/10", icon: "text-purple-500" },
|
||||
amber: { shell: "bg-amber-500/10", icon: "text-amber-500" },
|
||||
cyan: { shell: "bg-cyan-500/10", icon: "text-cyan-500" },
|
||||
};
|
||||
const resolved = colorMap[color as keyof typeof colorMap] || colorMap.blue;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${resolved.shell}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[22px] ${resolved.icon}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-text-main">{value}</p>
|
||||
<p className="text-[10px] text-text-muted uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import Editor from "@/shared/components/MonacoEditor";
|
||||
|
||||
interface CompressionPreviewResult {
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
tokensSaved: number;
|
||||
savingsPct: number;
|
||||
techniquesUsed: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export default function PlaygroundMode() {
|
||||
const t = useTranslations("translator");
|
||||
const tc = useTranslations("common");
|
||||
const [sourceFormat, setSourceFormat] = useState("claude");
|
||||
const [targetFormat, setTargetFormat] = useState("openai");
|
||||
const [inputContent, setInputContent] = useState("");
|
||||
const [outputContent, setOutputContent] = useState("");
|
||||
const [intermediateContent, setIntermediateContent] = useState("");
|
||||
const [translationPath, setTranslationPath] = useState("");
|
||||
const [detectedFormat, setDetectedFormat] = useState(null);
|
||||
const [translating, setTranslating] = useState(false);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [activeTemplate, setActiveTemplate] = useState(null);
|
||||
|
||||
// Compression preview state
|
||||
const [compressionMode, setCompressionMode] = useState<string>("standard");
|
||||
const [compressionResult, setCompressionResult] = useState<CompressionPreviewResult | null>(null);
|
||||
const [compressionLoading, setCompressionLoading] = useState(false);
|
||||
const [compressionError, setCompressionError] = useState<string | null>(null);
|
||||
const [showCompressionPanel, setShowCompressionPanel] = useState(false);
|
||||
|
||||
const templates = useMemo(() => getExampleTemplates(t), [t]);
|
||||
|
||||
// Auto-detect format when input changes
|
||||
const detectFormatFromInput = useCallback(async (content) => {
|
||||
if (!content || content.trim().length < 5) {
|
||||
setDetectedFormat(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
setDetecting(true);
|
||||
const res = await fetch("/api/translator/detect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: parsed }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setDetectedFormat(data.format);
|
||||
setSourceFormat(data.format);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON yet, ignore
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced auto-detect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
detectFormatFromInput(inputContent);
|
||||
}, 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [inputContent, detectFormatFromInput]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!inputContent.trim()) return;
|
||||
|
||||
setTranslating(true);
|
||||
setOutputContent("");
|
||||
setIntermediateContent("");
|
||||
setTranslationPath("");
|
||||
try {
|
||||
const parsed = JSON.parse(inputContent);
|
||||
|
||||
if (sourceFormat === targetFormat) {
|
||||
setOutputContent(JSON.stringify(parsed, null, 2));
|
||||
setTranslationPath("passthrough");
|
||||
setTranslating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let intermediate = parsed;
|
||||
let hasIntermediate = false;
|
||||
|
||||
if (sourceFormat !== "openai" && targetFormat !== "openai") {
|
||||
const step1 = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat,
|
||||
targetFormat: "openai",
|
||||
body: parsed,
|
||||
}),
|
||||
});
|
||||
const step1Data = await step1.json();
|
||||
if (!step1Data.success) {
|
||||
setOutputContent(JSON.stringify({ error: step1Data.error }, null, 2));
|
||||
return;
|
||||
}
|
||||
intermediate = step1Data.result;
|
||||
setIntermediateContent(JSON.stringify(intermediate, null, 2));
|
||||
hasIntermediate = true;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat: hasIntermediate ? "openai" : sourceFormat,
|
||||
targetFormat,
|
||||
body: hasIntermediate ? intermediate : parsed,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setOutputContent(JSON.stringify(data.result, null, 2));
|
||||
setTranslationPath(hasIntermediate ? "hub-and-spoke" : "direct");
|
||||
} else {
|
||||
setOutputContent(JSON.stringify({ error: data.error }, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
setOutputContent(
|
||||
JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2)
|
||||
);
|
||||
} finally {
|
||||
setTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTemplate = (template) => {
|
||||
const formatData = template.formats[sourceFormat] || template.formats.openai;
|
||||
setInputContent(JSON.stringify(formatData, null, 2));
|
||||
setActiveTemplate(template.id);
|
||||
setOutputContent("");
|
||||
setIntermediateContent("");
|
||||
setTranslationPath("");
|
||||
};
|
||||
|
||||
const handleCopy = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwapFormats = () => {
|
||||
setSourceFormat(targetFormat);
|
||||
setTargetFormat(sourceFormat);
|
||||
setInputContent(outputContent);
|
||||
setOutputContent("");
|
||||
setIntermediateContent("");
|
||||
setTranslationPath("");
|
||||
setDetectedFormat(null);
|
||||
};
|
||||
|
||||
const handleCompressionPreview = async () => {
|
||||
if (!inputContent.trim()) return;
|
||||
let messages;
|
||||
try {
|
||||
const parsed = JSON.parse(inputContent);
|
||||
messages = parsed.messages ?? [{ role: "user", content: inputContent }];
|
||||
} catch {
|
||||
messages = [{ role: "user", content: inputContent }];
|
||||
}
|
||||
setCompressionLoading(true);
|
||||
setCompressionError(null);
|
||||
try {
|
||||
const res = await fetch("/api/compression/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages, mode: compressionMode }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Preview failed");
|
||||
setCompressionResult(data);
|
||||
} catch (e: unknown) {
|
||||
setCompressionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCompressionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const srcMeta = FORMAT_META[sourceFormat] || FORMAT_META.openai;
|
||||
const tgtMeta = FORMAT_META[targetFormat] || FORMAT_META.openai;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("formatConverter")}</p>
|
||||
<p>{t("formatConverterDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Format Controls Bar */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col sm:flex-row items-center gap-4">
|
||||
{/* Source Format */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("source")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[20px] text-${srcMeta.color}-500`}>
|
||||
{srcMeta.icon}
|
||||
</span>
|
||||
<Select
|
||||
value={sourceFormat}
|
||||
onChange={(e) => {
|
||||
setSourceFormat(e.target.value);
|
||||
setDetectedFormat(null);
|
||||
}}
|
||||
options={FORMAT_OPTIONS}
|
||||
className="flex-1"
|
||||
/>
|
||||
{detectedFormat && (
|
||||
<Badge variant="primary" size="sm" icon="auto_awesome">
|
||||
{t("auto")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swap Button */}
|
||||
<button
|
||||
onClick={handleSwapFormats}
|
||||
className="p-2 rounded-full hover:bg-primary/10 text-text-muted hover:text-primary transition-all mt-4 sm:mt-5"
|
||||
title={t("swapFormats")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[24px]">swap_horiz</span>
|
||||
</button>
|
||||
|
||||
{/* Target Format */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("target")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[20px] text-${tgtMeta.color}-500`}>
|
||||
{tgtMeta.icon}
|
||||
</span>
|
||||
<Select
|
||||
value={targetFormat}
|
||||
onChange={(e) => setTargetFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translate Button */}
|
||||
<div className="pt-0 sm:pt-5">
|
||||
<Button
|
||||
icon="arrow_forward"
|
||||
onClick={handleTranslate}
|
||||
loading={translating}
|
||||
disabled={!inputContent.trim() || translating}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t("translateAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{translationPath && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span className="material-symbols-outlined text-[14px]">route</span>
|
||||
{translationPath === "hub-and-spoke" ? (
|
||||
<span>
|
||||
{t("translationPathHubSpoke", {
|
||||
source: FORMAT_META[sourceFormat]?.label || sourceFormat,
|
||||
target: FORMAT_META[targetFormat]?.label || targetFormat,
|
||||
})}
|
||||
</span>
|
||||
) : translationPath === "direct" ? (
|
||||
<span>
|
||||
{t("translationPathDirect", {
|
||||
source: FORMAT_META[sourceFormat]?.label || sourceFormat,
|
||||
target: FORMAT_META[targetFormat]?.label || targetFormat,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("translationPathPassthrough")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Split Editor View */}
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 ${
|
||||
intermediateContent ? "xl:grid-cols-3" : "lg:grid-cols-2"
|
||||
}`}
|
||||
>
|
||||
{/* Input Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">input</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("input")}</h3>
|
||||
{detectedFormat && (
|
||||
<Badge variant="info" size="sm" dot>
|
||||
{FORMAT_META[detectedFormat]?.label || detectedFormat}
|
||||
</Badge>
|
||||
)}
|
||||
{detecting && (
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(inputContent)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={tc("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputContent("");
|
||||
setOutputContent("");
|
||||
setDetectedFormat(null);
|
||||
setActiveTemplate(null);
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={t("clear")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={inputContent}
|
||||
onChange={(value) => setInputContent(value || "")}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
placeholder: t("inputPlaceholder"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Intermediate Panel */}
|
||||
{intermediateContent && (
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-amber-500">hub</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{t("openaiIntermediatePanel")}
|
||||
</h3>
|
||||
<Badge variant="warning" size="sm">
|
||||
Hub
|
||||
</Badge>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(intermediateContent)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={tc("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={intermediateContent}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Output Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
output
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("output")}</h3>
|
||||
{outputContent && (
|
||||
<Badge variant="success" size="sm" dot>
|
||||
{FORMAT_META[targetFormat]?.label || targetFormat}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(outputContent)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={tc("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={outputContent}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* {t("exampleTemplates")} */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
library_books
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("exampleTemplates")}</h3>
|
||||
<span className="text-xs text-text-muted">{t("exampleTemplatesHint")}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => loadTemplate(template)}
|
||||
className={`
|
||||
group flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all text-center
|
||||
${
|
||||
activeTemplate === template.id
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border hover:border-primary/30 hover:bg-primary/5 text-text-muted hover:text-text-main"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[22px] ${activeTemplate === template.id ? "text-primary" : "text-text-muted group-hover:text-primary"} transition-colors`}
|
||||
>
|
||||
{template.icon}
|
||||
</span>
|
||||
<span className="text-xs font-medium leading-tight">{template.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeTemplate && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span className="material-symbols-outlined text-[14px]">info</span>
|
||||
{t("templateLoadHint", {
|
||||
format: FORMAT_META[sourceFormat]?.label || sourceFormat,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Compression Preview Panel */}
|
||||
<Card>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left p-4 font-medium text-text"
|
||||
onClick={() => setShowCompressionPanel((v) => !v)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">compress</span>
|
||||
Compression Preview
|
||||
<span className="material-symbols-outlined ml-auto text-text-muted text-[18px]">
|
||||
{showCompressionPanel ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showCompressionPanel && (
|
||||
<div className="p-4 space-y-4 border-t border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={compressionMode}
|
||||
onChange={(e) => setCompressionMode(e.target.value)}
|
||||
options={[
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "lite", label: "Lite" },
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "aggressive", label: "Aggressive" },
|
||||
{ value: "ultra", label: "Ultra" },
|
||||
]}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
icon="play_arrow"
|
||||
onClick={handleCompressionPreview}
|
||||
loading={compressionLoading}
|
||||
disabled={compressionLoading || !inputContent.trim()}
|
||||
className="text-sm"
|
||||
>
|
||||
{compressionLoading ? "Previewing…" : "Preview Compression"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{compressionError && <div className="text-sm text-red-500">{compressionError}</div>}
|
||||
|
||||
{compressionResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Original</div>
|
||||
<div className="text-lg font-bold">{compressionResult.originalTokens}</div>
|
||||
<div className="text-xs text-text-muted">tokens</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Compressed</div>
|
||||
<div className="text-lg font-bold">{compressionResult.compressedTokens}</div>
|
||||
<div className="text-xs text-text-muted">tokens</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Saved</div>
|
||||
<div className="text-lg font-bold text-green-500">
|
||||
{compressionResult.tokensSaved}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{compressionResult.savingsPct}%</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Duration</div>
|
||||
<div className="text-lg font-bold">{compressionResult.durationMs}</div>
|
||||
<div className="text-xs text-text-muted">ms</div>
|
||||
</div>
|
||||
</div>
|
||||
{compressionResult.techniquesUsed.length > 0 && (
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="font-semibold">{t("techniques")}</span>{" "}
|
||||
{compressionResult.techniquesUsed.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
const TEXT_SAMPLE = `data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" from OmniRoute"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}}
|
||||
|
||||
data: [DONE]
|
||||
`;
|
||||
|
||||
const TOOL_SAMPLE = `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"lookup_weather","arguments":"{\\"city\\":\\"Tok"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"yo\\"}"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":23,"completion_tokens":9,"total_tokens":32}}
|
||||
|
||||
data: [DONE]
|
||||
`;
|
||||
|
||||
function getFramePreview(data: unknown): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (!data || typeof data !== "object") return "";
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
const delta = record.delta;
|
||||
if (typeof delta === "string") return delta;
|
||||
|
||||
const item = record.item;
|
||||
if (item && typeof item === "object") {
|
||||
const itemRecord = item as Record<string, unknown>;
|
||||
const type = itemRecord.type;
|
||||
const text = itemRecord.text;
|
||||
const name = itemRecord.name;
|
||||
if (typeof text === "string" && text) return text;
|
||||
if (typeof name === "string" && name) return `${type || "item"}: ${name}`;
|
||||
if (typeof type === "string" && type) return type;
|
||||
}
|
||||
|
||||
const text = record.text;
|
||||
if (typeof text === "string" && text) return text;
|
||||
|
||||
return JSON.stringify(data).slice(0, 140);
|
||||
}
|
||||
|
||||
function parseSseFrames(rawSse: string): Array<{ event: string; preview: string }> {
|
||||
return rawSse
|
||||
.split("\n\n")
|
||||
.map((frame) => frame.trim())
|
||||
.filter(Boolean)
|
||||
.map((frame) => {
|
||||
const eventLine = frame
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("event:"))
|
||||
?.replace(/^event:\s*/, "")
|
||||
.trim();
|
||||
const dataLine = frame
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data:"))
|
||||
?.replace(/^data:\s*/, "");
|
||||
|
||||
if (dataLine === "[DONE]") {
|
||||
return { event: "done", preview: "[DONE]" };
|
||||
}
|
||||
|
||||
let parsedData: unknown = dataLine || "";
|
||||
try {
|
||||
parsedData = dataLine ? JSON.parse(dataLine) : "";
|
||||
} catch {
|
||||
parsedData = dataLine || "";
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventLine || "message",
|
||||
preview: getFramePreview(parsedData),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function StreamTransformerMode() {
|
||||
const t = useTranslations("translator");
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string, values?: Record<string, unknown>) => {
|
||||
try {
|
||||
const translated = t(key, values);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const [rawSse, setRawSse] = useState(TEXT_SAMPLE);
|
||||
const [transformedSse, setTransformedSse] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const transformedFrames = useMemo(() => parseSseFrames(transformedSse), [transformedSse]);
|
||||
const eventCount = transformedFrames.length;
|
||||
const uniqueEventCount = new Set(transformedFrames.map((frame) => frame.event)).size;
|
||||
|
||||
const handleCopy = async (value: string, field: string) => {
|
||||
await copyToClipboard(value);
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
const runTransform = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/translator/transform-stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rawSse }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.error || translateOrFallback("requestFailed", "Request failed"));
|
||||
}
|
||||
|
||||
setTransformedSse(data.transformed || "");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to transform stream");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 min-w-0">
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
swap_horiz
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">
|
||||
{translateOrFallback("streamTransformerTitle", "Responses Stream Transformer")}
|
||||
</p>
|
||||
<p>
|
||||
{translateOrFallback(
|
||||
"streamTransformerDescription",
|
||||
"Paste a chat completions SSE stream, run it through OmniRoute's Responses transformer, and inspect the emitted response.* events before wiring a client."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setRawSse(TEXT_SAMPLE)}>
|
||||
{translateOrFallback("loadTextSample", "Load text sample")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setRawSse(TOOL_SAMPLE)}>
|
||||
{translateOrFallback("loadToolSample", "Load tool-call sample")}
|
||||
</Button>
|
||||
<Button size="sm" icon="play_arrow" onClick={runTransform} loading={loading}>
|
||||
{translateOrFallback("transformToResponses", "Transform to Responses")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{translateOrFallback("rawChatSseInput", "Raw chat completions SSE")}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleCopy(rawSse, "input")}>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copiedField === "input" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={rawSse}
|
||||
onChange={(e) => setRawSse(e.target.value)}
|
||||
className="min-h-[360px] w-full rounded-lg border border-border bg-bg-secondary px-3 py-3 text-xs font-mono text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{translateOrFallback("transformedResponsesSse", "Transformed Responses API SSE")}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(transformedSse, "output")}
|
||||
disabled={!transformedSse}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copiedField === "output" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="min-h-[360px] overflow-auto rounded-lg border border-border bg-bg-secondary px-3 py-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{transformedSse || translateOrFallback("noResultsYet", "No results yet")}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MiniStat
|
||||
label={translateOrFallback("transformedEvents", "Transformed events")}
|
||||
value={eventCount}
|
||||
/>
|
||||
<MiniStat
|
||||
label={translateOrFallback("uniqueEventTypes", "Unique event types")}
|
||||
value={uniqueEventCount}
|
||||
/>
|
||||
<MiniStat
|
||||
label={translateOrFallback("inputLines", "Input lines")}
|
||||
value={rawSse.split("\n").length}
|
||||
/>
|
||||
<MiniStat
|
||||
label={translateOrFallback("outputLines", "Output lines")}
|
||||
value={transformedSse ? transformedSse.split("\n").length : 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{translateOrFallback("transformedEventTimeline", "Transformed event timeline")}
|
||||
</h3>
|
||||
|
||||
{transformedFrames.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">
|
||||
{translateOrFallback(
|
||||
"transformerTimelineHint",
|
||||
"Run the transformer to inspect emitted response.output_* events in order."
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted border-b border-border">
|
||||
<th className="pb-2 pr-4">#</th>
|
||||
<th className="pb-2 pr-4">{translateOrFallback("eventType", "Event type")}</th>
|
||||
<th className="pb-2">{translateOrFallback("eventPreview", "Preview")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transformedFrames.map((frame, index) => (
|
||||
<tr
|
||||
key={`${frame.event}_${index}`}
|
||||
className="border-b border-border/50 align-top"
|
||||
>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted">{index + 1}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-primary">{frame.event}</td>
|
||||
<td className="py-2 text-xs text-text-muted break-all">{frame.preview}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<p className="text-lg font-bold text-text-main">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-text-muted">{label}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../hooks/useAvailableModels";
|
||||
|
||||
/**
|
||||
* Test Bench Mode:
|
||||
* Run translation + send scenarios between providers to validate compatibility.
|
||||
*
|
||||
* How it works:
|
||||
* Predefined scenarios (Simple Chat, Tool Calling, etc.) are loaded from example templates,
|
||||
* translated from the source format to the target provider, and sent to the provider API.
|
||||
* Results show pass/fail, latency, and chunk count, with a compatibility percentage.
|
||||
*/
|
||||
|
||||
const SCENARIOS = [
|
||||
{ id: "simple-chat", icon: "chat", templateId: "simple-chat" },
|
||||
{ id: "tool-calling", icon: "build", templateId: "tool-calling" },
|
||||
{ id: "multi-turn", icon: "forum", templateId: "multi-turn" },
|
||||
{ id: "thinking", icon: "psychology", templateId: "thinking" },
|
||||
{ id: "system-prompt", icon: "settings", templateId: "system-prompt" },
|
||||
{ id: "streaming", icon: "stream", templateId: "streaming" },
|
||||
{ id: "vision", icon: "image", templateId: "vision" },
|
||||
{ id: "schema-coercion", icon: "schema", templateId: "schema-coercion" },
|
||||
];
|
||||
|
||||
export default function TestBenchMode() {
|
||||
const t = useTranslations("translator");
|
||||
const translateOrFallback = (key: string, fallback: string) => {
|
||||
try {
|
||||
const translated = t(key);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
const scenarioLabels: Record<string, string> = {
|
||||
"simple-chat": t("scenarioSimpleChat"),
|
||||
"tool-calling": t("scenarioToolCalling"),
|
||||
"multi-turn": t("scenarioMultiTurn"),
|
||||
thinking: t("scenarioThinking"),
|
||||
"system-prompt": t("scenarioSystemPrompt"),
|
||||
streaming: t("scenarioStreaming"),
|
||||
vision: translateOrFallback("scenarioVision", "Vision"),
|
||||
"schema-coercion": translateOrFallback("scenarioSchemaCoercion", "Schema Coercion"),
|
||||
};
|
||||
const templates = useMemo(() => getExampleTemplates(t), [t]);
|
||||
const [sourceFormat, setSourceFormat] = useState("claude");
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [results, setResults] = useState({});
|
||||
const [runningAll, setRunningAll] = useState(false);
|
||||
|
||||
// Pick a smart default model when source format changes or models finish loading
|
||||
useEffect(() => {
|
||||
const picked = pickModelForFormat(sourceFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [sourceFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const runScenario = async (scenario) => {
|
||||
setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } }));
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Find template
|
||||
const template = templates.find((item) => item.id === scenario.templateId);
|
||||
const body = template?.formats[sourceFormat] || template?.formats.openai;
|
||||
|
||||
if (!body) {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: { status: "error", error: t("noTemplateForFormat"), latency: 0 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Override model in template body with user-selected model
|
||||
const bodyWithModel = { ...body, model };
|
||||
// For Gemini format that uses 'contents' instead of 'messages'
|
||||
if (body.contents) bodyWithModel.model = model;
|
||||
|
||||
// Step 1: Translate
|
||||
const translateRes = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "direct", sourceFormat, provider, body: bodyWithModel }),
|
||||
});
|
||||
const translateData = await translateRes.json();
|
||||
|
||||
if (!translateData.success) {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: {
|
||||
status: "error",
|
||||
error: t("translationFailed", { error: translateData.error }),
|
||||
latency: Date.now() - start,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Send to provider
|
||||
const sendRes = await fetch("/api/translator/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, body: translateData.result }),
|
||||
});
|
||||
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!sendRes.ok) {
|
||||
const errData = await sendRes.json().catch(() => ({}));
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: {
|
||||
status: "error",
|
||||
error: errData.error || t("errorMessage", { message: `HTTP ${sendRes.status}` }),
|
||||
latency,
|
||||
httpStatus: sendRes.status,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read response to consume stream
|
||||
const reader = sendRes.body.getReader();
|
||||
let chunks = 0;
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks++;
|
||||
}
|
||||
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: { status: "pass", latency: Date.now() - start, chunks },
|
||||
}));
|
||||
} catch (err) {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[scenario.id]: { status: "error", error: err.message, latency: Date.now() - start },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunAll = async () => {
|
||||
setRunningAll(true);
|
||||
setResults({});
|
||||
for (const scenario of SCENARIOS) {
|
||||
await runScenario(scenario);
|
||||
}
|
||||
setRunningAll(false);
|
||||
};
|
||||
|
||||
const passCount = Object.values(results).filter((r: any) => r.status === "pass").length;
|
||||
const failCount = Object.values(results).filter((r: any) => r.status === "error").length;
|
||||
const totalRun = passCount + failCount;
|
||||
const compatibility = totalRun > 0 ? Math.round((passCount / totalRun) * 100) : 0;
|
||||
const srcMeta = FORMAT_META[sourceFormat] || FORMAT_META.openai;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("compatibilityTester")}</p>
|
||||
<p>{t("testBenchDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-end gap-4 min-w-0">
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("source")}
|
||||
</label>
|
||||
<Select
|
||||
value={sourceFormat}
|
||||
onChange={(e) => {
|
||||
setSourceFormat(e.target.value);
|
||||
setResults({});
|
||||
}}
|
||||
options={FORMAT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center px-2">
|
||||
<span className="material-symbols-outlined text-[22px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("targetProvider")}
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
setResults({});
|
||||
}}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="play_arrow"
|
||||
onClick={handleRunAll}
|
||||
loading={runningAll}
|
||||
disabled={runningAll}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t("runAllTests")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("model")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="testbench-model-suggestions"
|
||||
placeholder={t("modelPlaceholder")}
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="testbench-model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Results summary bar */}
|
||||
{totalRun > 0 && (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("compatibilityReport")}</h3>
|
||||
<Badge
|
||||
variant={
|
||||
compatibility >= 80 ? "success" : compatibility >= 50 ? "warning" : "error"
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
{compatibility}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-green-500" /> {passCount} {t("passed")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-red-500" /> {failCount} {t("failed")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-2 bg-bg-subtle rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-500 to-emerald-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${compatibility}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Scenario cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{SCENARIOS.map((scenario) => {
|
||||
const result = results[scenario.id];
|
||||
const isRunning = result?.status === "running";
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={scenario.id}
|
||||
className={`transition-all ${result?.status === "pass" ? "border-green-500/30" : result?.status === "error" ? "border-red-500/30" : ""}`}
|
||||
>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-lg ${
|
||||
result?.status === "pass"
|
||||
? "bg-green-500/10 text-green-500"
|
||||
: result?.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
{isRunning
|
||||
? "progress_activity"
|
||||
: result?.status === "pass"
|
||||
? "check_circle"
|
||||
: result?.status === "error"
|
||||
? "error"
|
||||
: scenario.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">
|
||||
{scenarioLabels[scenario.id] || scenario.id}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted uppercase">
|
||||
{srcMeta.label} →{" "}
|
||||
{providerOptions.find((o) => o.value === provider)?.label || provider}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result details */}
|
||||
{result && result.status !== "running" && (
|
||||
<div
|
||||
className={`rounded-lg p-2 text-xs ${result.status === "pass" ? "bg-green-500/5 text-green-600 dark:text-green-400" : "bg-red-500/5 text-red-600 dark:text-red-400"}`}
|
||||
>
|
||||
{result.status === "pass" ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{t("passedIconLabel")}</span>
|
||||
<span className="text-text-muted">
|
||||
{result.latency}ms • {result.chunks} {t("chunks")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p>❌ {result.error}</p>
|
||||
<p className="text-text-muted mt-0.5">{result.latency}ms</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={result?.status === "pass" ? "ghost" : "outline"}
|
||||
icon={isRunning ? "progress_activity" : "play_arrow"}
|
||||
onClick={() => runScenario(scenario)}
|
||||
disabled={isRunning || runningAll}
|
||||
className="w-full"
|
||||
>
|
||||
{isRunning ? t("running") : result ? t("reRun") : t("runTest")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
tests/unit/translator-friendly-integration.test.tsx
Normal file
335
tests/unit/translator-friendly-integration.test.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* F9 Integration tests — TranslatorPageClient wired to child components via
|
||||
* mocked hooks. Tests verify deep-link prop propagation and conditional rendering.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── i18n stub ─────────────────────────────────────────────────────────────────
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── next/navigation — factory so each test can override mockGet ───────────────
|
||||
const mockReplace = vi.fn();
|
||||
let mockGetImpl: (key: string) => string | null = (key) => {
|
||||
if (key === "tab") return "translate";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => mockGetImpl(key),
|
||||
toString: () => {
|
||||
const tab = mockGetImpl("tab") ?? "translate";
|
||||
const mode = mockGetImpl("mode") ?? "send";
|
||||
const adv = mockGetImpl("advanced");
|
||||
return adv ? `tab=${tab}&mode=${mode}&advanced=${adv}` : `tab=${tab}&mode=${mode}`;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Shared component stubs ────────────────────────────────────────────────────
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="card" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Badge: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="badge">{children}</span>
|
||||
),
|
||||
SegmentedControl: ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
options: Array<{ value: string; label: string }>;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<div role="tablist" aria-label={ariaLabel} data-testid="segmented-control" data-value={value}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
role="tab"
|
||||
data-testid={`tab-${opt.value}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children }: { children: React.ReactNode }) => (
|
||||
<button data-testid="button">{children}</button>
|
||||
),
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select">{children}</div>
|
||||
),
|
||||
Collapsible: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="collapsible">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Sub-component stubs that capture received props ───────────────────────────
|
||||
const capturedTranslateTabProps: Array<{
|
||||
forceOpenAdvancedSlug?: string | null;
|
||||
}> = [];
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard",
|
||||
() => ({
|
||||
default: () => <div data-testid="translator-concept-card" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslateTab",
|
||||
() => ({
|
||||
default: ({
|
||||
forceOpenAdvancedSlug,
|
||||
}: {
|
||||
forceOpenAdvancedSlug?: string | null;
|
||||
onAdvancedSlugChange?: (slug: string | null) => void;
|
||||
}) => {
|
||||
capturedTranslateTabProps.push({ forceOpenAdvancedSlug });
|
||||
return (
|
||||
<div data-testid="translate-tab" data-force-open={forceOpenAdvancedSlug ?? "none"} />
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab",
|
||||
() => ({
|
||||
default: ({ onGoToTranslate }: { onGoToTranslate?: () => void }) => (
|
||||
<div data-testid="monitor-tab" data-has-callback={String(!!onGoToTranslate)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection",
|
||||
() => ({
|
||||
default: ({
|
||||
children,
|
||||
forceOpenSlug,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
forceOpenSlug?: string | null;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="advanced-section"
|
||||
data-force-open-slug={forceOpenSlug ?? "none"}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const capturedRawJsonProps: Array<{ forceOpen?: boolean }> = [];
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => {
|
||||
capturedRawJsonProps.push({ forceOpen });
|
||||
return (
|
||||
<div data-testid="raw-json-panel" data-force-open={String(forceOpen ?? false)} />
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean; steps?: unknown[] }) => (
|
||||
<div data-testid="pipeline-view" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div
|
||||
data-testid="stream-transformer-accordion"
|
||||
data-force-open={String(forceOpen ?? false)}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div data-testid="test-bench-accordion" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div
|
||||
data-testid="compression-preview-accordion"
|
||||
data-force-open={String(forceOpen ?? false)}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── DOM lifecycle helpers ─────────────────────────────────────────────────────
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
async function mount(component: React.ReactElement, container: HTMLElement) {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(component);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
// ── Import AFTER mocks ────────────────────────────────────────────────────────
|
||||
import TranslatorPageClient from "@/app/(dashboard)/dashboard/translator/TranslatorPageClient";
|
||||
|
||||
describe("TranslatorPageClient — integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedTranslateTabProps.length = 0;
|
||||
capturedRawJsonProps.length = 0;
|
||||
// Reset to default translate tab
|
||||
mockGetImpl = (key: string) => {
|
||||
if (key === "tab") return "translate";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return null;
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupCallbacks.forEach((fn) => fn());
|
||||
cleanupCallbacks.length = 0;
|
||||
});
|
||||
|
||||
it("smoke render — full component tree mounts without errors", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
expect(container.querySelector('[data-testid="translator-concept-card"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="segmented-control"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="translate-tab"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="advanced-section"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("when ?advanced=rawjson, RawJsonPanel receives forceOpen=true", async () => {
|
||||
mockGetImpl = (key: string) => {
|
||||
if (key === "tab") return "translate";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return "rawjson";
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
const rawJsonPanel = container.querySelector('[data-testid="raw-json-panel"]');
|
||||
expect(rawJsonPanel).toBeTruthy();
|
||||
expect(rawJsonPanel?.getAttribute("data-force-open")).toBe("true");
|
||||
|
||||
// AdvancedSection also receives the correct slug
|
||||
const advSection = container.querySelector('[data-testid="advanced-section"]');
|
||||
expect(advSection?.getAttribute("data-force-open-slug")).toBe("rawjson");
|
||||
});
|
||||
|
||||
it("when ?tab=monitor, MonitorTab renders and TranslateTab does NOT render", async () => {
|
||||
mockGetImpl = (key: string) => {
|
||||
if (key === "tab") return "monitor";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
expect(container.querySelector('[data-testid="monitor-tab"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="translate-tab"]')).toBeNull();
|
||||
// AdvancedSection should also not render in monitor tab
|
||||
expect(container.querySelector('[data-testid="advanced-section"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("AdvancedSection receives all 5 accordion slots in the DOM", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
const advSection = container.querySelector('[data-testid="advanced-section"]');
|
||||
expect(advSection).toBeTruthy();
|
||||
|
||||
const accordions = [
|
||||
"raw-json-panel",
|
||||
"pipeline-view",
|
||||
"stream-transformer-accordion",
|
||||
"test-bench-accordion",
|
||||
"compression-preview-accordion",
|
||||
];
|
||||
|
||||
for (const testId of accordions) {
|
||||
expect(advSection?.querySelector(`[data-testid="${testId}"]`)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("when ?advanced=pipeline, only PipelineView forceOpen=true (others false)", async () => {
|
||||
mockGetImpl = (key: string) => {
|
||||
if (key === "tab") return "translate";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return "pipeline";
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
const pipeline = container.querySelector('[data-testid="pipeline-view"]');
|
||||
expect(pipeline?.getAttribute("data-force-open")).toBe("true");
|
||||
|
||||
const rawJson = container.querySelector('[data-testid="raw-json-panel"]');
|
||||
expect(rawJson?.getAttribute("data-force-open")).toBe("false");
|
||||
});
|
||||
|
||||
it("MonitorTab receives onGoToTranslate callback", async () => {
|
||||
mockGetImpl = (key: string) => {
|
||||
if (key === "tab") return "monitor";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
const monitorTab = container.querySelector('[data-testid="monitor-tab"]');
|
||||
expect(monitorTab?.getAttribute("data-has-callback")).toBe("true");
|
||||
});
|
||||
});
|
||||
312
tests/unit/translator-friendly-page-client.test.tsx
Normal file
312
tests/unit/translator-friendly-page-client.test.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── i18n stub ─────────────────────────────────────────────────────────────────
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── next/navigation stub ──────────────────────────────────────────────────────
|
||||
const mockReplace = vi.fn();
|
||||
const mockGet = vi.fn((key: string) => {
|
||||
if (key === "tab") return "translate";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return null;
|
||||
return null;
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
useSearchParams: () => ({
|
||||
get: mockGet,
|
||||
toString: () => "tab=translate&mode=send",
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Shared component stubs ────────────────────────────────────────────────────
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="card" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Badge: ({
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) => (
|
||||
<span data-testid="badge" data-variant={variant} data-size={size}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
SegmentedControl: ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
size,
|
||||
"aria-label": ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
options: Array<{ value: string; label: string; icon?: string }>;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
size?: string;
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
data-testid="segmented-control"
|
||||
data-value={value}
|
||||
className={className}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
role="tab"
|
||||
aria-selected={value === opt.value}
|
||||
data-testid={`tab-${opt.value}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
||||
<button data-testid="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select">{children}</div>
|
||||
),
|
||||
Collapsible: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="collapsible">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Sub-component stubs ───────────────────────────────────────────────────────
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard",
|
||||
() => ({
|
||||
default: () => <div data-testid="translator-concept-card" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslateTab",
|
||||
() => ({
|
||||
default: ({
|
||||
forceOpenAdvancedSlug,
|
||||
onAdvancedSlugChange,
|
||||
}: {
|
||||
forceOpenAdvancedSlug?: string | null;
|
||||
onAdvancedSlugChange?: (slug: string | null) => void;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="translate-tab"
|
||||
data-force-open={forceOpenAdvancedSlug ?? "none"}
|
||||
onClick={() => onAdvancedSlugChange?.("rawjson")}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/MonitorTab",
|
||||
() => ({
|
||||
default: ({ onGoToTranslate }: { onGoToTranslate?: () => void }) => (
|
||||
<div data-testid="monitor-tab" onClick={onGoToTranslate} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/AdvancedSection",
|
||||
() => ({
|
||||
default: ({
|
||||
children,
|
||||
forceOpenSlug,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
forceOpenSlug?: string | null;
|
||||
}) => (
|
||||
<div data-testid="advanced-section" data-force-open-slug={forceOpenSlug ?? "none"}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div data-testid="raw-json-panel" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div data-testid="pipeline-view" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div data-testid="stream-transformer-accordion" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div data-testid="test-bench-accordion" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
<div
|
||||
data-testid="compression-preview-accordion"
|
||||
data-force-open={String(forceOpen ?? false)}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── DOM lifecycle helpers ─────────────────────────────────────────────────────
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
async function mount(component: React.ReactElement, container: HTMLElement) {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(component);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
// ── Import component AFTER mocks ──────────────────────────────────────────────
|
||||
import TranslatorPageClient from "@/app/(dashboard)/dashboard/translator/TranslatorPageClient";
|
||||
|
||||
describe("TranslatorPageClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mockGet to default (translate tab, no advanced)
|
||||
mockGet.mockImplementation((key: string) => {
|
||||
if (key === "tab") return "translate";
|
||||
if (key === "mode") return "send";
|
||||
if (key === "advanced") return null;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupCallbacks.forEach((fn) => fn());
|
||||
cleanupCallbacks.length = 0;
|
||||
});
|
||||
|
||||
it("renders smoke — default tab=translate shows TranslateTab and AdvancedSection", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
expect(container.querySelector('[data-testid="translator-concept-card"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="translate-tab"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="advanced-section"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="monitor-tab"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("SegmentedControl has role=tablist and aria-label", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
const ctrl = container.querySelector('[role="tablist"]');
|
||||
expect(ctrl).toBeTruthy();
|
||||
expect(ctrl?.getAttribute("aria-label")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ConceptCard and AutoFeaturesCard (Card) render", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
expect(container.querySelector('[data-testid="translator-concept-card"]')).toBeTruthy();
|
||||
// AutoFeaturesCard renders as a Card with a button
|
||||
const cards = container.querySelectorAll('[data-testid="card"]');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("clicking Monitor tab calls router.replace with tab=monitor", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
const monitorTabBtn = container.querySelector('[data-testid="tab-monitor"]');
|
||||
expect(monitorTabBtn).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
(monitorTabBtn as HTMLElement).click();
|
||||
});
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
expect.stringContaining("tab=monitor"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("8 FeatureChips render inside AutoFeaturesCard when expanded", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
// Find the toggle button inside the AutoFeaturesCard (Card)
|
||||
const toggleBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.querySelector(".material-symbols-outlined")?.textContent === "auto_fix_high",
|
||||
);
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
toggleBtn!.click();
|
||||
});
|
||||
|
||||
const chips = container.querySelectorAll('[data-testid="feature-chip"]');
|
||||
expect(chips.length).toBe(8);
|
||||
});
|
||||
|
||||
it("AdvancedSection receives 5 accordion children", async () => {
|
||||
const container = makeContainer();
|
||||
await mount(<TranslatorPageClient />, container);
|
||||
|
||||
const advSection = container.querySelector('[data-testid="advanced-section"]');
|
||||
expect(advSection).toBeTruthy();
|
||||
expect(advSection?.querySelector('[data-testid="raw-json-panel"]')).toBeTruthy();
|
||||
expect(advSection?.querySelector('[data-testid="pipeline-view"]')).toBeTruthy();
|
||||
expect(advSection?.querySelector('[data-testid="stream-transformer-accordion"]')).toBeTruthy();
|
||||
expect(advSection?.querySelector('[data-testid="test-bench-accordion"]')).toBeTruthy();
|
||||
expect(advSection?.querySelector('[data-testid="compression-preview-accordion"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user