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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 14:54:38 +02:00
parent 9e333b0e3e
commit ce49fdf9ff
14 changed files with 2175 additions and 1493 deletions
+96 -23
View File
@@ -16,7 +16,7 @@ import { IconMessageChatbot, IconSend, IconTrash } from "@tabler/icons-react";
import { KeyboardEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChatMessage, ChatToolCall, sendChat } from "../api";
import { ChatMessage, ChatStreamEvent, ChatToolCall, streamChat } from "../api";
const STORAGE_KEY = "kanban_chat_v1";
@@ -44,7 +44,11 @@ function loadStored(): StoredMessage[] {
export function ChatPanel({ onBoardChange }: Props) {
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
// Live in-flight assistant turn: incremental text + tool calls collected so
// far. When the turn finishes (done/error) it is committed to messages.
const [liveText, setLiveText] = useState("");
const [liveCalls, setLiveCalls] = useState<ChatToolCall[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -53,35 +57,89 @@ export function ChatPanel({ onBoardChange }: Props) {
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, loading]);
}, [messages, liveText, liveCalls, streaming]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
if (!text || streaming) return;
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
const next = [...messages, userMsg];
setMessages(next);
setInput("");
setLoading(true);
setStreaming(true);
setLiveText("");
setLiveCalls([]);
let accumulatedText = "";
const accumulatedCalls: ChatToolCall[] = [];
let boardChanged = false;
const onEvent = (ev: ChatStreamEvent) => {
switch (ev.type) {
case "delta":
accumulatedText += ev.text;
setLiveText(accumulatedText);
break;
case "tool_use": {
const call: ChatToolCall = { tool: ev.tool, ok: true, input: ev.input };
accumulatedCalls.push(call);
setLiveCalls([...accumulatedCalls]);
break;
}
case "tool_result": {
// Map by reverse order: the latest tool_use without is_error set.
for (let i = accumulatedCalls.length - 1; i >= 0; i--) {
const c = accumulatedCalls[i];
if (c.error === undefined && c.ok) {
if (ev.is_error) {
c.ok = false;
c.error = ev.result || "tool error";
}
break;
}
}
setLiveCalls([...accumulatedCalls]);
break;
}
case "result":
if (ev.text) {
// Final result text replaces the streamed delta only when no
// delta was emitted (some claude paths only emit the final).
if (accumulatedText.trim() === "") {
accumulatedText = ev.text;
setLiveText(accumulatedText);
}
}
break;
case "done":
if (ev.board_changed) boardChanged = true;
break;
case "error":
accumulatedText = `Error: ${ev.error}`;
setLiveText(accumulatedText);
break;
}
};
try {
const payload: ChatMessage[] = next.map((m) => ({ role: m.role, content: m.content }));
const res = await sendChat(payload);
await streamChat(payload, onEvent);
} catch (e) {
const msg = (e as Error).message;
notifications.show({ color: "red", message: msg });
accumulatedText = accumulatedText || `Error: ${msg}`;
} finally {
const assistant: StoredMessage = {
role: "assistant",
content: res.content,
content: accumulatedText,
ts: Date.now(),
tool_calls: res.tool_calls,
tool_calls: accumulatedCalls.length > 0 ? accumulatedCalls : undefined,
};
setMessages((prev) => [...prev, assistant]);
if (res.board_changed) onBoardChange();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${(e as Error).message}`, ts: Date.now() },
]);
} finally {
setLoading(false);
setLiveText("");
setLiveCalls([]);
setStreaming(false);
if (boardChanged) onBoardChange();
}
};
@@ -115,7 +173,7 @@ export function ChatPanel({ onBoardChange }: Props) {
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
<Stack gap="xs">
{messages.length === 0 && (
{messages.length === 0 && !streaming && (
<Text size="sm" c="dimmed" ta="center" mt="md">
Escribe algo. Ejemplos:
<br />- "crea columna Backlog"
@@ -126,7 +184,18 @@ export function ChatPanel({ onBoardChange }: Props) {
{messages.map((m, i) => (
<ChatBubble key={i} msg={m} />
))}
{loading && (
{streaming && (
<ChatBubble
msg={{
role: "assistant",
content: liveText,
ts: Date.now(),
tool_calls: liveCalls.length > 0 ? liveCalls : undefined,
}}
streaming
/>
)}
{streaming && liveText === "" && liveCalls.length === 0 && (
<Group gap={6} pl="xs">
<Loader size="xs" />
<Text size="xs" c="dimmed">
@@ -144,7 +213,7 @@ export function ChatPanel({ onBoardChange }: Props) {
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={onKey}
disabled={loading}
disabled={streaming}
autosize
minRows={1}
maxRows={6}
@@ -154,10 +223,10 @@ export function ChatPanel({ onBoardChange }: Props) {
size="lg"
variant="filled"
onClick={send}
disabled={!input.trim() || loading}
disabled={!input.trim() || streaming}
aria-label="Send"
>
{loading ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
{streaming ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
</ActionIcon>
</Group>
</Stack>
@@ -165,7 +234,7 @@ export function ChatPanel({ onBoardChange }: Props) {
);
}
function ChatBubble({ msg }: { msg: StoredMessage }) {
function ChatBubble({ msg, streaming = false }: { msg: StoredMessage; streaming?: boolean }) {
const isUser = msg.role === "user";
return (
<Paper
@@ -184,6 +253,9 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</Box>
)}
{streaming && msg.content && (
<Box style={{ display: "inline-block", width: 8, height: 14, background: "currentColor", opacity: 0.6 }} />
)}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<Group gap={4} wrap="wrap">
{msg.tool_calls.map((c, i) => (
@@ -193,6 +265,7 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
color={c.ok ? "teal" : "red"}
variant="light"
title={c.error || ""}
leftSection={c.ok && streaming ? <Loader size={8} color="teal" /> : null}
>
{c.tool}
{!c.ok && c.error ? `: ${c.error}` : ""}