Spaces:
Runtime error
Runtime error
| "use client"; | |
| // Combos screen = Compression Hub (top) + named-combos manager (below). | |
| // | |
| // IMPORTANT (hydration): no `useTranslations` here. The earlier combos redesign | |
| // failed to hydrate on the production build and the only structural difference from | |
| // the engine pages was a page-level `useTranslations`. Strings are literal English, | |
| // matching `EngineConfigPage` / `CompressionHub`, both of which hydrate cleanly. | |
| import { useEffect, useState } from "react"; | |
| import { STACKED_PIPELINE_ENGINE_INTENSITIES } from "@/shared/validation/compressionConfigSchemas"; | |
| import CompressionHub from "./CompressionHub"; | |
| type PipelineStep = { engine: string; intensity?: string }; | |
| type CompressionCombo = { | |
| id: string; | |
| name: string; | |
| description: string; | |
| pipeline: PipelineStep[]; | |
| languagePacks: string[]; | |
| outputMode: boolean; | |
| outputModeIntensity: string; | |
| isDefault: boolean; | |
| }; | |
| type RoutingCombo = { id?: string; name?: string }; | |
| type LanguagePack = { language: string; ruleCount: number }; | |
| const EMPTY_PIPELINE: PipelineStep[] = [ | |
| { engine: "rtk", intensity: "standard" }, | |
| { engine: "caveman", intensity: "full" }, | |
| ]; | |
| // Engine list is sourced from the API schema so the dropdown can never offer an engine | |
| // the `PUT /api/context/combos/[id]` route would reject with HTTP 400 (#4955). | |
| const ENGINE_INTENSITIES: Record<string, readonly string[]> = STACKED_PIPELINE_ENGINE_INTENSITIES; | |
| function NamedCombosManager() { | |
| const [combos, setCombos] = useState<CompressionCombo[]>([]); | |
| const [routingCombos, setRoutingCombos] = useState<RoutingCombo[]>([]); | |
| const [languagePacks, setLanguagePacks] = useState<LanguagePack[]>([]); | |
| const [editingId, setEditingId] = useState<string | null>(null); | |
| const [name, setName] = useState(""); | |
| const [description, setDescription] = useState(""); | |
| const [pipeline, setPipeline] = useState<PipelineStep[]>(EMPTY_PIPELINE); | |
| const [selectedPacks, setSelectedPacks] = useState<string[]>(["en"]); | |
| const [outputMode, setOutputMode] = useState(false); | |
| const [outputModeIntensity, setOutputModeIntensity] = useState("full"); | |
| const [assignmentIds, setAssignmentIds] = useState<string[]>([]); | |
| const [saving, setSaving] = useState(false); | |
| const [activeComboId, setActiveComboId] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const refresh = () => { | |
| fetch("/api/context/combos") | |
| .then((res) => (res.ok ? res.json() : null)) | |
| .then((data) => setCombos(Array.isArray(data?.combos) ? data.combos : [])) | |
| .catch(() => {}); | |
| }; | |
| useEffect(() => { | |
| refresh(); | |
| fetch("/api/combos") | |
| .then((res) => (res.ok ? res.json() : null)) | |
| .then((data) => setRoutingCombos(Array.isArray(data?.combos) ? data.combos : [])) | |
| .catch(() => {}); | |
| fetch("/api/compression/language-packs") | |
| .then((res) => (res.ok ? res.json() : null)) | |
| .then((data) => setLanguagePacks(Array.isArray(data?.packs) ? data.packs : [])) | |
| .catch(() => {}); | |
| fetch("/api/settings/compression") | |
| .then((res) => (res.ok ? res.json() : null)) | |
| .then((data) => setActiveComboId(data?.activeComboId ?? null)) | |
| .catch(() => {}); | |
| }, []); | |
| const resetForm = () => { | |
| setEditingId(null); | |
| setName(""); | |
| setDescription(""); | |
| setPipeline(EMPTY_PIPELINE); | |
| setSelectedPacks(["en"]); | |
| setOutputMode(false); | |
| setOutputModeIntensity("full"); | |
| setAssignmentIds([]); | |
| setError(null); | |
| }; | |
| const loadAssignments = async (id: string) => { | |
| const res = await fetch(`/api/context/combos/${id}/assignments`); | |
| if (!res.ok) return []; | |
| const data = await res.json(); | |
| return Array.isArray(data?.assignments) | |
| ? data.assignments.map((item: { routingComboId: string }) => item.routingComboId) | |
| : []; | |
| }; | |
| const editCombo = async (combo: CompressionCombo) => { | |
| setEditingId(combo.id); | |
| setName(combo.name); | |
| setDescription(combo.description ?? ""); | |
| setPipeline(combo.pipeline.length > 0 ? combo.pipeline : EMPTY_PIPELINE); | |
| setSelectedPacks(combo.languagePacks?.length ? combo.languagePacks : ["en"]); | |
| setOutputMode(Boolean(combo.outputMode)); | |
| setOutputModeIntensity(combo.outputModeIntensity ?? "full"); | |
| setAssignmentIds(await loadAssignments(combo.id)); | |
| }; | |
| const saveCombo = async () => { | |
| const trimmed = name.trim(); | |
| if (!trimmed) { | |
| setError("Enter a combo name before saving."); | |
| return; | |
| } | |
| if (pipeline.length === 0) { | |
| setError("Add at least one pipeline step before saving."); | |
| return; | |
| } | |
| setError(null); | |
| setSaving(true); | |
| try { | |
| const payload = { | |
| name: trimmed, | |
| description, | |
| pipeline, | |
| languagePacks: selectedPacks, | |
| outputMode, | |
| outputModeIntensity, | |
| }; | |
| const res = await fetch( | |
| editingId ? `/api/context/combos/${editingId}` : "/api/context/combos", | |
| { | |
| method: editingId ? "PUT" : "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| } | |
| ); | |
| if (!res.ok) { | |
| const body = await res.json().catch(() => null); | |
| setError(body?.error || `Failed to save combo (HTTP ${res.status}).`); | |
| return; | |
| } | |
| const combo = await res.json(); | |
| await fetch(`/api/context/combos/${combo.id}/assignments`, { | |
| method: "PUT", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ routingComboIds: assignmentIds }), | |
| }); | |
| resetForm(); | |
| refresh(); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const deleteCombo = async (combo: CompressionCombo) => { | |
| if (!confirm(`Delete combo "${combo.name}"?`)) return; | |
| const res = await fetch(`/api/context/combos/${combo.id}`, { method: "DELETE" }); | |
| if (res.ok) refresh(); | |
| }; | |
| const updateStep = (index: number, patch: Partial<PipelineStep>) => { | |
| setPipeline((current) => | |
| current.map((step, stepIndex) => { | |
| if (stepIndex !== index) return step; | |
| const next = { ...step, ...patch }; | |
| const allowed = ENGINE_INTENSITIES[next.engine] ?? ["standard"]; | |
| return { | |
| ...next, | |
| intensity: allowed.includes(next.intensity ?? "") ? next.intensity : allowed[0], | |
| }; | |
| }) | |
| ); | |
| }; | |
| const togglePack = (language: string, enabled: boolean) => { | |
| setSelectedPacks((current) => | |
| enabled | |
| ? [...new Set([...current, language])] | |
| : current.filter((item) => item !== language && item !== "en") | |
| ); | |
| }; | |
| const toggleAssignment = (id: string, enabled: boolean) => { | |
| setAssignmentIds((current) => | |
| enabled ? [...new Set([...current, id])] : current.filter((item) => item !== id) | |
| ); | |
| }; | |
| return ( | |
| <div className="flex flex-col gap-4"> | |
| <div> | |
| <h2 className="text-lg font-semibold text-text-main">Named combos</h2> | |
| <p className="text-sm text-text-muted"> | |
| Save different pipelines and assign them to specific routing combos. | |
| </p> | |
| </div> | |
| <section className="rounded-lg border border-border bg-surface p-4"> | |
| <div className="grid grid-cols-1 gap-3 md:grid-cols-2"> | |
| <input | |
| value={name} | |
| onChange={(event) => setName(event.target.value)} | |
| placeholder="Combo name" | |
| className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" | |
| /> | |
| <input | |
| value={description} | |
| onChange={(event) => setDescription(event.target.value)} | |
| placeholder="Description" | |
| className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" | |
| /> | |
| </div> | |
| <div className="mt-4 space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <h3 className="text-sm font-semibold text-text-main">Pipeline</h3> | |
| <button | |
| onClick={() => | |
| setPipeline((current) => [...current, { engine: "caveman", intensity: "full" }]) | |
| } | |
| className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main" | |
| > | |
| Add step | |
| </button> | |
| </div> | |
| {pipeline.map((step, index) => ( | |
| <div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-2"> | |
| <select | |
| value={step.engine} | |
| onChange={(event) => updateStep(index, { engine: event.target.value })} | |
| className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" | |
| > | |
| {Object.keys(ENGINE_INTENSITIES).map((engine) => ( | |
| <option key={engine} value={engine}> | |
| {engine} | |
| </option> | |
| ))} | |
| </select> | |
| <select | |
| value={step.intensity ?? ""} | |
| onChange={(event) => updateStep(index, { intensity: event.target.value })} | |
| className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" | |
| > | |
| {(ENGINE_INTENSITIES[step.engine] ?? ["standard"]).map((intensity) => ( | |
| <option key={intensity} value={intensity}> | |
| {intensity} | |
| </option> | |
| ))} | |
| </select> | |
| <button | |
| onClick={() => setPipeline((current) => current.filter((_, i) => i !== index))} | |
| className="rounded-lg border border-border px-3 py-2 text-sm text-text-main" | |
| disabled={pipeline.length <= 1} | |
| > | |
| Remove | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3"> | |
| <div> | |
| <h3 className="mb-2 text-sm font-semibold text-text-main">Language packs</h3> | |
| <div className="space-y-2 text-sm text-text-main"> | |
| {languagePacks.map((pack) => ( | |
| <label key={pack.language} className="flex items-center justify-between gap-2"> | |
| <span> | |
| {pack.language} ({pack.ruleCount}) | |
| </span> | |
| <input | |
| type="checkbox" | |
| checked={selectedPacks.includes(pack.language)} | |
| disabled={pack.language === "en"} | |
| onChange={(event) => togglePack(pack.language, event.target.checked)} | |
| /> | |
| </label> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="space-y-2 text-sm text-text-main"> | |
| <h3 className="text-sm font-semibold text-text-main">Output mode</h3> | |
| <label className="flex items-center gap-2"> | |
| <input | |
| type="checkbox" | |
| checked={outputMode} | |
| onChange={(event) => setOutputMode(event.target.checked)} | |
| /> | |
| Enabled | |
| </label> | |
| <select | |
| value={outputModeIntensity} | |
| onChange={(event) => setOutputModeIntensity(event.target.value)} | |
| className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm" | |
| > | |
| <option value="lite">lite</option> | |
| <option value="full">full</option> | |
| <option value="ultra">ultra</option> | |
| </select> | |
| </div> | |
| <div> | |
| <h3 className="mb-2 text-sm font-semibold text-text-main">Assign to routing</h3> | |
| <div className="max-h-44 space-y-2 overflow-auto text-sm text-text-main"> | |
| {routingCombos.length === 0 ? ( | |
| <p className="text-xs text-text-muted">No routing combos available.</p> | |
| ) : ( | |
| routingCombos.map((combo) => { | |
| const id = combo.id ?? combo.name ?? ""; | |
| if (!id) return null; | |
| return ( | |
| <label key={id} className="flex items-center justify-between gap-2"> | |
| <span className="truncate">{combo.name ?? id}</span> | |
| <input | |
| type="checkbox" | |
| checked={assignmentIds.includes(id)} | |
| onChange={(event) => toggleAssignment(id, event.target.checked)} | |
| /> | |
| </label> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {error && ( | |
| <p className="mt-4 text-sm text-danger" role="alert"> | |
| {error} | |
| </p> | |
| )} | |
| <div className="mt-4 flex flex-wrap gap-2"> | |
| <button | |
| onClick={saveCombo} | |
| disabled={saving} | |
| className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white" | |
| > | |
| {editingId ? "Save" : "Create combo"} | |
| </button> | |
| {editingId && ( | |
| <button | |
| onClick={resetForm} | |
| className="rounded-lg border border-border px-4 py-2 text-sm text-text-main" | |
| > | |
| Cancel | |
| </button> | |
| )} | |
| </div> | |
| </section> | |
| <section className="grid grid-cols-1 gap-4 lg:grid-cols-2"> | |
| {combos.map((combo) => ( | |
| <div key={combo.id} className="rounded-lg border border-border bg-surface p-4"> | |
| <div className="flex items-start justify-between gap-3"> | |
| <div className="min-w-0"> | |
| <h3 className="truncate text-base font-semibold text-text-main">{combo.name}</h3> | |
| <p className="mt-1 text-sm text-text-muted">{combo.description}</p> | |
| </div> | |
| {combo.id === activeComboId && ( | |
| <span | |
| data-testid={`active-badge-${combo.id}`} | |
| className="rounded-full bg-green-500/10 px-2 py-1 text-xs font-medium text-green-500" | |
| > | |
| ● Active | |
| </span> | |
| )} | |
| </div> | |
| <div className="mt-4 flex flex-wrap gap-2"> | |
| {combo.pipeline.map((step, index) => ( | |
| <span | |
| key={`${combo.id}-${index}`} | |
| className="rounded-lg border border-border bg-bg px-2 py-1 font-mono text-xs text-text-muted" | |
| > | |
| {index + 1}. {step.engine} | |
| {step.intensity ? `:${step.intensity}` : ""} | |
| </span> | |
| ))} | |
| </div> | |
| <p className="mt-3 text-xs text-text-muted"> | |
| Language packs: {combo.languagePacks.join(", ")} | |
| </p> | |
| <div className="mt-4 flex flex-wrap gap-2"> | |
| <button | |
| onClick={() => editCombo(combo)} | |
| className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main" | |
| > | |
| Edit | |
| </button> | |
| {!combo.isDefault && ( | |
| <button | |
| onClick={() => deleteCombo(combo)} | |
| className="rounded-lg border border-danger/40 px-3 py-1.5 text-xs text-danger" | |
| > | |
| Delete | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </section> | |
| </div> | |
| ); | |
| } | |
| export default function CompressionCombosPageClient() { | |
| return ( | |
| <div className="flex flex-col gap-8"> | |
| <CompressionHub /> | |
| <NamedCombosManager /> | |
| </div> | |
| ); | |
| } | |