7ce227ddea
- migration 009 + columna deadline TEXT en cards - backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared - KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue) - App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight - CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero - HistoryModal: render eventos deadline_set/deadline_cleared - .gitignore: *.log Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
const chatSystemPrompt = `Eres el asistente del tablero kanban. Tu trabajo es responder al usuario y, cuando pida cambios, modificar el tablero llamando a tools.
|
|
|
|
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.
|
|
|
|
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>
|
|
|
|
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.
|
|
|
|
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
|
|
|
|
type chatMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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.
|
|
//
|
|
// 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")
|
|
}
|
|
|
|
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 flattenMessages(msgs []chatMessage) string {
|
|
var b bytes.Buffer
|
|
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()
|
|
}
|
|
|
|
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).
|
|
func chatWorkdir(dbPath string) string {
|
|
abs, err := filepath.Abs(dbPath)
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
return filepath.Dir(abs)
|
|
}
|