a4d63cd768
Backend Go (web/server.go) que sirve un chat single-page y, por cada mensaje, lanza claude_pipe --stream como subprocess y reenvia sus eventos NDJSON (text_delta + result) al navegador via Server-Sent Events. Frontend vanilla (web/index.html), sin frameworks ni node_modules. Prueba el stack completo end to end a traves de una surface real: captura PTY -> vt_render -> parse_claude_tui (con fix del spinner) -> delta de streaming -> chat en vivo. Cada mensaje es una sesion claude one-shot (sin memoria entre turnos). Playground del padre, no indexado.
174 lines
5.9 KiB
HTML
174 lines
5.9 KiB
HTML
<!doctype html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>claude_pipe · chat (TUI parseada)</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0b0e14; --panel: #11151f; --border: #1f2633;
|
|
--user: #1e3a5f; --assist: #161b27; --text: #e6e9ef;
|
|
--muted: #7c8597; --accent: #0ea5e9;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; margin: 0; }
|
|
body {
|
|
background: var(--bg); color: var(--text);
|
|
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
display: flex; flex-direction: column; height: 100vh;
|
|
}
|
|
header {
|
|
padding: 12px 18px; border-bottom: 1px solid var(--border);
|
|
display: flex; align-items: center; gap: 10px; background: var(--panel);
|
|
}
|
|
header .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
|
|
header h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
header .sub { font-size: 12px; color: var(--muted); margin-left: auto; }
|
|
#log {
|
|
flex: 1; overflow-y: auto; padding: 18px;
|
|
display: flex; flex-direction: column; gap: 12px;
|
|
}
|
|
.msg { max-width: 760px; padding: 10px 14px; border-radius: 12px; white-space: pre-wrap; word-wrap: break-word; }
|
|
.msg.user { align-self: flex-end; background: var(--user); border: 1px solid #285183; }
|
|
.msg.assistant { align-self: flex-start; background: var(--assist); border: 1px solid var(--border); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 14px; }
|
|
.msg.assistant.pending::after { content: "▋"; color: var(--accent); animation: blink 1s steps(2) infinite; }
|
|
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
|
|
.msg.err { align-self: center; background: #3a1620; border: 1px solid #7f1d1d; color: #fecaca; font-size: 13px; }
|
|
@keyframes blink { 50% { opacity: 0; } }
|
|
form {
|
|
display: flex; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--border); background: var(--panel);
|
|
}
|
|
#prompt {
|
|
flex: 1; resize: none; background: var(--bg); color: var(--text);
|
|
border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; font: inherit;
|
|
}
|
|
#prompt:focus { outline: none; border-color: var(--accent); }
|
|
button {
|
|
background: var(--accent); color: #04121d; border: none; border-radius: 10px;
|
|
padding: 0 18px; font-weight: 600; cursor: pointer;
|
|
}
|
|
button:disabled { opacity: .5; cursor: default; }
|
|
.hint { font-size: 11px; color: var(--muted); padding: 0 18px 10px; background: var(--panel); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<span class="dot"></span>
|
|
<h1>claude_pipe · chat</h1>
|
|
<span class="sub">respuesta parseada de la TUI de claude, vía SSE</span>
|
|
</header>
|
|
|
|
<div id="log">
|
|
<div class="msg assistant">
|
|
<div class="role">sistema</div>Escribe un mensaje. Cada turno lanza una sesión nueva de claude (sin memoria entre mensajes) y muestra su respuesta en streaming, parseada desde la TUI.
|
|
</div>
|
|
</div>
|
|
|
|
<form id="form">
|
|
<textarea id="prompt" rows="2" placeholder="Escribe tu mensaje… (Enter envía, Shift+Enter salto de línea)" autofocus></textarea>
|
|
<button id="send" type="submit">Enviar</button>
|
|
</form>
|
|
<div class="hint">Cada mensaje = una captura PTY → vt_render → parse_claude_tui. Sin memoria entre turnos. Hay ~8s de warmup+idle antes de la primera respuesta.</div>
|
|
|
|
<script>
|
|
const log = document.getElementById('log');
|
|
const form = document.getElementById('form');
|
|
const promptEl = document.getElementById('prompt');
|
|
const sendBtn = document.getElementById('send');
|
|
let busy = false;
|
|
|
|
function addMsg(role, text, cls) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg ' + (cls || role);
|
|
const r = document.createElement('div');
|
|
r.className = 'role';
|
|
r.textContent = role;
|
|
div.appendChild(r);
|
|
div.appendChild(document.createTextNode(text || ''));
|
|
log.appendChild(div);
|
|
log.scrollTop = log.scrollHeight;
|
|
return div;
|
|
}
|
|
|
|
function setBody(div, text) {
|
|
// Keep the .role child, replace the trailing text node.
|
|
while (div.childNodes.length > 1) div.removeChild(div.lastChild);
|
|
div.appendChild(document.createTextNode(text));
|
|
log.scrollTop = log.scrollHeight;
|
|
}
|
|
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
send();
|
|
});
|
|
|
|
promptEl.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
send();
|
|
}
|
|
});
|
|
|
|
function send() {
|
|
if (busy) return;
|
|
const prompt = promptEl.value.trim();
|
|
if (!prompt) return;
|
|
|
|
addMsg('tú', prompt, 'user');
|
|
promptEl.value = '';
|
|
setBusy(true);
|
|
|
|
const bubble = addMsg('claude', '', 'assistant');
|
|
bubble.classList.add('pending');
|
|
let acc = '';
|
|
|
|
const es = new EventSource('/chat?prompt=' + encodeURIComponent(prompt));
|
|
|
|
es.onmessage = (ev) => {
|
|
let obj;
|
|
try { obj = JSON.parse(ev.data); } catch { return; }
|
|
if (obj.type === 'text_delta') {
|
|
acc += obj.text || '';
|
|
setBody(bubble, acc);
|
|
} else if (obj.type === 'result') {
|
|
// The result carries the authoritative final answer (clean last frame).
|
|
if (obj.result) { acc = obj.result; setBody(bubble, acc); }
|
|
}
|
|
};
|
|
|
|
es.addEventListener('done', () => {
|
|
bubble.classList.remove('pending');
|
|
if (!acc) setBody(bubble, '(respuesta vacía)');
|
|
es.close();
|
|
setBusy(false);
|
|
});
|
|
|
|
es.addEventListener('error', (ev) => {
|
|
let msg = 'error de conexión';
|
|
try { const o = JSON.parse(ev.data); if (o.message) msg = o.message; } catch {}
|
|
bubble.classList.remove('pending');
|
|
if (!acc) { addMsg('error', msg, 'err'); bubble.remove(); }
|
|
es.close();
|
|
setBusy(false);
|
|
});
|
|
|
|
es.onerror = () => {
|
|
// Network-level error / stream closed unexpectedly.
|
|
if (busy) {
|
|
bubble.classList.remove('pending');
|
|
es.close();
|
|
setBusy(false);
|
|
}
|
|
};
|
|
}
|
|
|
|
function setBusy(b) {
|
|
busy = b;
|
|
sendBtn.disabled = b;
|
|
sendBtn.textContent = b ? '…' : 'Enviar';
|
|
if (!b) promptEl.focus();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|