Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/assets/images/8 - AI_Expanded.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/images/8 - Chat_AI_Dialogue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/images/8 - Chat_AI_placeholder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/images/8 - Chat_AI_problem.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/images/8 - Chat_AI_scroll.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/images/8 - Chat_AI_type1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/images/8 - Chat_AI_type2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1,217 changes: 1,202 additions & 15 deletions frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@
"react-dom": "18.3.1",
"react-error-boundary": "5.0.0",
"react-i18next": "15.4.0",
"react-markdown": "^10.1.0",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"rehype-sanitize": "^6.0.0",
"uuid": "11.0.4",
"yup": "1.6.1"
},
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import AuthProvider from 'common/providers/AuthProvider';
import AxiosProvider from 'common/providers/AxiosProvider';
import ToastProvider from 'common/providers/ToastProvider';
import ScrollProvider from 'common/providers/ScrollProvider';
import { AIChatProvider } from 'common/providers/AIChatProvider';
import Toasts from 'common/components/Toast/Toasts';
import AppRouter from 'common/components/Router/AppRouter';
import ThemeProvider from 'pages/Chat/context/ThemeContext';

import 'pages/Chat/styles/theme-variables.scss';
import './theme/main.css';

setupIonicReact({
Expand Down Expand Up @@ -58,9 +61,13 @@ const App = (): JSX.Element => {
<AxiosProvider>
<ToastProvider>
<ScrollProvider>
<AppRouter />
<Toasts />
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
<ThemeProvider>
<AIChatProvider>
<AppRouter />
<Toasts />
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
</AIChatProvider>
</ThemeProvider>
</ScrollProvider>
</ToastProvider>
</AxiosProvider>
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/common/components/Router/TabNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IonRouterOutlet, IonTabBar, IonTabButton, IonTabs } from '@ionic/react';
import { Redirect, Route } from 'react-router';
import { useAIChat } from 'common/providers/AIChatProvider';

import './TabNavigation.scss';
import AppMenu from '../Menu/AppMenu';
Expand All @@ -11,6 +12,7 @@ import UserEditPage from 'pages/Users/components/UserEdit/UserEditPage';
import AccountPage from 'pages/Account/AccountPage';
import ProfilePage from 'pages/Account/components/Profile/ProfilePage';
import DiagnosticsPage from 'pages/Account/components/Diagnostics/DiagnosticsPage';
import ChatPage from 'pages/Chat/ChatPage';

/**
* The `TabNavigation` component provides a router outlet for all of the
Expand All @@ -28,6 +30,8 @@ import DiagnosticsPage from 'pages/Account/components/Diagnostics/DiagnosticsPag
* @see {@link AppMenu}
*/
const TabNavigation = (): JSX.Element => {
const { openChat } = useAIChat();

return (
<>
<AppMenu />
Expand Down Expand Up @@ -56,6 +60,9 @@ const TabNavigation = (): JSX.Element => {
<Route exact path="/tabs/account/diagnostics">
<DiagnosticsPage />
</Route>
<Route exact path="/tabs/chat">
<ChatPage />
</Route>
<Route exact path="/">
<Redirect to="/tabs/home" />
</Route>
Expand Down Expand Up @@ -89,7 +96,14 @@ const TabNavigation = (): JSX.Element => {
/>
</div>
</IonTabButton>
<IonTabButton className="ls-tab-navigation__bar-button" tab="chat" href="/tabs/chat">
<IonTabButton
className="ls-tab-navigation__bar-button"
tab="chat"
onClick={(e) => {
e.preventDefault();
openChat();
}}
>
<Icon
className="ls-tab-navigation__bar-button-icon"
icon="comment"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ vi.mock('common/hooks/useAuth', () => ({
}),
}));

// Mock the useAIChat hook
vi.mock('common/providers/AIChatProvider', () => ({
useAIChat: () => ({
openChat: vi.fn(),
closeChat: vi.fn(),
isVisible: false
}),
AIChatProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>
}));

// Use a custom render that uses our minimal providers
const render = (ui: React.ReactElement) => {
return defaultRender(ui, { wrapper: WithMinimalProviders });
Expand Down Expand Up @@ -148,9 +158,10 @@ describe('TabNavigation', () => {
const uploadTab = screen.getByTestId('mock-icon-arrowUpFromBracket').closest('ion-tab-button');
expect(uploadTab).toHaveAttribute('href', '/tabs/upload');

// Check for chat tab button
// Check for chat tab button - Now uses onClick instead of href
const chatTab = screen.getByTestId('mock-icon-comment').closest('ion-tab-button');
expect(chatTab).toHaveAttribute('href', '/tabs/chat');
expect(chatTab).not.toHaveAttribute('href');
expect(chatTab).toHaveAttribute('tab', 'chat');

// Check for account tab button
const accountTab = screen.getByTestId('mock-icon-userCircle').closest('ion-tab-button');
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/common/models/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Types for the AI Chat feature
*/

/**
* Represents a single chat message in the UI
*/
export interface ChatMessage {
id: string;
text: string;
sender: 'user' | 'ai';
timestamp: Date;
isRead?: boolean;
}

/**
* API message format for Bedrock
*/
export interface BedrockMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}

/**
* Request payload for chat completion
*/
export interface ChatCompletionRequest {
messages: BedrockMessage[];
temperature?: number;
maxTokens?: number;
stream?: boolean;
}

/**
* Response from the chat completion API
*/
export interface ChatCompletionResponse {
message: BedrockMessage;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
model?: string;
}

/**
* Status of a chat session
*/
export enum ChatSessionStatus {
IDLE = 'idle',
LOADING = 'loading',
ERROR = 'error'
}

/**
* Chat session configuration
*/
export interface ChatSessionConfig {
maxHistoryLength?: number;
persistHistory?: boolean;
defaultGreeting?: string;
model?: string;
}
49 changes: 49 additions & 0 deletions frontend/src/common/providers/AIChatProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { createContext, useState, useContext, ReactNode } from 'react';
import AIChatContainer from 'pages/Chat/components/AIChatContainer/AIChatContainer';

// Context to manage the visibility of the AI Chat globally
interface AIChatContextType {
openChat: () => void;
closeChat: () => void;
isVisible: boolean;
}

const AIChatContext = createContext<AIChatContextType | undefined>(undefined);

// Custom hook for components to access the AI Chat context
export const useAIChat = () => {
const context = useContext(AIChatContext);
if (!context) {
throw new Error('useAIChat must be used within an AIChatProvider');
}
return context;
};

interface AIChatProviderProps {
children: ReactNode;
}

/**
* Provider component that makes AI Chat available throughout the app
*/
export const AIChatProvider: React.FC<AIChatProviderProps> = ({ children }) => {
const [isVisible, setIsVisible] = useState(false);

const openChat = () => {
setIsVisible(true);
};

const closeChat = () => {
setIsVisible(false);
};

return (
<AIChatContext.Provider value={{ openChat, closeChat, isVisible }}>
{children}
<AIChatContainer
isVisible={isVisible}
onClose={closeChat}
/>
</AIChatContext.Provider>
);
};
19 changes: 18 additions & 1 deletion frontend/src/pages/Chat/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonButton } from '@ionic/react';
import { useTranslation } from 'react-i18next';
import { useAIChat } from 'common/providers/AIChatProvider';
import { useEffect } from 'react';

/**
* The `ChatPage` component displays the chat interface.
* This page can be accessed from the tab navigation and serves as the full
* page version of the AI chat functionality.
* @returns JSX
*/
const ChatPage = (): JSX.Element => {
const { t } = useTranslation();
const { openChat } = useAIChat();

// Automatically open the chat when navigating to this page
useEffect(() => {
openChat();
}, [openChat]);

return (
<IonPage>
Expand All @@ -19,6 +29,13 @@ const ChatPage = (): JSX.Element => {
<div className="ion-padding">
<h1>{t('pages.chat.subtitle')}</h1>
<p>{t('pages.chat.description')}</p>

<IonButton
expand="block"
onClick={openChat}
>
{t('pages.chat.openButton')}
</IonButton>
</div>
</IonContent>
</IonPage>
Expand Down
119 changes: 119 additions & 0 deletions frontend/src/pages/Chat/components/AIChat/AIChat.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
.ai-chat {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: var(--chat-bg-color);
border-radius: 1.25rem 1.25rem 0 0;
box-shadow: 0 -0.25rem 1rem var(--chat-shadow-color);
overflow: hidden;
transition: height 0.3s ease-in-out;
position: relative;

&--expanded {
border-radius: 1.25rem;
height: 90vh;
max-height: 40rem;
}

&__content {
flex: 1;
overflow-y: auto;
padding: 1rem;
--background: var(--chat-bg-color);
}

&__messages {
display: flex;
flex-direction: column;
gap: 1rem;
padding-bottom: 1rem;
}

&__empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;

p {
font-size: 1rem;
color: var(--chat-secondary-text);
text-align: center;
margin-bottom: 1.5rem;
}
}

&__loading {
display: flex;
align-items: center;
margin: 0.5rem 0;
padding-left: 1rem;
}

&__loading-dots {
display: flex;
align-items: center;
gap: 0.25rem;

span {
width: 0.5rem;
height: 0.5rem;
background-color: #4765ff;
border-radius: 50%;
display: inline-block;
animation: bounce 1.4s infinite ease-in-out both;

&:nth-child(1) {
animation-delay: -0.32s;
}

&:nth-child(2) {
animation-delay: -0.16s;
}
}
}

&__error {
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0;
padding: 0.75rem 1rem;
background-color: var(--chat-error-bg);
border-radius: 0.5rem;
border-left: 0.25rem solid var(--chat-error-border);

p {
color: var(--chat-error-text);
margin: 0;
font-size: 0.875rem;
}
}

&__typing-container {
display: flex;
justify-content: flex-start;
margin: 0.5rem 1rem;
animation: fadeIn 0.3s ease-in-out;
}
}

@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}

@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
Loading
Loading