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:
@@ -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