feat(playground): chat web sobre claude_session (caliente, con memoria + restart)
Backend Go (web/server.go) que lanza UN claude_session al boot y lo mantiene
vivo: por mensaje escribe {cmd:send} a su stdin y reenvia los eventos NDJSON
(text_delta + result) como SSE. Endpoint /restart -> {cmd:restart}. Frontend
vanilla (web/index.html) con boton Nueva conversacion.
A diferencia de los chats de pipe/wire (proceso nuevo por mensaje), este reusa
la sesion -> memoria entre turnos + ~2.7s/mensaje. Validado end-to-end:
recuerda un dato (42), lo recupera en el turno siguiente, y restart limpia la
memoria. Puerto 8101.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module cw_session_web
|
||||
|
||||
go 1.25.0
|
||||
@@ -0,0 +1,153 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>claude_session · chat caliente</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #14100a; --panel: #1a150d; --border: #2c2516;
|
||||
--user: #3a2c10; --assist: #1c170f; --text: #efe9df;
|
||||
--muted: #9a8c70; --accent: #f59e0b;
|
||||
}
|
||||
* { 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; }
|
||||
header button.restart {
|
||||
background: transparent; color: var(--muted); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 4px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
header button.restart:hover { color: var(--text); border-color: var(--accent); }
|
||||
#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 #5a4316; }
|
||||
.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; }
|
||||
.msg.sysline { align-self: center; background: transparent; color: var(--muted); font-size: 12px; padding: 2px; }
|
||||
@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.send {
|
||||
background: var(--accent); color: #1a1206; border: none; border-radius: 10px;
|
||||
padding: 0 18px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
button.send: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_session · chat caliente</h1>
|
||||
<button class="restart" id="restart" title="Empezar una conversación nueva">↻ Nueva conversación</button>
|
||||
<span class="sub">sesión viva · con memoria · ~2.7s/msg</span>
|
||||
</header>
|
||||
|
||||
<div id="log">
|
||||
<div class="msg assistant">
|
||||
<div class="role">sistema</div>Sesión claude caliente y persistente. Mantiene contexto entre mensajes (pruébalo: pregunta algo y luego refiérete a ello). Cada respuesta llega del SSE de la red, exacta, en ~2-3s. "Nueva conversación" reinicia la sesión sin memoria.
|
||||
</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 class="send" id="send" type="submit">Enviar</button>
|
||||
</form>
|
||||
<div class="hint">Un único daemon claude_session vivo detrás. Mensajes secuenciales. La memoria persiste hasta que reinicies.</div>
|
||||
|
||||
<script>
|
||||
const log = document.getElementById('log');
|
||||
const form = document.getElementById('form');
|
||||
const promptEl = document.getElementById('prompt');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const restartBtn = document.getElementById('restart');
|
||||
let busy = false;
|
||||
|
||||
function addMsg(role, text, cls) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + (cls || role);
|
||||
if (cls !== 'sysline') {
|
||||
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(); }
|
||||
});
|
||||
restartBtn.addEventListener('click', restart);
|
||||
|
||||
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); } }
|
||||
else if (obj.type === 'error') { setBody(bubble, '⚠ ' + (obj.message || 'error')); }
|
||||
};
|
||||
es.addEventListener('done', () => { bubble.classList.remove('pending'); if (!acc) setBody(bubble, '(vacío)'); es.close(); setBusy(false); });
|
||||
es.addEventListener('error', () => { bubble.classList.remove('pending'); es.close(); setBusy(false); });
|
||||
es.onerror = () => { if (busy) { bubble.classList.remove('pending'); es.close(); setBusy(false); } };
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
addMsg('', '↻ reiniciando sesión…', 'sysline');
|
||||
try {
|
||||
await fetch('/restart', { method: 'POST' });
|
||||
log.innerHTML = '';
|
||||
addMsg('', '— conversación nueva (sin memoria) —', 'sysline');
|
||||
} catch (e) {
|
||||
addMsg('error', 'fallo al reiniciar', 'err');
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function setBusy(b) {
|
||||
busy = b;
|
||||
sendBtn.disabled = b; restartBtn.disabled = b;
|
||||
sendBtn.textContent = b ? '…' : 'Enviar';
|
||||
if (!b) promptEl.focus();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
// Command server is a browser chat over a single hot claude_session daemon. Unlike
|
||||
// the claude_pipe/claude_wire chats (which spawn a fresh one-shot process per
|
||||
// message), this server launches ONE claude_session at boot and keeps it alive, so
|
||||
// the conversation has memory across turns and each message answers in ~2-3s.
|
||||
//
|
||||
// Per message it writes {"cmd":"send","prompt":...} to the daemon's stdin and
|
||||
// relays the daemon's NDJSON (text_delta + result) to the browser as SSE. A
|
||||
// /restart endpoint sends {"cmd":"restart"} to start a fresh conversation.
|
||||
//
|
||||
// The daemon's stdout is read only while holding a mutex, so requests are
|
||||
// serialized (a chat is sequential anyway).
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// cd apps/claude_session && go build -o claude_session .
|
||||
// cd playground && go run ./web # http://localhost:8101
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML []byte
|
||||
|
||||
type daemonEvent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Result string `json:"result"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
stdin io.Writer
|
||||
stdout *bufio.Scanner
|
||||
session *exec.Cmd
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "8101", "port to listen on")
|
||||
bin := flag.String("session", "/home/enmanuel/fn_registry/apps/claude_session/claude_session", "claude_session binary")
|
||||
cwd := flag.String("cwd", "/home/enmanuel/fn_registry", "cwd for the claude session")
|
||||
flag.Parse()
|
||||
|
||||
abs, err := filepath.Abs(*bin)
|
||||
if err != nil || !fileExists(abs) {
|
||||
log.Fatalf("claude_session binary not found at %s — build it first (cd .. && go build -o claude_session .)", abs)
|
||||
}
|
||||
|
||||
if err := startDaemon(abs, *cwd); err != nil {
|
||||
log.Fatalf("start daemon: %v", err)
|
||||
}
|
||||
|
||||
http.HandleFunc("/", handleIndex)
|
||||
http.HandleFunc("/chat", handleChat)
|
||||
http.HandleFunc("/restart", handleRestart)
|
||||
|
||||
log.Printf("claude_session web chat → http://localhost:%s (daemon=%s cwd=%s)", *port, abs, *cwd)
|
||||
log.Fatal(http.ListenAndServe(":"+*port, nil))
|
||||
}
|
||||
|
||||
// startDaemon launches claude_session and blocks until its initial {"type":"ready"}.
|
||||
func startDaemon(bin, cwd string) error {
|
||||
cmd := exec.Command(bin, "--cwd", cwd)
|
||||
cmd.Stderr = os.Stderr
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
sc := bufio.NewScanner(out)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
stdin, stdout, session = in, sc, cmd
|
||||
|
||||
log.Printf("waiting for claude session to warm up...")
|
||||
for sc.Scan() {
|
||||
var ev daemonEvent
|
||||
if json.Unmarshal(sc.Bytes(), &ev) == nil && ev.Type == "ready" {
|
||||
log.Printf("claude session ready")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("daemon exited before ready")
|
||||
}
|
||||
|
||||
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 sends one prompt to the live daemon and relays its events 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("X-Accel-Buffering", "no")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
cmd, _ := json.Marshal(map[string]string{"cmd": "send", "prompt": prompt})
|
||||
if _, err := fmt.Fprintln(stdin, string(cmd)); err != nil {
|
||||
sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Relay daemon events until result, then consume the trailing ready.
|
||||
gotResult := false
|
||||
for stdout.Scan() {
|
||||
line := stdout.Bytes()
|
||||
var ev daemonEvent
|
||||
if json.Unmarshal(line, &ev) != nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Type {
|
||||
case "text_delta", "result", "error":
|
||||
sse(w, flusher, "", string(line))
|
||||
if ev.Type == "result" || ev.Type == "error" {
|
||||
gotResult = true
|
||||
}
|
||||
case "ready":
|
||||
if gotResult {
|
||||
sse(w, flusher, "done", "{}")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
sse(w, flusher, "error", `{"message":"daemon stream ended"}`)
|
||||
}
|
||||
|
||||
// handleRestart tells the daemon to start a fresh conversation.
|
||||
func handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if _, err := fmt.Fprintln(stdin, `{"cmd":"restart"}`); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for stdout.Scan() {
|
||||
var ev daemonEvent
|
||||
if json.Unmarshal(stdout.Bytes(), &ev) == nil && ev.Type == "ready" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
}
|
||||
http.Error(w, "restart timeout", http.StatusGatewayTimeout)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user