diff --git a/playground/README.md b/playground/README.md index 78512f4..b45652e 100644 --- a/playground/README.md +++ b/playground/README.md @@ -44,3 +44,24 @@ go run artifact_probe.go --root /home/enmanuel/fn_registry --prompt "tu prompt a `--root` debe ser un repo cuyos MCP de claude ya estén aprobados, para que la TUI no muestre el diálogo de arranque. + +## Chat en el navegador (`web/`) + +Un chat web que prueba todo el stack end to end: backend Go con SSE que lanza +`claude_pipe --stream` por cada mensaje y reenvía los `text_delta` al navegador, +frontend chat vanilla (sin frameworks, sin node_modules). + +```bash +cd apps/claude_pipe +CGO_ENABLED=1 go build -tags fts5 -o claude_pipe . # binario con el fix del spinner + +cd playground +go run ./web # http://localhost:8099 +# o con flags: +go run ./web --port 8099 --root /home/enmanuel/fn_registry --warmup 4s --idle 4s --max 120s +``` + +Abre `http://localhost:8099` y escribe. Cada mensaje es una sesión `claude` nueva +(sin memoria entre turnos: `claude_pipe` es one-shot). Hay ~8s de `warmup`+`idle` +antes de la primera respuesta. La respuesta se reconstruye desde la TUI parseada, +ya sin el spinner de carga. diff --git a/playground/registry.db b/playground/registry.db new file mode 100644 index 0000000..e69de29 diff --git a/playground/web/index.html b/playground/web/index.html new file mode 100644 index 0000000..e61ad55 --- /dev/null +++ b/playground/web/index.html @@ -0,0 +1,173 @@ + + + + + +claude_pipe · chat (TUI parseada) + + + +
+ +

claude_pipe · chat

+ respuesta parseada de la TUI de claude, vía SSE +
+ +
+
+
sistema
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. +
+
+ +
+ + +
+
Cada mensaje = una captura PTY → vt_render → parse_claude_tui. Sin memoria entre turnos. Hay ~8s de warmup+idle antes de la primera respuesta.
+ + + + diff --git a/playground/web/server.go b/playground/web/server.go new file mode 100644 index 0000000..a6f8705 --- /dev/null +++ b/playground/web/server.go @@ -0,0 +1,140 @@ +// Command server is a browser chat playground for claude_pipe. It serves a small +// single-page chat UI and, for each message, launches `claude_pipe --stream` as a +// subprocess and relays its NDJSON events (text_delta + result) to the browser as +// Server-Sent Events. The browser renders the answer token-chunk by token-chunk. +// +// This proves the whole stack end to end through a real surface: the PTY capture, +// the VT render, the TUI parser (with the spinner fix), and the streaming delta — +// all driving a live chat in the browser. +// +// Each message is an independent one-shot claude session: claude_pipe does not keep +// conversation context between turns, so the chat has no memory across messages. +// +// Run: +// +// cd apps/claude_pipe && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe . +// cd playground && go run ./web # then open http://localhost:8099 +package main + +import ( + "bufio" + _ "embed" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" +) + +//go:embed index.html +var indexHTML []byte + +var ( + binPath string + root string + warmup string + idle string + maxDur string +) + +func main() { + port := flag.String("port", "8099", "port to listen on") + bin := flag.String("bin", "/home/enmanuel/fn_registry/apps/claude_pipe/claude_pipe", "path to the claude_pipe binary") + rootDir := flag.String("root", "/home/enmanuel/fn_registry", "cwd for claude (a repo whose MCP servers are approved)") + wu := flag.String("warmup", "4s", "claude_pipe --warmup") + id := flag.String("idle", "4s", "claude_pipe --idle") + md := flag.String("max", "120s", "claude_pipe --max") + flag.Parse() + + abs, err := filepath.Abs(*bin) + if err != nil { + log.Fatalf("resolve --bin: %v", err) + } + if _, err := os.Stat(abs); err != nil { + log.Fatalf("claude_pipe binary not found at %s — build it first:\n (cd .. && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe .)", abs) + } + binPath, root, warmup, idle, maxDur = abs, *rootDir, *wu, *id, *md + + http.HandleFunc("/", handleIndex) + http.HandleFunc("/chat", handleChat) + + addr := ":" + *port + log.Printf("claude_pipe web chat → http://localhost:%s (bin=%s root=%s)", *port, binPath, root) + log.Fatal(http.ListenAndServe(addr, 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_pipe --stream for the given prompt and relays each NDJSON +// line as a Server-Sent Event. The browser opens this via EventSource. +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") + + // The request context is cancelled when the browser closes the EventSource; + // CommandContext then kills the claude_pipe subprocess (and its PTY child). + cmd := exec.CommandContext(r.Context(), binPath, + "--stream", + "--cwd", root, + "--warmup", warmup, + "--idle", idle, + "--max", maxDur, + prompt, + ) + 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() { + // Each line is already a JSON object ({"type":"text_delta",...} or result). + // Relay it verbatim as the SSE data payload. + sse(w, flusher, "", sc.Text()) + } + _ = cmd.Wait() + + // Signal completion so the browser can close the stream. + sse(w, flusher, "done", "{}") +} + +// sse writes one Server-Sent Event. If event is empty, a default "message" event +// is emitted (what EventSource.onmessage receives). +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() +}