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.
141 lines
4.2 KiB
Go
141 lines
4.2 KiB
Go
// 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()
|
|
}
|