change header columns elements in airwatch

This commit is contained in:
Tykayn 2025-08-14 12:18:20 +02:00 committed by tykayn
parent 219fa8c4f9
commit fa8a7ca996
215 changed files with 26292 additions and 45 deletions

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ApiService } from './api-service';
describe('ApiService', () => {
let service: ApiService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ApiService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,484 @@
import {Injectable} from '@angular/core';
import {HttpClient, HttpErrorResponse, HttpHeaders} from '@angular/common/http';
import {from, Observable, Subject, throwError} from 'rxjs';
import {catchError, finalize, takeUntil, tap} from 'rxjs/operators';
import {Store} from '@ngrx/store';
import {ActionTypes, StateInterface} from './reducers';
import {ChatbotMessage} from './services/chatbot.message.type';
/**
* Interface for Open-Meteo API response
*/
export interface OpenMeteoResponse {
latitude: number;
longitude: number;
generationtime_ms: number;
utc_offset_seconds: number;
timezone: string;
timezone_abbreviation: string;
elevation: number;
hourly_units: {
time: string;
temperature_2m: string;
};
hourly: {
time: string[];
temperature_2m: number[];
};
}
/**
* gérer la connexion avec le backend
*/
@Injectable({
providedIn: 'root'
})
export class ApiService {
/**
* /api/v1
* /messages
* /{userId}/conversations
* /{conversationId}/feedback
* /conversations/{conversationId}
* /conversations/{conversationId}/last-answer
* /send-documents
* /file // lien pour récupérer un doc via s3
*
*/
private BASE_URL = "";
// Ollama API URL - default to localhost:11434
private OLLAMA_API_URL = "http://localhost:11434";
// Default Ollama model to use
// private OLLAMA_MODEL = "llava";
// private OLLAMA_MODEL = "mistral:7b";
private OLLAMA_MODEL = "tinyllama:latest";
private ENDPOINTS: any = {
"main": {
"v1": {
"messages": "messages",
"user": {
"conversations": "/{userId}/conversations",
"feedback": "/user-feedback"
},
// /{userId}/conversations
// /{conversationId}/feedback
// /conversations/{conversationId}
// /conversations/{conversationId}/last-answer
// /send-documents
// /file
"conversations": {
"id": "/{conversationId}",
"feedback": "/{conversationId}/feedback",
"last-anwser": "/{conversationId}/last-answer",
},
"send-documents": "send-documents",
"file": "file",
}
}
};
private CUSTOM_HEADERS: any = {
"method.response.header.Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
"method.response.header.Access-Control-Allow-Methods": "OPTIONS,POST",
"method.response.header.Access-Control-Allow-Origin": "'*'",
}
constructor(private http: HttpClient, private store: Store<StateInterface>) {
}
async fetchEndPoint(kind: string, subKind: string): Promise<Observable<Object>> {
// Record the start time for measuring response time
const startTime = Date.now();
let fetchUrl: string = `${this.BASE_URL}/api/v1`
if (undefined !== this.ENDPOINTS.main.v1[kind]) {
fetchUrl += this.ENDPOINTS.main.v1[kind]
if (undefined !== this.ENDPOINTS.main.v1[kind][subKind]) {
fetchUrl += '/' + this.ENDPOINTS.main.v1[kind][subKind]
}
}
// Get the response and pipe it to measure response time
const resp: Observable<Object> = await this.http.get(fetchUrl,
{headers: this.CUSTOM_HEADERS}
).pipe(
tap(() => {
// Measure and update the response time
this.measureResponseTime(startTime);
})
);
return resp;
}
/**
* Fetches weather forecast data from the Open-Meteo API
*
* @returns Observable with the typed JSON response from Open-Meteo API
*
* Example usage:
* ```
* this.apiService.fetchDemoResponse().subscribe(
* (data) => {
* console.log('Weather data:', data);
* console.log('Temperature at noon:', data.hourly.temperature_2m[12]);
* },
* (error) => console.error('Error fetching weather data:', error)
* );
* ```
*/
fetchDemoResponse(): Observable<OpenMeteoResponse> {
// Coordinates for Corbeil-Essonne
const latitude = "48.57727";
const longitude = "4.42634";
const url = 'https://api.open-meteo.com/v1/forecast';
// Record the start time for measuring response time
const startTime = Date.now();
// Get the current average response time for the delay
return new Observable<OpenMeteoResponse>(observer => {
this.store.select(state => state.app.averageResponseTime).pipe(
tap(averageResponseTime => {
// Use the average response time for the delay
setTimeout(() => {
this.http.get<OpenMeteoResponse>(url, {
params: {
latitude,
longitude,
hourly: 'temperature_2m',
forecast_days: '1'
}
}).pipe(
tap(response => {
console.log('Open-Meteo API Response:', response);
// Measure and update the response time
this.measureResponseTime(startTime);
}),
catchError(this.handleError)
).subscribe({
next: (response) => {
observer.next(response);
observer.complete();
},
error: (error) => {
observer.error(error);
}
});
}, averageResponseTime); // Use the average response time for the delay
})
).subscribe().unsubscribe(); // Unsubscribe after getting the value
});
}
/**
* Send user feedback to the API
* @param feedback The feedback text from the user
* @returns Observable with the API response
*/
async sendUserFeedback(feedback: string): Promise<Observable<Object>> {
// Record the start time for measuring response time
const startTime = Date.now();
const fetchUrl: string = `${this.BASE_URL}/api/v1/user-feedback`;
// Get the response and pipe it to measure response time
const resp: Observable<Object> = await this.http.post(
fetchUrl,
{feedback},
{headers: this.CUSTOM_HEADERS}
).pipe(
tap(() => {
// Measure and update the response time
this.measureResponseTime(startTime);
})
);
return resp;
}
/**
* Check if the Ollama backend is available
* @returns Observable that emits true if Ollama is available, false otherwise
*/
checkOllamaAvailability(): Observable<boolean> {
return new Observable<boolean>(observer => {
// Make a simple request to the Ollama API to check if it's available
this.http.get(`${this.OLLAMA_API_URL}/api/tags`, {
headers: new HttpHeaders({
'Content-Type': 'application/json'
}),
observe: 'response'
}).pipe(
catchError(error => {
console.error('Error checking Ollama availability:', error);
// Update the Redux store to indicate Ollama is not available
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
ollamaAvailable: false
}
});
// Return false to indicate Ollama is not available
return from([false]);
})
).subscribe({
next: (response) => {
// If we get a response, Ollama is available
// Check if response is an HttpResponse (has status property)
const isAvailable = typeof response === 'object' && response !== null && 'status' in response && response.status === 200;
// Update the Redux store with the result
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
ollamaAvailable: isAvailable
}
});
// Emit the result and complete the observable
observer.next(isAvailable);
observer.complete();
},
error: () => {
// If there's an error, Ollama is not available
observer.next(false);
observer.complete();
}
});
});
}
/**
* Query the Ollama backend with a prompt and handle streaming responses
* @param prompt The text prompt to send to Ollama
* @returns Observable that completes when the full response is received
*/
queryOllama(prompt: string): Observable<void> {
// Create a subject to notify when to complete the stream
const complete$ = new Subject<void>();
// Record the start time for measuring response time
const startTime = Date.now();
// Set loading state to true
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
loading: true
}
});
// Create an empty response to accumulate the streamed content
let accumulatedResponse = '';
// Create the request payload
const payload = {
model: this.OLLAMA_MODEL,
prompt: 'réponds en Français en étant un assistant amical et respectueux. Ta réponse doit être consiste steuplai. Et fais des retours à la ligne dans tes réponses. Voici ma question : ' + prompt,
stream: false,
// raw: true,
};
// Set up headers for the request
const headers = new HttpHeaders({
'Content-Type': 'application/json'
});
// Create a subject that will emit the final response
const result = new Subject<void>();
// Make the request to Ollama API
this.http.post(`${this.OLLAMA_API_URL}/api/generate`, payload, {
headers,
responseType: 'json',
observe: 'response'
})
.pipe(
takeUntil(complete$),
finalize(() => {
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
loading: false
}
});
this.measureResponseTime(startTime);
// If we have accumulated a response, create a message and add it to the conversation
if (accumulatedResponse) {
accumulatedResponse = accumulatedResponse.replace(/\. /g, ". <br/><br/>");
const responseMessage = new ChatbotMessage({
kind: 'llm',
user: {},
content: accumulatedResponse,
name: 'Ollama'
});
// Dispatch the message to the active conversation
this.store.dispatch({
type: ActionTypes.POST_MESSAGE_TO_ACTIVE_CONVERSATION,
payload: {
message: responseMessage
}
});
// Get the current active conversation from the store
this.store.select(state => state.activeConversation).subscribe(activeConv => {
if (activeConv && Object.keys(activeConv).length > 0) {
// Update the conversation in the conversations list
this.store.dispatch({
type: ActionTypes.UPDATE_SINGLE_CONVERSATION,
payload: {
conversation: activeConv
}
});
}
}).unsubscribe(); // Unsubscribe immediately after getting the value
}
// Complete the result subject
result.complete();
})
)
.subscribe({
next: (response) => {
console.log('Ollama response:', response);
if (response.body) {
// Process the non-streaming response
// Ollama returns a single JSON object with a 'response' field
try {
const responseData = response.body as any;
if (responseData.response) {
// Set the accumulated response text
accumulatedResponse = responseData.response;
console.log('Ollama response:', accumulatedResponse);
// Complete the stream
complete$.next();
complete$.complete();
} else {
console.error('No response field found in Ollama response:', responseData);
}
} catch (e) {
console.error('Error processing Ollama response:', e);
}
}
},
error: (error) => {
console.error('Error querying Ollama:', error);
// Set loading state to false
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
loading: false,
displayErrorResponse: true
}
});
// Send error message to chat
const errorMessage = new ChatbotMessage({
kind: 'llm',
user: {},
content: "Error connecting to Ollama backend. Please check if the service is running.",
name: 'System'
});
this.store.dispatch({
type: ActionTypes.POST_MESSAGE_TO_ACTIVE_CONVERSATION,
payload: {
message: errorMessage
}
});
// Get the current active conversation from the store
this.store.select(state => state.activeConversation).subscribe(activeConv => {
if (activeConv && Object.keys(activeConv).length > 0) {
// Update the conversation in the conversations list
this.store.dispatch({
type: ActionTypes.UPDATE_SINGLE_CONVERSATION,
payload: {
conversation: activeConv
}
});
}
}).unsubscribe(); // Unsubscribe immediately after getting the value
// Reset error state after 3 seconds
setTimeout(() => {
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
displayErrorResponse: false
}
});
}, 3000);
// Complete the subjects
complete$.next();
complete$.complete();
result.error(error);
}
});
return result.asObservable();
}
/**
* Updates the average response time in the store
* @param responseTime The actual response time in milliseconds
*/
private updateAverageResponseTime(responseTime: number): void {
// Get the current average response time from the store
this.store.select(state => state.app.averageResponseTime).subscribe(currentAverage => {
// Calculate the new average (average of current average and new response time)
const newAverage = Math.round((currentAverage + responseTime) / 2);
// Update the store with the new average
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
averageResponseTime: newAverage
}
});
}).unsubscribe(); // Unsubscribe immediately after getting the value
}
/**
* Measures the response time of an API call and updates the average
* @param startTime The timestamp when the API call started
*/
private measureResponseTime(startTime: number): void {
const endTime = Date.now();
const responseTime = endTime - startTime;
this.updateAverageResponseTime(responseTime);
}
/**
* Error handler for HTTP requests
* @param error The HTTP error response
* @returns An observable that errors with a user-friendly message
*/
private handleError(error: HttpErrorResponse): Observable<never> {
let errorMessage = 'An unknown error occurred';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = `Error: ${error.error.message}`;
} else {
// Server-side error
errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
}
console.error(errorMessage);
return throwError(() => new Error(errorMessage));
}
}

View file

@ -0,0 +1,12 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes))
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View file

@ -0,0 +1,20 @@
import {ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection, isDevMode} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideHttpClient, withFetch} from '@angular/common/http';
import { provideStore } from '@ngrx/store';
import { reducers, metaReducers } from './reducers';
import { provideStoreDevtools } from '@ngrx/store-devtools';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withFetch()),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideStore(reducers, { metaReducers }),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() })
]
};

View file

@ -0,0 +1,32 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
</style>
<main class="main">
<div class="content">
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div aria-label="Divider" class="divider" role="separator"></div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet/>

View file

@ -0,0 +1,8 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Prerender
}
];

View file

@ -0,0 +1,41 @@
import {Routes} from '@angular/router';
import {ColorsPage} from './pages/colors-page/colors-page';
import {AirwatchDemo} from './pages/airwatch-demo/airwatch-demo';
import {Csc} from './pages/csc/csc';
import {LayoutDemo} from './pages/layout-demo/layout-demo';
export const routes: Routes = [
{
path: '',
component: LayoutDemo,
title: 'Démo Layout'
},
{
path: 'home',
component: LayoutDemo,
title: 'Démo Layout'
},
{
path: 'grid',
loadComponent: () => import('./pages/grid-demo/grid-demo').then(m => m.GridDemo),
title: 'Démo AG Grid'
},
{
path: 'colors',
component: ColorsPage,
},
{
path: 'airwatch',
component: AirwatchDemo,
},
{
path: 'csc',
component: Csc,
},
{
path: '*',
redirectTo: 'home',
pathMatch: 'full'
}
];

View file

View file

@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, my-app');
});
});

View file

@ -0,0 +1,22 @@
import {Component, signal} from '@angular/core';
import {RouterOutlet} from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `
<div id="app_main_page">
<main>
<router-outlet/>
</main>
<footer class="main-footer">
</footer>
</div>
`,
styles: [`
`],
})
export class App {
protected readonly title = signal('Exemple de Boutons');
}

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth-service';
describe('AuthService', () => {
let service: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,35 @@
import {Injectable} from '@angular/core';
import {Inject} from '@angular/core';
import {DOCUMENT} from '@angular/common';
@Injectable({
providedIn: 'root'
})
export class AuthService {
// check if we have auth at load, or redirect to the given app url
app_check_url: string = "https://example.com/check";
app_auth_url: string = "https://example.com/auth";
auth_data: any = null;
private window: WindowProxy & typeof globalThis | null;
constructor(@Inject(DOCUMENT) private document: Document) {
this.window = this.document.defaultView;
this.checkAuthData()
}
checkAuthData() {
if (this.auth_data) {
fetch(this.app_auth_url).then(resp => {
if (resp.status !== 200) {
this.redirectToAuth()
}
})
}
}
redirectToAuth() {
this.window?.open(this.app_auth_url)
}
}

View file

@ -0,0 +1,297 @@
<div [ngClass]="{
'is-expanded-left': appState.displayConversationListPanelLarge,
'is-small-left': ! appState.displayConversationListPanelLarge,
'is-expanded-right': appState.displaySourcesPanelLarge,
'is-small-right': ! appState.displaySourcesPanelLarge,
}" class="visible-imbrication chatbot-land"
id="layout_demo"
>
<div class="demo-airwatch">
<div class="layout-split">
<nav aria-label="main navigation" class="navbar" role="navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/#">
<!-- <i class="ri-robot-2-fill"></i>-->
<img alt="safran logo" class="logo" src="/safran_logo_large.svg">
</a>
<a aria-expanded="false" aria-label="menu" class="navbar-burger" data-target="navbarBasicExample"
role="button">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu" id="airwatchNavigation">
<div class="navbar-start">
<a (click)="toggleSidePanelConversationsList()" class="navbar-item aside-toggle-button">
<span class="label">
airwatch
</span>
@if (appState.displayConversationListPanelLarge) {
<i class="ri-sidebar-unfold-line"></i>
} @else {
<i class="ri-sidebar-fold-line"></i>
}
</a>
</div>
<div class="navbar-end">
<a (click)="toggleSourcesPanel()" class="navbar-item aside-toggle-button">
@if (appState.displaySourcesPanelLarge) {
<i class="ri-layout-right-2-line"></i>
} @else {
<i class="ri-layout-right-line"></i>
}
</a>
<a class="navbar-item">
Documentation
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
<i class="ri-notification-2-line"></i>
</a>
<div class="navbar-dropdown">
<a class="navbar-item">
About
</a>
<a class="navbar-item is-selected">
Jobs
</a>
<a class="navbar-item">
Contact
</a>
<hr class="navbar-divider">
<a class="navbar-item">
Report an issue
</a>
</div>
</div>
</div>
</div>
</nav>
<div class="chatbot-container-box">
<main class="columns ">
<div
[ngClass]="{'is-expanded': appState.displayConversationListPanelLarge, 'is-small': ! appState.displayConversationListPanelLarge}"
class="aside column-conversation ">
<div (click)="newChat()" class="new-button">
<i class="ri-chat-ai-line"></i>
<span class="label">
New chat
</span>
</div>
@if (appState.displayConversationListPanelLarge) {
<div class="search-button is-expanded is-clickable">
<i class="ri-search-line"></i>
<input type="text" class="no-borders" placeholder="Search a chat" value="blah">
<i class="funnel ri-filter-3-line"></i>
</div>
} @else {
<div class="search-button is-clickable">
<i class="ri-search-line"></i>
</div>
}
@if (appState.displayConversationListPanelLarge) {
<div class="conversation-container">
@for (conversation of conversationsList; track conversation) {
<div class="conversation-item"
[ngClass]="{'is-active' : conversation.name == activeConversation?.name }"
(click)="updateActiveConversation(conversation)">
<!-- @if (conversation.pinned) {-->
<!-- }-->
<!-- <i class="ri-arrow-right-s-line"></i>-->
<div class="container-text">
<span class="pinned-icon">
<i class="ri-pushpin-line"></i>
<!-- pinned-->
</span>
<span class="name">
{{ conversation.name || 'New Conversation' }}
</span>
<span class="date">
{{ conversation.lastMessageDate | date: 'dd/MM/yyyy' }}
</span>
<span class="description">
{{ conversation.description || 'No description' }}
</span>
<input type="text" [(ngModel)]="conversation.name"
[ngClass]="{'is-expanded': ! conversation.visibleInput}">
<!-- <i class="ri-pencil-line" (click)="toggleVisibleEditInput(conversation)"></i>-->
</div>
<div class="active-peak">
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 7L0 13.9282V0.0717969L12 7Z" fill="#3B87CC" fill-opacity="0.1"/>
</svg>
</div>
</div>
}
<app-version-infos [versionInfo]="appVersion"></app-version-infos>
</div>
<!-- <div class="admin-actions debug">-->
<!-- <app-theme-selector></app-theme-selector>-->
<!-- <app-language-selector></app-language-selector>-->
<!-- <br>-->
<!-- <app-main-button (click)="changeTheme()" icon="chat-new-line" label="thème"></app-main-button>-->
<!-- <app-main-button (click)="newChat()" icon="chat-new-line" label="nouveau"></app-main-button>-->
<!-- <app-main-button (click)="addMockMessage('user')" icon="user-line"-->
<!-- label="ajout message utilisateur"></app-main-button>-->
<!-- <app-main-button (click)="addMockMessage('llm')" icon="robot-2-line"-->
<!-- label="ajout message llm"></app-main-button>-->
<!-- <app-tuto-tour></app-tuto-tour>-->
<!-- </div>-->
}
</div>
<div class="chat chat-column ">
<div id="newChatBar">
<div class="new-chat-title">
<div class="title">
{{ translate('newChat') }}
</div>
<div class="chips">
@if (displayChipsTitleConversations) {
<div class="chips-container is-grey">
<i class="ri-pushpin-line"></i>
<span class="label">
Chips land
</span>
</div>
}
</div>
<div class="action">
@if (displayExportConversation) {
<div class="export-chat-button is-clickable">
<i class="ri-download-2-line"></i>
<span class="label">
Export chat
</span>
</div>
}
</div>
</div>
</div>
<!-- tchatt-->
@if (!activeConversation?.messages?.length) {
<app-new-input></app-new-input>
} @else {
<div #conversationContainer class="main-conversation-container">
<!-- main-conversation-container-->
<!-- <div class="top-bar">-->
<!-- top bar-->
<!-- <div class="conversation-title">-->
<!-- {{ activeConversation?.name || 'New Conversation' }}-->
<!-- </div>-->
<!-- <div class="conversation-typing">-->
<!-- typing-->
<!-- </div>-->
<!-- </div>-->
<div class="conversation-messages-container">
@if (activeConversation && activeConversation.messages) {
@for (message of activeConversation.messages; track message) {
<app-message-box [message]="message" [kind]="message.kind"
[content]="message.content"></app-message-box>
}
}
@if (appState.loading) {
<app-loading-notification></app-loading-notification>
}
<!-- <app-time-separator></app-time-separator>-->
<!-- <sae-alert-box _alertKind="error" message="">System error - I couldnt process your request.-->
<!-- Please-->
<!-- try-->
<!-- again in-->
<!-- few moments-->
<!-- </sae-alert-box>-->
</div>
<div class="conversation-bottom">
<app-prompt-input></app-prompt-input>
<app-tools-options [alignLeft]="true" [hideDisabledButtons]="true"></app-tools-options>
</div>
</div>
}
<div class="bottom-warning-container">
<app-warning-bugs></app-warning-bugs>
</div>
</div>
<div [ngClass]="{'expanded': appState.displaySourcesPanelLarge}" class="column panel-more">
<div class="has-text-right">
<div class="export-chat-button is-clickable">
<i class="ri-download-2-line"></i>
<span class="label">
Export all sources
</span>
</div>
</div>
<div class="panel-more-inside">
<div class="main-title">Knowledge Base documents :</div>
<div class="sources-list">
@if (activeConversation && activeConversation.sources && activeConversation.sources.length > 0) {
@for (source of activeConversation.sources; track source) {
<app-source-block [source]="source"></app-source-block>
}
} @else {
<div class="no-sources">
Aucune source disponible
</div>
}
</div>
</div>
</div>
<app-feedback-button></app-feedback-button>
</main>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,93 @@
.panel-more-inside {
border-radius: 10px;
background: #F5F5F5;
padding: 20px 16px;
min-height: 100vh;
margin-top: 14px;
.main-title {
color: #1E1F22;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
.sources-list {
margin-top: 17px;
}
.source {
border-radius: 4px;
background: #ECF3FA;
}
}
.chips-container {
display: inline-flex;
padding: 4px 10px 6px 10px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 6px;
background: #979797;
margin-left: 12px;
margin-right: 12px;
position: relative;
top: -3px;
i {
color: white;
}
.label {
color: #FFF;
text-align: center;
font-family: Barlow;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 8px; /* 66.667% */
}
}
.export-chat-button {
position: relative;
right: 0;
display: inline-flex;
height: 44px;
padding: 0 20px;
justify-content: center;
align-items: center;
gap: 10px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid #005AA2;
color: #005AA2;
text-align: center;
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 100px; /* 500% */
}
.aside-toggle-button {
color: white;
color: #FFF;
font-family: Barlow;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: normal;
text-transform: uppercase;
.label {
margin-right: 22px;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Chatbot } from './chatbot';
describe('Chatbot', () => {
let component: Chatbot;
let fixture: ComponentFixture<Chatbot>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Chatbot]
})
.compileComponents();
fixture = TestBed.createComponent(Chatbot);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,323 @@
import {AfterViewChecked, Component, ElementRef, ViewChild} from '@angular/core';
import {PromptInput} from './prompt-input/prompt-input';
import {MessageBox} from './message-box/message-box';
import {NewInput} from './new-input/new-input';
import {FeedbackButton} from './feedback-button/feedback-button';
import {WarningBugs} from './warning-bugs/warning-bugs';
import {VersionInfos} from './version-infos/version-infos';
import {LoadingNotification} from './loading-notification/loading-notification';
import {SourceBlock} from './source-block/source-block';
import {ChatbotConversation, ChatbotSource, ConversationsService} from '../services/conversations.service';
import {Store} from '@ngrx/store';
import {ActionTypes, StateInterface} from '../reducers';
import {FormsModule} from '@angular/forms';
import {ApiService} from '../api-service';
import {CommonModule} from '@angular/common';
import {ChatbotMessage, ChatbotMessageKind} from '../services/chatbot.message.type';
import {ToolsOptions} from './tools-options/tools-options';
import {TranslationService} from '../services/translation.service';
@Component({
selector: 'app-chatbot',
imports: [
PromptInput,
MessageBox,
NewInput,
FeedbackButton,
WarningBugs,
VersionInfos,
LoadingNotification,
SourceBlock,
FormsModule,
CommonModule,
ToolsOptions,
],
templateUrl: './chatbot.html',
styleUrl: './chatbot.scss'
})
export class Chatbot implements AfterViewChecked {
public conversationsList: Array<ChatbotConversation> = [];
public activeConversation: ChatbotConversation | null = null;
public appVersion: string = '';
public appState: any = '';
conversationName: any;
// fonctions d'exports
visibleInput: boolean = false;
// demo tour lors du premier chargement
displayChipsTitleConversations = true;
displayExportConversation: boolean = true;
@ViewChild('conversationContainer') private conversationContainer: ElementRef | null = null;
private shouldScrollToBottom: boolean = false;
constructor(
private conversations: ConversationsService,
private apiService: ApiService,
private store: Store<StateInterface>,
private translationService: TranslationService
) {
// Initialize conversations in the store
this.store.dispatch({
type: ActionTypes.UPDATE_CONVERSATIONS_LIST,
payload: conversations.chatbotConversations
});
// Subscribe to conversations list from store
this.store.select(state => state.conversationsList).subscribe(convList => {
this.conversationsList = convList;
if (!this.conversationsList.length) {
this.newChat();
}
});
// Subscribe to active conversation from store
this.store.select(state => state.activeConversation).subscribe(activeConv => {
if (activeConv && Object.keys(activeConv).length > 0) {
const prevMessagesLength = this.activeConversation?.messages?.length || 0;
this.activeConversation = activeConv as ChatbotConversation;
const newMessagesLength = this.activeConversation?.messages?.length || 0;
console.log('prevMessagesLength', prevMessagesLength, newMessagesLength);
// Set flag to scroll to bottom when active conversation changes or when messages are added
if (prevMessagesLength !== newMessagesLength) {
this.shouldScrollToBottom = true;
}
} else if (this.conversationsList.length > 0) {
this.updateActiveConversation(this.conversationsList[0]);
}
});
// Get app version from store
this.store.select(state => state.app.version).subscribe(version => {
this.appVersion = version;
});
// Get app state from store
this.store.select(state => state.app).subscribe(app => {
this.appState = app;
});
// à désactiver en mode production
this.demoConversation();
}
/**
* Lifecycle hook that is called after the view has been checked
* Used to scroll to the bottom of the conversation container when needed
*/
ngAfterViewChecked(): void {
if (this.shouldScrollToBottom && this.conversationContainer) {
this.scrollToBottom();
this.shouldScrollToBottom = false;
}
}
/**
* Translates a key using the translation service
* @param key The key to translate
* @param defaultValue Optional default value if translation is not found
* @returns The translated string
*/
translate(key: string, defaultValue?: string): string {
return this.translationService.translate(key, defaultValue);
}
demoConversation() {
// Add example sources if in demo mode
if (this.appState.demoMode && this.activeConversation) {
console.log('mode démo: on ajoute des demoConversation');
// Create a proper instance of ChatbotConversation from the active conversation
const updatedConversation: ChatbotConversation = ChatbotConversation.fromObject(this.activeConversation);
this.addMockMessage("user");
// Add three example sources
let source1 = new ChatbotSource();
source1.title = "abc-1258.pdf";
source1.url = "https://example.com/source1";
source1.description = "Admittedly Air Traffic Control in smaller airports are being effected by ...";
let source2 = new ChatbotSource();
source2.title = "abc-45689.pdf";
source2.url = "https://example.com/source1";
source2.description = "DE GAGNE DATE: JANUARY 18, 2008 VEN #: V6-CAW-M2700-10 APPROVED BY: MARTIN SWAN DATE: FEBRUARY 5, 2008 -TITLE- DHC-6 ELEVATOR CONTROL CABLE WEAR SURVEY RESULTS ISSUE: 2 PAGE 2 respondents, representing 16 aircraft, operate outside the tropics and have a cycle/hour ratio of 1.6 to 2.8. Most respondents have reported that carbon steel elevator control cables are more wear resistant than stainless steel cables. Two operators in the tropics, representing 39 aircraft, use.\n" +
"I remember having reconunended while dealing with the Guwahati crash in regard to a Vayudoot aircraft that the National Airports Authority should ensure that only trained and";
updatedConversation.sources = [source1, source2];
const conversation2 = new ChatbotConversation();
conversation2.name = "Liste de courses";
conversation2.addMessage(new ChatbotMessage({
kind: 'user',
user: {},
content: "Comment puis-je utiliser cette application?",
name: "User"
}));
conversation2.addMessage(new ChatbotMessage({
kind: 'llm',
user: {},
content: "Cette application vous permet de discuter avec un assistant IA. Vous pouvez poser des questions et obtenir des réponses.",
name: "Assistant"
}));
// Update the active conversation in the store
this.updateActiveConversation(updatedConversation);
// Update the conversations list in the store
const updatedList = this.conversationsList.map(conv =>
conv === this.activeConversation ? updatedConversation : conv
);
updatedList.push(conversation2)
this.store.dispatch({
type: ActionTypes.UPDATE_CONVERSATIONS_LIST,
payload: updatedList
});
// Set the first conversation as active
this.updateActiveConversation(updatedConversation);
}
}
updateActiveConversation(conversation: ChatbotConversation) {
this.store.dispatch({
type: ActionTypes.UPDATE_ACTIVE_CONVERSATION,
payload: conversation
});
}
addMockMessage(kind: ChatbotMessageKind, content?: string) {
if (this.activeConversation) {
// Create a proper instance of ChatbotConversation from the active conversation
const updatedConversation = ChatbotConversation.fromObject(this.activeConversation);
// Add the message to the copy
updatedConversation.addMessage(new ChatbotMessage({
kind: kind,
user: {},
content: content ? content : "blah",
name: "Mock Message"
}));
// Update the active conversation in the store
this.updateActiveConversation(updatedConversation);
// Update the conversations list in the store
const updatedList = this.conversationsList.map(conv =>
conv === this.activeConversation ? updatedConversation : conv
);
this.store.dispatch({
type: ActionTypes.UPDATE_CONVERSATIONS_LIST,
payload: updatedList
});
// Set flag to scroll to bottom after adding a message
this.shouldScrollToBottom = true;
}
}
newChat() {
const newConv = new ChatbotConversation();
newConv.name = "Other conversation " + (this.conversationsList.length + 1);
// Update the conversations list in the store by adding the new conversation to the existing list
this.store.dispatch({
type: ActionTypes.UPDATE_CONVERSATIONS_LIST,
payload: [...this.conversationsList, newConv]
});
// Set the new conversation as active
this.updateActiveConversation(newConv);
}
toggleSidePanelConversationsList() {
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
displayConversationListPanelLarge: !this.appState.displayConversationListPanelLarge
}
});
}
toggleSourcesPanel() {
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
displaySourcesPanelLarge: !this.appState.displaySourcesPanelLarge
}
});
}
launchDemoTour() {
}
endDemoTour() {
}
exportMessage() {
}
exportConversation() {
}
copyToClipboard(text: string) {
}
toggleVisibleEditInput(conversation: ChatbotConversation) {
// Create a copy of the conversation
const updatedConversation = ChatbotConversation.fromObject(conversation);
// Toggle the visibleInput property
updatedConversation.visibleInput = !conversation.visibleInput;
// Update the active conversation if this is the active one
if (this.activeConversation === conversation) {
this.updateActiveConversation(updatedConversation);
}
// Update the single conversation in the store without replacing the entire list
this.store.dispatch({
type: ActionTypes.UPDATE_SINGLE_CONVERSATION,
payload: {
conversation: updatedConversation
}
});
}
changeTheme() {
// Dispatch the action to switch to the next theme
this.store.dispatch({type: ActionTypes.SWITCH_TO_NEXT_THEME});
}
changeLang() {
// Dispatch the action to switch to the next language
this.store.dispatch({type: ActionTypes.SWITCH_TO_NEXT_LANGUAGE});
}
/**
* Scrolls to the bottom of the conversation container
*/
private scrollToBottom(): void {
try {
const element = this.conversationContainer?.nativeElement;
if (element) {
element.scrollTop = element.scrollHeight;
}
} catch (err) {
console.error('Error scrolling to bottom:', err);
}
}
}

View file

@ -0,0 +1,67 @@
<div class="feedback-button" (click)="toggleModal()">
<span class="text">
Feedback
</span>
<i class="ri-message-2-line"></i>
</div>
<!-- Feedback Modal -->
@if (isModalOpen) {
<div class="feedback-modal-overlay">
<div class="feedback-modal">
<div class="modal-header">
<h3>Send Feedback</h3>
<button class="close-button" (click)="toggleModal()">
<i class="ri-close-line"></i>
</button>
</div>
<div class="modal-body">
<p class="modal-description">
Help us improve by sharing your feedback, suggestions, or reporting issues.
</p>
<textarea
[(ngModel)]="feedbackText"
placeholder="Enter your feedback here..."
[disabled]="isSubmitting"
rows="5"
></textarea>
@if (submitSuccess) {
<div class="success-message">
<i class="ri-check-line"></i> Thank you for your feedback!
</div>
}
@if (submitError) {
<div class="error-message">
<i class="ri-error-warning-line"></i> Failed to submit feedback. Please try again.
</div>
}
</div>
<div class="modal-footer">
<button
class="cancel-button"
(click)="toggleModal()"
[disabled]="isSubmitting"
>
Cancel
</button>
<button
class="submit-button"
(click)="submitFeedback()"
[disabled]="isSubmitting || !feedbackText.trim()"
>
@if (isSubmitting) {
<i class="ri-loader-4-line spinning"></i> Sending...
} @else {
Send Feedback
}
</button>
</div>
</div>
</div>
}

View file

@ -0,0 +1,191 @@
.feedback-button {
background: #ecf3fa;
color: #083b7d;
padding: 12px;
border-radius: 8px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
transform: rotate(270deg);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
margin-right: -35px;
position: fixed;
right: 0;
top: 240px;
z-index: 100;
&:hover {
background: #d9e8f6;
}
i {
font-size: 16px;
}
}
// Modal styles
.feedback-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.feedback-modal {
background: white;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #083b7d;
}
.close-button {
background: none;
border: none;
cursor: pointer;
font-size: 20px;
color: #666;
padding: 4px;
&:hover {
color: #333;
}
}
}
.modal-body {
padding: 20px;
overflow-y: auto;
.modal-description {
margin-bottom: 16px;
color: #555;
}
textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
font-family: inherit;
font-size: 14px;
&:focus {
outline: none;
border-color: #083b7d;
}
&:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
}
.success-message, .error-message {
margin-top: 16px;
padding: 10px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
i {
font-size: 18px;
}
}
.success-message {
background-color: #e6f7e6;
color: #2e7d32;
}
.error-message {
background-color: #fdecea;
color: #d32f2f;
}
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 12px;
button {
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.cancel-button {
background: none;
border: 1px solid #ddd;
color: #555;
&:hover:not(:disabled) {
background-color: #f5f5f5;
}
}
.submit-button {
background-color: #083b7d;
color: white;
border: none;
display: flex;
align-items: center;
gap: 8px;
&:hover:not(:disabled) {
background-color: #062c5e;
}
}
}
// Spinner animation
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FeedbackButton } from './feedback-button';
describe('FeedbackButton', () => {
let component: FeedbackButton;
let fixture: ComponentFixture<FeedbackButton>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FeedbackButton]
})
.compileComponents();
fixture = TestBed.createComponent(FeedbackButton);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,88 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { ApiService } from '../../api-service';
import { ActionTypes, StateInterface } from '../../reducers';
@Component({
selector: 'app-feedback-button',
imports: [CommonModule, FormsModule],
templateUrl: './feedback-button.html',
styleUrl: './feedback-button.scss'
})
export class FeedbackButton {
isModalOpen: boolean = false;
feedbackText: string = '';
isSubmitting: boolean = false;
submitSuccess: boolean = false;
submitError: boolean = false;
constructor(
private store: Store<StateInterface>,
private apiService: ApiService
) {}
toggleModal() {
this.isModalOpen = !this.isModalOpen;
// Reset state when opening modal
if (this.isModalOpen) {
this.feedbackText = '';
this.submitSuccess = false;
this.submitError = false;
}
// Update app state to show/hide feedback panel
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
displayFeedBackPanel: this.isModalOpen,
feedBackInput: this.feedbackText
}
});
}
async submitFeedback() {
if (!this.feedbackText.trim()) {
return; // Don't submit empty feedback
}
this.isSubmitting = true;
this.submitSuccess = false;
this.submitError = false;
try {
// Update Redux state with feedback text
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
feedBackInput: this.feedbackText
}
});
// Dispatch action to send feedback
this.store.dispatch({
type: ActionTypes.SEND_USER_FEEDBACK,
payload: {
feedback: this.feedbackText
}
});
// Call API service directly
await this.apiService.sendUserFeedback(this.feedbackText);
this.submitSuccess = true;
// Close modal after a short delay
setTimeout(() => {
this.toggleModal();
}, 2000);
} catch (error) {
console.error('Error submitting feedback:', error);
this.submitError = true;
} finally {
this.isSubmitting = false;
}
}
}

View file

@ -0,0 +1 @@
<p>feedback-message works!</p>

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FeedbackMessage } from './feedback-message';
describe('FeedbackMessage', () => {
let component: FeedbackMessage;
let fixture: ComponentFixture<FeedbackMessage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FeedbackMessage]
})
.compileComponents();
fixture = TestBed.createComponent(FeedbackMessage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-feedback-message',
imports: [],
templateUrl: './feedback-message.html',
styleUrl: './feedback-message.scss'
})
export class FeedbackMessage {
}

View file

@ -0,0 +1,9 @@
<div class="language-selector">
<button
(click)="switchToNextLanguage()"
class="language-selector__button"
title="Switch to next language">
<span class="language-selector__current-lang">{{ getLanguageDisplayName(currentLang) }}</span>
<span class="language-selector__icon">🌐</span>
</button>
</div>

View file

@ -0,0 +1,56 @@
.language-selector {
display: inline-block;
margin: 10px;
&__button {
display: flex;
align-items: center;
padding: 8px 12px;
background-color: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: #e0e0e0;
}
&:active {
transform: translateY(1px);
}
}
&__current-lang {
margin-right: 8px;
text-transform: capitalize;
}
&__icon {
font-size: 1.2em;
}
}
// Language-specific styles that will be applied when the body has the corresponding class
:host-context(body.app-theme-light) {
.language-selector__button {
background-color: #f8f8f8;
color: #333;
}
}
:host-context(body.app-theme-dark) {
.language-selector__button {
background-color: #333;
color: #f8f8f8;
border-color: #555;
}
}
:host-context(body.app-theme-funky) {
.language-selector__button {
background-color: #ff00ff;
color: #00ffff;
border-color: #ffff00;
}
}

View file

@ -0,0 +1,57 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { ActionTypes, StateInterface } from '../../reducers';
import { TranslationService } from '../../services/translation.service';
@Component({
selector: 'app-language-selector',
standalone: true,
imports: [CommonModule],
templateUrl: './language-selector.html',
styleUrl: './language-selector.scss'
})
export class LanguageSelector implements OnInit, OnDestroy {
currentLang: string = '';
langsList: string[] = [];
private storeSubscription: Subscription | null = null;
constructor(
private store: Store<StateInterface>,
private translationService: TranslationService
) {}
ngOnInit(): void {
// Subscribe to the store to get the current language and languages list
this.storeSubscription = this.store.select(state => state.app)
.subscribe(app => {
this.currentLang = app.lang;
this.langsList = app.langsList;
});
}
ngOnDestroy(): void {
// Unsubscribe to prevent memory leaks
if (this.storeSubscription) {
this.storeSubscription.unsubscribe();
}
}
// Method to switch to the next language
switchToNextLanguage(): void {
this.store.dispatch({ type: ActionTypes.SWITCH_TO_NEXT_LANGUAGE });
}
// Helper method to display a friendly language name
getLanguageDisplayName(langCode: string): string {
switch (langCode) {
case 'fr_FR':
return 'Français';
case 'en_US':
return 'English';
default:
return langCode;
}
}
}

View file

@ -0,0 +1,10 @@
<div class="loading-notification">
<div class="progress-container">
<div [style.animation-duration.ms]="averageResponseTime" class="progress-bar"></div>
</div>
<!-- <span class="text">-->
<!-- ça charge-->
<!-- </span>-->
</div>

View file

@ -0,0 +1,40 @@
.loading-notification {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
.text {
margin-top: 8px;
font-size: 14px;
color: #666;
}
.progress-container {
width: 100%;
height: 4px;
background-color: #f0f0f0;
border-radius: 2px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
width: 100%;
background-color: #083b7d; // Same blue color as in the feedback button
transform: translateX(-100%);
animation-name: progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
}
@keyframes progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(0);
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoadingNotification } from './loading-notification';
describe('LoadingNotification', () => {
let component: LoadingNotification;
let fixture: ComponentFixture<LoadingNotification>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoadingNotification]
})
.compileComponents();
fixture = TestBed.createComponent(LoadingNotification);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,27 @@
import {Component} from '@angular/core';
import {Store} from '@ngrx/store';
import {StateInterface} from '../../reducers';
import {CommonModule} from '@angular/common';
@Component({
selector: 'app-loading-notification',
imports: [CommonModule],
templateUrl: './loading-notification.html',
styleUrl: './loading-notification.scss'
})
export class LoadingNotification {
protected loading: boolean = false;
protected averageResponseTime: number = 500; // Default value
constructor(private store: Store<StateInterface>) {
// Subscribe to the app state to get the loading state
this.store.select(state => state.app.loading).subscribe(loading => {
this.loading = loading;
});
// Subscribe to the app state to get the average response time
this.store.select(state => state.app.averageResponseTime).subscribe(time => {
this.averageResponseTime = time;
});
}
}

View file

@ -0,0 +1,62 @@
<div class="message {{kind}}" id="message_{{id}}">
<div class="actions ">
@if (kind === 'user') {
<button (click)="editMessage()" class="button edit">
<i class="ri-edit-box-line"></i>
</button>
} @else {
<div class="">
<div class="action-feedback">
<app-feedback-button></app-feedback-button>
</div>
</div>
<div class=" has-text-right">
<button (click)="generateResponse()" class="button generate-response">
<i class="ri-refresh-line"></i>
<span class="label">
Generate Response
</span>
</button>
<app-copy [textToCopy]="content"></app-copy>
<button (click)="bookmark()" class="button bookmark">
<i class="ri-bookmark-line"></i>
</button>
</div>
}
</div>
<div class="user-infos ">
<div class="avatar">
<!-- avatar-->
</div>
<div class="user-more-infos">
<span class="user-name ">
<!-- user name-->
@if (kind === 'user') {
You
} @else {
Response
}
</span>
<span class="time-ago">
<!-- time ago-->
Il y a 5 min
</span>
</div>
</div>
<div class="message-content is-{{kind}}">
@if (content) {
<div [innerHTML]="sanitizedContent"></div>
} @else {
@if (kind === 'llm') {
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Aspernatur, consectetur cum eaque iure optio recusandae vel.
} @else {
Delectus, est, molestiae! Asperiores at consequatur cupiditate dicta iure neque pariatur perspiciatis, quia ut?
}
}
</div>
</div>

View file

@ -0,0 +1,28 @@
.avatar {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 8px;
background-size: contain !important;
.user & {
background-size: contain;
background: yellow url('../../../../public/user.png');
}
.llm & {
background: yellow url('../../../../public/chatbot.png');
}
}
.user-more-infos {
margin-top: -35px;
margin-left: 39px;
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MessageBox } from './message-box';
describe('MessageBox', () => {
let component: MessageBox;
let fixture: ComponentFixture<MessageBox>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MessageBox]
})
.compileComponents();
fixture = TestBed.createComponent(MessageBox);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,53 @@
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
import {Copy} from 'sae-lib/buttons/copy/copy';
import {FeedbackButton} from '../feedback-button/feedback-button';
import {ChatbotMessage} from '../../services/chatbot.message.type';
type MessageKind = "user" | "llm";
@Component({
selector: 'app-message-box',
imports: [
Copy,
FeedbackButton
],
templateUrl: './message-box.html',
styleUrl: './message-box.scss'
})
export class MessageBox implements OnChanges {
@Input() kind: MessageKind = <"user" | "llm">""
@Input() conf: any = {}
@Input() content: any = ""
@Input() message: ChatbotMessage = {} as ChatbotMessage;
id: string = "00122121221312";
sanitizedContent: SafeHtml = "";
constructor(private sanitizer: DomSanitizer) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['content']) {
this.sanitizeContent();
}
}
sanitizeContent(): void {
this.sanitizedContent = this.sanitizer.bypassSecurityTrustHtml(this.content);
}
bookmark() {
console.log("TODO bookmark")
}
generateResponse() {
console.log("TODO generateResponse")
}
editMessage() {
console.log("TODO editMessage")
}
}

View file

@ -0,0 +1,18 @@
<div class="new-input">
<!-- new input-->
<div class="welcome-text">
<div class="welcome-icon">
<!-- <i class="ri-robot-2-line"></i>-->
<img alt="chatbot image" src="/chatbot.png">
</div>
How can we
<span class="emphasis">
assist
</span>
you today?
</div>
<app-prompt-input></app-prompt-input>
<app-tools-options></app-tools-options>
</div>

View file

@ -0,0 +1,23 @@
.new-input {
min-height: 80vh;
padding: 0 200px;
.welcome-text {
margin: 100px;
font-size: 38px;
font-weight: 500;
letter-spacing: -7%;
}
.welcome-icon {
i {
font-size: 3rem;
}
margin-bottom: 1rem;
}
.emphasis {
color: #083b7d;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NewInput } from './new-input';
describe('NewInput', () => {
let component: NewInput;
let fixture: ComponentFixture<NewInput>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NewInput]
})
.compileComponents();
fixture = TestBed.createComponent(NewInput);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import {PromptInput} from "../prompt-input/prompt-input";
import {ToolsOptions} from "../tools-options/tools-options";
@Component({
selector: 'app-new-input',
imports: [
PromptInput,
ToolsOptions
],
templateUrl: './new-input.html',
styleUrl: './new-input.scss'
})
export class NewInput {
}

View file

@ -0,0 +1,29 @@
<div class="selected-mode-{{inputMode}}">
<span class="indicator"></span>
<div class="dropdown" [ngClass]="{'is-active': isDropdownActive }">
<div class="dropdown-trigger">
<button (click)="toggleDropdown()" class="button">
<span>{{inputMode}}</span>
<span class="icon is-small">
<i class="ri-arrow-down-s-line" [ngClass]="{'ri-arrow-up-s-line': isDropdownActive}"></i>
</span>
</button>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a (click)="changeInputMode('auto')" [ngClass]="{'is-active': inputMode === 'auto'}" class="dropdown-item">
Auto
</a>
<a (click)="changeInputMode('structured')" [ngClass]="{'is-active': inputMode === 'structured'}"
class="dropdown-item">
Structured
</a>
<a (click)="changeInputMode('specific')" [ngClass]="{'is-active': inputMode === 'specific'}"
class="dropdown-item">
Specific
</a>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,99 @@
:host {
display: block;
position: relative;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
min-width: 12rem;
}
.dropdown-content {
background-color: white;
border-radius: 4px;
box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02);
padding: 0.5rem 0;
}
.dropdown-item {
color: #4a4a4a;
display: block;
font-size: 0.875rem;
line-height: 1.5;
padding: 0.375rem 1rem;
position: relative;
cursor: pointer;
text-decoration: none;
&:hover {
background-color: #f5f5f5;
color: #363636;
}
&.is-active {
background-color: #3273dc;
color: #fff;
}
}
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.5rem;
}
.selected-mode-auto .indicator {
background-color: #48c774;
}
.selected-mode-structured .indicator {
background-color: #ffdd57;
}
.selected-mode-specific .indicator {
background-color: #f14668;
}
.button {
background-color: white;
border: 1px solid #dbdbdb;
border-radius: 4px;
color: #363636;
cursor: pointer;
justify-content: center;
padding: 0.5em 0.75em;
text-align: center;
white-space: nowrap;
display: inline-flex;
align-items: center;
&:hover {
border-color: #b5b5b5;
color: #363636;
}
.icon {
height: 1.5rem;
width: 1.5rem;
margin-left: 0.25rem;
}
}
// Ensure dropdown is displayed when active
.dropdown.is-active .dropdown-menu {
display: block;
}
.dropdown:not(.is-active) .dropdown-menu {
display: none;
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PromptIndicator } from './prompt-indicator';
describe('PromptIndicator', () => {
let component: PromptIndicator;
let fixture: ComponentFixture<PromptIndicator>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PromptIndicator]
})
.compileComponents();
fixture = TestBed.createComponent(PromptIndicator);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,52 @@
import {Component} from '@angular/core';
import {inputModeChoicesType} from '../../services/AirWatchState';
import {Store} from '@ngrx/store';
import {ActionTypes, StateInterface} from '../../reducers';
import {ApiService} from '../../api-service';
import {NgClass} from '@angular/common';
@Component({
selector: 'app-prompt-indicator',
imports: [NgClass],
templateUrl: './prompt-indicator.html',
styleUrl: './prompt-indicator.scss'
})
export class PromptIndicator {
inputMode: inputModeChoicesType = 'auto';
isDropdownActive: boolean = false;
constructor(private store: Store<StateInterface>, private apiService: ApiService) {
// Subscribe to the app state to get the current input mode
this.store.select(state => state.app.inputMode).subscribe(mode => {
if (mode) {
this.inputMode = mode;
}
});
}
// Toggle dropdown menu
toggleDropdown() {
this.isDropdownActive = !this.isDropdownActive;
}
// Change input mode
changeInputMode(mode
:
inputModeChoicesType
) {
this.inputMode = mode;
this.isDropdownActive = false; // Close dropdown after selection
// Update the app state with the new input mode
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
inputMode: mode
}
});
}
}

View file

@ -0,0 +1,22 @@
<div class="prompt-input">
<!-- bottom input-->
<div class="columns">
<div class="column is-four-fifths">
<div class="magic">
<i class="ri-sparkling-2-line"></i>
</div>
<input (keydown)="handleKeyEvent($event)" [(ngModel)]="userInput"
placeholder="Ask questions, request or a web search" type="text">
</div>
<div class="column is-one-fifth">
<app-prompt-indicator></app-prompt-indicator>
<button (click)="submitMessage()" class="submit">
<i class="ri-send-plane-fill"></i>
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,121 @@
@use 'sae-lib/src/styles/variables.scss';
.prompt-input {
margin: 20px auto;
max-width: 914px;
background: variables.$neutral-white;
border-radius: 0.5rem;
padding: 10px 10px;
box-shadow: 0 5px 10px #ededed;
.column {
padding-top: 10px;
}
input {
width: 100%;
border: 0;
padding: 10px;
@extend .is-shadowed !optional;
float: left;
margin-left: 50px;
margin-top: -40px;
&:focus {
outline: none;
}
}
button {
background: white;
padding: 10px;
border: 1px solid transparent;
cursor: pointer;
border-radius: 10px;
&:hover {
background: grey;
border: 1px solid black;
}
}
.magic {
padding: 10px;
border: 1px solid transparent;
border-radius: 10px;
background: #f5f5f5;
width: 40px;
height: 40px;
float: left;
}
// Dropdown styles
.dropdown {
position: relative;
display: inline-block;
&.is-active {
.dropdown-menu {
display: block;
}
}
.dropdown-trigger {
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
z-index: 20;
top: 100%;
left: 0;
min-width: 12rem;
background-color: white;
border-radius: 4px;
box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.02);
padding-bottom: 0.5rem;
padding-top: 0.5rem;
}
.dropdown-content {
padding: 0.5rem 0;
}
.dropdown-item {
color: #4a4a4a;
display: block;
font-size: 0.875rem;
line-height: 1.5;
padding: 0.375rem 1rem;
position: relative;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
color: #0a0a0a;
}
&.is-active {
background-color: #3273dc;
color: white;
}
}
}
.selected-mode-specific,
.selected-mode-structured {
.indicator {
background: red;
border-radius: 100%;
width: 10px;
height: 10px;
display: block;
right: 0;
top: 0.3rem;
position: absolute;
}
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PromptInput } from './prompt-input';
describe('PromptInput', () => {
let component: PromptInput;
let fixture: ComponentFixture<PromptInput>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PromptInput]
})
.compileComponents();
fixture = TestBed.createComponent(PromptInput);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,313 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {Store} from '@ngrx/store';
import {ActionTypes, StateInterface} from '../../reducers';
import {ChatbotMessage} from '../../services/chatbot.message.type';
import {ChatbotConversation} from '../../services/conversations.service';
import {ApiService, OpenMeteoResponse} from '../../api-service';
import {interval, Subscription} from 'rxjs';
import {PromptIndicator} from '../prompt-indicator/prompt-indicator';
@Component({
selector: 'app-prompt-input',
imports: [CommonModule, FormsModule, PromptIndicator],
templateUrl: './prompt-input.html',
styleUrl: './prompt-input.scss'
})
export class PromptInput implements OnInit, OnDestroy {
// Static counter to track API calls
static apiCallCounter: number = 0;
userInput: string = '';
activeConversation: ChatbotConversation | null = null;
private appState: any = '';
private ollamaCheckSubscription: Subscription | null = null;
// Check interval in milliseconds (30 seconds)
private readonly CHECK_INTERVAL = 30000;
constructor(private store: Store<StateInterface>, private apiService: ApiService) {
// Get the active conversation from the store
this.store.select(state => state.activeConversation).subscribe(activeConv => {
if (activeConv && Object.keys(activeConv).length > 0) {
this.activeConversation = activeConv as ChatbotConversation;
}
})
this.store.select(state => state.app).subscribe(app => {
this.appState = app;
});
// Check if Ollama is available when the component is initialized
this.checkOllamaAvailability();
}
/**
* Check if the Ollama backend is available
* This will update the Redux store with the result
*/
checkOllamaAvailability(): void {
this.apiService.checkOllamaAvailability().subscribe({
next: (isAvailable) => {
console.log('Ollama availability:', isAvailable);
// The store is already updated in the ApiService
},
error: (error) => {
console.error('Error checking Ollama availability:', error);
}
});
}
/**
* Angular lifecycle hook that is called after the component is initialized
* Sets up periodic checking for Ollama availability
*/
ngOnInit(): void {
// Set up periodic checking for Ollama availability
this.ollamaCheckSubscription = interval(this.CHECK_INTERVAL).subscribe(() => {
console.log('Performing periodic Ollama availability check');
this.checkOllamaAvailability();
});
}
/**
* Angular lifecycle hook that is called when the component is destroyed
* Cleans up subscriptions to prevent memory leaks
*/
ngOnDestroy(): void {
// Clean up the subscription when the component is destroyed
if (this.ollamaCheckSubscription) {
this.ollamaCheckSubscription.unsubscribe();
this.ollamaCheckSubscription = null;
}
}
submitMessage() {
console.log(this.userInput);
if (!this.userInput.trim() || !this.activeConversation) {
console.info('no input to send')
return;
}
// Store the user input in a local variable before clearing it
const userInputText = this.userInput.trim();
console.log('userInputText', userInputText)
// Create a new message
const newMessage = new ChatbotMessage({
kind: 'user',
user: {},
content: userInputText,
name: 'User Message'
});
// Update the active conversation in the store
this.store.dispatch({
type: ActionTypes.POST_MESSAGE_TO_ACTIVE_CONVERSATION,
payload: newMessage
});
// Clear the input
this.userInput = '';
const elemFound: HTMLInputElement | null = document.querySelector('.prompt-input input') as HTMLInputElement
if (elemFound) {
elemFound.focus();
}
// Check if Ollama is available
if (this.appState.ollamaAvailable) {
// If Ollama is available, use it regardless of demo mode
console.log('Using Ollama backend for response');
// Query Ollama with the stored user input
this.apiService.queryOllama(userInputText).subscribe({
error: (error) => {
console.error('Error querying Ollama:', error);
}
});
} else if (this.appState.demoMode) {
// If Ollama is not available and we're in demo mode, use the demo response
console.log('Using demo response (Ollama not available)');
// Static counter to track API calls
if (!PromptInput.apiCallCounter) {
PromptInput.apiCallCounter = 0;
}
PromptInput.apiCallCounter++;
// Set loading state to true before API call
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
loading: true
}
});
this.apiService.fetchDemoResponse().subscribe({
next: (resp: OpenMeteoResponse) => {
console.log('fetchDemoResponse', resp);
// Set loading state to false after API call
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
loading: false
}
});
// Every third call, show an error instead of the actual response
if (PromptInput.apiCallCounter % 3 === 0) {
// Set error state to true
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
displayErrorResponse: true
}
});
// Send error message to chat
const errorMessage = new ChatbotMessage({
kind: 'llm',
user: {},
content: "System error - I couldn't process your request. Please try again in few moments",
name: 'Assistant'
});
this.store.dispatch({
type: ActionTypes.POST_MESSAGE_TO_ACTIVE_CONVERSATION,
payload: errorMessage
});
// Reset error state after 3 seconds
setTimeout(() => {
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
displayErrorResponse: false
}
});
}, 3000);
return;
}
// Format the weather data in a readable way
const currentTemp = resp.hourly.temperature_2m[new Date().getHours()];
const minTemp = Math.min(...resp.hourly.temperature_2m);
const maxTemp = Math.max(...resp.hourly.temperature_2m);
const weatherApiDemoResult = `
<h2>
Météo pour aujourd'hui à Corbeil-Essonne
</h2>
<br>
- <strong>Température actuelle:</strong> ${currentTemp}${resp.hourly_units.temperature_2m}
<br>
<ul>
<li>
Élévation: ${resp.elevation}m<br>
</li>
</ul>
<br>
<table class="table is-striped">
<thead>
<th>label</th>
<th>valeur</th>
</thead>
<tbody>
<tr>
<td>
Température min
</td>
<td>
${minTemp}${resp.hourly_units.temperature_2m}
</td>
</tr>
<tr>
<td>
Température max
</td>
<td>
${maxTemp}${resp.hourly_units.temperature_2m}
</td>
</tr>
</tbody>
</table>
<a href="https://www.open-meteo.com">
*Données fournies par Open-Meteo API*
</a>
`;
const newMessage = new ChatbotMessage({
kind: 'llm',
user: {},
content: `Voici les données météo que vous avez demandées:\n${weatherApiDemoResult}`,
name: 'Assistant'
});
// Update the active conversation in the store
this.store.dispatch({
type: ActionTypes.POST_MESSAGE_TO_ACTIVE_CONVERSATION,
payload: newMessage
});
},
error: (error) => {
console.error('Error fetching weather data:', error);
// Set loading state to false after API call
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
loading: false
}
});
// Send error message to chat
const errorMessage = new ChatbotMessage({
kind: 'llm',
user: {},
content: "Désolé, je n'ai pas pu récupérer les données météo. Veuillez réessayer plus tard.",
name: 'Assistant'
});
this.store.dispatch({
type: ActionTypes.POST_MESSAGE_TO_ACTIVE_CONVERSATION,
payload: errorMessage
});
}
});
} else {
// Not in demo mode and Ollama is not available
console.error('Error: Ollama is not available and not in demo mode');
// Send error message to chat
const errorMessage = new ChatbotMessage({
kind: 'llm',
user: {},
content: "Error: The AI backend is not available. Please check your configuration or try again later.",
name: 'System'
});
this.store.dispatch({
type: ActionTypes.POST_MESSAGE_TO_ACTIVE_CONVERSATION,
payload: errorMessage
});
}
}
// Handle key events for Ctrl+Enter
handleKeyEvent(event: KeyboardEvent) {
// Check if Ctrl key is pressed and Enter key is pressed
if (event.ctrlKey && event.key === 'Enter') {
this.submitMessage();
event.preventDefault(); // Prevent default behavior
}
}
}

View file

@ -0,0 +1,16 @@
<div class="source">
<i class="ri-file-3-line"></i>
<span class="title">{{ source.title || 'Source sans nom' }}</span>
<div class="actions has-text-right">
<i class="ri-file-add-line"></i>
<i class="ri-arrow-down-s-line"></i>
</div>
<div class="description">
{{ source.description || description }}
</div>
</div>

View file

@ -0,0 +1,31 @@
.source {
padding: 10px;
margin-bottom: 10px;
background: white;
color: #000;
font-size: 14px;
font-weight: 400;
line-height: 20px;
cursor: pointer;
border-radius: 4px;
.title {
color: #1E1F22;
font-size: 14px;
font-weight: 600;
margin-left: 3px;
}
.actions {
color: #005AA2;
float: right;
}
&:hover {
background: #ECF3FA;
}
.description {
margin-top: 7px;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SourceBlock } from './source-block';
describe('SourceBlock', () => {
let component: SourceBlock;
let fixture: ComponentFixture<SourceBlock>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SourceBlock]
})
.compileComponents();
fixture = TestBed.createComponent(SourceBlock);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,14 @@
import {Component, Input} from '@angular/core';
import {ChatbotSource} from '../../services/conversations.service';
@Component({
selector: 'app-source-block',
imports: [],
templateUrl: './source-block.html',
styleUrl: './source-block.scss'
})
export class SourceBlock {
@Input() source: ChatbotSource = {} as ChatbotSource;
description: string = "lorem blah blah";
}

View file

@ -0,0 +1,9 @@
<div class="theme-selector">
<button
class="theme-selector__button"
(click)="switchToNextTheme()"
title="Switch to next theme">
<span class="theme-selector__current-theme">{{ activeTheme }}</span>
<span class="theme-selector__icon">🎨</span>
</button>
</div>

View file

@ -0,0 +1,56 @@
.theme-selector {
display: inline-block;
margin: 10px;
&__button {
display: flex;
align-items: center;
padding: 8px 12px;
background-color: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: #e0e0e0;
}
&:active {
transform: translateY(1px);
}
}
&__current-theme {
margin-right: 8px;
text-transform: capitalize;
}
&__icon {
font-size: 1.2em;
}
}
// Theme-specific styles that will be applied when the body has the corresponding class
:host-context(body.app-theme-light) {
.theme-selector__button {
background-color: #f8f8f8;
color: #333;
}
}
:host-context(body.app-theme-dark) {
.theme-selector__button {
background-color: #333;
color: #f8f8f8;
border-color: #555;
}
}
:host-context(body.app-theme-funky) {
.theme-selector__button {
background-color: #ff00ff;
color: #00ffff;
border-color: #ffff00;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ThemeSelector } from './theme-selector';
describe('ThemeSelector', () => {
let component: ThemeSelector;
let fixture: ComponentFixture<ThemeSelector>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ThemeSelector]
})
.compileComponents();
fixture = TestBed.createComponent(ThemeSelector);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,55 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Store} from '@ngrx/store';
import {Subscription} from 'rxjs';
import {ActionTypes, StateInterface} from '../../reducers';
@Component({
selector: 'app-theme-selector',
standalone: true,
imports: [CommonModule],
templateUrl: './theme-selector.html',
styleUrl: './theme-selector.scss'
})
export class ThemeSelector implements OnInit, OnDestroy {
activeTheme: string = '';
themesList: string[] = [];
private storeSubscription: Subscription | null = null;
constructor(private store: Store<StateInterface>) {
}
ngOnInit(): void {
// Subscribe to the store to get the active theme and themes list
this.storeSubscription = this.store.select(state => state.app)
.subscribe(app => {
// If the active theme has changed, update the body class
if (this.activeTheme !== app.activeTheme) {
// Remove the previous theme class if it exists
if (this.activeTheme) {
document.body.classList.remove(`app-theme-${this.activeTheme}`);
}
document.body.classList.add(`app-theme-${app.activeTheme}`);
this.activeTheme = app.activeTheme;
}
this.themesList = app.themesList;
});
}
ngOnDestroy(): void {
// Unsubscribe to prevent memory leaks
if (this.storeSubscription) {
this.storeSubscription.unsubscribe();
}
// Remove the theme class from the body when the component is destroyed
if (this.activeTheme) {
document.body.classList.remove(this.activeTheme);
}
}
// Method to switch to the next theme
switchToNextTheme(): void {
this.store.dispatch({type: ActionTypes.SWITCH_TO_NEXT_THEME});
}
}

View file

@ -0,0 +1,16 @@
<div class="time-separator">
<div class="columns">
<div class="column ">
<div class="line"></div>
</div>
<div class="column time-ago">
<!-- some time ago-->
{{timeAgo}}
</div>
<div class="column">
<div class="line"></div>
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
.time-separator {
padding: 0 16px;
font-size: 0.8rem;
color: grey;
margin-top: 2rem;
margin-bottom: 2rem;
.column {
padding: 0;
}
.time-ago {
margin-top: -1rem;
text-align: center;
}
.line {
border-bottom: 1px solid grey;
margin-top: -0.25rem;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TimeSeparator } from './time-separator';
describe('TimeSeparator', () => {
let component: TimeSeparator;
let fixture: ComponentFixture<TimeSeparator>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TimeSeparator]
})
.compileComponents();
fixture = TestBed.createComponent(TimeSeparator);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-time-separator',
imports: [],
templateUrl: './time-separator.html',
styleUrl: './time-separator.scss'
})
export class TimeSeparator {
timeAgo: string = 'today';
}

View file

@ -0,0 +1,7 @@
<button (click)="toggleState()"
class="togglable-button {{kind}} {{active ? 'is-active' : ''}}">
@if (icon) {
<span class="icon ri-{{icon}}"></span>
}
{{ label }}
</button>

View file

@ -0,0 +1,48 @@
.togglable-button {
cursor: pointer;
font-size: 12px;
line-height: 8px;
font-weight: 600;
border-radius: 20px;
padding: 10px;
border-width: 2px;
background: transparent;
margin-right: 10px;
margin-bottom: 10px;
&:first-letter {
text-transform: capitalize;
}
&.local {
border: solid 1px #005aa2;
color: #005aa2;
&.is-active {
background: #083b7d;
color: white;
}
}
&.outside {
border: solid 1px #50d494;
color: #50d494;
&.is-active {
background: #159256;
color: white;
}
}
&:hover {
border: solid 1px black;
color: black;
&.is-active {
color: black;
}
}
}

View file

@ -0,0 +1,22 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
type ToggleButtonKind = "local" | "outside";
@Component({
selector: 'app-toggle-button',
imports: [],
templateUrl: './toggle-button.html',
styleUrl: './toggle-button.scss'
})
export class ToggleButton {
@Input() label: string = "(sans label)";
@Input() kind: ToggleButtonKind = "local";
@Input() active: boolean = false;
@Input() icon: string = "";
@Output() stateChanged = new EventEmitter<boolean>();
toggleState() {
this.active = !this.active;
this.stateChanged.emit(this.active);
}
}

View file

@ -0,0 +1,14 @@
<div class="tools-options {{alignLeft ? 'has-text-left' : ''}}">
@for (filter of filters; track filter.name) {
@if (filter.enabled && hideDisabledButtons) {
<app-toggle-button
[active]="filter.enabled"
[icon]="filter.kind === 'local' ? 'database-2-line' : 'google-line'"
[kind]="filter.kind"
[label]="filter.name"
(stateChanged)="toggleFilter(filter, $event)">
</app-toggle-button>
}
}
</div>

View file

@ -0,0 +1,7 @@
.tools-options {
margin-top: 1rem;
padding-top: 1rem;
border-top: solid 0.5px #b7b7b7;
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToolsOptions } from './tools-options';
describe('ToolsOptions', () => {
let component: ToolsOptions;
let fixture: ComponentFixture<ToolsOptions>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToolsOptions]
})
.compileComponents();
fixture = TestBed.createComponent(ToolsOptions);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,47 @@
import {Component, Input} from '@angular/core';
import {ToggleButton} from '../toggle-button/toggle-button';
import {Store} from '@ngrx/store';
import {ActionTypes, StateInterface} from '../../reducers';
import {filterType} from '../../services/AirWatchState';
@Component({
selector: 'app-tools-options',
imports: [
ToggleButton
],
templateUrl: './tools-options.html',
styleUrl: './tools-options.scss'
})
export class ToolsOptions {
filters: filterType[] = [];
@Input() public hideDisabledButtons: boolean = false;
@Input() public alignLeft: boolean = false;
constructor(private store: Store<StateInterface>) {
this.store.select(state => state.app.filters).subscribe(filters => {
this.filters = filters;
});
}
toggleFilter(filter: filterType, newState: boolean) {
// Find the index of the filter in the array
const filterIndex = this.filters.findIndex(f => f.name === filter.name);
if (filterIndex !== -1) {
// Create a new array with the updated filter
const updatedFilters = [...this.filters];
updatedFilters[filterIndex] = {
...filter,
enabled: newState
};
// Dispatch the action to update the store
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
filters: updatedFilters
}
});
}
}
}

View file

@ -0,0 +1 @@
<p class="version">version {{ versionInfo }}</p>

View file

@ -0,0 +1,8 @@
.version {
color: #000;
text-align: center;
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin: 10px 0;
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VersionInfos } from './version-infos';
describe('VersionInfos', () => {
let component: VersionInfos;
let fixture: ComponentFixture<VersionInfos>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VersionInfos]
})
.compileComponents();
fixture = TestBed.createComponent(VersionInfos);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,12 @@
import {Component, Input} from '@angular/core';
@Component({
selector: 'app-version-infos',
imports: [],
templateUrl: './version-infos.html',
styleUrl: './version-infos.scss'
})
export class VersionInfos {
@Input() versionInfo: string="version 1.0.0";
}

View file

@ -0,0 +1,7 @@
@if (displayError) {
<sae-alert-box _alertKind="error"
message="System error - I couldn't process your request. Please try again in few moments"></sae-alert-box>
} @else {
<sae-alert-box _alertKind="warning"
message="AI can make mistakes - consider verifying important information."></sae-alert-box>
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WarningBugs } from './warning-bugs';
describe('WarningBugs', () => {
let component: WarningBugs;
let fixture: ComponentFixture<WarningBugs>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WarningBugs]
})
.compileComponents();
fixture = TestBed.createComponent(WarningBugs);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,26 @@
import {Component} from '@angular/core';
import {Store} from '@ngrx/store';
import {StateInterface} from '../../reducers';
import {CommonModule} from '@angular/common';
import {AlertBox} from 'sae-lib/alert-box/alert-box';
@Component({
selector: 'app-warning-bugs',
imports: [
CommonModule,
AlertBox
],
templateUrl: './warning-bugs.html',
styleUrl: './warning-bugs.scss'
})
export class WarningBugs {
protected displayError: boolean = false;
constructor(private store: Store<StateInterface>) {
// Subscribe to the app state to get the error state
this.store.select(state => state.app.displayErrorResponse).subscribe(displayError => {
this.displayError = displayError;
});
}
}

View file

@ -0,0 +1,15 @@
<div class="color-box"
[ngStyle]="{ background: backgroundColor, 'border-color': backgroundColor }"
>
<div class="top" >
</div>
<div class="bottom">
<div class="name">
{{name}}
</div>
<div class="hexacode">
{{hexaCode}}
</div>
</div>
</div>

View file

@ -0,0 +1,31 @@
:host {
display: inline-block;
width: 100%;
height: 11.4rem;
border-radius: 0.5rem;
border: solid 2px transparent;
box-shadow: 0 5px 10px #eee;
color: #aaa;
.top {
height: 8rem;
}
.bottom {
border: solid 1px #aaa;
border-bottom-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
.name{
color: #333;
}
.name, .hexacode {
padding: 0.5rem;
background: white;
}
.hexacode{
padding-top:0;
font-size: 0.8rem;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ColorDisplay } from './color-display';
describe('ColorDisplay', () => {
let component: ColorDisplay;
let fixture: ComponentFixture<ColorDisplay>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ColorDisplay]
})
.compileComponents();
fixture = TestBed.createComponent(ColorDisplay);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,22 @@
import {Component, Input, OnInit} from '@angular/core';
import {NgStyle} from '@angular/common';
@Component({
selector: 'app-color-display',
imports: [
NgStyle
],
templateUrl: './color-display.html',
styleUrl: './color-display.scss'
})
export class ColorDisplay implements OnInit {
@Input() public hexaCode: string = '#cc0000';
@Input() public name: string = 'color name';
@Input() public backgroundColor: string = '';
ngOnInit() {
if (this.hexaCode) {
this.backgroundColor = this.hexaCode
}
}
}

View file

@ -0,0 +1,74 @@
<header class="container">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="https://cipherbliss.com">
<app-logo></app-logo>
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"
data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item">
Home
</a>
</div>
<div class="navbar-end">
<button class="navbar-item" routerLink="home" routerLinkActive="active-link">
accueil
</button>
<span class="navbar-item" routerLink="colors" routerLinkActive="active-link">
colors
</span>
<a class="navbar-item" routerLink="grid" routerLinkActive="active-link">
tableau
</a>
<a class="navbar-item" routerLink="csc" routerLinkActive="active-link">
CSC démo
</a>
<a class="navbar-item" routerLink="layout" routerLinkActive="active-link">
layout
</a>
<a class="navbar-item">
Ask question
</a>
<a class="navbar-item">
Knowledge base
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
<i class="ri-notification-2-line"></i>
</a>
<div class="navbar-dropdown">
<a class="navbar-item">
About
</a>
<a class="navbar-item is-selected">
Jobs
</a>
<a class="navbar-item">
Contact
</a>
<hr class="navbar-divider">
<a class="navbar-item">
Report an issue
</a>
</div>
</div>
</div>
</div>
</nav>
</header>

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DsNavbar } from './ds-navbar';
describe('DsNavbar', () => {
let component: DsNavbar;
let fixture: ComponentFixture<DsNavbar>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DsNavbar]
})
.compileComponents();
fixture = TestBed.createComponent(DsNavbar);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import {Logo} from "../logo/logo";
import {RouterLink, RouterLinkActive} from "@angular/router";
@Component({
selector: 'app-ds-navbar',
imports: [
Logo,
RouterLink,
RouterLinkActive
],
templateUrl: './ds-navbar.html',
styleUrl: './ds-navbar.scss'
})
export class DsNavbar {
}

View file

@ -0,0 +1,10 @@
<div>
<h1>Démo de données tabulaires</h1>
<!-- <div id="demo_table"></div>-->
<ag-grid-angular
style="width: 100%; height: 550px;"
[rowData]="rowData"
[columnDefs]="colDefs"
(gridReady)="onGridReady($event)"
/>
</div>

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GridDemo } from './grid-demo';
describe('GridDemo', () => {
let component: GridDemo;
let fixture: ComponentFixture<GridDemo>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GridDemo]
})
.compileComponents();
fixture = TestBed.createComponent(GridDemo);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,49 @@
import {Component} from '@angular/core';
import {AgGridAngular} from "ag-grid-angular";
import type {ColDef, GridReadyEvent} from "ag-grid-community";
import {HttpClient} from '@angular/common/http';
interface IRow {
mission: string;
company: string;
location: string;
date: string;
time: string;
rocket: string;
price: number;
successful: boolean;
}
@Component({
selector: 'app-grid-demo',
imports: [
AgGridAngular
],
templateUrl: './grid-demo.html',
styleUrl: './grid-demo.scss'
})
export class GridDemo {
rowData: IRow[] = [];
colDefs: ColDef[] = [
{field: "mission"},
{field: "company"},
{field: "location"},
{field: "date"},
{field: "price"},
{field: "successful"},
{field: "rocket"}
];
constructor(private http: HttpClient) {
}
onGridReady(params: GridReadyEvent) {
let self = this;
this.http
.get<any[]>('https://www.ag-grid.com/example-assets/space-mission-data.json')
.subscribe((data): any => self.rowData = data);
}
}

View file

@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Logo } from './logo';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<Logo> = {
title: 'Components/Logo',
component: Logo,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<Logo>;
// More on writing stories with args: https://storybook.js.org/docs/angular/writing-stories/args
export const Default: Story = {
args: {},
};

View file

@ -0,0 +1,15 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-logo',
imports: [],
template: `
<span class="logo">
😎 logo
</span>
`,
styles: ``
})
export class Logo {
}

View file

@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { MainButton } from './main-button';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<MainButton> = {
title: 'Components/MainButton',
component: MainButton,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
icon: { control: 'text' },
},
};
export default meta;
type Story = StoryObj<MainButton>;
// More on writing stories with args: https://storybook.js.org/docs/angular/writing-stories/args
export const Primary: Story = {
args: {
label: 'Button',
icon: 'ri-home-line',
},
};
export const WithoutIcon: Story = {
args: {
label: 'Button without icon',
icon: '',
},
};
export const WithIcon: Story = {
args: {
label: 'Button with icon',
icon: 'ri-user-line',
},
};

View file

@ -0,0 +1,29 @@
import {Component, Input} from '@angular/core';
export type ButtonKind = '' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger';
@Component({
selector: 'app-main-button',
imports: [],
template: `
<button class="is-{{kind}}">
@if (icon) {
<i class="ri ri-{{icon}}"></i>
}
<span class="label">
{{ label }}
</span>
</button>
`,
styles: `
`
})
export class MainButton {
@Input() label: string = '';
@Input() icon: string = '';
@Input() kind: ButtonKind = '';
}

View file

@ -0,0 +1,4 @@
<div class="welcome-input">
<h2>Bienvenue </h2>
<input type="text">
</div>

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WelcomeInput } from './welcome-input';
describe('WelcomeInput', () => {
let component: WelcomeInput;
let fixture: ComponentFixture<WelcomeInput>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WelcomeInput]
})
.compileComponents();
fixture = TestBed.createComponent(WelcomeInput);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-welcome-input',
imports: [],
templateUrl: './welcome-input.html',
styleUrl: './welcome-input.scss'
})
export class WelcomeInput {
}

View file

@ -0,0 +1,11 @@
<div>
<h1 class="title is-1">
AirWatch démo
</h1>
<app-welcome-input></app-welcome-input>
<app-layout-demo>
salut la démo
</app-layout-demo>
</div>

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AirwatchDemo } from './airwatch-demo';
describe('AirwatchDemo', () => {
let component: AirwatchDemo;
let fixture: ComponentFixture<AirwatchDemo>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AirwatchDemo]
})
.compileComponents();
fixture = TestBed.createComponent(AirwatchDemo);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,16 @@
import {Component} from '@angular/core';
import {LayoutDemo} from '../layout-demo/layout-demo';
import {WelcomeInput} from '../../molecules/welcome-input/welcome-input';
@Component({
selector: 'app-airwatch-demo',
imports: [
LayoutDemo,
WelcomeInput
],
templateUrl: './airwatch-demo.html',
styleUrl: './airwatch-demo.scss'
})
export class AirwatchDemo {
}

View file

@ -0,0 +1 @@
<p>airwatch works!</p>

Some files were not shown because too many files have changed in this diff Show more