/* ===========================================================
Views (set 2): Visor, Terminal, Arena, Ranking, Sources, Settings
=========================================================== */
const { useState: useState2, useEffect: useEffect2, useMemo: useMemo2, useRef: useRef2, Fragment } = React;
/* ------------------------- RETRY VISOR ------------------------- */
function VisorView({ run, nav }) {
const attempts = run.writer.attempts_history;
const [selIdx, setSelIdx] = useState2(attempts.length - 1);
const [showDiff, setShowDiff] = useState2(false);
const sel = attempts[selIdx];
const prev = selIdx > 0 ? attempts[selIdx - 1] : null;
return (
nav(`/runs/${run.id}`)} style={{cursor:'pointer'}}> Run
VISOR DE REINTENTOS · {run.id}
{run.editor.selected_titulo}
{attempts.length} intentos
writer · {run.writer.winner_model}
{/* LEFT git-log */}
Historial · git-log
{attempts.map((a, i) => (
setSelIdx(i)}
>
Attempt {a.attempt_n}{a.passed ? ' ✓' : ''}
{a.model}
{a.passed
? approved
: a.reason === 'factcheck'
? factcheck
: quality }
{a.duration_seconds.toFixed(1)}s · {a.prompt_tokens}↑/{a.output_tokens}↓ tok
))}
Veredicto del selector
{run.selector.reason}
Modelo ganador
{run.selector.winner}
mistral {run.selector.scores['mistral-nemo:12b'].toFixed(2)} · gemma {run.selector.scores['gemma4:e4b'].toFixed(2)}
{/* RIGHT main */}
{attempts.map((a, i) => (
setSelIdx(i)}
>
Intento {a.attempt_n}
{a.passed && }
{!a.passed && }
))}
{selIdx > 0 && (
setShowDiff(e.target.checked)} />
Ver diff vs Intento {selIdx}
)}
{/* header strip */}
Duración
{sel.duration_seconds.toFixed(1)} s
Prompt
{sel.prompt_tokens.toLocaleString()} tok
Output
{sel.output_tokens.toLocaleString()} tok
Estado
{sel.passed
? aprobado
: sel.reason === 'factcheck'
? rechazado · factcheck
: rechazado · quality gate }
{/* rejection card */}
{!sel.passed && sel.quality_issues.length > 0 && (
{sel.reason === 'factcheck' ? 'Fact-check gate' : 'Quality gate'}
Motivos del rechazo
{sel.quality_issues.length} problemas
{sel.quality_issues.map((iss, k) => {
const cat = iss.startsWith('Frase prohibida') ? 'frase'
: iss.startsWith('Adjetivo') ? 'adjetivo'
: iss.startsWith('Párrafo') ? 'densidad'
: iss.startsWith('Estructura') ? 'estructura'
: 'otro';
return (
{cat}
"$1"')}} />
);
})}
{sel.removed_phrases.length > 0 && (
Frases marcadas para reescribir
{sel.removed_phrases.map((p, k) => (
"{p}"
))}
)}
)}
{sel.passed && (
Quality gate aprobado en {sel.duration_seconds.toFixed(1)}s
Todas las verificaciones pasaron: conteo de palabras, párrafos, frases prohibidas, densidad factual. Este intento fue elegido por el selector como el ganador del A/B.
)}
{/* article preview / diff */}
{showDiff && prev ? (
DIFF Intento {prev.attempt_n} → Intento {sel.attempt_n}
removed
added
Intento {prev.attempt_n} (rechazado)
Intento {sel.attempt_n}{sel.passed ? ' (aprobado)' : ' (rechazado)'}
) : (
PREVIEW Borrador renderizado
frase marcada
texto removido
{run.editor.selected_titulo}
Borrador · intento {sel.attempt_n} · {sel.model}
)}
);
}
function DiffPane({ html, mode }) {
// Cheap line-diff visualization: strip html tags into paragraphs
const paras = html.replace(/([^<]+)<\/h2>/g, '\n## $1\n').replace(/<\/?p>/g, '\n').split('\n').filter(p => p.trim()).map(p => p.replace(/<[^>]+>/g, '').trim());
return (
{paras.map((p, i) => {
// mark first paragraph as "changed" for diff illusion
const isMark = i === 1;
const isHeader = p.startsWith('##');
const text = isHeader ? p.slice(2).trim() : p;
if (isMark && mode === 'left') {
return
{text}
;
}
if (isMark && mode === 'right') {
return
{text.replace(/Cabe destacar que/i, '').replace(/importante/g, 'inédito').replace(/No obstante, el contexto sigue siendo complejo\./, '')}
;
}
if (isHeader) return
{text} ;
return
{text}
;
})}
);
}
/* ------------------------- TERMINAL ------------------------- */
function TerminalView({ logs }) {
const [filter, setFilter] = useState2('ALL');
const [search, setSearch] = useState2('');
const [follow, setFollow] = useState2(true);
const [visible, setVisible] = useState2(50);
const scrollRef = useRef2(null);
// simulate "live" appending
useEffect2(() => {
if (!follow) return;
const id = setInterval(() => {
setVisible(v => Math.min(v + 1, logs.length));
}, 900);
return () => clearInterval(id);
}, [follow, logs.length]);
useEffect2(() => {
if (follow && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [visible, follow]);
const filtered = useMemo2(() => {
return logs.slice(0, visible).filter(l => {
if (filter !== 'ALL' && l.level !== filter) return false;
if (search && !(l.message.toLowerCase().includes(search.toLowerCase()) || l.module.toLowerCase().includes(search.toLowerCase()))) return false;
return true;
});
}, [logs, visible, filter, search]);
return (
Stream
Live terminal
~/logs/newsroom-2026-05-12.log
{filtered.length} líneas
CONNECTED
·
ws://localhost:8000/logs
setFilter(e.target.value)}>
ALL
INFO
STEP
WARNING
ERROR
setSearch(e.target.value)} placeholder="grep…" />
setFollow(e.target.checked)} />
follow
{filtered.map(l => (
{l.ts_compact}
[{l.level}]
{l.module}: {l.message}
))}
{follow && (
▌
_
)}
);
}
/* ------------------------- RANKING ------------------------- */
function RankingView({ runs }) {
const latest = runs[0];
const candidates = latest.collector.candidates;
const [sortBy, setSortBy] = useState2('score');
const sorted = [...candidates].sort((a, b) => {
if (sortBy === 'score') return b.editorial_score - a.editorial_score;
if (sortBy === 'source') return a.fuente.localeCompare(b.fuente);
if (sortBy === 'freshness') return b.freshness_score - a.freshness_score;
return 0;
});
const maxScore = Math.max(...candidates.map(c => c.editorial_score));
return (
Cola actual
Ranking de candidatos
{candidates.length} candidatos
run {latest.id}
{fmtClock(latest.timestamp)}
#
setSortBy('source')} style={{cursor:'pointer'}}>Titular candidato
Fuente
setSortBy('score')} style={{cursor:'pointer', textAlign:'right'}}>Editorial ▼
Desglose
{sorted.map((c, i) => {
const isSelected = c.titulo === latest.editor.selected_titulo;
const bd = {
source: c.source_score,
freshness: c.freshness_score,
relevance: c.relevance_score,
trends: c.depth_bonus,
gemini: Math.max(0, c.editorial_score - c.source_score - c.freshness_score - c.relevance_score - c.depth_bonus),
};
return (
{String(i+1).padStart(2, '0')}
{c.titulo}
{isSelected &&
seleccionado }
{c.fuente} · w {(window.SOURCES.find(s => s.name === c.fuente)?.weight || 0.85).toFixed(2)}
{c.editorial_score.toFixed(3)}
);
})}
Source weight
Freshness
Relevance
Trends boost
Gemini boost
);
}
/* ------------------------- ARENA ------------------------- */
function ArenaView({ runs }) {
const wins = { 'mistral-nemo:12b': 0, 'gemma4:e4b': 0 };
runs.forEach(r => { wins[r.selector.winner] = (wins[r.selector.winner] || 0) + 1; });
const totalDuels = runs.length;
const mistralRate = (wins['mistral-nemo:12b'] / totalDuels * 100);
const gemmaRate = (wins['gemma4:e4b'] / totalDuels * 100);
const leader = mistralRate > gemmaRate ? 'mistral-nemo:12b' : 'gemma4:e4b';
// build cumulative win-rate "over time" (synthetic but coherent)
const points = 30;
const mistralLine = [];
const gemmaLine = [];
let mw = 0, gw = 0;
for (let i = 0; i < points; i++) {
const isMistral = ((i * 7 + 3) % 11) < 7;
if (isMistral) mw++; else gw++;
const total = i + 1;
mistralLine.push((mw / total) * 100);
gemmaLine.push((gw / total) * 100);
}
// by category
const cats = window.CATEGORIES;
const byCat = cats.map(c => {
const a = 0.45 + Math.random() * 0.4;
return { cat: c, mistral: a * 100, gemma: (1 - a) * 100 };
});
// recent duels (use runs)
const duels = runs.slice(0, 10);
// sheet state
const [open, setOpen] = useState2(null);
return (
A/B
Arena de escritores
{totalDuels} duelos · ganador líder · {leader}
{/* HERO */}
Writer A
mistral-nemo:12b
{mistralRate.toFixed(0)}%
win-rate
duelos {wins['mistral-nemo:12b']}
dur. media 11.4s
longitud media 458 pal.
vs
Writer B
gemma4:e4b
{gemmaRate.toFixed(0)}%
win-rate
duelos {wins['gemma4:e4b']}
dur. media 9.8s
longitud media 421 pal.
{/* Win-rate over time + by category */}
TENDENCIA Win-rate acumulado · últimos 30 duelos
CATEGORÍAS Win-rate por sección
{byCat.map(b => (
{b.cat}
m {b.mistral.toFixed(0)}% · g {b.gemma.toFixed(0)}%
))}
{/* Recent duels */}
HISTORIAL Duelos recientes
click → veredicto completo
Run
Titular
Ganador
Perdedor
Razón del selector
Δ score
{duels.map(d => {
const mScore = d.selector.scores['mistral-nemo:12b'];
const gScore = d.selector.scores['gemma4:e4b'];
const gap = Math.abs(mScore - gScore);
const loser = d.selector.winner === 'mistral-nemo:12b' ? 'gemma4:e4b' : 'mistral-nemo:12b';
return (
setOpen(d)}>
{d.id}
{d.editor.selected_titulo}
{d.selector.winner}
{loser}
{d.selector.reason}
+{gap.toFixed(2)}
);
})}
{open &&
setOpen(null)} />}
);
}
function LineChart({ series }) {
const w = 600, h = 220, pad = 30;
const all = series.flatMap(s => s.data);
const min = 0, max = 100;
const n = series[0].data.length;
const xAt = i => pad + (i / (n - 1)) * (w - pad - 10);
const yAt = v => h - pad + 6 - ((v - min) / (max - min)) * (h - pad - 16);
return (
{/* gridlines */}
{[0,25,50,75,100].map(v => (
{v}%
))}
{series.map((s, si) => (
`${xAt(i)},${yAt(v)}`).join(' ')}
fill="none" stroke={s.color} strokeWidth="2"
/>
{s.data.map((v, i) => i % 5 === 0 && )}
))}
{/* x axis */}
run 1
run {n}
{/* legend */}
{series.map((s, i) => (
{s.label}
))}
);
}
function DuelSheet({ run, onClose }) {
const attempts = run.writer.attempts_history;
// synthesize draft B as a variant of the first attempt's html
const draftA = attempts[attempts.length - 1];
const draftB = {
model: draftA.model === 'mistral-nemo:12b' ? 'gemma4:e4b' : 'mistral-nemo:12b',
html: attempts[0].html,
};
return (
e.stopPropagation()}>
Veredicto del A/B · {run.id}
{run.editor.selected_titulo}
cerrar
{draftA.model}
{run.selector.winner === draftA.model &&
winner}
{draftB.model}
{run.selector.winner === draftB.model &&
winner}
Razón del selector
"{run.selector.reason}"
scores ·
mistral {run.selector.scores['mistral-nemo:12b'].toFixed(2)}
gemma {run.selector.scores['gemma4:e4b'].toFixed(2)}
Δ {Math.abs(run.selector.scores['mistral-nemo:12b'] - run.selector.scores['gemma4:e4b']).toFixed(2)}
);
}
/* ------------------------- SOURCES ------------------------- */
function SourcesView({ runs }) {
// aggregate per-source
const sources = window.SOURCES.map(s => {
const delivered = runs.reduce((a, r) => a + r.collector.candidates.filter(c => c.fuente === s.name).length, 0);
const selected = runs.filter(r => r.editor.selected_fuente === s.name).length;
const published = runs.filter(r => r.editor.selected_fuente === s.name && r.publisher.success).length;
return { ...s, delivered, selected, published, winrate: delivered ? (selected / delivered * 100) : 0 };
}).sort((a, b) => b.weight - a.weight);
const [open, setOpen] = useState2(null);
return (
Performance
Matriz de fuentes
{sources.length} fuentes · últimas {runs.length} ejecuciones
Fuente
Peso
Candidatos
Seleccionados
Publicados
Win-rate
{sources.map(s => (
setOpen(s)}>
{s.name}
1.2 ? 'gold' : s.weight > 0.9 ? 'info' : ''}>w {s.weight.toFixed(2)}
{s.delivered}
{s.selected}
{s.published}
))}
{open && (
setOpen(null)}>
e.stopPropagation()} style={{maxWidth: 720}}>
Fuente
{open.name}
peso · {open.weight.toFixed(2)} · {open.delivered} candidatos · {open.selected} seleccionados
setOpen(null)} style={{cursor:'pointer'}}> cerrar
Últimos titulares
{runs.flatMap(r => r.collector.candidates.filter(c => c.fuente === open.name).map(c => ({...c, runId: r.id, ts: r.timestamp})))
.slice(0, 10).map((c, i) => (
{c.titulo}
score {c.editorial_score.toFixed(3)}
{c.runId}
{fmtClock(c.ts)}
))}
)}
);
}
/* ------------------------- SETTINGS ------------------------- */
function SettingsView() {
const cfg = window.getMockConfig();
const maxW = Math.max(...Object.values(cfg.source_weights));
return (
Config
Settings
Read-only · edita config.py en el backend
{Object.entries(cfg.models).map(([agent, model]) => (
{agent}
{model}
))}
{Object.entries(cfg.pipeline).map(([k, v]) => (
{k}
{String(v)}
))}
{Object.entries(cfg.source_weights).map(([name, w]) => (
{name}
{w.toFixed(2)}
1.2 ? 'var(--gold)' : w > 0.9 ? 'var(--info)' : 'var(--text3)'}} />
))}
);
}
Object.assign(window, {
VisorView, TerminalView, RankingView, ArenaView, SourcesView, SettingsView,
});