package main import ( "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "time" "fn-registry/functions/core" "fn-registry/functions/infra" "nhooyr.io/websocket" ) const chatSystemPrompt = `Asistente del tablero kanban. Modifica el tablero llamando a tools MCP cuando el usuario pida cambios. Responde texto en markdown cuando solo informe. Tools (MCP server "kanban"): - Lectura: list_board, find_cards, card_history, list_users - Columnas: create_column, update_column, delete_column, reorder_columns - Tarjetas: create_card, update_card, delete_card, move_card, assign_card El estado actual del tablero viene en al final del mensaje. Usa esos IDs directamente — NO llames list_board si ya tienes lo que necesitas. NUNCA inventes IDs. Cuando termines, responde texto natural sin mas llamadas — eso cierra la conversacion.` const claudeTimeout = 300 * time.Second func claudeBinary() string { if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" { return b } return "claude" } func claudeModel() string { if m := os.Getenv("KANBAN_CLAUDE_MODEL"); m != "" { return m } return "claude-haiku-4-5-20251001" } type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } type chatRequest struct { Messages []chatMessage `json:"messages"` } // 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"` } // handleChatWS upgrades the request to WebSocket and streams claude events. // // 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, "") } } 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) if board, err := boardSnapshot(db); err == nil && board != "" { prompt += "\n\n\n" + board + "\n\n" } stdin := strings.NewReader(prompt) events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{ Bin: claudeBinary(), Args: []string{ "--model", claudeModel(), "--no-session-persistence", "--mcp-config", mcpPath, "--strict-mcp-config", "--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____" 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 strings.Builder for _, m := range msgs { role := "Usuario" if m.Role == "assistant" { role = "Asistente" } b.WriteString(role) b.WriteString(": ") b.WriteString(m.Content) b.WriteString("\n\n") } return b.String() } // boardSnapshot returns a JSON dump of columns + cards to inject in the // initial prompt, saving a list_board round-trip. 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.Marshal(map[string]any{"columns": cols, "cards": cards}) if err != nil { return "", err } return string(b), nil } // chatWorkdir resolves an absolute working directory for `claude -p`. func chatWorkdir(dbPath string) string { abs, err := filepath.Abs(dbPath) if err != nil { return "." } 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", }) } }