Commit 968999b8 authored by Labros Papadopoulos's avatar Labros Papadopoulos
Browse files

feat: native llm chatbot window

parent e6404fbb
Loading
Loading
Loading
Loading
+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';
@@ -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>
  );

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>
  );
}
+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 };
}
+7 −0
Changes for package-lock.json: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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",
@@ -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