Skip to content

Commit 92d204d

Browse files
authored
Added Streaming output rendering functionality (#7)
This pull request introduces several changes to the chat functionality, focusing on adding support for streaming responses and refactoring the existing code to improve maintainability. The most important changes include the addition of streaming response handling, updates to the `ChatService` to support streaming, and modifications to the `ChatContainer` and `ChatInput` components to accommodate these new features. ### Streaming Response Handling: * [`src/components/ChatContainer/ChatContainer.tsx`](diffhunk://#diff-c0bfccaf03a29a5059f6a47c6dd2cdd194fcb1e06fe8e0e188d700a86204f073R23): Added a new state `useStreaming` and modified the `handleMessageSubmit` function to handle streaming responses using the new `handleStreamingResponse` and `handleSyncResponse` functions. [[1]](diffhunk://#diff-c0bfccaf03a29a5059f6a47c6dd2cdd194fcb1e06fe8e0e188d700a86204f073R23) [[2]](diffhunk://#diff-c0bfccaf03a29a5059f6a47c6dd2cdd194fcb1e06fe8e0e188d700a86204f073L101-L108) [[3]](diffhunk://#diff-c0bfccaf03a29a5059f6a47c6dd2cdd194fcb1e06fe8e0e188d700a86204f073R109-R114) [[4]](diffhunk://#diff-c0bfccaf03a29a5059f6a47c6dd2cdd194fcb1e06fe8e0e188d700a86204f073R124-R198) [[5]](diffhunk://#diff-c0bfccaf03a29a5059f6a47c6dd2cdd194fcb1e06fe8e0e188d700a86204f073L163-R226) * [`src/components/ChatInput/ChatInput.tsx`](diffhunk://#diff-e56b5877d49403da7c452c9efab46ee9a6e3daa2be72e4aeca9bf085afd81b17R5-L24): Updated the `onSubmit` function to accept a `MessageHandlerConfig` parameter and handle streaming settings. [[1]](diffhunk://#diff-e56b5877d49403da7c452c9efab46ee9a6e3daa2be72e4aeca9bf085afd81b17R5-L24) [[2]](diffhunk://#diff-e56b5877d49403da7c452c9efab46ee9a6e3daa2be72e4aeca9bf085afd81b17L38-R58) [[3]](diffhunk://#diff-e56b5877d49403da7c452c9efab46ee9a6e3daa2be72e4aeca9bf085afd81b17L50-L56) * [`src/services/ChatService.ts`](diffhunk://#diff-9dd13b51c673219aa9d6fb02c8bc1100134172df11c9c69008b0b299c0b3d53fR22-R27): Added the `sendStreamMessage` function to handle streaming responses from the API. [[1]](diffhunk://#diff-9dd13b51c673219aa9d6fb02c8bc1100134172df11c9c69008b0b299c0b3d53fR22-R27) [[2]](diffhunk://#diff-9dd13b51c673219aa9d6fb02c8bc1100134172df11c9c69008b0b299c0b3d53fR49-R112) * [`src/types/chat.ts`](diffhunk://#diff-149830263521dd5458cda6d3607035614de3c2d8c55a4784ac632891361601e7R13-R45): Added new interfaces `StreamSettings` and `MessageHandlerConfig` to support streaming configurations. ### Code Refactoring: * [`src/components/ChatContainer/ChatContainer.tsx`](diffhunk://#diff-c0bfccaf03a29a5059f6a47c6dd2cdd194fcb1e06fe8e0e188d700a86204f073R3-L21): Refactored the `ChatContainerProps` interface to move it to the `types/chat.ts` file. * [`src/services/APIClient.ts`](diffhunk://#diff-c541918b610d84d9d4591faecad3008e1a8a244d446b8b929f3e017ea97fd558R54-R69): Added a new `fetchStream` method to handle streaming API requests.
1 parent 0776196 commit 92d204d

File tree

9 files changed

+256
-69
lines changed

9 files changed

+256
-69
lines changed

src/components/ChatContainer/ChatContainer.tsx

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,10 @@
11
import React, {useEffect, useRef, useState} from 'react';
22
import {useAuth0} from '@auth0/auth0-react';
3+
import {ChatContainerProps, ChatPayload, MessageHandlerConfig} from "../../types/chat.ts";
34
import {ApiChatMessage, ChatService, ClientChatMessage} from "../../services/ChatService.ts";
4-
import {ChatPayload} from "../../types/chat.ts";
5+
import {useNotification} from "../../context/useNotification.ts";
56
import {Message} from "../Message/Message.tsx";
67
import {ChatInput} from "../ChatInput/ChatInput.tsx";
7-
import {useNotification} from "../../context/useNotification.ts";
8-
9-
interface ModelSettings {
10-
temperature: number;
11-
maxTokens: number;
12-
topP: number;
13-
topK: number;
14-
}
15-
16-
interface ChatContainerProps {
17-
selectedTools: string[];
18-
modelSettings: ModelSettings;
19-
selectedChatId?: string; // Add this prop
20-
}
21-
228

239
export const ChatContainer: React.FC<ChatContainerProps> = ({
2410
modelSettings,
@@ -34,6 +20,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
3420
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
3521
const { isAuthenticated, loginWithRedirect } = useAuth0();
3622
const { getAccessTokenSilently } = useAuth0();
23+
const [useStreaming, setUseStreaming] = useState(true);
24+
25+
const handleStreamingChange = (value: boolean) => {
26+
setUseStreaming(value);
27+
};
3728

3829
const handleProviderChange = (provider: string, modelId: string) => {
3930
setSelectedProvider(provider);
@@ -98,14 +89,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
9889
);
9990
}
10091

101-
const handleMessageSubmit = async (message: string) => {
92+
const handleMessageSubmit = async (
93+
message: string,
94+
config: MessageHandlerConfig
95+
) => {
10296
setIsLoading(true);
10397

98+
// Add user message
10499
const newMessage: ClientChatMessage = {
105100
content: message,
106101
isUser: true
107102
};
108-
109103
setMessages(prev => [...prev, newMessage]);
110104

111105
try {
@@ -116,6 +110,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
116110
question: message,
117111
selectedTools,
118112
modelSettings,
113+
...(config.streamResponse && config.streamSettings && {
114+
stream_settings: {
115+
chunk_size: config.streamSettings.chunkSize,
116+
delay_ms: config.streamSettings.delayMs
117+
}
118+
}),
119119
...(chatUuid && { chat_uuid: chatUuid }),
120120
...(selectedProvider && selectedModelId && {
121121
llmProvider: {
@@ -125,21 +125,71 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
125125
})
126126
};
127127

128-
const data = await chatService.sendMessage(payload);
129-
130-
if (data.data.chat_uuid && !chatUuid) {
131-
setChatUuid(data.data.chat_uuid);
128+
if (config.streamResponse) {
129+
await handleStreamingResponse(chatService, payload);
130+
} else {
131+
await handleSyncResponse(chatService, payload);
132132
}
133-
134-
setMessages(prev => [...prev, { content: data.data.answer, isUser: false }]);
135133
} catch (error) {
136-
const errorMessage = error instanceof Error ? error.message : "Sorry, there was an error processing your request.";
137-
addNotification('error', errorMessage);
134+
addNotification(
135+
'error',
136+
error instanceof Error ? error.message : 'Failed to send message'
137+
);
138138
} finally {
139139
setIsLoading(false);
140140
}
141141
};
142142

143+
const handleStreamingResponse = async (
144+
chatService: ChatService,
145+
payload: ChatPayload
146+
) => {
147+
// Initialize empty assistant message
148+
const assistantMessage: ClientChatMessage = {
149+
content: '',
150+
isUser: false
151+
};
152+
setMessages(prev => [...prev, assistantMessage]);
153+
154+
let accumulatedContent = '';
155+
156+
await chatService.sendStreamMessage(
157+
payload,
158+
(chunk) => {
159+
if (!chunk.done) {
160+
accumulatedContent += chunk.content;
161+
setMessages(prev => {
162+
const newMessages = [...prev];
163+
const lastMessage = newMessages[newMessages.length - 1];
164+
if (!lastMessage.isUser) {
165+
newMessages[newMessages.length - 1] = {
166+
...lastMessage,
167+
content: accumulatedContent
168+
};
169+
}
170+
return newMessages;
171+
});
172+
}
173+
}
174+
);
175+
};
176+
177+
const handleSyncResponse = async (
178+
chatService: ChatService,
179+
payload: ChatPayload
180+
) => {
181+
const data = await chatService.sendMessage(payload);
182+
183+
if (data.data.chat_uuid && !chatUuid) {
184+
setChatUuid(data.data.chat_uuid);
185+
}
186+
187+
setMessages(prev => [...prev, {
188+
content: data.data.answer,
189+
isUser: false
190+
}]);
191+
};
192+
143193
return (
144194
<div className="max-w-6xl mx-auto chat-container overflow-hidden flex flex-col h-full">
145195
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400">
@@ -167,8 +217,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
167217
selectedProvider={selectedProvider}
168218
selectedModelId={selectedModelId}
169219
onProviderChange={handleProviderChange}
220+
useStreaming={useStreaming}
221+
onStreamingChange={handleStreamingChange}
170222
/>
171-
172223
</div>
173224
);
174225
};
Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,40 @@
11
import React, {KeyboardEvent, useState} from 'react';
22
import {ToolsToggle} from "../ChatInputButton/ToolsToggle.tsx";
3-
import {LLMProviderToggle} from "../LLMProviderToggle/LLMProviderToggle.tsx";
3+
import {LLMProviderToggle} from "../ChatInputButton/LLMProviderToggle.tsx";
4+
import {StreamingToggle} from "../ChatInputButton/StreamingToggle.tsx";
5+
6+
interface MessageHandlerConfig {
7+
streamResponse: boolean;
8+
streamSettings?: {
9+
chunkSize: number;
10+
delayMs: number;
11+
};
12+
}
413

514
interface ChatInputProps {
6-
onSubmit: (message: string) => Promise<void>;
15+
onSubmit: (message: string, config: MessageHandlerConfig) => Promise<void>;
716
isLoading: boolean;
817
selectedTools: string[];
918
onToolsChange: (tools: string[]) => void;
1019
selectedProvider: string | null;
1120
selectedModelId: string | null;
1221
onProviderChange: (provider: string, modelId: string) => void;
22+
useStreaming?: boolean;
23+
onStreamingChange: (value: boolean) => void;
1324
}
1425

26+
1527
export const ChatInput: React.FC<ChatInputProps> = ({
1628
onSubmit,
1729
isLoading,
1830
selectedTools,
1931
onToolsChange,
20-
selectedProvider, // Add this
21-
selectedModelId, // Add this
22-
onProviderChange // Add this
32+
selectedProvider,
33+
selectedModelId,
34+
onProviderChange,
35+
useStreaming = true,
36+
onStreamingChange,
2337
}) => {
24-
2538
const [inputValue, setInputValue] = useState('');
2639

2740
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
@@ -35,25 +48,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({
3548
e.preventDefault();
3649
if (!inputValue.trim() || isLoading) return;
3750

38-
await onSubmit(inputValue);
51+
const config: MessageHandlerConfig = {
52+
streamResponse: useStreaming,
53+
...(useStreaming && {
54+
streamSettings: {
55+
chunkSize: 1,
56+
delayMs: 10
57+
}
58+
})
59+
};
60+
61+
await onSubmit(inputValue, config);
3962
setInputValue('');
4063
};
4164

4265
return (
4366
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200">
4467
<div className="flex flex-col gap-2">
45-
<div className="flex gap-2">
68+
<div className="flex gap-2 items-center">
4669
<ToolsToggle
4770
selectedTools={selectedTools}
4871
onToolsChange={onToolsChange}
4972
/>
50-
5173
<LLMProviderToggle
5274
selectedProvider={selectedProvider}
5375
selectedModelId={selectedModelId}
5476
onProviderChange={onProviderChange}
5577
/>
56-
78+
<StreamingToggle
79+
isStreaming={useStreaming}
80+
onToggle={onStreamingChange}
81+
/>
5782
</div>
5883

5984
<textarea
@@ -66,4 +91,4 @@ export const ChatInput: React.FC<ChatInputProps> = ({
6691
</div>
6792
</form>
6893
);
69-
};
94+
};

src/components/LLMProviderToggle/LLMProviderToggle.tsx renamed to src/components/ChatInputButton/LLMProviderToggle.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,19 @@ export const LLMProviderToggle: React.FC<LLMProviderToggleProps> = ({
1616
}) => {
1717
const [isModalOpen, setIsModalOpen] = useState(false);
1818

19-
const getDisplayText = () => {
20-
if (!selectedProvider) return 'Select LLM Provider';
21-
return `${selectedProvider} (${selectedModelId})`;
22-
};
23-
2419
return (
2520
<>
2621
<button
2722
type="button"
2823
onClick={() => setIsModalOpen(true)}
29-
className={`px-3 py-1.5 text-sm rounded-lg transition-colors duration-200 w-fit flex items-center gap-2 ${
24+
title={selectedProvider ? `${selectedProvider} (${selectedModelId})` : 'Select LLM Provider'}
25+
className={`px-2 py-1 text-sm rounded-lg transition-colors duration-200 w-fit flex items-center gap-2 ${
3026
selectedProvider
3127
? 'bg-blue-600 text-white hover:bg-blue-700'
3228
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
3329
}`}
3430
>
3531
<CircleStackIcon className="h-4 w-4" />
36-
{getDisplayText()}
3732
</button>
3833

3934
<LLMProvidersModal
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
import { CiStreamOn, CiStreamOff } from 'react-icons/ci';
3+
4+
interface StreamingToggleProps {
5+
isStreaming: boolean;
6+
onToggle: (value: boolean) => void;
7+
}
8+
9+
export const StreamingToggle: React.FC<StreamingToggleProps> = ({ isStreaming, onToggle }) => {
10+
return (
11+
<button
12+
onClick={() => onToggle(!isStreaming)}
13+
className={`p-1 rounded transition-colors duration-200 ${
14+
isStreaming ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'
15+
} hover:bg-blue-50`}
16+
title={isStreaming ? "Streaming responses enabled" : "Streaming responses disabled"}
17+
>
18+
{isStreaming ? (
19+
<CiStreamOn size={16} />
20+
) : (
21+
<CiStreamOff size={16} />
22+
)}
23+
</button>
24+
);
25+
};

src/components/ChatInputButton/ToolsToggle.test.tsx

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,28 +33,6 @@ describe('ToolsToggle', () => {
3333
jest.clearAllMocks();
3434
});
3535

36-
37-
it('renders with default state (no tools selected)', () => {
38-
render(<ToolsToggle selectedTools={[]} onToolsChange={mockOnToolsChange} />);
39-
40-
const button = screen.getByRole('button');
41-
expect(button).toHaveTextContent('Tools Disabled');
42-
expect(button).toHaveClass('bg-gray-200');
43-
});
44-
45-
it('renders with selected tools count', () => {
46-
render(
47-
<ToolsToggle
48-
selectedTools={['tool1', 'tool2']}
49-
onToolsChange={mockOnToolsChange}
50-
/>
51-
);
52-
53-
const button = screen.getByRole('button');
54-
expect(button).toHaveTextContent('Tools (2)');
55-
expect(button).toHaveClass('bg-gray-800');
56-
});
57-
5836
it('opens modal when clicked', () => {
5937
render(<ToolsToggle selectedTools={[]} onToolsChange={mockOnToolsChange} />);
6038

src/components/ChatInputButton/ToolsToggle.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ export const ToolsToggle: React.FC<ToolsToggleButtonProps> = ({
2222
<button
2323
type="button"
2424
onClick={() => setIsModalOpen(true)}
25-
className={`px-3 py-1.5 text-sm rounded-lg transition-colors duration-200 w-fit flex items-center gap-2 ${
25+
title={selectedTools.length > 0 ? `Selected Tools (${selectedTools.length})` : 'Configure Tools'}
26+
className={`px-2 py-1 text-sm rounded-lg transition-colors duration-200 w-fit flex items-center gap-2 ${
2627
selectedTools.length > 0
2728
? 'bg-gray-800 text-white hover:bg-gray-700'
2829
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
2930
}`}
3031
>
3132
<WrenchScrewdriverIcon className="h-4 w-4" />
32-
Tools {selectedTools.length > 0 ? `(${selectedTools.length})` : 'Disabled'}
3333
</button>
3434

3535
<ToolsModal

src/services/APIClient.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,20 @@ export class APIClient {
5151
};
5252
}
5353
}
54+
55+
protected async fetchStream(
56+
endpoint: string,
57+
options: RequestInit = {}
58+
): Promise<Response> {
59+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
60+
...options,
61+
headers: this.getHeaders(),
62+
});
63+
64+
if (!response.ok) {
65+
throw new Error(`API Error: ${response.statusText}`);
66+
}
67+
68+
return response;
69+
}
5470
}

0 commit comments

Comments
 (0)