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_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
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
|
||||
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
@@ -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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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}` : ""}
|
||||
|
||||
Reference in New Issue
Block a user