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:
@@ -35,6 +35,9 @@ uses_functions:
|
|||||||
- color_border_ts_ui
|
- color_border_ts_ui
|
||||||
- color_swatch_ts_ui
|
- color_swatch_ts_ui
|
||||||
- fetch_json_ts_infra
|
- fetch_json_ts_infra
|
||||||
|
- claude_stream_go_core
|
||||||
|
- mcp_server_stdio_go_infra
|
||||||
|
- ws_upgrader_go_infra
|
||||||
uses_types:
|
uses_types:
|
||||||
- DurationStats_go_datascience
|
- DurationStats_go_datascience
|
||||||
framework: "net/http + vite + react + mantine + dnd-kit"
|
framework: "net/http + vite + react + mantine + dnd-kit"
|
||||||
|
|||||||
+184
-271
@@ -1,64 +1,40 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
"fn-registry/functions/infra"
|
"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:
|
Llama directamente a las tools cuando necesites mutar el tablero. Usa list_board al principio si necesitas resolver nombres a IDs. NUNCA inventes IDs.
|
||||||
<actions>
|
|
||||||
[
|
|
||||||
{"tool": "create_card", "input": {"column_id": "abc123", "requester": "Lucas", "title": "Revisar PR", "description": ""}},
|
|
||||||
{"tool": "rename_column", "input": {"id": "def456", "name": "En curso"}}
|
|
||||||
]
|
|
||||||
</actions>
|
|
||||||
|
|
||||||
Tools disponibles (todas con sus inputs):
|
Cuando termines, responde texto natural en markdown (sin llamadas extra) — eso señala el fin de la conversacion.`
|
||||||
- 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.
|
|
||||||
|
|
||||||
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 claudeModel = "claude-sonnet-4-6"
|
||||||
const claudeTimeout = 120 * time.Second
|
const claudeTimeout = 300 * time.Second
|
||||||
const maxChatIterations = 8
|
|
||||||
|
func claudeBinary() string {
|
||||||
|
if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return "claude"
|
||||||
|
}
|
||||||
|
|
||||||
type chatMessage struct {
|
type chatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
@@ -69,83 +45,163 @@ type chatRequest struct {
|
|||||||
Messages []chatMessage `json:"messages"`
|
Messages []chatMessage `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatResponse struct {
|
// wsEvent is the envelope sent to the browser. Type discriminates the payload.
|
||||||
Role string `json:"role"`
|
type wsEvent struct {
|
||||||
Content string `json:"content"`
|
Type string `json:"type"`
|
||||||
BoardChanged bool `json:"board_changed"`
|
Text string `json:"text,omitempty"`
|
||||||
ToolCalls []toolCallInfo `json:"tool_calls,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 {
|
// handleChatWS upgrades the request to WebSocket and streams claude events.
|
||||||
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.
|
|
||||||
//
|
//
|
||||||
// stdin: the user-facing prompt (history flattened).
|
// Wire protocol:
|
||||||
// returns: assistant's text reply.
|
// client → server (one message): { "messages": [{role, content}, ...] }
|
||||||
func runClaude(ctx context.Context, systemPrompt, userInput, boardJSON, workdir string) (string, error) {
|
// server → client (many): wsEvent ndjson-style messages
|
||||||
if _, err := exec.LookPath(claudeBin); err != nil {
|
// types: "delta" (assistant text), "tool_use", "tool_result", "result", "error"
|
||||||
return "", errors.New("claude CLI not found in PATH")
|
// 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`.
|
func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, token string, msgs []chatMessage, logger *ChatLogger) (bool, error) {
|
||||||
// Format: lines of `Usuario: ...` / `Asistente: ...`. Last user message ends the prompt.
|
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 {
|
func flattenMessages(msgs []chatMessage) string {
|
||||||
var b bytes.Buffer
|
var b strings.Builder
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
role := "Usuario"
|
role := "Usuario"
|
||||||
if m.Role == "assistant" {
|
if m.Role == "assistant" {
|
||||||
@@ -159,165 +215,7 @@ func flattenMessages(msgs []chatMessage) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleChat(db *DB, workdir string, logger *ChatLogger) http.HandlerFunc {
|
// chatWorkdir resolves an absolute working directory for `claude -p`.
|
||||||
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).
|
|
||||||
func chatWorkdir(dbPath string) string {
|
func chatWorkdir(dbPath string) string {
|
||||||
abs, err := filepath.Abs(dbPath)
|
abs, err := filepath.Abs(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -325,3 +223,18 @@ func chatWorkdir(dbPath string) string {
|
|||||||
}
|
}
|
||||||
return filepath.Dir(abs)
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
-1136
File diff suppressed because one or more lines are too long
+1151
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+3
-1
@@ -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{
|
return []infra.Route{
|
||||||
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
|
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
|
||||||
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(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: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
|
||||||
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
|
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
|
||||||
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
|
{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/metrics", Handler: handleMetrics(db)},
|
||||||
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
||||||
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
|
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
|
||||||
|
|||||||
@@ -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
@@ -22,6 +22,15 @@ import (
|
|||||||
var frontendDist embed.FS
|
var frontendDist embed.FS
|
||||||
|
|
||||||
func main() {
|
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)
|
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
||||||
port := flags.Int("port", 8095, "HTTP port")
|
port := flags.Int("port", 8095, "HTTP port")
|
||||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||||
@@ -37,10 +46,15 @@ func main() {
|
|||||||
bootstrapAdmin(db, *initialAdmin)
|
bootstrapAdmin(db, *initialAdmin)
|
||||||
startSessionCleanup(db)
|
startSessionCleanup(db)
|
||||||
|
|
||||||
|
internalToken := os.Getenv("KANBAN_INTERNAL_TOKEN")
|
||||||
|
if internalToken == "" {
|
||||||
|
internalToken = generateInternalToken()
|
||||||
|
}
|
||||||
|
|
||||||
wd := chatWorkdir(*dbPath)
|
wd := chatWorkdir(*dbPath)
|
||||||
logger := newChatLogger(filepath.Join(wd, "chat.log"))
|
logger := newChatLogger(filepath.Join(wd, "chat.log"))
|
||||||
log.Printf("chat tool log: %s", logger.path)
|
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()
|
feHandler := frontendHandler()
|
||||||
if feHandler != nil {
|
if feHandler != nil {
|
||||||
@@ -53,7 +67,7 @@ func main() {
|
|||||||
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
|
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
|
||||||
DB: db.conn,
|
DB: db.conn,
|
||||||
CookieName: cookieName,
|
CookieName: cookieName,
|
||||||
SkipPaths: []string{"/api/auth/", "/health", "/assets/", "/index.html"},
|
SkipPaths: []string{"/api/auth/", "/api/tool/", "/health", "/assets/", "/index.html"},
|
||||||
UserCtxKey: userCtxKey,
|
UserCtxKey: userCtxKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+302
@@ -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
|
||||||
|
}
|
||||||
@@ -339,27 +339,6 @@ func toolFindCards(db *DB, input json.RawMessage) ToolResult {
|
|||||||
return okResult(out)
|
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.
|
// validateToolName fails fast with clearer error than the dispatch's default.
|
||||||
func validateToolName(name string) error {
|
func validateToolName(name string) error {
|
||||||
known := map[string]bool{
|
known := map[string]bool{
|
||||||
|
|||||||
@@ -340,37 +340,6 @@ func TestExecuteTool_Unknown(t *testing.T) {
|
|||||||
mustErr(t, res, "unknown tool")
|
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 ---
|
// --- chat logger ---
|
||||||
|
|
||||||
func TestChatLogger_AppendsJSONLines(t *testing.T) {
|
func TestChatLogger_AppendsJSONLines(t *testing.T) {
|
||||||
|
|||||||
+63
-7
@@ -121,17 +121,73 @@ export interface ChatToolCall {
|
|||||||
tool: string;
|
tool: string;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
input?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
// WebSocket streaming events emitted by /api/chat/ws.
|
||||||
role: "assistant";
|
export type ChatStreamEvent =
|
||||||
content: string;
|
| { type: "delta"; text: string }
|
||||||
board_changed: boolean;
|
| { type: "tool_use"; tool_id: string; tool: string; input?: unknown }
|
||||||
tool_calls?: ChatToolCall[];
|
| { 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> {
|
// streamChat opens a WebSocket, sends the message history, and streams events
|
||||||
return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) });
|
// 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> {
|
export function login(username: string, password: string): Promise<User> {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { IconMessageChatbot, IconSend, IconTrash } from "@tabler/icons-react";
|
|||||||
import { KeyboardEvent, useEffect, useRef, useState } from "react";
|
import { KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { ChatMessage, ChatToolCall, sendChat } from "../api";
|
import { ChatMessage, ChatStreamEvent, ChatToolCall, streamChat } from "../api";
|
||||||
|
|
||||||
const STORAGE_KEY = "kanban_chat_v1";
|
const STORAGE_KEY = "kanban_chat_v1";
|
||||||
|
|
||||||
@@ -44,7 +44,11 @@ function loadStored(): StoredMessage[] {
|
|||||||
export function ChatPanel({ onBoardChange }: Props) {
|
export function ChatPanel({ onBoardChange }: Props) {
|
||||||
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
|
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
|
||||||
const [input, setInput] = useState("");
|
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);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,35 +57,89 @@ export function ChatPanel({ onBoardChange }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||||
}, [messages, loading]);
|
}, [messages, liveText, liveCalls, streaming]);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text || loading) return;
|
if (!text || streaming) return;
|
||||||
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
|
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
|
||||||
const next = [...messages, userMsg];
|
const next = [...messages, userMsg];
|
||||||
setMessages(next);
|
setMessages(next);
|
||||||
setInput("");
|
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 {
|
try {
|
||||||
const payload: ChatMessage[] = next.map((m) => ({ role: m.role, content: m.content }));
|
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 = {
|
const assistant: StoredMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: res.content,
|
content: accumulatedText,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
tool_calls: res.tool_calls,
|
tool_calls: accumulatedCalls.length > 0 ? accumulatedCalls : undefined,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, assistant]);
|
setMessages((prev) => [...prev, assistant]);
|
||||||
if (res.board_changed) onBoardChange();
|
setLiveText("");
|
||||||
} catch (e) {
|
setLiveCalls([]);
|
||||||
notifications.show({ color: "red", message: (e as Error).message });
|
setStreaming(false);
|
||||||
setMessages((prev) => [
|
if (boardChanged) onBoardChange();
|
||||||
...prev,
|
|
||||||
{ role: "assistant", content: `Error: ${(e as Error).message}`, ts: Date.now() },
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +173,7 @@ export function ChatPanel({ onBoardChange }: Props) {
|
|||||||
|
|
||||||
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
|
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && !streaming && (
|
||||||
<Text size="sm" c="dimmed" ta="center" mt="md">
|
<Text size="sm" c="dimmed" ta="center" mt="md">
|
||||||
Escribe algo. Ejemplos:
|
Escribe algo. Ejemplos:
|
||||||
<br />- "crea columna Backlog"
|
<br />- "crea columna Backlog"
|
||||||
@@ -126,7 +184,18 @@ export function ChatPanel({ onBoardChange }: Props) {
|
|||||||
{messages.map((m, i) => (
|
{messages.map((m, i) => (
|
||||||
<ChatBubble key={i} msg={m} />
|
<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">
|
<Group gap={6} pl="xs">
|
||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -144,7 +213,7 @@ export function ChatPanel({ onBoardChange }: Props) {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.currentTarget.value)}
|
onChange={(e) => setInput(e.currentTarget.value)}
|
||||||
onKeyDown={onKey}
|
onKeyDown={onKey}
|
||||||
disabled={loading}
|
disabled={streaming}
|
||||||
autosize
|
autosize
|
||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
@@ -154,10 +223,10 @@ export function ChatPanel({ onBoardChange }: Props) {
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={!input.trim() || loading}
|
disabled={!input.trim() || streaming}
|
||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
>
|
>
|
||||||
{loading ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
|
{streaming ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</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";
|
const isUser = msg.role === "user";
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -184,6 +253,9 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
|
|||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||||
</Box>
|
</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 && (
|
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||||
<Group gap={4} wrap="wrap">
|
<Group gap={4} wrap="wrap">
|
||||||
{msg.tool_calls.map((c, i) => (
|
{msg.tool_calls.map((c, i) => (
|
||||||
@@ -193,6 +265,7 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
|
|||||||
color={c.ok ? "teal" : "red"}
|
color={c.ok ? "teal" : "red"}
|
||||||
variant="light"
|
variant="light"
|
||||||
title={c.error || ""}
|
title={c.error || ""}
|
||||||
|
leftSection={c.ok && streaming ? <Loader size={8} color="teal" /> : null}
|
||||||
>
|
>
|
||||||
{c.tool}
|
{c.tool}
|
||||||
{!c.ok && c.error ? `: ${c.error}` : ""}
|
{!c.ok && c.error ? `: ${c.error}` : ""}
|
||||||
|
|||||||
Reference in New Issue
Block a user