11import { useState , useEffect , useRef } from "react" ;
2- import { AlertCircle } from "lucide-react" ;
2+ import { AlertCircle , Download } from "lucide-react" ;
33import MessageBubble from "./MessageBubble" ;
4- import { sendMessage , checkHealth } from "@/utils/api" ;
4+ import { sendMessageStream , checkHealth } from "@/utils/api" ;
55import { toast } from "sonner" ;
66import type { Message } from "@/types/chat" ;
77
@@ -65,7 +65,7 @@ const ChatSection = () => {
6565 const [ inputValue , setInputValue ] = useState ( "" ) ;
6666 const [ isLoading , setIsLoading ] = useState ( false ) ;
6767 const [ isServerOnline , setIsServerOnline ] = useState ( true ) ;
68- const [ typingMessageId , setTypingMessageId ] = useState < string | null > ( null ) ;
68+ const [ streamingMessageId , setStreamingMessageId ] = useState < string | null > ( null ) ;
6969 const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
7070 const isInitialLoad = useRef ( true ) ;
7171 const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
@@ -107,14 +107,33 @@ const ChatSection = () => {
107107 return ( ) => clearInterval ( interval ) ;
108108 } , [ ] ) ;
109109
110+ const handleExport = ( ) => {
111+ if ( messages . length <= 1 ) {
112+ toast . info ( "Nothing to export yet — start a conversation first!" ) ;
113+ return ;
114+ }
115+ const lines = messages . map ( ( m ) => {
116+ const time = m . timestamp . toLocaleTimeString ( [ ] , { hour : "2-digit" , minute : "2-digit" } ) ;
117+ return m . isUser
118+ ? `**[${ time } ] You:** ${ m . text } `
119+ : `**[${ time } ] AI:** ${ m . text } ` ;
120+ } ) ;
121+ const content = `# Chat Export\n\n_Exported on ${ new Date ( ) . toLocaleString ( ) } _\n\n---\n\n${ lines . join ( "\n\n---\n\n" ) } ` ;
122+ const blob = new Blob ( [ content ] , { type : "text/markdown" } ) ;
123+ const url = URL . createObjectURL ( blob ) ;
124+ const a = document . createElement ( "a" ) ;
125+ a . href = url ;
126+ a . download = `chat-${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .md` ;
127+ a . click ( ) ;
128+ URL . revokeObjectURL ( url ) ;
129+ } ;
130+
110131 const handleSendMessage = async ( messageText : string ) => {
111132 if ( ! messageText . trim ( ) ) return ;
112133 if ( ! isServerOnline ) {
113134 toast . error ( "AI server is currently offline. Please try again later." ) ;
114135 return ;
115136 }
116- // Finish any ongoing typewriter immediately
117- setTypingMessageId ( null ) ;
118137
119138 historyRef . current = [ messageText , ...historyRef . current ] ;
120139 historyIndexRef . current = - 1 ;
@@ -132,36 +151,55 @@ const ChatSection = () => {
132151 setIsLoading ( true ) ;
133152 requestAnimationFrame ( scrollChatToBottom ) ;
134153
135- try {
136- const response = await sendMessage ( messageText ) ;
137- const aiId = crypto . randomUUID ( ) ;
138- const aiMessage : Message = {
139- id : aiId ,
140- text : response . success
141- ? response . response
142- : response . response || "Sorry, I'm having trouble connecting right now. Please try again later." ,
143- isUser : false ,
144- timestamp : new Date ( ) ,
145- } ;
146- if ( ! response . success ) toast . error ( "Failed to get response. Please try again." ) ;
147- setMessages ( ( prev ) => [ ...prev , aiMessage ] ) ;
148- setTypingMessageId ( aiId ) ;
149- } catch ( error ) {
150- console . error ( "Error getting AI response:" , error ) ;
151- toast . error ( "Failed to get response. Please try again later." ) ;
152- setMessages ( ( prev ) => [
153- ...prev ,
154- {
155- id : crypto . randomUUID ( ) ,
156- text : "Sorry, I'm having trouble connecting right now. Please try again later." ,
157- isUser : false ,
158- timestamp : new Date ( ) ,
159- } ,
160- ] ) ;
161- } finally {
162- setIsLoading ( false ) ;
163- inputRef . current ?. focus ( ) ;
164- }
154+ const aiId = crypto . randomUUID ( ) ;
155+
156+ await sendMessageStream (
157+ messageText ,
158+ // onChunk: first chunk creates the message, subsequent ones append
159+ ( chunk ) => {
160+ setIsLoading ( false ) ;
161+ setMessages ( ( prev ) => {
162+ const existing = prev . find ( ( m ) => m . id === aiId ) ;
163+ if ( existing ) {
164+ return prev . map ( ( m ) => ( m . id === aiId ? { ...m , text : m . text + chunk } : m ) ) ;
165+ }
166+ // First chunk — add message and mark it streaming
167+ setStreamingMessageId ( aiId ) ;
168+ requestAnimationFrame ( scrollChatToBottom ) ;
169+ return [
170+ ...prev ,
171+ { id : aiId , text : chunk , isUser : false , timestamp : new Date ( ) } ,
172+ ] ;
173+ } ) ;
174+ } ,
175+ // onDone
176+ ( ) => {
177+ setIsLoading ( false ) ;
178+ setStreamingMessageId ( null ) ;
179+ inputRef . current ?. focus ( ) ;
180+ } ,
181+ // onError
182+ ( error ) => {
183+ console . error ( "Stream error:" , error ) ;
184+ setIsLoading ( false ) ;
185+ setStreamingMessageId ( null ) ;
186+ toast . error ( "Failed to get response. Please try again later." ) ;
187+ setMessages ( ( prev ) => {
188+ const hasAiMessage = prev . some ( ( m ) => m . id === aiId ) ;
189+ if ( hasAiMessage ) return prev ; // partial response already shown, keep it
190+ return [
191+ ...prev ,
192+ {
193+ id : aiId ,
194+ text : "Sorry, I'm having trouble connecting right now. Please try again later." ,
195+ isUser : false ,
196+ timestamp : new Date ( ) ,
197+ } ,
198+ ] ;
199+ } ) ;
200+ inputRef . current ?. focus ( ) ;
201+ }
202+ ) ;
165203 } ;
166204
167205 const handleKeyPress = ( e : React . KeyboardEvent ) => {
@@ -222,28 +260,52 @@ const ChatSection = () => {
222260 { isServerOnline ? "online" : "offline" }
223261 </ span >
224262 </ span >
225- { /* Server status dot */ }
226- < span
227- style = { {
228- fontSize : "11px" ,
229- color : isServerOnline ? "var(--term-green)" : "var(--term-red)" ,
230- display : "flex" ,
231- alignItems : "center" ,
232- gap : "4px" ,
233- } }
234- >
263+ < div style = { { display : "flex" , alignItems : "center" , gap : "10px" } } >
264+ { /* Server status dot */ }
235265 < span
236266 style = { {
237- width : "6px" ,
238- height : "6px" ,
239- borderRadius : "50%" ,
240- backgroundColor : isServerOnline ? "var(--term-green)" : "var(--term-red)" ,
241- display : "inline-block" ,
242- animation : isServerOnline ? "neuralPulse 2s ease-in-out infinite" : "none" ,
267+ fontSize : "11px" ,
268+ color : isServerOnline ? "var(--term-green)" : "var(--term-red)" ,
269+ display : "flex" ,
270+ alignItems : "center" ,
271+ gap : "4px" ,
243272 } }
244- />
245- { isServerOnline ? "server:online" : "server:offline" }
246- </ span >
273+ >
274+ < span
275+ style = { {
276+ width : "6px" ,
277+ height : "6px" ,
278+ borderRadius : "50%" ,
279+ backgroundColor : isServerOnline ? "var(--term-green)" : "var(--term-red)" ,
280+ display : "inline-block" ,
281+ animation : isServerOnline ? "neuralPulse 2s ease-in-out infinite" : "none" ,
282+ } }
283+ />
284+ { isServerOnline ? "server:online" : "server:offline" }
285+ </ span >
286+ { /* Export button */ }
287+ < button
288+ onClick = { handleExport }
289+ title = "Export chat as Markdown"
290+ style = { {
291+ background : "transparent" ,
292+ border : "1px solid var(--term-border)" ,
293+ color : "var(--term-dim)" ,
294+ cursor : "pointer" ,
295+ padding : "2px 6px" ,
296+ display : "flex" ,
297+ alignItems : "center" ,
298+ gap : "4px" ,
299+ fontSize : "11px" ,
300+ fontFamily : "inherit" ,
301+ transition : "color 0.15s, border-color 0.15s" ,
302+ } }
303+ className = "chat-quick-btn"
304+ >
305+ < Download size = { 11 } />
306+ export
307+ </ button >
308+ </ div >
247309 </ div >
248310
249311 { /* Header command */ }
@@ -296,8 +358,7 @@ const ChatSection = () => {
296358 < div key = { message . id } className = "msg-slide-up" >
297359 < MessageBubble
298360 message = { message }
299- isTyping = { typingMessageId === message . id }
300- onTypingComplete = { ( ) => setTypingMessageId ( null ) }
361+ isStreaming = { streamingMessageId === message . id }
301362 />
302363 </ div >
303364 ) ) }
0 commit comments