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:
+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> {
|
||||
|
||||
Reference in New Issue
Block a user