@@ -41,7 +41,7 @@ export default function AIPage() {
4141 const router = useRouter ( ) ;
4242 const [ profile , setProfile ] = useState < { first_name : string ; last_name : string } | null > ( null ) ;
4343 const [ profileLoading , setProfileLoading ] = useState ( true ) ;
44-
44+
4545 const [ messages , setMessages ] = useState < Message [ ] > ( [ ] ) ;
4646 const [ input , setInput ] = useState ( '' ) ;
4747 const [ isLoading , setIsLoading ] = useState ( false ) ;
@@ -61,7 +61,7 @@ export default function AIPage() {
6161 useEffect ( ( ) => {
6262 const fetchProfile = async ( ) => {
6363 if ( ! user ) return ;
64-
64+
6565 try {
6666 const supabase = createClient ( ) ;
6767 const { data, error } = await supabase
@@ -184,11 +184,28 @@ export default function AIPage() {
184184 setMessages ( prev => [ ...prev , userMessage ] ) ;
185185 setInput ( '' ) ;
186186 setIsLoading ( true ) ;
187- // setIsTyping(true); // Typing indicator disabled
188187 setShowSuggestions ( false ) ;
189188
190- // Don't add typing message to the messages array anymore
191- // We'll handle it with a separate state
189+ // Show immediate typing indicator while waiting for stream to start
190+ const thinkingId = `page-thinking-${ Date . now ( ) } ` ;
191+ const thinkingMessage : Message = {
192+ id : thinkingId ,
193+ text : '' ,
194+ sender : 'ai' ,
195+ timestamp : new Date ( ) ,
196+ isTyping : true
197+ } ;
198+ setMessages ( prev => [ ...prev , thinkingMessage ] ) ;
199+
200+ // Create streaming message placeholder (will replace thinking indicator)
201+ const streamingId = `page-streaming-${ Date . now ( ) } ` ;
202+ const streamingMessage : Message = {
203+ id : streamingId ,
204+ text : '' ,
205+ sender : 'ai' ,
206+ timestamp : new Date ( ) ,
207+ isTyping : false
208+ } ;
192209
193210 try {
194211 const response = await fetch ( '/api/ai' , {
@@ -203,26 +220,87 @@ export default function AIPage() {
203220 throw new Error ( `HTTP error! status: ${ response . status } ` ) ;
204221 }
205222
206- const data : AIResponse = await response . json ( ) ;
207-
208- // setIsTyping(false); // Typing indicator disabled
209-
210- if ( data . success ) {
211- const aiMessage : Message = {
212- id : `page-ai-${ Date . now ( ) + 1 } ` ,
213- text : data . response ,
214- sender : 'ai' ,
215- timestamp : new Date ( ) ,
216- context : data . context
217- } ;
218- setMessages ( prev => [ ...prev , aiMessage ] ) ;
223+ // Check if response is streaming (SSE) or regular JSON
224+ const contentType = response . headers . get ( 'content-type' ) ;
225+
226+ if ( contentType ?. includes ( 'text/event-stream' ) ) {
227+ // STREAMING MODE
228+ // Replace thinking indicator with streaming message
229+ setMessages ( prev => prev . filter ( m => m . id !== thinkingId ) . concat ( streamingMessage ) ) ;
230+
231+ const reader = response . body ?. getReader ( ) ;
232+ const decoder = new TextDecoder ( ) ;
233+ let accumulatedText = '' ;
234+ let currentContext = '' ;
235+
236+ if ( ! reader ) throw new Error ( 'No reader available' ) ;
237+
238+ while ( true ) {
239+ const { done, value } = await reader . read ( ) ;
240+
241+ if ( done ) break ;
242+
243+ const chunk = decoder . decode ( value ) ;
244+ const lines = chunk . split ( '\n' ) ;
245+
246+ for ( const line of lines ) {
247+ if ( line . startsWith ( 'data: ' ) ) {
248+ const data = line . slice ( 6 ) ;
249+
250+ try {
251+ const parsed = JSON . parse ( data ) ;
252+
253+ if ( parsed . done ) {
254+ break ;
255+ }
256+
257+ if ( parsed . error ) {
258+ throw new Error ( parsed . error ) ;
259+ }
260+
261+ if ( parsed . content ) {
262+ accumulatedText += parsed . content ;
263+ currentContext = parsed . context || currentContext ;
264+
265+ // Update the streaming message in real-time
266+ setMessages ( prev =>
267+ prev . map ( msg =>
268+ msg . id === streamingId
269+ ? { ...msg , text : accumulatedText , context : currentContext }
270+ : msg
271+ )
272+ ) ;
273+ }
274+ } catch {
275+ // Ignore malformed JSON
276+ continue ;
277+ }
278+ }
279+ }
280+ }
219281 } else {
220- throw new Error ( data . error || 'AI response was not successful' ) ;
282+ // NON-STREAMING MODE (fallback)
283+ const data : AIResponse = await response . json ( ) ;
284+
285+ if ( data . success ) {
286+ const aiMessage : Message = {
287+ id : `page-ai-${ Date . now ( ) + 1 } ` ,
288+ text : data . response ,
289+ sender : 'ai' ,
290+ timestamp : new Date ( ) ,
291+ context : data . context
292+ } ;
293+ setMessages ( prev => [ ...prev , aiMessage ] ) ;
294+ } else {
295+ throw new Error ( data . error || 'AI response was not successful' ) ;
296+ }
221297 }
222298 } catch ( error ) {
223299 console . error ( 'Error sending message:' , error ) ;
224- // setIsTyping(false); // Typing indicator disabled
225-
300+
301+ // Remove both thinking indicator and streaming message if present
302+ setMessages ( prev => prev . filter ( m => m . id !== thinkingId && m . id !== streamingId ) ) ;
303+
226304 const errorMessage : Message = {
227305 id : `page-error-${ Date . now ( ) + 1 } ` ,
228306 text : 'Sorry, I\'m having trouble connecting to the AI service. Please try again later.' ,
@@ -309,27 +387,27 @@ export default function AIPage() {
309387 </ div >
310388 < div className = "flex items-center gap-1 sm:gap-2" >
311389 < Link href = "/" >
312- < Button
313- variant = "ghost"
314- size = "sm"
390+ < Button
391+ variant = "ghost"
392+ size = "sm"
315393 className = "text-gray-400 hover:bg-gray-800 hover:text-gray-300 rounded-lg h-8 w-8 sm:h-9 sm:w-9 p-0"
316394 title = "Go to Homepage"
317395 >
318396 < Home className = "w-3.5 h-3.5 sm:w-4 sm:h-4" />
319397 </ Button >
320398 </ Link >
321- < Button
322- variant = "ghost"
323- size = "sm"
399+ < Button
400+ variant = "ghost"
401+ size = "sm"
324402 className = "text-gray-400 hover:bg-gray-800 hover:text-gray-300 rounded-lg h-8 w-8 sm:h-9 sm:w-9 p-0"
325403 onClick = { ( ) => window . location . reload ( ) }
326404 title = "Refresh Chat"
327405 >
328406 < RefreshCw className = "w-3.5 h-3.5 sm:w-4 sm:h-4" />
329407 </ Button >
330- < Button
331- variant = "ghost"
332- size = "sm"
408+ < Button
409+ variant = "ghost"
410+ size = "sm"
333411 className = "text-gray-400 hover:bg-gray-800 hover:text-gray-300 rounded-lg h-8 w-8 sm:h-9 sm:w-9 p-0"
334412 title = "More Options"
335413 >
@@ -343,7 +421,7 @@ export default function AIPage() {
343421 { /* Responsive Messages Container for All Screen Sizes */ }
344422 < div className = "flex-1 overflow-hidden bg-gray-900" >
345423 < div className = "max-w-6xl mx-auto h-full flex flex-col" >
346-
424+
347425 { /* Messages */ }
348426 < div className = "flex-1 overflow-y-auto px-4 md:px-6 py-4 sm:py-8" >
349427 < div className = "space-y-4 sm:space-y-6 max-w-4xl mx-auto" >
@@ -361,26 +439,24 @@ export default function AIPage() {
361439 </ p >
362440 </ div >
363441 ) }
364-
442+
365443 { messages . slice ( 1 ) . map ( ( message ) => (
366444 < div
367445 key = { message . id }
368- className = { `flex gap-3 sm:gap-4 group ${
369- message . sender === 'user' ? 'justify-end' : 'justify-start'
370- } `}
446+ className = { `flex gap-3 sm:gap-4 group ${ message . sender === 'user' ? 'justify-end' : 'justify-start'
447+ } `}
371448 >
372449 { message . sender === 'ai' && (
373450 < div className = "w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center flex-shrink-0 mt-1" >
374451 < CodeuniaLogo size = "sm" showText = { false } noLink = { true } instanceId = { `ai-msg-${ message . id } ` } />
375452 </ div >
376453 ) }
377-
454+
378455 < div
379- className = { `max-w-[80%] sm:max-w-[85%] group transition-all duration-200 ${
380- message . sender === 'user'
381- ? 'bg-gray-700 text-white rounded-2xl rounded-br-md'
382- : 'bg-transparent text-gray-100 rounded-2xl'
383- } px-4 sm:px-5 py-3 sm:py-4 relative`}
456+ className = { `max-w-[80%] sm:max-w-[85%] group transition-all duration-200 ${ message . sender === 'user'
457+ ? 'bg-gray-700 text-white rounded-2xl rounded-br-md'
458+ : 'bg-transparent text-gray-100 rounded-2xl'
459+ } px-4 sm:px-5 py-3 sm:py-4 relative`}
384460 >
385461 { message . isTyping ? (
386462 < div className = "flex items-center gap-2 sm:gap-3 py-2 sm:py-3" >
@@ -414,28 +490,28 @@ export default function AIPage() {
414490 ) : (
415491 < p className = "text-sm sm:text-base leading-relaxed whitespace-pre-wrap" > { message . text } </ p >
416492 ) }
417-
493+
418494 < div className = "flex items-center justify-between mt-2 sm:mt-3 gap-2 sm:gap-3" >
419- < span className = { `text-xs ${ message . sender === 'user' ? 'text-gray-300' : 'text-gray-500' } ` } >
495+ < span className = { `text-xs ${ message . sender === 'user' ? 'text-gray-300' : 'text-gray-500'
496+ } `} >
420497 { message . timestamp . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) }
421498 </ span >
422-
499+
423500 < div className = "flex items-center gap-1.5 sm:gap-2" >
424501 { message . context && message . sender === 'ai' && (
425- < Badge variant = "secondary" className = { ` text-xs bg-gray-800 text-gray-300 border-gray-700` } >
502+ < Badge variant = "secondary" className = " text-xs bg-gray-800 text-gray-300 border-gray-700" >
426503 { message . context }
427504 </ Badge >
428505 ) }
429-
430- < div className = { ` opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex gap-1` } >
506+
507+ < div className = " opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex gap-1" >
431508 < Button
432509 variant = "ghost"
433510 size = "sm"
434- className = { `h-6 w-6 p-0 rounded-lg transition-all duration-200 ${
435- message . sender === 'user'
436- ? 'text-gray-300 hover:text-white hover:bg-gray-600'
437- : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'
438- } `}
511+ className = { `h-6 w-6 p-0 rounded-lg transition-all duration-200 ${ message . sender === 'user'
512+ ? 'text-gray-300 hover:text-white hover:bg-gray-600'
513+ : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'
514+ } `}
439515 onClick = { ( ) => copyMessage ( message . text ) }
440516 title = "Copy message"
441517 >
@@ -458,7 +534,7 @@ export default function AIPage() {
458534 </ >
459535 ) }
460536 </ div >
461-
537+
462538 { message . sender === 'user' && (
463539 < div className = "w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center flex-shrink-0 mt-1" >
464540 < User className = "w-4 h-4 text-gray-300" />
@@ -493,7 +569,7 @@ export default function AIPage() {
493569 </ div >
494570 </ div >
495571 ) }
496-
572+
497573 < div className = "relative" >
498574 < Textarea
499575 ref = { textareaRef }
@@ -508,7 +584,7 @@ export default function AIPage() {
508584 className = "resize-none min-h-[52px] sm:min-h-[60px] max-h-[140px] sm:max-h-[160px] w-full rounded-2xl sm:rounded-3xl border-2 border-gray-700 focus:border-gray-600 focus:ring-0 bg-gray-800 text-gray-100 placeholder-gray-500 px-5 sm:px-6 py-4 sm:py-5 pr-14 sm:pr-16 text-base sm:text-lg leading-relaxed transition-all duration-200 hover:border-gray-600"
509585 rows = { 1 }
510586 />
511-
587+
512588 < Button
513589 onClick = { handleSendClick }
514590 disabled = { isLoading || ! input . trim ( ) }
@@ -518,7 +594,7 @@ export default function AIPage() {
518594 { isLoading ? < Loader2 className = "w-5 h-5 sm:w-6 sm:h-6 animate-spin" /> : < Send className = "w-5 h-5 sm:w-6 sm:h-6" /> }
519595 </ Button >
520596 </ div >
521-
597+
522598 < div className = "flex items-center justify-center mt-3 sm:mt-4 px-2" >
523599 < p className = "text-xs text-gray-500 text-center leading-relaxed" >
524600 Unio may display inaccurate info, including about people, so double-check its responses.
0 commit comments