chore: eliminar playground (chat web exploratorio)
This commit is contained in:
@@ -1,67 +0,0 @@
|
|||||||
# claude_pipe — artifact probe (playground)
|
|
||||||
|
|
||||||
Herramienta desechable para auditar la calidad del parseo de la TUI que hace
|
|
||||||
`claude_pipe`. No se indexa, no tiene `app.md`, vive dentro de `apps/claude_pipe/`
|
|
||||||
y se mueve con su sub-repo.
|
|
||||||
|
|
||||||
## Qué comprueba
|
|
||||||
|
|
||||||
Por cada prompt, lanza el binario `claude_pipe` en modo one-shot y en modo
|
|
||||||
`--stream`, y busca:
|
|
||||||
|
|
||||||
1. **Artefactos** que se cuelan del render en la respuesta parseada: caracteres de
|
|
||||||
caja (`╭│╰`), reglas horizontales (`────`), fragmentos de la status bar
|
|
||||||
(`CTX:`, `IN:`, `$…`, `← for agents`), la línea meta `✻ Crunched`, el prompt
|
|
||||||
`❯`, el carácter de reemplazo `�`, palabras pegadas (>40 chars sin espacio), o
|
|
||||||
el prompt repetido literalmente.
|
|
||||||
2. **Consistencia del streaming**: la concatenación de los `text_delta` debe
|
|
||||||
reconstruir el `result` final. Si no, la heurística de prefijo perdió o duplicó
|
|
||||||
texto bajo reflow.
|
|
||||||
3. **(Opcional, `--ref`)** discrepancia contra `claude -p` real para el mismo
|
|
||||||
prompt (normalizado por espacios). claude no es determinista, así que solo los
|
|
||||||
prompts triviales se espera que coincidan exactamente.
|
|
||||||
|
|
||||||
Sale con código 2 si encuentra artefactos o inconsistencias (para poder usarlo
|
|
||||||
como gate).
|
|
||||||
|
|
||||||
## Cómo lanzarlo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/claude_pipe
|
|
||||||
CGO_ENABLED=1 go build -tags fts5 -o claude_pipe . # asegúrate de tener el binario
|
|
||||||
|
|
||||||
cd playground
|
|
||||||
|
|
||||||
# Set de prompts por defecto, sin comparar con claude -p
|
|
||||||
go run artifact_probe.go --root /home/enmanuel/fn_registry
|
|
||||||
|
|
||||||
# Comparando además contra `claude -p` real (gasta llamadas reales)
|
|
||||||
go run artifact_probe.go --root /home/enmanuel/fn_registry --ref
|
|
||||||
|
|
||||||
# Un solo prompt custom
|
|
||||||
go run artifact_probe.go --root /home/enmanuel/fn_registry --prompt "tu prompt aqui"
|
|
||||||
```
|
|
||||||
|
|
||||||
`--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.
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
// artifact_probe drives the claude_pipe binary across a set of prompts and looks
|
|
||||||
// for two classes of problems that are inherent to parsing the claude TUI:
|
|
||||||
//
|
|
||||||
// 1. Artifacts: bits of the render that leaked into the parsed answer — box
|
|
||||||
// drawing characters from the banner, status-bar fragments (CTX:, IN:, $...),
|
|
||||||
// the "✻ Crunched" meta line, the echoed prompt, replacement characters, or
|
|
||||||
// glued words (a heuristic: very long runs with no spaces).
|
|
||||||
//
|
|
||||||
// 2. Streaming inconsistencies: in --stream mode, the concatenation of all
|
|
||||||
// text_delta events should reconstruct the final result. If it doesn't, the
|
|
||||||
// prefix-delta heuristic dropped or duplicated text under reflow.
|
|
||||||
//
|
|
||||||
// Optionally (--ref) it also runs the real `claude -p` for the same prompt and
|
|
||||||
// reports whether claude_pipe's answer matches it (whitespace-normalized). claude
|
|
||||||
// is not deterministic, so only trivial prompts are expected to match exactly;
|
|
||||||
// for open prompts the comparison is informational.
|
|
||||||
//
|
|
||||||
// This is a playground tool: it is not indexed, has no registry entry, and exists
|
|
||||||
// only to probe claude_pipe's TUI-parsing quality. Run it when you want to audit
|
|
||||||
// the parser against real claude output.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// go run artifact_probe.go --root /home/enmanuel/fn_registry # default prompts, no ref
|
|
||||||
// go run artifact_probe.go --root /home/enmanuel/fn_registry --ref # also compare vs claude -p
|
|
||||||
// go run artifact_probe.go --root /repo --prompt "tu prompt" # single custom prompt
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// defaultPrompts exercise different shapes: one word, a short list, a multi-line
|
|
||||||
// answer, and one that mentions code (markers that often trip up TUI parsing).
|
|
||||||
var defaultPrompts = []string{
|
|
||||||
"responde unicamente con la palabra PONG, sin explicaciones",
|
|
||||||
"lista exactamente tres frutas, una por linea, sin numeracion ni texto extra",
|
|
||||||
"explica en dos frases que es un pseudo-terminal (PTY)",
|
|
||||||
"escribe una linea de codigo Go que imprima hola, sin explicaciones",
|
|
||||||
}
|
|
||||||
|
|
||||||
// artifactPatterns are substrings/regexes that should NEVER appear in a clean
|
|
||||||
// parsed answer. Each is a piece of TUI chrome, not model output.
|
|
||||||
var artifactPatterns = []struct {
|
|
||||||
name string
|
|
||||||
re *regexp.Regexp
|
|
||||||
}{
|
|
||||||
{"box_drawing", regexp.MustCompile(`[╭╮╰╯┌┐└┘├┤┬┴┼│─]`)},
|
|
||||||
{"horizontal_rule", regexp.MustCompile(`─{8,}`)},
|
|
||||||
{"status_ctx", regexp.MustCompile(`CTX:\s*[\d█░]`)},
|
|
||||||
{"status_inout", regexp.MustCompile(`\bIN:\d|\bOUT:\d`)},
|
|
||||||
{"status_limits", regexp.MustCompile(`Limits:|Total:\s*↓|⎇\s`)},
|
|
||||||
{"status_cost", regexp.MustCompile(`\$\d+\.\d`)},
|
|
||||||
{"for_agents", regexp.MustCompile(`←\s*for agents`)},
|
|
||||||
// Spinner detected by structure (any glyph + word…) and by signature
|
|
||||||
// ("(Ns ... tokens", "esc to interrupt"), not by the ever-changing word.
|
|
||||||
{"meta_spinner", regexp.MustCompile(`[✻✽✢✶✺✷✦✳✱]|esc to interrupt|\(\d+s\b[^)]*tokens?\b`)},
|
|
||||||
{"prompt_marker", regexp.MustCompile(`❯`)},
|
|
||||||
{"replacement_char", regexp.MustCompile("�")},
|
|
||||||
}
|
|
||||||
|
|
||||||
// gluedWordRe flags a run of >40 non-space characters, the signature of stripped
|
|
||||||
// cursor moves collapsing columns together (e.g. "2newMCPservers").
|
|
||||||
var gluedWordRe = regexp.MustCompile(`\S{41,}`)
|
|
||||||
|
|
||||||
type streamEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Result string `json:"result"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type caseResult struct {
|
|
||||||
prompt string
|
|
||||||
oneshot string
|
|
||||||
streamDeltas []string
|
|
||||||
streamResult string
|
|
||||||
ref string
|
|
||||||
artifactsOne []string
|
|
||||||
artifactsStrm []string
|
|
||||||
streamConsistent bool
|
|
||||||
matchesRef string // "yes" | "no" | "n/a"
|
|
||||||
errs []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := flag.String("root", "/home/enmanuel/fn_registry", "cwd for claude (a repo whose MCP servers are approved)")
|
|
||||||
bin := flag.String("bin", "../claude_pipe", "path to the claude_pipe binary")
|
|
||||||
single := flag.String("prompt", "", "run a single custom prompt instead of the default set")
|
|
||||||
ref := flag.Bool("ref", false, "also run real `claude -p` and compare")
|
|
||||||
warmup := flag.String("warmup", "4s", "claude_pipe --warmup")
|
|
||||||
idle := flag.String("idle", "4s", "claude_pipe --idle")
|
|
||||||
maxDur := flag.String("max", "90s", "claude_pipe --max")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
prompts := defaultPrompts
|
|
||||||
if *single != "" {
|
|
||||||
prompts = []string{*single}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(*bin); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "claude_pipe binary not found at %s — build it first:\n (cd .. && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe .)\n", *bin)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var results []caseResult
|
|
||||||
for i, p := range prompts {
|
|
||||||
fmt.Fprintf(os.Stderr, "[%d/%d] probing: %s\n", i+1, len(prompts), truncate(p, 60))
|
|
||||||
results = append(results, probe(*bin, *root, p, *warmup, *idle, *maxDur, *ref))
|
|
||||||
}
|
|
||||||
|
|
||||||
report(results, *ref)
|
|
||||||
|
|
||||||
// Exit non-zero if any artifact was found, so this can gate CI if desired.
|
|
||||||
for _, r := range results {
|
|
||||||
if len(r.artifactsOne) > 0 || len(r.artifactsStrm) > 0 || !r.streamConsistent {
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func probe(bin, root, prompt, warmup, idle, maxDur string, withRef bool) caseResult {
|
|
||||||
r := caseResult{prompt: prompt, streamConsistent: true, matchesRef: "n/a"}
|
|
||||||
|
|
||||||
// One-shot, text format.
|
|
||||||
one, err := run(90*time.Second, bin,
|
|
||||||
"--format", "text", "--cwd", root,
|
|
||||||
"--warmup", warmup, "--idle", idle, "--max", maxDur, prompt)
|
|
||||||
if err != nil {
|
|
||||||
r.errs = append(r.errs, "oneshot: "+err.Error())
|
|
||||||
}
|
|
||||||
r.oneshot = strings.TrimRight(one, "\n")
|
|
||||||
r.artifactsOne = findArtifacts(r.oneshot, prompt)
|
|
||||||
|
|
||||||
// Streaming.
|
|
||||||
strm, err := run(90*time.Second, bin,
|
|
||||||
"--stream", "--cwd", root,
|
|
||||||
"--warmup", warmup, "--idle", idle, "--max", maxDur,
|
|
||||||
"--snapshot-interval", "150ms", prompt)
|
|
||||||
if err != nil {
|
|
||||||
r.errs = append(r.errs, "stream: "+err.Error())
|
|
||||||
}
|
|
||||||
r.streamDeltas, r.streamResult = parseStream(strm)
|
|
||||||
r.artifactsStrm = findArtifacts(r.streamResult, prompt)
|
|
||||||
// Consistency: concatenated deltas should reconstruct the final result.
|
|
||||||
recon := strings.Join(r.streamDeltas, "")
|
|
||||||
r.streamConsistent = normalize(recon) == normalize(r.streamResult)
|
|
||||||
|
|
||||||
if withRef {
|
|
||||||
refOut, err := run(90*time.Second, "claude", "-p", prompt)
|
|
||||||
if err != nil {
|
|
||||||
r.errs = append(r.errs, "ref: "+err.Error())
|
|
||||||
} else {
|
|
||||||
r.ref = strings.TrimRight(refOut, "\n")
|
|
||||||
if normalize(r.ref) == normalize(r.oneshot) {
|
|
||||||
r.matchesRef = "yes"
|
|
||||||
} else {
|
|
||||||
r.matchesRef = "no"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// run executes a command with a timeout and returns its stdout.
|
|
||||||
func run(timeout time.Duration, name string, args ...string) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStream splits the NDJSON stream into the ordered text_delta texts and the
|
|
||||||
// final result string.
|
|
||||||
func parseStream(s string) (deltas []string, result string) {
|
|
||||||
sc := bufio.NewScanner(strings.NewReader(s))
|
|
||||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
||||||
for sc.Scan() {
|
|
||||||
line := strings.TrimSpace(sc.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var ev streamEvent
|
|
||||||
if json.Unmarshal([]byte(line), &ev) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch ev.Type {
|
|
||||||
case "text_delta":
|
|
||||||
deltas = append(deltas, ev.Text)
|
|
||||||
case "result":
|
|
||||||
result = ev.Result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deltas, result
|
|
||||||
}
|
|
||||||
|
|
||||||
func findArtifacts(text, prompt string) []string {
|
|
||||||
var found []string
|
|
||||||
for _, ap := range artifactPatterns {
|
|
||||||
if ap.re.MatchString(text) {
|
|
||||||
found = append(found, ap.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if gluedWordRe.MatchString(text) {
|
|
||||||
found = append(found, "glued_words")
|
|
||||||
}
|
|
||||||
// Prompt echoed verbatim into the answer (claude shouldn't repeat the prompt).
|
|
||||||
if len(prompt) > 12 && strings.Contains(text, prompt) {
|
|
||||||
found = append(found, "prompt_echo")
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalize collapses all whitespace runs to single spaces and trims, so that
|
|
||||||
// layout-induced spacing differences don't count as content differences.
|
|
||||||
func normalize(s string) string {
|
|
||||||
return strings.Join(strings.Fields(s), " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n] + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
func report(results []caseResult, withRef bool) {
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("=== claude_pipe artifact probe ===")
|
|
||||||
for i, r := range results {
|
|
||||||
fmt.Printf("\n[%d] %s\n", i+1, truncate(r.prompt, 70))
|
|
||||||
fmt.Printf(" oneshot: %q\n", truncate(r.oneshot, 80))
|
|
||||||
fmt.Printf(" stream: %d deltas, result=%q\n", len(r.streamDeltas), truncate(r.streamResult, 60))
|
|
||||||
fmt.Printf(" consistent: %s\n", yesno(r.streamConsistent))
|
|
||||||
printArtifacts(" artifacts(oneshot):", r.artifactsOne)
|
|
||||||
printArtifacts(" artifacts(stream): ", r.artifactsStrm)
|
|
||||||
if withRef {
|
|
||||||
fmt.Printf(" matches claude -p: %s\n", r.matchesRef)
|
|
||||||
if r.matchesRef == "no" {
|
|
||||||
fmt.Printf(" ref: %q\n", truncate(r.ref, 80))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range r.errs {
|
|
||||||
fmt.Printf(" ERROR: %s\n", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary.
|
|
||||||
clean := 0
|
|
||||||
for _, r := range results {
|
|
||||||
if len(r.artifactsOne) == 0 && len(r.artifactsStrm) == 0 && r.streamConsistent {
|
|
||||||
clean++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n=== %d/%d cases clean (no artifacts, stream consistent) ===\n", clean, len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
func printArtifacts(label string, a []string) {
|
|
||||||
if len(a) == 0 {
|
|
||||||
fmt.Printf("%s none\n", label)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("%s %s\n", label, strings.Join(a, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
func yesno(b bool) string {
|
|
||||||
if b {
|
|
||||||
return "yes"
|
|
||||||
}
|
|
||||||
return "NO"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module cp_playground
|
|
||||||
|
|
||||||
go 1.25.0
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user