feat(chat): enhance streaming status and tool call events
This commit is contained in:
parent
bf080eed88
commit
4498a3f666
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user