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,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;
});
}
}