feat(chat): MCP server + WebSocket streaming, replace XML actions

- Backend: kanban binary gana subcomando `kanban mcp` que actua como MCP
  server via stdio. Tools = mismo set que executeTool (14). El subprocess
  llama de vuelta al backend via /api/tool/{name} con token interno.
- Backend: nuevo endpoint POST /api/tool/{name} (auth: X-Internal-Token).
- Backend: chat.go refactor — POST /api/chat reemplazado por GET
  /api/chat/ws (WebSocket). Lanza claude -p con --output-format stream-json
  --verbose --mcp-config y reenvia eventos (delta/tool_use/tool_result/
  result/done/error) como mensajes JSON al cliente.
- Backend: usa funciones nuevas del registry claude_stream_go_core (spawn
  + parser NDJSON) y mcp_server_stdio_go_infra (JSON-RPC stdio).
- Frontend: streamChat sobre WebSocket. ChatPanel renderiza deltas en
  vivo, chips para tool_use, badges teal/red para tool_result.
- Borrado: extractActions, actionsBlockMarker, XML system prompt.
- Tests: 7 nuevos en backend (chat_ws_test.go + endpoint /api/tool).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 14:54:38 +02:00
parent 9e333b0e3e
commit ce49fdf9ff
14 changed files with 2175 additions and 1493 deletions
+3
View File
@@ -35,6 +35,9 @@ uses_functions:
- color_border_ts_ui
- color_swatch_ts_ui
- fetch_json_ts_infra
- claude_stream_go_core
- mcp_server_stdio_go_infra
- ws_upgrader_go_infra
uses_types:
- DurationStats_go_datascience
framework: "net/http + vite + react + mantine + dnd-kit"
+184 -271
View File
@@ -1,64 +1,40 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/core"
"fn-registry/functions/infra"
"nhooyr.io/websocket"
)
const chatSystemPrompt = `Eres el asistente del tablero kanban. Tu trabajo es responder al usuario y, cuando pida cambios, modificar el tablero llamando a tools.
const chatSystemPrompt = `Eres el asistente del tablero kanban. Responde al usuario y, cuando pida cambios, modifica el tablero llamando a tools nativas (MCP).
Cuando necesites modificar el tablero, responde EXCLUSIVAMENTE con un bloque <actions>...</actions> que contenga JSON valido (un array de acciones). Sin texto antes ni despues.
Tools disponibles via MCP server "kanban":
- list_board / find_cards / card_history / list_users — lectura
- create_column / update_column / delete_column / reorder_columns — columnas
- create_card / update_card / delete_card / move_card / assign_card — tarjetas
Ejemplo:
<actions>
[
{"tool": "create_card", "input": {"column_id": "abc123", "requester": "Lucas", "title": "Revisar PR", "description": ""}},
{"tool": "rename_column", "input": {"id": "def456", "name": "En curso"}}
]
</actions>
Llama directamente a las tools cuando necesites mutar el tablero. Usa list_board al principio si necesitas resolver nombres a IDs. NUNCA inventes IDs.
Tools disponibles (todas con sus inputs):
- list_board {} -> {columns, cards}
- create_column {name}
- update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas).
- delete_column {id}
- reorder_columns {ids:[...]}
- create_card {column_id, requester?, title, description?}
- update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar.
- delete_card {id}
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
- card_history {id}
- find_cards {query?, column_id?, requester?}
- list_users {} -> [{id, username, display_name}]
- assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar.
Cuando termines, responde texto natural en markdown (sin llamadas extra) — eso señala el fin de la conversacion.`
Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
Para resolver IDs a partir de nombres, mira el board_state que viene al final del prompt del usuario. NO inventes IDs.
LOOP ITERATIVO: Despues de aplicar tus acciones, el sistema te volvera a llamar con:
- Los resultados de las tool calls anteriores (incluyendo IDs reales de columnas/tarjetas creadas).
- El board_state actualizado.
- Tu mensaje de usuario original.
Cuando recibas resultados de iteraciones anteriores, USA LOS IDs REALES devueltos en lugar de inventar placeholders. Continua emitiendo mas <actions> hasta completar la tarea.
Cuando hayas terminado COMPLETAMENTE la tarea, responde texto natural (markdown) SIN bloque <actions> — eso señala el fin del loop.`
const claudeBin = "claude"
const claudeModel = "claude-sonnet-4-6"
const claudeTimeout = 120 * time.Second
const maxChatIterations = 8
const claudeTimeout = 300 * time.Second
func claudeBinary() string {
if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" {
return b
}
return "claude"
}
type chatMessage struct {
Role string `json:"role"`
@@ -69,83 +45,163 @@ type chatRequest struct {
Messages []chatMessage `json:"messages"`
}
type chatResponse struct {
Role string `json:"role"`
Content string `json:"content"`
BoardChanged bool `json:"board_changed"`
ToolCalls []toolCallInfo `json:"tool_calls,omitempty"`
// wsEvent is the envelope sent to the browser. Type discriminates the payload.
type wsEvent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Tool string `json:"tool,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
BoardChanged bool `json:"board_changed,omitempty"`
Error string `json:"error,omitempty"`
}
type toolCallInfo struct {
Tool string `json:"tool"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Iteration int `json:"iteration,omitempty"`
// Result is included only for the loop's internal feedback to claude;
// it is omitted from the JSON response sent to the frontend (clients
// can use board_changed + reload to fetch fresh state).
Result any `json:"-"`
}
type claudeJSONResult struct {
Type string `json:"type"`
IsError bool `json:"is_error"`
Result string `json:"result"`
StopReason string `json:"stop_reason"`
}
// runClaude invokes the `claude` CLI in print mode with the given system prompt
// and user message. The board JSON is appended to the user message under a
// `board_state` marker so the assistant can resolve names to IDs.
// handleChatWS upgrades the request to WebSocket and streams claude events.
//
// stdin: the user-facing prompt (history flattened).
// returns: assistant's text reply.
func runClaude(ctx context.Context, systemPrompt, userInput, boardJSON, workdir string) (string, error) {
if _, err := exec.LookPath(claudeBin); err != nil {
return "", errors.New("claude CLI not found in PATH")
// Wire protocol:
// client → server (one message): { "messages": [{role, content}, ...] }
// server → client (many): wsEvent ndjson-style messages
// types: "delta" (assistant text), "tool_use", "tool_result", "result", "error"
// server closes connection at end.
func handleChatWS(db *DB, workdir string, logger *ChatLogger, internalToken string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := infra.WSUpgrader(w, r, []string{"*"})
if err != nil {
return
}
defer conn.Close(websocket.StatusInternalError, "internal")
ctx, cancel := context.WithTimeout(r.Context(), claudeTimeout)
defer cancel()
// Read the initial chat request.
_, raw, err := conn.Read(ctx)
if err != nil {
return
}
var req chatRequest
if err := json.Unmarshal(raw, &req); err != nil {
sendWS(ctx, conn, wsEvent{Type: "error", Error: "invalid chat request: " + err.Error()})
return
}
if len(req.Messages) == 0 {
sendWS(ctx, conn, wsEvent{Type: "error", Error: "messages required"})
return
}
boardChanged, err := streamChat(ctx, conn, db, workdir, internalToken, req.Messages, logger)
if err != nil {
sendWS(ctx, conn, wsEvent{Type: "error", Error: err.Error()})
return
}
sendWS(ctx, conn, wsEvent{Type: "done", BoardChanged: boardChanged})
conn.Close(websocket.StatusNormalClosure, "")
}
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, claudeBin,
"-p",
"--model", claudeModel,
"--output-format", "json",
"--no-session-persistence",
"--tools", "",
"--system-prompt", systemPrompt,
)
cmd.Dir = workdir
prompt := userInput
if boardJSON != "" {
prompt += "\n\n<board_state>\n" + boardJSON + "\n</board_state>\n"
}
cmd.Stdin = bytes.NewBufferString(prompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("claude exec: %w (stderr: %s)", err, stderr.String())
}
var res claudeJSONResult
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
return "", fmt.Errorf("parse claude json: %w (raw: %s)", err, stdout.String())
}
if res.IsError {
return "", fmt.Errorf("claude error: %s", res.Result)
}
return res.Result, nil
}
// flattenMessages converts a chat history into a single text prompt for `claude -p`.
// Format: lines of `Usuario: ...` / `Asistente: ...`. Last user message ends the prompt.
func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, token string, msgs []chatMessage, logger *ChatLogger) (bool, error) {
binPath, err := os.Executable()
if err != nil {
return false, fmt.Errorf("locate kanban binary: %w", err)
}
// Backend URL: trust X-Forwarded or fall back to localhost (kanban listens
// on its main port). The MCP subprocess hits the loopback interface.
backendURL := os.Getenv("KANBAN_PUBLIC_URL")
if backendURL == "" {
port := os.Getenv("KANBAN_LISTEN_PORT")
if port == "" {
port = "8095"
}
backendURL = "http://127.0.0.1:" + port
}
mcpPath, err := writeMCPConfig(binPath, backendURL, token)
if err != nil {
return false, fmt.Errorf("write mcp config: %w", err)
}
defer os.Remove(mcpPath)
prompt := flattenMessages(msgs)
stdin := strings.NewReader(prompt)
events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{
Bin: claudeBinary(),
Args: []string{
"--model", claudeModel,
"--mcp-config", mcpPath,
"--system-prompt", chatSystemPrompt,
"--allowedTools",
"mcp__kanban__list_board,mcp__kanban__create_column,mcp__kanban__update_column,mcp__kanban__rename_column,mcp__kanban__delete_column,mcp__kanban__reorder_columns,mcp__kanban__create_card,mcp__kanban__update_card,mcp__kanban__delete_card,mcp__kanban__move_card,mcp__kanban__card_history,mcp__kanban__find_cards,mcp__kanban__list_users,mcp__kanban__assign_card",
},
Stdin: stdin,
Workdir: workdir,
})
if err != nil {
return false, fmt.Errorf("spawn claude: %w", err)
}
boardChanged := false
for ev := range events {
switch ev.Type {
case core.ClaudeEventTextDelta:
sendWS(ctx, conn, wsEvent{Type: "delta", Text: ev.Text})
case core.ClaudeEventToolUse:
toolName := stripMCPPrefix(ev.ToolName)
sendWS(ctx, conn, wsEvent{
Type: "tool_use",
ToolID: ev.ToolUseID,
Tool: toolName,
Input: ev.ToolInput,
})
if toolMutates(toolName) {
boardChanged = true
}
case core.ClaudeEventToolResult:
sendWS(ctx, conn, wsEvent{
Type: "tool_result",
ToolID: ev.ToolResultID,
Result: ev.ToolResultContent,
IsError: ev.ToolResultIsError,
})
case core.ClaudeEventResult:
sendWS(ctx, conn, wsEvent{
Type: "result",
Text: ev.Result,
IsError: ev.IsError,
})
case core.ClaudeEventError:
sendWS(ctx, conn, wsEvent{Type: "error", Error: ev.Error})
}
}
return boardChanged, nil
}
// stripMCPPrefix removes the "mcp__<server>__" prefix added by claude when
// tools come from an MCP server, leaving the bare tool name.
func stripMCPPrefix(name string) string {
const pre = "mcp__kanban__"
if strings.HasPrefix(name, pre) {
return name[len(pre):]
}
return name
}
func sendWS(ctx context.Context, conn *websocket.Conn, ev wsEvent) {
b, err := json.Marshal(ev)
if err != nil {
return
}
wctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_ = conn.Write(wctx, websocket.MessageText, b)
}
// flattenMessages converts chat history into a single prompt for `claude -p`.
func flattenMessages(msgs []chatMessage) string {
var b bytes.Buffer
var b strings.Builder
for _, m := range msgs {
role := "Usuario"
if m.Role == "assistant" {
@@ -159,165 +215,7 @@ func flattenMessages(msgs []chatMessage) string {
return b.String()
}
func handleChat(db *DB, workdir string, logger *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := infra.HTTPParseBody(r, &req, 1<<20); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
return
}
if len(req.Messages) == 0 {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "messages required"})
return
}
baseUserInput := flattenMessages(req.Messages)
allCalls := []toolCallInfo{}
var finalText string
boardChanged := false
for iter := 1; iter <= maxChatIterations; iter++ {
boardJSON, err := boardSnapshot(db)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "internal", Message: err.Error()})
return
}
prompt := buildIterationPrompt(baseUserInput, allCalls, iter)
assistantText, err := runClaude(r.Context(), chatSystemPrompt, prompt, boardJSON, workdir)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "claude_error", Message: err.Error()})
return
}
actionsJSON, stripped, found := extractActions(assistantText)
if !found {
finalText = assistantText
break
}
calls, changed := applyActions(db, actionsJSON, logger)
for i := range calls {
calls[i].Iteration = iter
}
allCalls = append(allCalls, calls...)
if changed {
boardChanged = true
}
finalText = stripped // tentative; overwritten if next iter responds free text
if iter == maxChatIterations {
finalText = strings.TrimSpace(stripped + "\n\n_Limite de iteraciones alcanzado._")
break
}
}
// Strip Result fields before serializing (not exported but defensive).
respCalls := make([]toolCallInfo, len(allCalls))
for i, c := range allCalls {
respCalls[i] = toolCallInfo{Tool: c.Tool, OK: c.OK, Error: c.Error, Iteration: c.Iteration}
}
resp := chatResponse{
Role: "assistant",
Content: finalText,
ToolCalls: respCalls,
BoardChanged: boardChanged,
}
if resp.Content == "" {
resp.Content = summarizeCalls(respCalls)
}
infra.HTTPJSONResponse(w, http.StatusOK, resp)
}
}
// buildIterationPrompt composes the user prompt for iteration N.
// Iteration 1 = original user input; later iterations also include a summary
// of previous tool calls so the assistant can use real IDs.
func buildIterationPrompt(baseUserInput string, prevCalls []toolCallInfo, iter int) string {
if iter == 1 || len(prevCalls) == 0 {
return baseUserInput
}
var b bytes.Buffer
b.WriteString(baseUserInput)
b.WriteString("\n[Resultados de iteraciones anteriores]\n")
for _, c := range prevCalls {
if c.OK {
summary := summarizeResult(c.Result)
fmt.Fprintf(&b, "- iter %d %s: ok %s\n", c.Iteration, c.Tool, summary)
} else {
fmt.Fprintf(&b, "- iter %d %s: ERROR %s\n", c.Iteration, c.Tool, c.Error)
}
}
fmt.Fprintf(&b, "\n[Iteracion %d] Continua con las acciones pendientes. Si terminaste, responde texto natural sin <actions>.\n", iter)
return b.String()
}
func boardSnapshot(db *DB) (string, error) {
cols, err := db.ListColumns()
if err != nil {
return "", err
}
cards, err := db.ListCardsWithTime()
if err != nil {
return "", err
}
b, err := json.MarshalIndent(map[string]any{"columns": cols, "cards": cards}, "", " ")
if err != nil {
return "", err
}
return string(b), nil
}
func applyActions(db *DB, actionsJSON string, logger *ChatLogger) ([]toolCallInfo, bool) {
var actions []struct {
Tool string `json:"tool"`
Input json.RawMessage `json:"input"`
}
if err := json.Unmarshal([]byte(actionsJSON), &actions); err != nil {
return []toolCallInfo{{Tool: "<parse>", OK: false, Error: err.Error()}}, false
}
results := make([]toolCallInfo, 0, len(actions))
changed := false
for _, a := range actions {
if err := validateToolName(a.Tool); err != nil {
info := toolCallInfo{Tool: a.Tool, OK: false, Error: err.Error()}
results = append(results, info)
logger.Log(a.Tool, a.Input, ToolResult{OK: false, Error: err.Error()})
continue
}
res := executeTool(db, a.Tool, a.Input)
logger.Log(a.Tool, a.Input, res)
info := toolCallInfo{Tool: a.Tool, OK: res.OK, Result: res.Result}
if !res.OK {
info.Error = res.Error
} else if toolMutates(a.Tool) {
changed = true
}
results = append(results, info)
}
return results, changed
}
func summarizeCalls(calls []toolCallInfo) string {
if len(calls) == 0 {
return ""
}
var b bytes.Buffer
b.WriteString("Acciones aplicadas:\n")
for _, c := range calls {
if c.OK {
fmt.Fprintf(&b, "- %s: ok\n", c.Tool)
} else {
fmt.Fprintf(&b, "- %s: error (%s)\n", c.Tool, c.Error)
}
}
return b.String()
}
// chatWorkdir resolves an absolute working directory for `claude -p` (avoids
// inheriting CLAUDE.md from parent directories with unrelated context).
// chatWorkdir resolves an absolute working directory for `claude -p`.
func chatWorkdir(dbPath string) string {
abs, err := filepath.Abs(dbPath)
if err != nil {
@@ -325,3 +223,18 @@ func chatWorkdir(dbPath string) string {
}
return filepath.Dir(abs)
}
// --- Legacy handleChat retained as a thin shim that returns 410 Gone. -------
// Kept so existing clients see a clear error instead of a 404 while they
// migrate to the WebSocket endpoint.
func handleChat(_ *DB, _ string, _ *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusGone,
Code: "deprecated",
Message: "POST /api/chat removed; use WebSocket at /api/chat/ws",
})
}
}
+296
View File
@@ -0,0 +1,296 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"nhooyr.io/websocket"
)
// fakeClaudeScript writes a bash script that emits NDJSON stream-json events
// to stdout and exits 0. Returns the absolute path of the script.
func fakeClaudeScript(t *testing.T, payload string) string {
t.Helper()
if _, err := os.Stat("/bin/bash"); err != nil {
t.Skip("/bin/bash not available")
}
dir := t.TempDir()
path := filepath.Join(dir, "claude")
body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n"
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write fake claude: %v", err)
}
return path
}
// chatWSTestServer wires the WebSocket chat handler in front of a test DB.
func chatWSTestServer(t *testing.T) (*httptest.Server, *DB, string) {
t.Helper()
db := setupTestDB(t)
dir := t.TempDir()
logger := newChatLogger(filepath.Join(dir, "chat.log"))
token := generateInternalToken()
srv := httptest.NewServer(handleChatWS(db, dir, logger, token))
t.Cleanup(srv.Close)
return srv, db, token
}
func dialChatWS(t *testing.T, srv *httptest.Server) *websocket.Conn {
t.Helper()
u, _ := url.Parse(srv.URL)
wsURL := "ws://" + u.Host
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("dial %s: %v", wsURL, err)
}
return c
}
func readWSEvent(t *testing.T, conn *websocket.Conn) wsEvent {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
var ev wsEvent
if err := json.Unmarshal(data, &ev); err != nil {
t.Fatalf("unmarshal %q: %v", string(data), err)
}
return ev
}
func sendInitial(t *testing.T, conn *websocket.Conn, msgs []chatMessage) {
t.Helper()
body, _ := json.Marshal(chatRequest{Messages: msgs})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.Write(ctx, websocket.MessageText, body); err != nil {
t.Fatalf("write: %v", err)
}
}
// --- WS streaming tests ---------------------------------------------------
func TestChatWS_StreamsTextDelta(t *testing.T) {
payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}`
t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload))
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{{Role: "user", Content: "saluda"}})
var deltas []string
var sawResult, sawDone bool
for i := 0; i < 12 && !sawDone; i++ {
ev := readWSEvent(t, conn)
switch ev.Type {
case "delta":
deltas = append(deltas, ev.Text)
case "result":
sawResult = true
case "done":
sawDone = true
case "error":
t.Fatalf("unexpected error event: %s", ev.Error)
}
}
if !sawDone {
t.Fatalf("never received done event")
}
if !sawResult {
t.Fatalf("never received result event")
}
if got := strings.Join(deltas, ""); got != "Hola mundo" {
t.Fatalf("expected 'Hola mundo' from deltas, got %q", got)
}
}
func TestChatWS_StreamsToolUseAndResult(t *testing.T) {
payload := `{"type":"system","subtype":"init"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}`
t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload))
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{{Role: "user", Content: "crea Backlog"}})
var sawToolUse, sawToolResult, sawDelta, sawDone bool
var doneEv wsEvent
for i := 0; i < 16 && !sawDone; i++ {
ev := readWSEvent(t, conn)
switch ev.Type {
case "tool_use":
sawToolUse = true
if ev.Tool != "create_column" {
t.Errorf("tool name not stripped: %q", ev.Tool)
}
if !strings.Contains(string(ev.Input), "Backlog") {
t.Errorf("input missing Backlog: %s", ev.Input)
}
case "tool_result":
sawToolResult = true
if ev.IsError {
t.Errorf("tool_result is_error true")
}
case "delta":
sawDelta = true
case "done":
sawDone = true
doneEv = ev
case "error":
t.Fatalf("unexpected error: %s", ev.Error)
}
}
if !sawToolUse || !sawToolResult || !sawDelta || !sawDone {
t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v",
sawToolUse, sawToolResult, sawDelta, sawDone)
}
if !doneEv.BoardChanged {
t.Errorf("expected board_changed=true (create_column is a mutator)")
}
}
func TestChatWS_RejectsEmptyMessages(t *testing.T) {
t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t,
`{"type":"result","subtype":"success","is_error":false,"result":""}`))
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{})
ev := readWSEvent(t, conn)
if ev.Type != "error" {
t.Fatalf("expected error event, got %+v", ev)
}
if !strings.Contains(ev.Error, "messages required") {
t.Fatalf("unexpected error: %s", ev.Error)
}
}
func TestChatWS_PropagatesClaudeFailure(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "claude")
body := "#!/bin/bash\necho 'broken' >&2\nexit 7\n"
if err := os.WriteFile(bin, []byte(body), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
t.Setenv("KANBAN_CLAUDE_BIN", bin)
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{{Role: "user", Content: "hola"}})
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
ev := readWSEvent(t, conn)
switch ev.Type {
case "error":
if !strings.Contains(ev.Error, "claude exit") {
t.Fatalf("expected claude exit error, got: %s", ev.Error)
}
return
case "done":
t.Fatalf("done received before error")
}
}
t.Fatalf("never received error event")
}
// --- /api/tool internal endpoint tests ------------------------------------
func internalToolServer(t *testing.T) (*httptest.Server, *DB, string) {
t.Helper()
db := setupTestDB(t)
logger := newChatLogger(filepath.Join(t.TempDir(), "log"))
token := generateInternalToken()
mux := http.NewServeMux()
mux.Handle("POST /api/tool/{name}", handleInternalTool(db, token, logger))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, db, token
}
func TestInternalTool_CreateColumnRoundtrip(t *testing.T) {
srv, db, token := internalToolServer(t)
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"Backlog"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, token)
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
var tr ToolResult
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
t.Fatalf("decode: %v", err)
}
if !tr.OK {
t.Fatalf("create_column failed: %s", tr.Error)
}
cols, err := db.ListColumns()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(cols) != 1 || cols[0].Name != "Backlog" {
t.Fatalf("expected 1 col Backlog, got %+v", cols)
}
}
func TestInternalTool_RejectsMissingToken(t *testing.T) {
srv, _, _ := internalToolServer(t)
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"X"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestInternalTool_UnknownTool(t *testing.T) {
srv, _, token := internalToolServer(t)
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/no_such", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, token)
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 404 {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-BKxzRoLi.js"></script>
<script type="module" crossorigin src="/assets/index-CPqSy0gZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
</head>
<body>
+3 -1
View File
@@ -330,7 +330,7 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string) []infra.Route {
return []infra.Route{
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
@@ -353,6 +353,8 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
{Method: "POST", Path: "/api/tool/{name}", Handler: handleInternalTool(db, internalToken, logger)},
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"fn-registry/functions/infra"
)
const internalTokenHeader = "X-Internal-Token"
// generateInternalToken returns a 32-byte hex token used by the kanban-mcp
// subprocess to call back into /api/tool/{name}. Generated fresh per process.
func generateInternalToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("rand.Read: " + err.Error())
}
return hex.EncodeToString(b)
}
// handleInternalTool exposes executeTool via HTTP for the MCP subprocess.
// Auth: shared internal token in X-Internal-Token header. Constant-time compare.
func handleInternalTool(db *DB, expectedToken string, logger *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get(internalTokenHeader)
if subtle.ConstantTimeCompare([]byte(got), []byte(expectedToken)) != 1 {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid internal token"})
return
}
name := r.PathValue("name")
if name == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: "tool name required"})
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: err.Error()})
return
}
if len(body) == 0 {
body = []byte("{}")
}
input := json.RawMessage(body)
if err := validateToolName(name); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "unknown_tool", Message: err.Error()})
return
}
res := executeTool(db, name, input)
if logger != nil {
logger.Log(name, input, res)
}
// Always 200 — MCP-side maps res.OK to MCP isError.
infra.HTTPJSONResponse(w, http.StatusOK, res)
}
}
+16 -2
View File
@@ -22,6 +22,15 @@ import (
var frontendDist embed.FS
func main() {
// Subcommand `kanban mcp` runs as MCP server over stdio (spawned by claude -p).
if len(os.Args) > 1 && os.Args[1] == "mcp" {
if err := runMCPServer(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban mcp: %v\n", err)
os.Exit(1)
}
return
}
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path")
@@ -37,10 +46,15 @@ func main() {
bootstrapAdmin(db, *initialAdmin)
startSessionCleanup(db)
internalToken := os.Getenv("KANBAN_INTERNAL_TOKEN")
if internalToken == "" {
internalToken = generateInternalToken()
}
wd := chatWorkdir(*dbPath)
logger := newChatLogger(filepath.Join(wd, "chat.log"))
log.Printf("chat tool log: %s", logger.path)
mux := infra.HTTPRouter(apiRoutes(db, wd, logger))
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken))
feHandler := frontendHandler()
if feHandler != nil {
@@ -53,7 +67,7 @@ func main() {
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db.conn,
CookieName: cookieName,
SkipPaths: []string{"/api/auth/", "/health", "/assets/", "/index.html"},
SkipPaths: []string{"/api/auth/", "/api/tool/", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})
+302
View File
@@ -0,0 +1,302 @@
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"fn-registry/functions/infra"
)
// runMCPServer is the entry point for the `kanban mcp` subcommand. It runs
// stdio JSON-RPC and forwards each tool call to the kanban backend's
// /api/tool/{name} endpoint, authenticated with a shared internal token.
//
// Required env vars (set by the parent kanban process when generating mcp.json):
// KANBAN_BACKEND_URL — e.g. http://127.0.0.1:8095
// KANBAN_INTERNAL_TOKEN — token to send in X-Internal-Token header
func runMCPServer(args []string) error {
fs := flag.NewFlagSet("kanban mcp", flag.ContinueOnError)
urlFlag := fs.String("url", os.Getenv("KANBAN_BACKEND_URL"), "kanban backend URL")
tokenFlag := fs.String("token", os.Getenv("KANBAN_INTERNAL_TOKEN"), "internal token")
if err := fs.Parse(args); err != nil {
return err
}
if *urlFlag == "" {
return fmt.Errorf("--url or KANBAN_BACKEND_URL required")
}
if *tokenFlag == "" {
return fmt.Errorf("--token or KANBAN_INTERNAL_TOKEN required")
}
httpClient := &http.Client{Timeout: 30 * time.Second}
tools := mcpToolDefs()
handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
body := []byte(input)
if len(body) == 0 {
body = []byte("{}")
}
req, err := http.NewRequestWithContext(ctx, "POST", *urlFlag+"/api/tool/"+name, bytes.NewReader(body))
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, *tokenFlag)
resp, err := httpClient.Do(req)
if err != nil {
return nil, false, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, err
}
if resp.StatusCode >= 500 {
return nil, false, fmt.Errorf("backend %d: %s", resp.StatusCode, string(buf))
}
// 4xx and 2xx both serialize as ToolResult JSON. Decode and map.
var tr ToolResult
if err := json.Unmarshal(buf, &tr); err != nil {
// Non-ToolResult body (e.g. unauthorized error envelope from infra).
return string(buf), resp.StatusCode >= 400, nil
}
if !tr.OK {
return tr.Error, true, nil
}
return tr.Result, false, nil
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
return infra.ServeMCP(ctx, infra.MCPServerOpts{
Name: "kanban",
Version: "1.0.0",
Tools: tools,
Handler: handler,
In: os.Stdin,
Out: os.Stdout,
Logger: os.Stderr,
})
}
// mcpToolDefs returns the JSON-Schema definitions for every kanban tool.
// Names match the executeTool dispatch table in tools.go.
func mcpToolDefs() []infra.MCPToolDef {
return []infra.MCPToolDef{
{
Name: "list_board",
Description: "Lista columnas y tarjetas del tablero. Sin argumentos. Devuelve {columns, cards}.",
InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}),
},
{
Name: "create_column",
Description: "Crea una columna nueva. Devuelve la columna creada con su id.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string", "description": "Nombre de la columna"},
},
"required": []string{"name"},
}),
},
{
Name: "update_column",
Description: "Modifica una columna existente. Pasa al menos uno: name, location ('board'|'sidebar'), width (200..800 px), wip_limit (0=sin limite), is_done (terminal: cards cuentan como completadas).",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"},
"location": map[string]any{"type": "string", "enum": []string{"board", "sidebar"}},
"width": map[string]any{"type": "integer"},
"wip_limit": map[string]any{"type": "integer"},
"is_done": map[string]any{"type": "boolean"},
},
"required": []string{"id"},
}),
},
{
Name: "rename_column",
Description: "Alias de update_column con solo {id, name}.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"},
},
"required": []string{"id", "name"},
}),
},
{
Name: "delete_column",
Description: "Elimina una columna y todas sus tarjetas (las envia a la papelera).",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
},
"required": []string{"id"},
}),
},
{
Name: "reorder_columns",
Description: "Reordena columnas. ids es el array completo de columnas en el nuevo orden.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
"required": []string{"ids"},
}),
},
{
Name: "create_card",
Description: "Crea una tarjeta en una columna. column_id y title obligatorios.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"column_id": map[string]any{"type": "string"},
"requester": map[string]any{"type": "string"},
"title": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
},
"required": []string{"column_id", "title"},
}),
},
{
Name: "update_card",
Description: "Edita campos de una tarjeta. Color: blue|teal|violet|pink|orange|green|yellow|red|''. locked bloquea movimiento. assignee_id null para desasignar.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"requester": map[string]any{"type": "string"},
"title": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
"color": map[string]any{"type": "string"},
"locked": map[string]any{"type": "boolean"},
"assignee_id": map[string]any{"type": []string{"string", "null"}},
},
"required": []string{"id"},
}),
},
{
Name: "delete_card",
Description: "Envia una tarjeta a la papelera.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
},
"required": []string{"id"},
}),
},
{
Name: "move_card",
Description: "Mueve una tarjeta a otra columna. Si omites ordered_ids, se anade al final.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"column_id": map[string]any{"type": "string"},
"ordered_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
"required": []string{"id", "column_id"},
}),
},
{
Name: "card_history",
Description: "Devuelve el historial de cambios de una tarjeta.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
},
"required": []string{"id"},
}),
},
{
Name: "find_cards",
Description: "Busca tarjetas. query (texto en title/description/requester), column_id (filtra por columna), requester (filtra por solicitante).",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string"},
"column_id": map[string]any{"type": "string"},
"requester": map[string]any{"type": "string"},
},
}),
},
{
Name: "list_users",
Description: "Lista usuarios disponibles para asignar tarjetas.",
InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}),
},
{
Name: "assign_card",
Description: "Asigna o desasigna una tarjeta. assignee_id null para desasignar.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"assignee_id": map[string]any{"type": []string{"string", "null"}},
},
"required": []string{"id"},
}),
},
}
}
func rawSchema(s map[string]any) json.RawMessage {
b, err := json.Marshal(s)
if err != nil {
panic(err)
}
return b
}
// writeMCPConfig writes a temporary mcp.json that points to this binary's
// `mcp` subcommand with the given URL and token. Returns the absolute path of
// the file created. Caller is responsible for removing it.
func writeMCPConfig(binPath, backendURL, token string) (string, error) {
cfg := map[string]any{
"mcpServers": map[string]any{
"kanban": map[string]any{
"command": binPath,
"args": []string{"mcp"},
"env": map[string]string{
"KANBAN_BACKEND_URL": backendURL,
"KANBAN_INTERNAL_TOKEN": token,
},
},
},
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
f, err := os.CreateTemp("", "kanban-mcp-*.json")
if err != nil {
return "", err
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
-21
View File
@@ -339,27 +339,6 @@ func toolFindCards(db *DB, input json.RawMessage) ToolResult {
return okResult(out)
}
// chatActionsRegex matches an <actions>...</actions> block (DOTALL mode).
// Used by chat.go to extract tool invocations from the assistant's response.
var actionsBlockMarker = struct{ Open, Close string }{Open: "<actions>", Close: "</actions>"}
func extractActions(text string) (jsonBlock string, stripped string, found bool) {
openIdx := strings.Index(text, actionsBlockMarker.Open)
if openIdx < 0 {
return "", text, false
}
closeIdx := strings.Index(text[openIdx:], actionsBlockMarker.Close)
if closeIdx < 0 {
return "", text, false
}
closeIdx += openIdx
jsonBlock = strings.TrimSpace(text[openIdx+len(actionsBlockMarker.Open) : closeIdx])
before := strings.TrimRight(text[:openIdx], " \n\t")
after := strings.TrimLeft(text[closeIdx+len(actionsBlockMarker.Close):], " \n\t")
stripped = strings.TrimSpace(before + "\n" + after)
return jsonBlock, stripped, true
}
// validateToolName fails fast with clearer error than the dispatch's default.
func validateToolName(name string) error {
known := map[string]bool{
-31
View File
@@ -340,37 +340,6 @@ func TestExecuteTool_Unknown(t *testing.T) {
mustErr(t, res, "unknown tool")
}
// --- extractActions ---
func TestExtractActions(t *testing.T) {
cases := []struct {
name string
in string
want string
stripOK string
found bool
}{
{"with block", "Hola\n<actions>[{\"tool\":\"x\"}]</actions>\nHecho", `[{"tool":"x"}]`, "Hola\nHecho", true},
{"only block", "<actions>[]</actions>", `[]`, "", true},
{"no block", "Solo texto", "", "Solo texto", false},
{"unclosed", "<actions>foo", "", "<actions>foo", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, stripped, found := extractActions(c.in)
if found != c.found {
t.Fatalf("found = %v want %v", found, c.found)
}
if got != c.want {
t.Fatalf("got %q want %q", got, c.want)
}
if stripped != c.stripOK {
t.Fatalf("stripped = %q want %q", stripped, c.stripOK)
}
})
}
}
// --- chat logger ---
func TestChatLogger_AppendsJSONLines(t *testing.T) {
+63 -7
View File
@@ -121,17 +121,73 @@ export interface ChatToolCall {
tool: string;
ok: boolean;
error?: string;
input?: unknown;
}
export interface ChatResponse {
role: "assistant";
content: string;
board_changed: boolean;
tool_calls?: ChatToolCall[];
// WebSocket streaming events emitted by /api/chat/ws.
export type ChatStreamEvent =
| { type: "delta"; text: string }
| { type: "tool_use"; tool_id: string; tool: string; input?: unknown }
| { type: "tool_result"; tool_id: string; result?: string; is_error?: boolean }
| { type: "result"; text?: string; is_error?: boolean }
| { type: "done"; board_changed?: boolean }
| { type: "error"; error: string };
// chatWSURL builds the absolute ws:// or wss:// URL of the streaming endpoint.
export function chatWSURL(): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/api/chat/ws`;
}
export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> {
return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) });
// streamChat opens a WebSocket, sends the message history, and streams events
// to onEvent. Returns a Promise that resolves when the server closes the
// connection (after a "done" event) and rejects on transport errors.
export function streamChat(
messages: ChatMessage[],
onEvent: (ev: ChatStreamEvent) => void,
signal?: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(chatWSURL());
let settled = false;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
/* ignore */
}
if (err) reject(err);
else resolve();
};
if (signal) {
const abort = () => finish(new Error("aborted"));
if (signal.aborted) {
abort();
return;
}
signal.addEventListener("abort", abort, { once: true });
}
ws.onopen = () => {
ws.send(JSON.stringify({ messages }));
};
ws.onmessage = (e) => {
try {
const ev = JSON.parse(typeof e.data === "string" ? e.data : "") as ChatStreamEvent;
onEvent(ev);
if (ev.type === "done" || ev.type === "error") {
finish(ev.type === "error" ? new Error(ev.error) : undefined);
}
} catch (err) {
finish(err as Error);
}
};
ws.onerror = () => finish(new Error("websocket error"));
ws.onclose = () => finish();
});
}
export function login(username: string, password: string): Promise<User> {
+96 -23
View File
@@ -16,7 +16,7 @@ import { IconMessageChatbot, IconSend, IconTrash } from "@tabler/icons-react";
import { KeyboardEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChatMessage, ChatToolCall, sendChat } from "../api";
import { ChatMessage, ChatStreamEvent, ChatToolCall, streamChat } from "../api";
const STORAGE_KEY = "kanban_chat_v1";
@@ -44,7 +44,11 @@ function loadStored(): StoredMessage[] {
export function ChatPanel({ onBoardChange }: Props) {
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
// Live in-flight assistant turn: incremental text + tool calls collected so
// far. When the turn finishes (done/error) it is committed to messages.
const [liveText, setLiveText] = useState("");
const [liveCalls, setLiveCalls] = useState<ChatToolCall[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -53,35 +57,89 @@ export function ChatPanel({ onBoardChange }: Props) {
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, loading]);
}, [messages, liveText, liveCalls, streaming]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
if (!text || streaming) return;
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
const next = [...messages, userMsg];
setMessages(next);
setInput("");
setLoading(true);
setStreaming(true);
setLiveText("");
setLiveCalls([]);
let accumulatedText = "";
const accumulatedCalls: ChatToolCall[] = [];
let boardChanged = false;
const onEvent = (ev: ChatStreamEvent) => {
switch (ev.type) {
case "delta":
accumulatedText += ev.text;
setLiveText(accumulatedText);
break;
case "tool_use": {
const call: ChatToolCall = { tool: ev.tool, ok: true, input: ev.input };
accumulatedCalls.push(call);
setLiveCalls([...accumulatedCalls]);
break;
}
case "tool_result": {
// Map by reverse order: the latest tool_use without is_error set.
for (let i = accumulatedCalls.length - 1; i >= 0; i--) {
const c = accumulatedCalls[i];
if (c.error === undefined && c.ok) {
if (ev.is_error) {
c.ok = false;
c.error = ev.result || "tool error";
}
break;
}
}
setLiveCalls([...accumulatedCalls]);
break;
}
case "result":
if (ev.text) {
// Final result text replaces the streamed delta only when no
// delta was emitted (some claude paths only emit the final).
if (accumulatedText.trim() === "") {
accumulatedText = ev.text;
setLiveText(accumulatedText);
}
}
break;
case "done":
if (ev.board_changed) boardChanged = true;
break;
case "error":
accumulatedText = `Error: ${ev.error}`;
setLiveText(accumulatedText);
break;
}
};
try {
const payload: ChatMessage[] = next.map((m) => ({ role: m.role, content: m.content }));
const res = await sendChat(payload);
await streamChat(payload, onEvent);
} catch (e) {
const msg = (e as Error).message;
notifications.show({ color: "red", message: msg });
accumulatedText = accumulatedText || `Error: ${msg}`;
} finally {
const assistant: StoredMessage = {
role: "assistant",
content: res.content,
content: accumulatedText,
ts: Date.now(),
tool_calls: res.tool_calls,
tool_calls: accumulatedCalls.length > 0 ? accumulatedCalls : undefined,
};
setMessages((prev) => [...prev, assistant]);
if (res.board_changed) onBoardChange();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${(e as Error).message}`, ts: Date.now() },
]);
} finally {
setLoading(false);
setLiveText("");
setLiveCalls([]);
setStreaming(false);
if (boardChanged) onBoardChange();
}
};
@@ -115,7 +173,7 @@ export function ChatPanel({ onBoardChange }: Props) {
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
<Stack gap="xs">
{messages.length === 0 && (
{messages.length === 0 && !streaming && (
<Text size="sm" c="dimmed" ta="center" mt="md">
Escribe algo. Ejemplos:
<br />- "crea columna Backlog"
@@ -126,7 +184,18 @@ export function ChatPanel({ onBoardChange }: Props) {
{messages.map((m, i) => (
<ChatBubble key={i} msg={m} />
))}
{loading && (
{streaming && (
<ChatBubble
msg={{
role: "assistant",
content: liveText,
ts: Date.now(),
tool_calls: liveCalls.length > 0 ? liveCalls : undefined,
}}
streaming
/>
)}
{streaming && liveText === "" && liveCalls.length === 0 && (
<Group gap={6} pl="xs">
<Loader size="xs" />
<Text size="xs" c="dimmed">
@@ -144,7 +213,7 @@ export function ChatPanel({ onBoardChange }: Props) {
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={onKey}
disabled={loading}
disabled={streaming}
autosize
minRows={1}
maxRows={6}
@@ -154,10 +223,10 @@ export function ChatPanel({ onBoardChange }: Props) {
size="lg"
variant="filled"
onClick={send}
disabled={!input.trim() || loading}
disabled={!input.trim() || streaming}
aria-label="Send"
>
{loading ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
{streaming ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
</ActionIcon>
</Group>
</Stack>
@@ -165,7 +234,7 @@ export function ChatPanel({ onBoardChange }: Props) {
);
}
function ChatBubble({ msg }: { msg: StoredMessage }) {
function ChatBubble({ msg, streaming = false }: { msg: StoredMessage; streaming?: boolean }) {
const isUser = msg.role === "user";
return (
<Paper
@@ -184,6 +253,9 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</Box>
)}
{streaming && msg.content && (
<Box style={{ display: "inline-block", width: 8, height: 14, background: "currentColor", opacity: 0.6 }} />
)}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<Group gap={4} wrap="wrap">
{msg.tool_calls.map((c, i) => (
@@ -193,6 +265,7 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
color={c.ok ? "teal" : "red"}
variant="light"
title={c.error || ""}
leftSection={c.ok && streaming ? <Loader size={8} color="teal" /> : null}
>
{c.tool}
{!c.ok && c.error ? `: ${c.error}` : ""}