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
+63 -7
View File
@@ -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> {