diff --git a/README.md b/README.md
index 01f6f06..ad85aec 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@ React frontend for interacting with the [composable-agents](https://github.com/s
## Features
-- **Agent management** -- Create, view, configure, and delete agents via YAML file upload
-- **Real-time chat** -- Stream AI responses via SSE (Server-Sent Events)
-- **Human-in-the-loop** -- Review and approve/reject tool calls before execution
+- **Agent management** — Create, view, configure, and delete agents via YAML file upload
+- **Real-time chat** — Stream AI responses via SSE with typed events (thinking, content, structured response)
+- **Human-in-the-loop** — Review and approve/reject tool calls before execution
- **Thread history** -- Conversation threads grouped by agent in a sidebar
- **RAG file browser** -- Browse MinIO folders and files with breadcrumb navigation and file metadata display
- **Material Design 3** -- Inspired design system with shadcn/ui components
diff --git a/package-lock.json b/package-lock.json
index 96154a5..ed92b0c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@microsoft/fetch-event-source": "^2.0.1",
+ "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
@@ -20,6 +21,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
@@ -31,10 +33,11 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.38.0",
+ "js-yaml": "^4.1.1",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
- "react-hook-form": "^7.72.1",
+ "react-hook-form": "^7.73.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
"remark-gfm": "^4.0.1",
@@ -50,6 +53,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
+ "@types/js-yaml": "^4.0.9",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -720,6 +724,37 @@
"version": "1.1.3",
"license": "MIT"
},
+ "node_modules/@radix-ui/react-accordion": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
+ "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collapsible": "1.1.12",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"license": "MIT",
@@ -842,6 +877,36 @@
}
}
},
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
+ "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"license": "MIT",
@@ -1506,6 +1571,35 @@
}
}
},
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+ "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"license": "MIT",
@@ -2798,6 +2892,13 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"dev": true,
@@ -3335,6 +3436,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"license": "MIT",
@@ -4626,6 +4733,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/jsdom": {
"version": "25.0.1",
"dev": true,
diff --git a/src/application/components/chat/ChatMessage.tsx b/src/application/components/chat/ChatMessage.tsx
index 008511d..21615e8 100644
--- a/src/application/components/chat/ChatMessage.tsx
+++ b/src/application/components/chat/ChatMessage.tsx
@@ -8,6 +8,8 @@ import {
} from "@/domain/entities/chat/message";
import StatusBadge from "@/application/components/shared/StatusBadge";
import HITLReviewPanel from "@/application/components/chat/HITLReviewPanel";
+import ThinkingBlock from "@/application/components/chat/ThinkingBlock";
+import StructuredResponseCard from "@/application/components/chat/StructuredResponseCard";
interface ChatMessageProps {
message: Message;
@@ -52,7 +54,6 @@ export default function ChatMessage({
if (isAi) {
return (
- {/* Avatar */}
hub
@@ -60,7 +61,6 @@ export default function ChatMessage({
- {/* Agent name + status */}
{agentName}
@@ -68,7 +68,6 @@ export default function ChatMessage({
{message.status && }
- {/* Content bubble */}
- {/* HITL Review Panel */}
- {isAwaitingHitl &&
- message.tool_calls &&
- message.tool_calls.length > 0 &&
- threadId && (
-
- )}
+ {isAwaitingHitl && message.tool_calls?.length && threadId && (
+
+ )}
- {/* Timestamp */}
{formatTimestamp(message.timestamp)}
@@ -106,7 +103,6 @@ export default function ChatMessage({
);
}
- // System / Tool messages: compact, centered
return (
diff --git a/src/application/components/chat/MessageList.tsx b/src/application/components/chat/MessageList.tsx
index bfcfcba..d8b1c33 100644
--- a/src/application/components/chat/MessageList.tsx
+++ b/src/application/components/chat/MessageList.tsx
@@ -5,6 +5,8 @@ import { useMessages } from "@/application/hooks/chat/useMessages";
import { useChatStore } from "@/application/stores/useChatStore";
import { MessageRole } from "@/domain/entities/chat/message";
import ChatMessage from "@/application/components/chat/ChatMessage";
+import ThinkingBlock from "@/application/components/chat/ThinkingBlock";
+import StructuredResponseCard from "@/application/components/chat/StructuredResponseCard";
interface MessageListProps {
threadId: string;
@@ -16,15 +18,27 @@ export default function MessageList({
agentName,
}: Readonly
) {
const { data: messages, isLoading } = useMessages(threadId);
- const { streamingContent, isStreaming, pendingUserMessage } = useChatStore();
+ const {
+ streamingContent,
+ streamingThinking,
+ structuredResponse,
+ isStreaming,
+ pendingUserMessage,
+ error,
+ } = useChatStore();
const scrollRef = useRef(null);
- // Auto-scroll to bottom when messages change or streaming content updates
useEffect(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ const el = scrollRef.current;
+ if (!el) return;
+ const isNearBottom =
+ el.scrollHeight - el.scrollTop - el.clientHeight < 150;
+ if (isNearBottom) {
+ requestAnimationFrame(() => {
+ el.scrollTop = el.scrollHeight;
+ });
}
- }, [messages, streamingContent, pendingUserMessage, isStreaming]);
+ }, [messages, streamingContent, streamingThinking, pendingUserMessage, error]);
if (isLoading) {
return (
@@ -34,9 +48,9 @@ export default function MessageList({
);
}
- const hasMessages = messages && messages.length > 0;
+ const hasMessages = (messages?.length ?? 0) > 0;
- if (!hasMessages && !isStreaming) {
+ if (!hasMessages && !isStreaming && !error) {
return (
@@ -66,7 +80,6 @@ export default function MessageList({
/>
))}
- {/* Pending user message (optimistic) */}
{pendingUserMessage && (
)}
- {/* Streaming: single agent bubble with content + spinner */}
{isStreaming && (
@@ -104,7 +117,9 @@ export default function MessageList({
)}
-
+
+
+
progress_activity
@@ -116,6 +131,29 @@ export default function MessageList({
)}
+
+ {error && !isStreaming && (
+
+
+
+ error
+
+
+
+
+
+ {agentName}
+
+
+
+
+ Something went wrong
+
+
{error}
+
+
+
+ )}
);
diff --git a/src/application/components/chat/StructuredResponseCard.tsx b/src/application/components/chat/StructuredResponseCard.tsx
new file mode 100644
index 0000000..0cd61ea
--- /dev/null
+++ b/src/application/components/chat/StructuredResponseCard.tsx
@@ -0,0 +1,88 @@
+import { useRef, useState, useEffect } from "react";
+import { cn } from "@/application/lib/utils";
+
+interface StructuredResponseCardProps {
+ data: unknown;
+ className?: string;
+}
+
+export default function StructuredResponseCard({
+ data,
+ className,
+}: StructuredResponseCardProps) {
+ const [copied, setCopied] = useState(false);
+ const timerRef = useRef
| null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ };
+ }, []);
+
+ if (data == null) return null;
+
+ let displayText = "";
+ try {
+ displayText = JSON.stringify(data, null, 2);
+ } catch {
+ try {
+ displayText = String(data);
+ } catch {
+ displayText = "[Unable to display data]";
+ }
+ }
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(displayText);
+ setCopied(true);
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => setCopied(false), 1500);
+ } catch {
+ /* ignore */
+ }
+ };
+
+ return (
+
+
+
+
+ data_object
+
+
+ Structured Response
+
+
+
+
+
+
+ );
+}
diff --git a/src/application/components/chat/ThinkingBlock.tsx b/src/application/components/chat/ThinkingBlock.tsx
new file mode 100644
index 0000000..880b505
--- /dev/null
+++ b/src/application/components/chat/ThinkingBlock.tsx
@@ -0,0 +1,44 @@
+import { useState, useId } from "react";
+import { cn } from "@/application/lib/utils";
+
+interface ThinkingBlockProps {
+ text: string | null;
+}
+
+export default function ThinkingBlock({ text }: ThinkingBlockProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const panelId = useId();
+
+ if (!text?.trim()) return null;
+
+ return (
+
+
+ {isExpanded && (
+
+ )}
+
+ );
+}
diff --git a/src/application/hooks/chat/useStreamChat.ts b/src/application/hooks/chat/useStreamChat.ts
index b8ea279..f607980 100644
--- a/src/application/hooks/chat/useStreamChat.ts
+++ b/src/application/hooks/chat/useStreamChat.ts
@@ -1,28 +1,39 @@
import { useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
import { chatApi } from "@/infrastructure/api/chat/chatApi";
import { useChatStore } from "@/application/stores/useChatStore";
+import { StreamEventType } from "@/domain/entities/chat/streamEvent";
import type { ChatRequest } from "@/domain/entities/chat/chatRequest";
export function useStreamChat(threadId: string | null) {
const queryClient = useQueryClient();
- const { setStreaming, appendStreamChunk, clearStream } = useChatStore();
const abortRef = useRef(null);
const stream = useCallback(
(request: ChatRequest) => {
if (!threadId) return;
+ const {
+ clearStream,
+ setStreaming,
+ setPendingUserMessage,
+ // setError read via getState() in callbacks
+ } = useChatStore.getState();
+
clearStream();
setStreaming(true);
- useChatStore.getState().setPendingUserMessage(request.message ?? null);
+ setPendingUserMessage(request.message ?? null);
abortRef.current = chatApi.streamMessage(
threadId,
request,
- (chunk) => {
- appendStreamChunk(chunk);
+ (event) => {
+ if (event.type === StreamEventType.ERROR) {
+ useChatStore.getState().setStreaming(false);
+ useChatStore.getState().setError(event.data);
+ return;
+ }
+ useChatStore.getState().appendStreamEvent(event);
},
async () => {
try {
@@ -30,29 +41,31 @@ export function useStreamChat(threadId: string | null) {
queryKey: ["messages", threadId],
});
} finally {
- useChatStore.getState().setPendingUserMessage(null);
+ const { setPendingUserMessage, setStreaming } =
+ useChatStore.getState();
+ setPendingUserMessage(null);
setStreaming(false);
}
},
(error) => {
console.error("Stream error:", error);
- useChatStore.getState().setPendingUserMessage(null);
- setStreaming(false);
- toast.error("Stream error", {
- description: error.message || "An error occurred while streaming.",
- });
+ useChatStore.getState().setStreaming(false);
+ useChatStore.getState().setError(
+ error.message || "An error occurred while streaming.",
+ );
},
);
},
- [threadId, clearStream, setStreaming, appendStreamChunk, queryClient],
+ [threadId, queryClient],
);
const cancel = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
- setStreaming(false);
+ abortRef.current = null;
+ useChatStore.getState().clearStream();
}
- }, [setStreaming]);
+ }, []);
useEffect(() => {
return () => {
diff --git a/src/application/stores/useChatStore.ts b/src/application/stores/useChatStore.ts
index 2755762..c15ab69 100644
--- a/src/application/stores/useChatStore.ts
+++ b/src/application/stores/useChatStore.ts
@@ -1,32 +1,80 @@
import { create } from "zustand";
+import type { StreamEvent } from "@/domain/entities/chat/streamEvent";
interface ChatState {
activeThreadId: string | null;
streamingContent: string;
+ streamingThinking: string;
+ structuredResponse: unknown | null;
isStreaming: boolean;
pendingUserMessage: string | null;
useStreaming: boolean;
+ error: string | null;
setActiveThread: (id: string | null) => void;
- appendStreamChunk: (chunk: string) => void;
+ appendStreamEvent: (event: StreamEvent) => void;
clearStream: () => void;
setStreaming: (streaming: boolean) => void;
setPendingUserMessage: (msg: string | null) => void;
+ setError: (msg: string | null) => void;
toggleStreaming: () => void;
}
export const useChatStore = create((set) => ({
activeThreadId: null,
streamingContent: "",
+ streamingThinking: "",
+ structuredResponse: null,
isStreaming: false,
pendingUserMessage: null,
useStreaming: true,
+ error: null,
setActiveThread: (id) => set({ activeThreadId: id }),
- appendStreamChunk: (chunk) =>
- set((state) => ({ streamingContent: state.streamingContent + chunk })),
+ appendStreamEvent: (ev: StreamEvent) =>
+ set((state) => {
+ if (!ev.type || typeof ev.data !== "string") {
+ console.warn("[ChatStore] Invalid stream event:", ev);
+ return state;
+ }
+ switch (ev.type) {
+ case "thinking":
+ return { streamingThinking: state.streamingThinking + ev.data };
+ case "content":
+ return { streamingContent: state.streamingContent + ev.data };
+ case "structured":
+ try {
+ return { structuredResponse: JSON.parse(ev.data) };
+ } catch (e) {
+ console.warn("[ChatStore] Failed to parse structured event data:", ev.data, e);
+ return state;
+ }
+ case "message":
+ try {
+ const msg = JSON.parse(ev.data);
+ return { structuredResponse: msg.structured_response ?? null };
+ } catch (e) {
+ console.warn(
+ "[ChatStore] Failed to parse message event data:",
+ ev.data,
+ e,
+ );
+ return state;
+ }
+ default:
+ return state;
+ }
+ }),
clearStream: () =>
- set({ streamingContent: "", isStreaming: false, pendingUserMessage: null }),
+ set({
+ streamingContent: "",
+ streamingThinking: "",
+ structuredResponse: null,
+ isStreaming: false,
+ pendingUserMessage: null,
+ error: null,
+ }),
setStreaming: (streaming) => set({ isStreaming: streaming }),
setPendingUserMessage: (msg) => set({ pendingUserMessage: msg }),
+ setError: (msg) => set({ error: msg }),
toggleStreaming: () =>
set((state) => ({ useStreaming: !state.useStreaming })),
}));
diff --git a/src/domain/entities/chat/message.ts b/src/domain/entities/chat/message.ts
index 8542bcd..2e4a125 100644
--- a/src/domain/entities/chat/message.ts
+++ b/src/domain/entities/chat/message.ts
@@ -24,4 +24,5 @@ export interface Message {
tool_calls: ToolCall[] | null;
status: MessageStatus | null;
structured_response: unknown;
+ thinking: string | null;
}
diff --git a/src/domain/entities/chat/streamEvent.ts b/src/domain/entities/chat/streamEvent.ts
new file mode 100644
index 0000000..abd4f70
--- /dev/null
+++ b/src/domain/entities/chat/streamEvent.ts
@@ -0,0 +1,12 @@
+export enum StreamEventType {
+ THINKING = "thinking",
+ CONTENT = "content",
+ STRUCTURED = "structured",
+ MESSAGE = "message",
+ ERROR = "error",
+}
+
+export interface StreamEvent {
+ type: StreamEventType;
+ data: string;
+}
diff --git a/src/domain/ports/chat/chatPort.ts b/src/domain/ports/chat/chatPort.ts
index 6f23449..226567b 100644
--- a/src/domain/ports/chat/chatPort.ts
+++ b/src/domain/ports/chat/chatPort.ts
@@ -1,5 +1,6 @@
import type { ChatRequest } from "@/domain/entities/chat/chatRequest";
import type { Message } from "@/domain/entities/chat/message";
+import type { StreamEvent } from "@/domain/entities/chat/streamEvent";
import type { Thread } from "@/domain/entities/chat/thread";
export interface IChatPort {
@@ -12,7 +13,7 @@ export interface IChatPort {
streamMessage(
threadId: string,
request: ChatRequest,
- onChunk: (data: string) => void,
+ onChunk: (event: StreamEvent) => void,
onComplete: () => void,
onError: (err: Error) => void,
): AbortController;
diff --git a/src/infrastructure/api/chat/chatApi.ts b/src/infrastructure/api/chat/chatApi.ts
index 872d067..c8698b2 100644
--- a/src/infrastructure/api/chat/chatApi.ts
+++ b/src/infrastructure/api/chat/chatApi.ts
@@ -1,11 +1,23 @@
import type { ChatRequest } from "@/domain/entities/chat/chatRequest";
import type { Message } from "@/domain/entities/chat/message";
+import { StreamEventType } from "@/domain/entities/chat/streamEvent";
+import type { StreamEvent } from "@/domain/entities/chat/streamEvent";
import type { Thread } from "@/domain/entities/chat/thread";
import type { IChatPort } from "@/domain/ports/chat/chatPort";
import { apiClient } from "@/infrastructure/api/axiosInstance";
import { configRepository } from "@/infrastructure/config/configRepositoryInstance";
import { fetchEventSource } from "@microsoft/fetch-event-source";
+const VALID_EVENT_TYPES: string[] = Object.values(StreamEventType);
+
+function isValidStreamEvent(parsed: unknown): parsed is StreamEvent {
+ if (typeof parsed !== "object" || parsed === null) return false;
+ const p = parsed as Record;
+ if (!VALID_EVENT_TYPES.includes(p.type as string)) return false;
+ if (typeof p.data !== "string") return false;
+ return true;
+}
+
export const chatApi: IChatPort = {
async createThread(agentName: string): Promise {
const response = await apiClient.post("/api/v1/threads", {
@@ -47,7 +59,7 @@ export const chatApi: IChatPort = {
streamMessage(
threadId: string,
request: ChatRequest,
- onChunk: (data: string) => void,
+ onChunk: (event: StreamEvent) => void,
onComplete: () => void,
onError: (err: Error) => void,
): AbortController {
@@ -65,8 +77,22 @@ export const chatApi: IChatPort = {
body: JSON.stringify(request),
signal: ctrl.signal,
onmessage(ev) {
- if (ev.data) {
- onChunk(ev.data);
+ if (ev.data === "[DONE]") return;
+ if (!ev.data) return;
+ try {
+ const parsed = JSON.parse(ev.data);
+ if (isValidStreamEvent(parsed)) {
+ onChunk(parsed);
+ } else {
+ console.warn("[SSE] Unknown event format:", ev.data);
+ onChunk({
+ type: StreamEventType.CONTENT,
+ data: ev.data,
+ });
+ }
+ } catch {
+ // Fallback: treat raw data as content for backward compatibility
+ onChunk({ type: StreamEventType.CONTENT, data: ev.data });
}
},
onclose() {
diff --git a/tests/unit/stores/useChatStore.test.ts b/tests/unit/stores/useChatStore.test.ts
index 05afccc..b590d0a 100644
--- a/tests/unit/stores/useChatStore.test.ts
+++ b/tests/unit/stores/useChatStore.test.ts
@@ -6,6 +6,8 @@ describe("useChatStore", () => {
useChatStore.setState({
activeThreadId: null,
streamingContent: "",
+ streamingThinking: "",
+ structuredResponse: null,
isStreaming: false,
pendingUserMessage: null,
useStreaming: true,
@@ -66,13 +68,34 @@ describe("useChatStore", () => {
expect(state.pendingUserMessage).toBeNull();
});
- it("appendStreamChunk appends to streamingContent", () => {
- useChatStore.getState().appendStreamChunk("Hello ");
- useChatStore.getState().appendStreamChunk("world");
+ it("appendStreamEvent appends content to streamingContent", () => {
+ useChatStore.getState().appendStreamEvent({ type: "content", data: "Hello " });
+ useChatStore.getState().appendStreamEvent({ type: "content", data: "world" });
expect(useChatStore.getState().streamingContent).toBe("Hello world");
});
+ it("appendStreamEvent appends thinking to streamingThinking", () => {
+ useChatStore.getState().appendStreamEvent({ type: "thinking", data: "I think " });
+ useChatStore.getState().appendStreamEvent({ type: "thinking", data: "this" });
+
+ expect(useChatStore.getState().streamingThinking).toBe("I think this");
+ });
+
+ it("appendStreamEvent sets structuredResponse from message event", () => {
+ const msg = { structured_response: { answer: 42 } };
+ useChatStore.getState().appendStreamEvent({ type: "message", data: JSON.stringify(msg) });
+
+ expect(useChatStore.getState().structuredResponse).toEqual({ answer: 42 });
+ });
+
+ it("appendStreamEvent ignores unknown event types", () => {
+ const initial = useChatStore.getState().streamingContent;
+ useChatStore.getState().appendStreamEvent({ type: "unknown" as "content", data: "x" });
+
+ expect(useChatStore.getState().streamingContent).toBe(initial);
+ });
+
it("setActiveThread updates activeThreadId", () => {
useChatStore.getState().setActiveThread("thread-42");