feat(playground): chat web sobre claude_wire (SSE interceptado de la red)

Backend Go (web/server.go) con SSE que por cada mensaje lanza claude_wire y
reenvia su NDJSON (text_delta + result) al navegador. Frontend vanilla
(web/index.html). Mensajes serializados (mutex: claude_wire usa puerto mitmproxy
fijo). Validado end-to-end: la respuesta llega token a token del SSE real
(O, K -> OK), sin parsear el render. ~9s por mensaje vs ~15s del chat de
claude_pipe. Puerto 8100.
This commit is contained in:
agent
2026-06-04 00:30:52 +02:00
parent 9c7d9705fd
commit ed78bdb81a
4 changed files with 333 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# claude_wire — chat web (playground)
Chat en el navegador sobre `claude_wire`: el texto de la respuesta se obtiene
interceptando el SSE del modelo en la red (no parseando el render de la terminal).
Backend Go con SSE (`web/server.go`) que, por cada mensaje, lanza `claude_wire`
y reenvía su NDJSON (`text_delta` + `result`) al navegador. Frontend vanilla
(`web/index.html`), sin frameworks ni node_modules. No se indexa (playground del
padre).
## Prerrequisitos
- `mitmproxy` instalado (`mitmdump` en el PATH) + CA generada
(`~/.mitmproxy/mitmproxy-ca-cert.pem`).
- Binarios compilados:
- `apps/claude_pipe/claude_pipe` (driver de la TUI).
- `apps/claude_wire/claude_wire` (runner que intercepta el SSE).
```bash
cd apps/claude_pipe && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe .
cd ../claude_wire && go build -o claude_wire .
```
## Lanzar
```bash
cd apps/claude_wire/playground
go run ./web # http://localhost:8100
# o con flags:
go run ./web --port 8100 --root /home/enmanuel/fn_registry --wire ../claude_wire
```
Abre `http://localhost:8100` y escribe. Cada mensaje dirige la TUI interactiva de
claude por un mitmproxy y lee la respuesta del SSE de `api.anthropic.com`, token a
token, exacta. Corta en `message_stop` (sin idle ciego): ~9s por respuesta.
## vs el chat de claude_pipe
| | `claude_pipe` chat (`:8099`) | `claude_wire` chat (`:8100`) |
|---|---|---|
| Texto | parseado del render (heurístico) | exacto del SSE de la red |
| Streaming | snapshots del grid | token real |
| Latencia | ~15s | ~9s |
## Notas
- Mensajes serializados (un `claude_wire` a la vez: usa un puerto mitmproxy fijo).
- Sin memoria entre turnos (cada mensaje es una sesión claude one-shot).
+3
View File
@@ -0,0 +1,3 @@
module cw_web
go 1.25.0
+159
View File
@@ -0,0 +1,159 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>claude_wire · chat (SSE interceptado)</title>
<style>
:root {
--bg: #0a0f0c; --panel: #0f1612; --border: #1c2a22;
--user: #14352a; --assist: #131b16; --text: #e6efe9;
--muted: #7c9788; --accent: #10b981;
}
* { 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); box-shadow: 0 0 8px 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 #1f5240; }
.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: #04140d; 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_wire · chat</h1>
<span class="sub">texto exacto del modelo, interceptado del SSE de la red</span>
</header>
<div id="log">
<div class="msg assistant">
<div class="role">sistema</div>Escribe un mensaje. Cada turno dirige la TUI interactiva de claude por un mitmproxy y lee la respuesta DEL CABLE (el SSE de api.anthropic.com), no de la pantalla. Texto exacto, token a token, sin memoria entre mensajes.
</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 = captura del SSE via mitmproxy. Corta en message_stop (sin idle ciego). ~9s por respuesta; el grueso es el arranque de la TUI.</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) {
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') {
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 = () => {
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>
+123
View File
@@ -0,0 +1,123 @@
// Command server is a browser chat over claude_wire — the network-intercept path.
// For each message it launches claude_wire, which drives the interactive claude TUI
// through a mitmproxy and reads the model's SSE off the wire, then relays the
// resulting NDJSON (text_delta + result) to the browser as Server-Sent Events.
//
// Versus the claude_pipe chat (which parses the terminal render), this one shows the
// exact model text token by token and finishes as soon as the model's message_stop
// arrives — no blind idle wait. ~9s vs ~15s per message.
//
// claude_wire uses a fixed mitmproxy port, so messages are serialized with a mutex
// (a chat is sequential anyway: you wait for the reply before sending the next).
//
// Each message is an independent one-shot claude session (no memory across turns).
//
// Run:
//
// cd apps/claude_pipe && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe . # the TUI driver
// cd ../claude_wire && go build -o claude_wire . # the runner
// cd playground && go run ./web # http://localhost:8100
package main
import (
"bufio"
_ "embed"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sync"
)
//go:embed index.html
var indexHTML []byte
var (
wireBin string
root string
one sync.Mutex // serialize: one claude_wire at a time (fixed proxy port)
)
func main() {
port := flag.String("port", "8100", "port to listen on")
bin := flag.String("wire", "/home/enmanuel/fn_registry/apps/claude_wire/claude_wire", "path to the claude_wire binary")
rootDir := flag.String("root", "/home/enmanuel/fn_registry", "cwd for claude (MCP-approved repo)")
flag.Parse()
abs, err := filepath.Abs(*bin)
if err != nil {
log.Fatalf("resolve --wire: %v", err)
}
if _, err := os.Stat(abs); err != nil {
log.Fatalf("claude_wire binary not found at %s — build it first:\n (cd .. && go build -o claude_wire .)", abs)
}
wireBin, root = abs, *rootDir
http.HandleFunc("/", handleIndex)
http.HandleFunc("/chat", handleChat)
log.Printf("claude_wire web chat → http://localhost:%s (wire=%s root=%s)", *port, wireBin, root)
log.Fatal(http.ListenAndServe(":"+*port, nil))
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexHTML)
}
// handleChat runs claude_wire for the prompt and relays its NDJSON as SSE.
func handleChat(w http.ResponseWriter, r *http.Request) {
prompt := r.URL.Query().Get("prompt")
if prompt == "" {
http.Error(w, "missing prompt", http.StatusBadRequest)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
// Serialize: claude_wire binds a fixed mitmproxy port.
one.Lock()
defer one.Unlock()
cmd := exec.CommandContext(r.Context(), wireBin, "--prompt", prompt, "--cwd", root)
stdout, err := cmd.StdoutPipe()
if err != nil {
sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error()))
return
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error()))
return
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
sse(w, flusher, "", sc.Text())
}
_ = cmd.Wait()
sse(w, flusher, "done", "{}")
}
func sse(w http.ResponseWriter, f http.Flusher, event, data string) {
if event != "" {
fmt.Fprintf(w, "event: %s\n", event)
}
fmt.Fprintf(w, "data: %s\n\n", data)
f.Flush()
}