Loading app/layout.tsx +2 −2 Changes for app/layout.tsx: 2 added lines, 2 removed lines. Original line number Diff line number Diff line import React from 'react'; import { NextUIProvider } from '@nextui-org/react'; import Providers from './providers'; import type { Metadata } from 'next'; import { Roboto } from 'next/font/google'; import './globals.css'; Loading @@ -23,7 +23,7 @@ export default function RootLayout({ return ( <html lang="en" className={roboto.className}> <body> <NextUIProvider className="h-full">{children}</NextUIProvider> <Providers>{children}</Providers> </body> </html> ); Loading app/providers.tsx 0 → 100644 +30 −0 Changes for app/providers.tsx: 30 added lines, 0 removed lines. Original line number Diff line number Diff line 'use client'; import React from 'react'; import { usePathname } from 'next/navigation'; import { SessionProvider } from 'next-auth/react'; import { NextUIProvider } from '@nextui-org/react'; import ChatWidget from '@/components/chat-widget'; // Pages whose map has zoom controls in the bottom-right corner. The launcher is // lifted above them there. Exact match: '/' must not match every route. const MAP_PATHS = ['/']; function Widget() { const pathname = usePathname(); const onMap = pathname !== null && MAP_PATHS.includes(pathname); return <ChatWidget bottomOffset={onMap ? 120 : 24} />; } // Client-side wrapper: SessionProvider and NextUIProvider are client components, // and ChatWidget needs both (useSession + NextUI Button/Textarea). export default function Providers({ children }: { children: React.ReactNode }) { return ( <SessionProvider> <NextUIProvider className="h-full"> {children} <Widget /> </NextUIProvider> </SessionProvider> ); } components/chat-widget.tsx 0 → 100644 +208 −0 Changes for components/chat-widget.tsx: 208 added lines, 0 removed lines. Original line number Diff line number Diff line 'use client'; import React, { useEffect, useRef, useState } from 'react'; import { useSession } from 'next-auth/react'; import { Button, Textarea } from '@nextui-org/react'; import { useHypoChat } from '@/hooks/useHypoChat'; // adjust to wherever you put the hook type ChatWidgetProps = { /** Distance of the launcher from the bottom of the screen, in px. Raise it on pages * where something else (e.g. map zoom controls) lives in the bottom-right corner. */ bottomOffset?: number; }; export default function ChatWidget({ bottomOffset = 24 }: ChatWidgetProps) { const { data: session, status } = useSession(); // One conversation per page load, so the UI history and the server-side // memory reset together. Replace with a persisted ID if you store history. const [conversationId, setConversationId] = useState('default'); useEffect(() => setConversationId(crypto.randomUUID()), []); const { messages, send, stop, isStreaming, error } = useHypoChat(conversationId); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(''); const listRef = useRef<HTMLDivElement>(null); // Keep the newest text in view while tokens stream in useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, open]); // Escape closes the panel. Capture phase, because NextUI inputs stop key events from bubbling. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [open]); // Only users with a real Keycloak token get the assistant. This also excludes the // test Credentials login (it has no accessToken) and sessions whose refresh failed. if (status !== 'authenticated' || !session?.accessToken || session.error) return null; const submit = () => { const text = draft.trim(); if (!text || isStreaming) return; setDraft(''); void send(text); }; const last = messages[messages.length - 1]; const waitingForFirstToken = isStreaming && last?.role === 'assistant' && last.content === ''; return ( <div className="fixed right-6 z-[1000] max-sm:right-4" style={{ bottom: bottomOffset }}> {open && ( <section role="dialog" aria-label="HypO AI Assistant" style={ { '--chat-h': `min(560px, calc(100vh - ${bottomOffset + 96}px))`, } as React.CSSProperties } className="absolute bottom-[72px] right-0 flex h-[var(--chat-h)] w-[380px] flex-col overflow-hidden rounded-2xl border border-divider bg-content1 text-foreground shadow-2xl max-sm:fixed max-sm:inset-0 max-sm:h-auto max-sm:w-auto max-sm:rounded-none max-sm:border-0 max-sm:pb-[env(safe-area-inset-bottom)] max-sm:pt-[env(safe-area-inset-top)]" > <header className="flex items-center justify-between gap-3 bg-maestro-black px-4 py-3 text-white"> <div> <h2 className="text-base font-semibold">HypO Assistant</h2> <p className="text-xs text-maestro-black-30">Ask a question or request an action</p> </div> <Button isIconOnly size="sm" variant="light" className="text-white" onPress={() => setOpen(false)} aria-label="Close chat" > <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg> </Button> </header> <div ref={listRef} role="log" aria-live="polite" className="flex flex-1 flex-col gap-2 overflow-y-auto overscroll-contain p-4" > {messages.map((m, i) => // the empty assistant bubble is replaced by the typing indicator below m.role === 'assistant' && m.content === '' ? null : ( <div key={i} className={ 'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3.5 py-2 text-sm leading-relaxed ' + (m.role === 'user' ? 'self-end rounded-br-sm bg-maestro-turquoise text-white' : 'self-start rounded-bl-sm bg-default-100 text-foreground') } > {m.content} </div> ), )} {waitingForFirstToken && ( <div aria-label="Assistant is typing" className="flex items-center gap-1 self-start rounded-2xl rounded-bl-sm bg-default-100 px-3.5 py-3" > {[0, 150, 300].map((delay) => ( <i key={delay} style={{ animationDelay: `${delay}ms` }} className="h-1.5 w-1.5 animate-pulse rounded-full bg-default-500 motion-reduce:animate-none" /> ))} </div> )} </div> {error && ( <div role="alert" className="mx-4 mb-2 rounded-lg bg-maestro-red/15 px-3 py-2 text-sm text-maestro-red" > {error} </div> )} <div className="flex items-end gap-2 border-t border-divider p-3"> <Textarea autoFocus aria-label="Message" placeholder="Type your message" variant="bordered" minRows={1} maxRows={4} maxLength={4000} value={draft} onValueChange={setDraft} onKeyDown={(e) => { // Enter sends, Shift+Enter adds a new line; ignore Enter while an IME is composing if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); } }} classNames={{ base: 'flex-1', inputWrapper: 'min-h-10' }} /> {isStreaming ? ( <Button isIconOnly className="bg-maestro-turquoise text-white" onPress={stop} aria-label="Stop response" > <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> <rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" /> </svg> </Button> ) : ( <Button isIconOnly className="bg-maestro-turquoise text-white" onPress={submit} isDisabled={!draft.trim()} aria-label="Send message" > <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> <path d="M4 12l16-8-6 16-2-6-8-2z" fill="currentColor" /> </svg> </Button> )} </div> </section> )} <Button isIconOnly radius="full" className="ml-auto h-14 w-14 min-w-14 bg-maestro-turquoise text-white shadow-lg" onPress={() => setOpen((o) => !o)} aria-label={open ? 'Close HypO assistant' : 'Open HypO assistant'} aria-expanded={open} > <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"> <path d="M4 5h16v11H9l-5 4V5z" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" /> </svg> </Button> </div> ); } hooks/useHypoChat.ts 0 → 100644 +98 −0 Changes for hooks/useHypoChat.ts: 98 added lines, 0 removed lines. Original line number Diff line number Diff line 'use client'; import { useCallback, useRef, useState } from 'react'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { getSession } from 'next-auth/react'; export type Message = { role: 'user' | 'assistant'; content: string }; const API_URL = process.env.NEXT_PUBLIC_HYPO_API_URL + '/customer-support-agent'; const WELCOME = "Welcome to HypO's AI Assistant! How can I help you today?"; class FatalError extends Error {} export function useHypoChat(conversationId = 'default') { const [messages, setMessages] = useState<Message[]>([{ role: 'assistant', content: WELCOME }]); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState<string | null>(null); const abortRef = useRef<AbortController | null>(null); const send = useCallback( async (text: string) => { if (!text.trim() || isStreaming) return; setError(null); setIsStreaming(true); // Ask NextAuth for the session right before sending. This hits /api/auth/session, // which runs your jwt/session callbacks and refreshes the Keycloak token if it // expired. useSession() alone can hand back a stale token on a long-open page. const session = await getSession(); const token = session?.accessToken; if (!token || session?.error) { setError('Session expired. Please sign in again.'); setIsStreaming(false); return; } // user message + empty assistant message that we fill token by token setMessages((m) => [ ...m, { role: 'user', content: text }, { role: 'assistant', content: '' }, ]); const ctrl = new AbortController(); abortRef.current = ctrl; try { await fetchEventSource(API_URL, { method: 'POST', signal: ctrl.signal, openWhenHidden: true, // don't abort/restart when the tab loses focus headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ message: text, conversationId }), async onopen(res) { if (res.ok && res.headers.get('content-type')?.startsWith('text/event-stream')) return; if (res.status === 401) throw new FatalError('Session expired. Please sign in again.'); throw new FatalError(`Request failed (${res.status})`); }, onmessage(ev) { const evt = JSON.parse(ev.data) as { type: string; content?: string }; if (evt.type === 'token') { setMessages((m) => { const copy = [...m]; const last = copy[copy.length - 1]; copy[copy.length - 1] = { ...last, content: last.content + (evt.content ?? '') }; return copy; }); } else if (evt.type === 'error') { setError(evt.content ?? 'Unknown error'); } // "done": nothing to do, the server closes the stream }, // Rethrow so the library does NOT auto-retry (a retry would re-POST the same message). onerror(err) { throw err; }, }); } catch (e) { if (!ctrl.signal.aborted) setError(e instanceof Error ? e.message : 'Connection error'); } finally { setIsStreaming(false); abortRef.current = null; } }, [conversationId, isStreaming], ); const stop = useCallback(() => abortRef.current?.abort(), []); return { messages, send, stop, isStreaming, error }; } package-lock.json +7 −0 Changes for package-lock.json: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -10,6 +10,7 @@ "dependencies": { "@hookform/resolvers": "^3.9.0", "@internationalized/date": "^3.5.5", "@microsoft/fetch-event-source": "^2.0.1", "@nextui-org/react": "^2.4.8", "@supabase/supabase-js": "^2.45.4", "@xterm/xterm": "^5.5.0", Loading Loading @@ -694,6 +695,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@microsoft/fetch-event-source": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", "license": "MIT" }, "node_modules/@next/env": { "version": "14.2.33", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.33.tgz", Loading Loading
app/layout.tsx +2 −2 Changes for app/layout.tsx: 2 added lines, 2 removed lines. Original line number Diff line number Diff line import React from 'react'; import { NextUIProvider } from '@nextui-org/react'; import Providers from './providers'; import type { Metadata } from 'next'; import { Roboto } from 'next/font/google'; import './globals.css'; Loading @@ -23,7 +23,7 @@ export default function RootLayout({ return ( <html lang="en" className={roboto.className}> <body> <NextUIProvider className="h-full">{children}</NextUIProvider> <Providers>{children}</Providers> </body> </html> ); Loading
app/providers.tsx 0 → 100644 +30 −0 Changes for app/providers.tsx: 30 added lines, 0 removed lines. Original line number Diff line number Diff line 'use client'; import React from 'react'; import { usePathname } from 'next/navigation'; import { SessionProvider } from 'next-auth/react'; import { NextUIProvider } from '@nextui-org/react'; import ChatWidget from '@/components/chat-widget'; // Pages whose map has zoom controls in the bottom-right corner. The launcher is // lifted above them there. Exact match: '/' must not match every route. const MAP_PATHS = ['/']; function Widget() { const pathname = usePathname(); const onMap = pathname !== null && MAP_PATHS.includes(pathname); return <ChatWidget bottomOffset={onMap ? 120 : 24} />; } // Client-side wrapper: SessionProvider and NextUIProvider are client components, // and ChatWidget needs both (useSession + NextUI Button/Textarea). export default function Providers({ children }: { children: React.ReactNode }) { return ( <SessionProvider> <NextUIProvider className="h-full"> {children} <Widget /> </NextUIProvider> </SessionProvider> ); }
components/chat-widget.tsx 0 → 100644 +208 −0 Changes for components/chat-widget.tsx: 208 added lines, 0 removed lines. Original line number Diff line number Diff line 'use client'; import React, { useEffect, useRef, useState } from 'react'; import { useSession } from 'next-auth/react'; import { Button, Textarea } from '@nextui-org/react'; import { useHypoChat } from '@/hooks/useHypoChat'; // adjust to wherever you put the hook type ChatWidgetProps = { /** Distance of the launcher from the bottom of the screen, in px. Raise it on pages * where something else (e.g. map zoom controls) lives in the bottom-right corner. */ bottomOffset?: number; }; export default function ChatWidget({ bottomOffset = 24 }: ChatWidgetProps) { const { data: session, status } = useSession(); // One conversation per page load, so the UI history and the server-side // memory reset together. Replace with a persisted ID if you store history. const [conversationId, setConversationId] = useState('default'); useEffect(() => setConversationId(crypto.randomUUID()), []); const { messages, send, stop, isStreaming, error } = useHypoChat(conversationId); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(''); const listRef = useRef<HTMLDivElement>(null); // Keep the newest text in view while tokens stream in useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, open]); // Escape closes the panel. Capture phase, because NextUI inputs stop key events from bubbling. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [open]); // Only users with a real Keycloak token get the assistant. This also excludes the // test Credentials login (it has no accessToken) and sessions whose refresh failed. if (status !== 'authenticated' || !session?.accessToken || session.error) return null; const submit = () => { const text = draft.trim(); if (!text || isStreaming) return; setDraft(''); void send(text); }; const last = messages[messages.length - 1]; const waitingForFirstToken = isStreaming && last?.role === 'assistant' && last.content === ''; return ( <div className="fixed right-6 z-[1000] max-sm:right-4" style={{ bottom: bottomOffset }}> {open && ( <section role="dialog" aria-label="HypO AI Assistant" style={ { '--chat-h': `min(560px, calc(100vh - ${bottomOffset + 96}px))`, } as React.CSSProperties } className="absolute bottom-[72px] right-0 flex h-[var(--chat-h)] w-[380px] flex-col overflow-hidden rounded-2xl border border-divider bg-content1 text-foreground shadow-2xl max-sm:fixed max-sm:inset-0 max-sm:h-auto max-sm:w-auto max-sm:rounded-none max-sm:border-0 max-sm:pb-[env(safe-area-inset-bottom)] max-sm:pt-[env(safe-area-inset-top)]" > <header className="flex items-center justify-between gap-3 bg-maestro-black px-4 py-3 text-white"> <div> <h2 className="text-base font-semibold">HypO Assistant</h2> <p className="text-xs text-maestro-black-30">Ask a question or request an action</p> </div> <Button isIconOnly size="sm" variant="light" className="text-white" onPress={() => setOpen(false)} aria-label="Close chat" > <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg> </Button> </header> <div ref={listRef} role="log" aria-live="polite" className="flex flex-1 flex-col gap-2 overflow-y-auto overscroll-contain p-4" > {messages.map((m, i) => // the empty assistant bubble is replaced by the typing indicator below m.role === 'assistant' && m.content === '' ? null : ( <div key={i} className={ 'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3.5 py-2 text-sm leading-relaxed ' + (m.role === 'user' ? 'self-end rounded-br-sm bg-maestro-turquoise text-white' : 'self-start rounded-bl-sm bg-default-100 text-foreground') } > {m.content} </div> ), )} {waitingForFirstToken && ( <div aria-label="Assistant is typing" className="flex items-center gap-1 self-start rounded-2xl rounded-bl-sm bg-default-100 px-3.5 py-3" > {[0, 150, 300].map((delay) => ( <i key={delay} style={{ animationDelay: `${delay}ms` }} className="h-1.5 w-1.5 animate-pulse rounded-full bg-default-500 motion-reduce:animate-none" /> ))} </div> )} </div> {error && ( <div role="alert" className="mx-4 mb-2 rounded-lg bg-maestro-red/15 px-3 py-2 text-sm text-maestro-red" > {error} </div> )} <div className="flex items-end gap-2 border-t border-divider p-3"> <Textarea autoFocus aria-label="Message" placeholder="Type your message" variant="bordered" minRows={1} maxRows={4} maxLength={4000} value={draft} onValueChange={setDraft} onKeyDown={(e) => { // Enter sends, Shift+Enter adds a new line; ignore Enter while an IME is composing if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); } }} classNames={{ base: 'flex-1', inputWrapper: 'min-h-10' }} /> {isStreaming ? ( <Button isIconOnly className="bg-maestro-turquoise text-white" onPress={stop} aria-label="Stop response" > <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> <rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" /> </svg> </Button> ) : ( <Button isIconOnly className="bg-maestro-turquoise text-white" onPress={submit} isDisabled={!draft.trim()} aria-label="Send message" > <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> <path d="M4 12l16-8-6 16-2-6-8-2z" fill="currentColor" /> </svg> </Button> )} </div> </section> )} <Button isIconOnly radius="full" className="ml-auto h-14 w-14 min-w-14 bg-maestro-turquoise text-white shadow-lg" onPress={() => setOpen((o) => !o)} aria-label={open ? 'Close HypO assistant' : 'Open HypO assistant'} aria-expanded={open} > <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"> <path d="M4 5h16v11H9l-5 4V5z" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" /> </svg> </Button> </div> ); }
hooks/useHypoChat.ts 0 → 100644 +98 −0 Changes for hooks/useHypoChat.ts: 98 added lines, 0 removed lines. Original line number Diff line number Diff line 'use client'; import { useCallback, useRef, useState } from 'react'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { getSession } from 'next-auth/react'; export type Message = { role: 'user' | 'assistant'; content: string }; const API_URL = process.env.NEXT_PUBLIC_HYPO_API_URL + '/customer-support-agent'; const WELCOME = "Welcome to HypO's AI Assistant! How can I help you today?"; class FatalError extends Error {} export function useHypoChat(conversationId = 'default') { const [messages, setMessages] = useState<Message[]>([{ role: 'assistant', content: WELCOME }]); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState<string | null>(null); const abortRef = useRef<AbortController | null>(null); const send = useCallback( async (text: string) => { if (!text.trim() || isStreaming) return; setError(null); setIsStreaming(true); // Ask NextAuth for the session right before sending. This hits /api/auth/session, // which runs your jwt/session callbacks and refreshes the Keycloak token if it // expired. useSession() alone can hand back a stale token on a long-open page. const session = await getSession(); const token = session?.accessToken; if (!token || session?.error) { setError('Session expired. Please sign in again.'); setIsStreaming(false); return; } // user message + empty assistant message that we fill token by token setMessages((m) => [ ...m, { role: 'user', content: text }, { role: 'assistant', content: '' }, ]); const ctrl = new AbortController(); abortRef.current = ctrl; try { await fetchEventSource(API_URL, { method: 'POST', signal: ctrl.signal, openWhenHidden: true, // don't abort/restart when the tab loses focus headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ message: text, conversationId }), async onopen(res) { if (res.ok && res.headers.get('content-type')?.startsWith('text/event-stream')) return; if (res.status === 401) throw new FatalError('Session expired. Please sign in again.'); throw new FatalError(`Request failed (${res.status})`); }, onmessage(ev) { const evt = JSON.parse(ev.data) as { type: string; content?: string }; if (evt.type === 'token') { setMessages((m) => { const copy = [...m]; const last = copy[copy.length - 1]; copy[copy.length - 1] = { ...last, content: last.content + (evt.content ?? '') }; return copy; }); } else if (evt.type === 'error') { setError(evt.content ?? 'Unknown error'); } // "done": nothing to do, the server closes the stream }, // Rethrow so the library does NOT auto-retry (a retry would re-POST the same message). onerror(err) { throw err; }, }); } catch (e) { if (!ctrl.signal.aborted) setError(e instanceof Error ? e.message : 'Connection error'); } finally { setIsStreaming(false); abortRef.current = null; } }, [conversationId, isStreaming], ); const stop = useCallback(() => abortRef.current?.abort(), []); return { messages, send, stop, isStreaming, error }; }
package-lock.json +7 −0 Changes for package-lock.json: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -10,6 +10,7 @@ "dependencies": { "@hookform/resolvers": "^3.9.0", "@internationalized/date": "^3.5.5", "@microsoft/fetch-event-source": "^2.0.1", "@nextui-org/react": "^2.4.8", "@supabase/supabase-js": "^2.45.4", "@xterm/xterm": "^5.5.0", Loading Loading @@ -694,6 +695,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@microsoft/fetch-event-source": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", "license": "MIT" }, "node_modules/@next/env": { "version": "14.2.33", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.33.tgz", Loading