feat(chat): enhance streaming status and tool call events

This commit is contained in:
Rodrigo Verdiani 2026-06-11 21:46:53 -03:00
parent bf080eed88
commit 4498a3f666
3 changed files with 171 additions and 55 deletions

View File

@ -29,6 +29,8 @@ export function AIChatPanel() {
isLoadingMessages, isLoadingMessages,
isStreaming, isStreaming,
streamingContent, streamingContent,
toolStatus,
isToolRunning,
streamError, streamError,
isCreatingSession, isCreatingSession,
createSession, createSession,
@ -215,8 +217,6 @@ export function AIChatPanel() {
<> <>
{messages.map((message, idx) => { {messages.map((message, idx) => {
const isUser = message.role === "USER"; const isUser = message.role === "USER";
const isLastMessage = idx === messages.length - 1;
const isStreamingMessage = isLastMessage && isStreaming && !message.id;
return ( return (
<div <div
@ -245,20 +245,6 @@ export function AIChatPanel() {
> >
{isUser ? ( {isUser ? (
<div className="break-words whitespace-pre-wrap">{message.content}</div> <div className="break-words whitespace-pre-wrap">{message.content}</div>
) : isStreamingMessage ? (
<div className="break-words whitespace-pre-wrap">
{streamingContent ? (
<>
{streamingContent}
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-current align-middle" />
</>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Analyzing...</span>
</div>
)}
</div>
) : ( ) : (
<div className="[&_th]:border-border [&_td]:border-border max-w-none break-words [&_ol]:my-2 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_pre]:my-2 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2"> <div className="[&_th]:border-border [&_td]:border-border max-w-none break-words [&_ol]:my-2 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_pre]:my-2 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2">
<ReactMarkdown <ReactMarkdown
@ -323,6 +309,40 @@ export function AIChatPanel() {
})} })}
</> </>
)} )}
{isStreaming && (
<>
<div className="mb-4 flex gap-3">
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Bot className="h-4 w-4" />
</div>
<div className="flex max-w-[85%] min-w-0 flex-col items-start gap-1">
<div className="bg-muted overflow-hidden rounded-lg px-3 py-2 text-sm">
<div className="break-words whitespace-pre-wrap">
{streamingContent ? (
<>
{streamingContent}
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-current align-middle" />
</>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="text-primary h-4 w-4 animate-spin" />
<span>Analyzing...</span>
</div>
)}
</div>
</div>
</div>
</div>
{toolStatus && (
<div className="text-muted-foreground bg-muted border-l-primary my-1 flex items-center gap-2 rounded-lg border-l-[3px] px-3 py-2 text-sm">
{isToolRunning && (
<Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
)}
<span>{toolStatus}</span>
</div>
)}
</>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
)} )}

View File

@ -10,6 +10,46 @@ import {
} from "@/api/generated/chat/chat.ts"; } from "@/api/generated/chat/chat.ts";
import type { SessionResponse, MessageResponse } from "@/api/generated/api.schemas.ts"; import type { SessionResponse, MessageResponse } from "@/api/generated/api.schemas.ts";
interface ChunkEvent {
type: "chunk";
content: string;
}
interface ToolCallStartEvent {
type: "tool_call_start";
tool: string;
}
interface ToolCallEndEvent {
type: "tool_call_end";
tool: string;
durationMs: number;
resultSummary: string;
}
interface ThinkingEvent {
type: "thinking";
message: string;
}
interface DoneEvent {
type: "done";
sessionId: string;
}
interface ErrorEvent {
type: "error";
message: string;
}
type SSEEvent =
| ChunkEvent
| ToolCallStartEvent
| ToolCallEndEvent
| ThinkingEvent
| DoneEvent
| ErrorEvent;
export function useAIChat() { export function useAIChat() {
const params = useParams(); const params = useParams();
const binaryId = params.binaryId!; const binaryId = params.binaryId!;
@ -20,6 +60,8 @@ export function useAIChat() {
const [streamingContent, setStreamingContent] = useState(""); const [streamingContent, setStreamingContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null); const [streamError, setStreamError] = useState<string | null>(null);
const [toolStatus, setToolStatus] = useState<string | null>(null);
const [isToolRunning, setIsToolRunning] = useState(false);
const [pendingUserMessage, setPendingUserMessage] = useState<MessageResponse | null>(null); const [pendingUserMessage, setPendingUserMessage] = useState<MessageResponse | null>(null);
const { data: binary } = useGetBinary(binaryId, { const { data: binary } = useGetBinary(binaryId, {
@ -89,6 +131,8 @@ export function useAIChat() {
setIsStreaming(true); setIsStreaming(true);
setStreamingContent(""); setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
setStreamError(null); setStreamError(null);
setPendingUserMessage({ setPendingUserMessage({
role: "USER", role: "USER",
@ -137,23 +181,48 @@ export function useAIChat() {
if (!line.startsWith("data:")) continue; if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim()); const event = JSON.parse(line.slice(5).trim());
if (event.type === "chunk") { const sseEvent = event as SSEEvent;
fullContent += event.content; switch (sseEvent.type) {
setStreamingContent(fullContent); case "chunk":
} else if (event.type === "done") { fullContent += sseEvent.content;
setIsStreaming(false); setStreamingContent(fullContent);
setStreamingContent(""); setToolStatus(null);
setPendingUserMessage(null); setIsToolRunning(false);
queryClient.invalidateQueries({ break;
queryKey: [`/chat/sessions/${activeSessionId}/messages`], case "tool_call_start":
}); setToolStatus(`Calling ${sseEvent.tool}...`);
queryClient.invalidateQueries({ setIsToolRunning(true);
queryKey: [`/binaries/${binaryId}/chat/sessions`], break;
}); case "tool_call_end":
} else if (event.type === "error") { setToolStatus(
setIsStreaming(false); `${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
setPendingUserMessage(null); );
setStreamError(event.message); setIsToolRunning(false);
break;
case "thinking":
setToolStatus(sseEvent.message);
setIsToolRunning(true);
break;
case "done":
setIsStreaming(false);
setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
queryClient.invalidateQueries({
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
});
queryClient.invalidateQueries({
queryKey: [`/binaries/${binaryId}/chat/sessions`],
});
break;
case "error":
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
setStreamError(sseEvent.message);
break;
} }
} }
} }
@ -164,23 +233,48 @@ export function useAIChat() {
if (!line.startsWith("data:")) continue; if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim()); const event = JSON.parse(line.slice(5).trim());
if (event.type === "chunk") { const sseEvent = event as SSEEvent;
fullContent += event.content; switch (sseEvent.type) {
setStreamingContent(fullContent); case "chunk":
} else if (event.type === "done") { fullContent += sseEvent.content;
setIsStreaming(false); setStreamingContent(fullContent);
setStreamingContent(""); setToolStatus(null);
setPendingUserMessage(null); setIsToolRunning(false);
queryClient.invalidateQueries({ break;
queryKey: [`/chat/sessions/${activeSessionId}/messages`], case "tool_call_start":
}); setToolStatus(`Calling ${sseEvent.tool}...`);
queryClient.invalidateQueries({ setIsToolRunning(true);
queryKey: [`/binaries/${binaryId}/chat/sessions`], break;
}); case "tool_call_end":
} else if (event.type === "error") { setToolStatus(
setIsStreaming(false); `${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
setPendingUserMessage(null); );
setStreamError(event.message); setIsToolRunning(false);
break;
case "thinking":
setToolStatus(sseEvent.message);
setIsToolRunning(true);
break;
case "done":
setIsStreaming(false);
setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
queryClient.invalidateQueries({
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
});
queryClient.invalidateQueries({
queryKey: [`/binaries/${binaryId}/chat/sessions`],
});
break;
case "error":
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
setStreamError(sseEvent.message);
break;
} }
} }
} }
@ -190,6 +284,8 @@ export function useAIChat() {
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") { if (err instanceof Error && err.name !== "AbortError") {
setIsStreaming(false); setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null); setPendingUserMessage(null);
setStreamError(err.message || "An unexpected error occurred"); setStreamError(err.message || "An unexpected error occurred");
} }
@ -201,17 +297,14 @@ export function useAIChat() {
const cancelStream = useCallback(() => { const cancelStream = useCallback(() => {
abortRef.current?.abort(); abortRef.current?.abort();
setIsStreaming(false); setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null); setPendingUserMessage(null);
}, []); }, []);
const messages: MessageResponse[] = [...historicalMessages]; const messages: MessageResponse[] = [...historicalMessages];
if (isStreaming && pendingUserMessage) { if (isStreaming && pendingUserMessage) {
messages.push(pendingUserMessage); messages.push(pendingUserMessage);
messages.push({
role: "ASSISTANT",
content: streamingContent,
createdAt: new Date().toISOString(),
});
} }
const userFriendlyError = streamError const userFriendlyError = streamError
@ -231,6 +324,8 @@ export function useAIChat() {
isLoadingMessages: messagesQuery.isLoading, isLoadingMessages: messagesQuery.isLoading,
isStreaming, isStreaming,
streamingContent, streamingContent,
toolStatus,
isToolRunning,
streamError: userFriendlyError, streamError: userFriendlyError,
isCreatingSession: createSessionMutation.isPending, isCreatingSession: createSessionMutation.isPending,
createSession, createSession,

View File

@ -28,6 +28,7 @@
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-success: var(--success);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);