rename airwatch folder

This commit is contained in:
Tykayn 2025-09-09 14:43:57 +02:00 committed by tykayn
parent a05388fcbc
commit 949641d881
262 changed files with 21196 additions and 245 deletions

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() })
]
};

32
airwatch/src/app/app.html Normal file
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,46 @@
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';
import {TestingApi} from './pages/testing-api/testing-api';
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: 'api-testing',
component: TestingApi,
},
{
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');
});
});

22
airwatch/src/app/app.ts Normal file
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,70 @@
@use "sae-lib/src/styles/shadows.scss";
@use "sae-lib/src/styles/states.scss";
@use "sae-lib/src/styles/variables.scss";
@use "sass:color";
:host {
display: inline-block;
button {
background: shadows.$primary-color;
color: shadows.$neutral-white;
border-radius: shadows.$radius-main;
padding: 1rem 2rem;
cursor: pointer;
transition: all 0.25s ease;
border: 0;
width: 100%;
margin-top: 8px;
i {
margin-right: 1rem;
}
&:hover, &:active, &:focus {
background: shadows.$main-bg-color-active;
color: shadows.$main-color-active;
transition: all 0.25s ease;
}
// state colors
&.is-primary {
background-color: variables.$primary-color;
color: variables.$neutral-white;
border-color: color.adjust(variables.$primary-color, $lightness: - 10%);
}
&.is-secondary {
background-color: variables.$secondary-color;
color: variables.$neutral-white;
}
&.is-warning {
background: variables.$neutral-white;
color: variables.$warning-color-text;
border: 0;
}
&.is-info {
background: variables.$info-color;
color: variables.$neutral-white;
border-color: color.adjust(variables.$info-color, $lightness: -50%);
}
&.is-success {
background: variables.$success-color;
color: variables.$neutral-white;
border-color: color.adjust(variables.$success-color, $lightness: -50%);
}
&.is-error {
background: rgba(variables.$danger-color, 10%);
color: variables.$danger-color;
border: 0;
}
}
}

View file

@ -0,0 +1,94 @@
import type {Meta, StoryObj} from '@storybook/angular';
import {MainButton} from './main-button';
import {moduleMetadata} from '@storybook/angular';
import {CommonModule} from '@angular/common';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<MainButton> = {
title: 'UI/Buttons/MainButton',
component: MainButton,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [CommonModule],
providers: []
})
],
argTypes: {
label: {control: 'text'},
icon: {control: 'text'},
kind: {
control: 'select',
options: ['', 'primary', 'secondary', 'info', 'success', 'warning', 'danger'],
description: 'Style du bouton'
},
},
};
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: 'home-line-2',
kind: 'primary'
},
};
export const Secondary: Story = {
args: {
label: 'Secondary Button',
icon: 'settings-line',
kind: 'secondary'
},
};
export const Info: Story = {
args: {
label: 'Info Button',
icon: 'information-line',
kind: 'info'
},
};
export const Success: Story = {
args: {
label: 'Success Button',
icon: 'check-line',
kind: 'success'
},
};
export const Warning: Story = {
args: {
label: 'Warning Button',
icon: 'alert-line',
kind: 'warning'
},
};
export const Danger: Story = {
args: {
label: 'Danger Button',
icon: 'close-circle-line',
kind: 'danger'
},
};
export const WithoutIcon: Story = {
args: {
label: 'Button without icon',
icon: '',
kind: 'primary'
},
};
export const WithIcon: Story = {
args: {
label: 'Button with icon',
icon: 'user-line',
kind: ''
},
};

View file

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

View file

@ -0,0 +1,384 @@
<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-start">
<a (click)="toggleSidePanelConversationsList()" class="navbar-item aside-toggle-button">
@if (appState.displayConversationListPanelLarge) {
<i class="ri-sidebar-fold-line"></i>
} @else {
<i class="ri-sidebar-unfold-line"></i>
}
</a>
</div>
<div class="navbar-brand">
<a class="navbar-item" href="/#">
<!-- <i class="ri-robot-2-fill"></i>-->
@if (appState.displayConversationListPanelLarge) {
<img alt="safran logo" class="logo" src="/safran_logo_large.svg">
<span class="label">
airwatch
</span>
} @else {
<img src="safran_logo_small.svg" alt="logo">
}
</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-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 is-active">
<i class="ri-chat-4-line"></i>
Ask question
</a>
<a class="navbar-item">
<i class="ri-database-2-line"></i>
Knowledge base
</a>
<a class="navbar-item">
<i class="ri-compass-3-line"></i>
Quick start
</a>
<div class="navbar-item ">
<!-- <div class="navbar-item has-dropdown is-hoverable">-->
<!-- <a aria-expanded="false" aria-haspopup="true" class="navbar-linking">-->
<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>
<a class="navbar-item user-account-item">
<i class="ri-layout-right-line"></i>
Borhène
</a>
</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)">
<div class="actions">
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<div class="menu">
<i class="ri-more-line"></i>
</div>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" (click)="copyConversationLink(conversation)">
<i class="ri-link-m"></i>
<span>Share</span>
</a>
<a class="dropdown-item" (click)="toggleVisibleEditInput(conversation)">
<i class="ri-edit-line"></i>
<span>Rename</span>
</a>
<a class="dropdown-item" (click)="pinConversation(conversation)">
<i class="ri-pushpin-fill"></i>
<span>Pin</span>
</a>
<hr class="dropdown-divider">
<a class="dropdown-item trash" (click)="deleteConversation(conversation)">
<i class="ri-delete-bin-line"></i>
<span>Delete</span>
</a>
</div>
</div>
</div>
</div>
<div class="container-text">
@if (conversation.pinned) {
<span class="pinned-icon">
<i class="ri-pushpin-line"></i>
</span>
} @else {
<div class="pinned-icon llm-avatar">
</div>
}
<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>
}
</div>
<app-version-infos [versionInfo]="appVersion"></app-version-infos>
<!-- <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">
Auto mode
</span>
</div>
}
</div>
<div class="action">
@if (displayExportConversation) {
<div class="dropdown" [ngClass]="{'is-active': isExportDropdownActive}">
<div class="dropdown-trigger">
<div class="export-chat-button is-clickable" (click)="toggleExportDropdown()">
<i class="ri-download-2-line"></i>
<span class="label">
Export chat
</span>
</div>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" (click)="exportConversation('csv')">
CSV
</a>
<a class="dropdown-item" (click)="exportConversation('excel')">
Excel
</a>
<a class="dropdown-item" (click)="exportConversation('txt')">
TXT
</a>
</div>
</div>
</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">
<app-time-separator></app-time-separator>
@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>
}
</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 [ngClass]="{'is-active': isExportSourcesDropdownActive}" class="dropdown">
<div class="dropdown-trigger">
<div (click)="toggleExportSourcesDropdown()" class="export-chat-button is-clickable">
<i class="ri-download-2-line"></i>
<span class="label">
Export all sources
</span>
</div>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a (click)="exportSources('csv')" class="dropdown-item">
CSV
</a>
<a (click)="exportSources('excel')" class="dropdown-item">
Excel
</a>
<a (click)="exportSources('txt')" class="dropdown-item">
TXT
</a>
</div>
</div>
</div>
</div>
<div class="panel-more-inside">
<div class="main-title">Knowledge Base documents :
<div class="filter">
<i class="ri-download-2-line"></i>
</div>
</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 class="bottom-gradient">
</div>
</div>
</div>
<app-feedback-button></app-feedback-button>
</main>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,204 @@
@use "sae-lib/src/styles/variables.scss" as variables;
.panel-more-inside {
border-radius: 10px;
background: #F5F5F5;
padding: 20px 16px;
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;
height: 90vh;
overflow: auto;
}
.source {
border-radius: 4px;
background: #ECF3FA;
}
.bottom-gradient {
border-radius: 8px 8px 0 0;
background: linear-gradient(354deg, #F5F5F5 27.6%, rgba(255, 255, 255, 0.72) 47.82%, rgba(245, 245, 245, 0.00) 72.79%);
}
.filter {
cursor: pointer;
border-radius: 8px;
background: rgba(59, 135, 204, 0.5);
display: flex;
width: 34px;
padding: 10px;
justify-content: center;
align-items: center;
position: absolute;
right: 100px;
top: 170px;
}
}
.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-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% */
&:hover {
background: #005AA2;
color: white;
}
}
// Dropdown styles for export buttons
.dropdown {
position: relative;
display: inline-block;
&.is-active,
&.is-hoverable:hover {
.dropdown-menu {
display: block;
}
}
.dropdown-trigger {
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
z-index: 20;
top: 100%;
right: 0;
min-width: 175px;
background-color: white;
border-radius: 10px;
padding-bottom: 20px;
padding-top: 20px;
margin-top: 2px;
margin-right: 0;
box-shadow: 0 4px 13px 0 rgba(37, 91, 142, 0.10);
}
.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;
padding-bottom: 20px;
&:hover {
background-color: #f5f5f5;
color: #0a0a0a;
}
&.is-active {
background-color: #3273dc;
color: white;
}
i {
margin-right: 16px;
width: 20px;
height: 20px;
}
hr {
stroke-width: 0.5px;
stroke: #ABABAB;
color: #ABABAB;
border-color: #ABABAB;
margin-bottom: 20px;
}
}
}
.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;
}
}
.llm-avatar {
background: white url("./../../../public/chatbot.png") no-repeat center center;
width: 24px;
height: 24px;
border-radius: 8px;
background-size: contain;
margin-right: 10px;
}

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,105 @@
import type {Meta, StoryObj} from '@storybook/angular';
import {moduleMetadata} from '@storybook/angular';
import {Chatbot} from './chatbot';
import {Store, StoreModule} from '@ngrx/store';
import {reducers} from '../reducers';
import {ConversationsService} from '../services/conversations.service';
import {ApiService} from '../services/api-service';
import {HttpClientModule} from '@angular/common/http';
import {TranslationService} from '../services/translation.service';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
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 {ToolsOptions} from './tools-options/tools-options';
const appReducer = reducers.app;
const meta: Meta<Chatbot> = {
title: 'App/Features/Chatbot',
component: Chatbot,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
CommonModule,
FormsModule,
HttpClientModule,
StoreModule.forRoot({app: appReducer as any}),
PromptInput,
MessageBox,
NewInput,
FeedbackButton,
WarningBugs,
VersionInfos,
LoadingNotification,
SourceBlock,
ToolsOptions,
],
providers: [
ConversationsService,
ApiService,
TranslationService,
Store
]
})
],
parameters: {
layout: 'fullscreen',
}
};
export default meta;
type Story = StoryObj<Chatbot>;
export const Default: Story = {
args: {
// Les propriétés sont injectées par les services
}
};
export const DemoMode: Story = {
args: {
// Les propriétés sont injectées par les services
},
parameters: {
store: {
init: (store: Store) => {
store.dispatch({
type: 'UPDATE_APP',
payload: {
demoMode: true,
displayConversationListPanelLarge: true,
displaySourcesPanelLarge: true
}
});
}
}
}
};
export const CompactView: Story = {
args: {
// Les propriétés sont injectées par les services
},
parameters: {
store: {
init: (store: Store) => {
store.dispatch({
type: 'UPDATE_APP',
payload: {
demoMode: true,
displayConversationListPanelLarge: false,
displaySourcesPanelLarge: false
}
});
}
}
}
};

View file

@ -0,0 +1,417 @@
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 '../services/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';
import {TimeSeparator} from './time-separator/time-separator';
@Component({
selector: 'app-chatbot',
imports: [
PromptInput,
MessageBox,
NewInput,
FeedbackButton,
WarningBugs,
VersionInfos,
LoadingNotification,
SourceBlock,
FormsModule,
CommonModule,
ToolsOptions,
TimeSeparator,
],
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;
isExportDropdownActive: boolean = false;
isExportSourcesDropdownActive: boolean = false;
@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
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: "The documents also highlight various post- incident safety measures implemented, including improved training procedures, enhanced maintenance protocols, and aircraft design modifications. Regulatory agencies such as the NTSB, BEA, AAIB, and others were involved in investigating these incidents and making safety recommendations.\n" +
"This summary provides an overview of significant aviation incidents, their causes, and outcomes, which may be useful for understanding patterns in aircraft accidents and areas for improving aviation safety.\n" +
"<br/>" +
"Internet Search Not Activated.<br/>" +
"No internet search was activated for this query<br/>" +
"<br/>" +
"Search results.",
name: "Assistant"
}));
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";
conversation2.sources = [source1, source2, source2, source2, source2, source2, source2, source2, source2, source2, source2, source2, source2];
// 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() {
}
toggleExportDropdown() {
this.isExportDropdownActive = !this.isExportDropdownActive;
// Close the other dropdown if it's open
if (this.isExportDropdownActive) {
this.isExportSourcesDropdownActive = false;
}
}
toggleExportSourcesDropdown() {
this.isExportSourcesDropdownActive = !this.isExportSourcesDropdownActive;
// Close the other dropdown if it's open
if (this.isExportSourcesDropdownActive) {
this.isExportDropdownActive = false;
}
}
exportMessage() {
// This method is kept for backward compatibility
this.exportConversation('txt');
}
exportConversation(format: 'csv' | 'excel' | 'txt') {
console.log(`Exporting conversation in ${format} format`);
// Close the dropdown after selection
this.isExportDropdownActive = false;
if (!this.activeConversation) {
console.error('No active conversation to export');
return;
}
// Implementation will depend on the specific requirements
// This is a placeholder for the actual export functionality
switch (format) {
case 'csv':
// Export as CSV
console.log('TODO Exporting as CSV');
break;
case 'excel':
// Export as Excel
console.log('TODO Exporting as Excel');
break;
case 'txt':
// Export as TXT
console.log('TODO Exporting as TXT');
break;
}
}
exportSources(format: 'csv' | 'excel' | 'txt') {
console.log(`Exporting sources in ${format} format`);
// Close the dropdown after selection
this.isExportSourcesDropdownActive = false;
if (!this.activeConversation || !this.activeConversation.sources || this.activeConversation.sources.length === 0) {
console.error('No sources to export');
return;
}
// Implementation will depend on the specific requirements
// This is a placeholder for the actual export functionality
switch (format) {
case 'csv':
// Export as CSV
console.log('Exporting sources as CSV');
break;
case 'excel':
// Export as Excel
console.log('Exporting sources as Excel');
break;
case 'txt':
// Export as TXT
console.log('Exporting sources as TXT');
break;
}
}
copyToClipboard(text: string) {
// Implementation for copying to clipboard
}
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});
}
pinConversation(conversation: ChatbotConversation) {
conversation.pinned = !conversation.pinned;
}
copyConversationLink(conversation: ChatbotConversation) {
console.log('todo copy conversation link', conversation);
}
deleteConversation(conversation: ChatbotConversation) {
console.log('todo delete conversation', conversation);
}
/**
* 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,65 @@
<div class="conversation-item"
[ngClass]="{'is-active': isActive}"
(click)="selectConversation()">
<div class="actions">
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<div class="menu">
<i class="ri-more-2-line"></i>
</div>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" (click)="shareConversation($event)">
<i class="ri-link-m"></i>
<span>Share</span>
</a>
<a class="dropdown-item" (click)="renameConversation($event)">
<i class="ri-edit-line"></i>
<span>Rename</span>
</a>
<a class="dropdown-item" (click)="pinConversation($event)">
<i class="ri-pushpin-fill"></i>
<span>Pin</span>
</a>
<hr class="dropdown-divider">
<a class="dropdown-item trash" (click)="deleteConversation($event)">
<i class="ri-delete-bin-line"></i>
<span>Delete</span>
</a>
</div>
</div>
</div>
</div>
<div class="container-text">
<ng-container *ngIf="conversation.pinned; else avatarTemplate">
<span class="pinned-icon">
<i class="ri-pushpin-line"></i>
</span>
</ng-container>
<ng-template #avatarTemplate>
<div class="pinned-icon llm-avatar"></div>
</ng-template>
<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-visible': conversation.visibleInput}">
</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>

View file

@ -0,0 +1,186 @@
.conversation-item {
margin-bottom: 16px;
margin-top: 16px;
padding: 16px 16px 18px 32px;
cursor: pointer;
border-radius: 8px;
color: #6D717C;
.actions {
width: 28px;
height: 28px;
border-radius: 6px;
background: #B9D6ED;
position: relative;
top: 0;
right: 0;
float: right;
.menu {
cursor: pointer;
text-align: right;
i {
font-size: 1.2rem;
}
}
.dropdown {
.dropdown-menu {
right: 0;
left: auto;
}
.dropdown-item {
i {
margin-right: 0.5rem;
}
}
&.is-hoverable:hover {
.dropdown-menu {
display: block;
}
}
}
.trash {
color: red;
fill: red;
}
}
&:hover {
background: #3B87CC1A;
}
&.is-active {
background: rgba(#3B87CC1A, 10%);
font-weight: 600;
.active-peak {
position: relative;
right: calc(-100% - 16px);
top: -35px;
visibility: visible;
}
}
.pinned-icon {
position: relative;
left: -1.7em;
i {
color: #FEC553;
fill: #FEC553;
}
}
.active-peak {
visibility: hidden;
}
.name {
color: #1E1F22;
font-size: 14px;
font-weight: 600;
display: block;
margin-bottom: 4px;
margin-top: -1.5em;
margin-left: 0;
}
.description {
color: #6D717C;
font-size: 12px;
font-weight: 400;
line-height: 20px;
margin-right: 4px;
}
.date {
color: rgba(#5F5F5F99, 60%);
float: right;
text-align: right;
font-family: Barlow;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: 8px;
margin-top: -1.5em;
}
input {
display: none;
&.is-visible {
display: block;
}
}
}
// Dropdown styles
.dropdown {
position: relative;
display: inline-block;
&.is-active,
&.is-hoverable:hover {
.dropdown-menu {
display: block;
}
}
.dropdown-trigger {
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
z-index: 20;
top: 100%;
right: 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;
margin-top: 0.25rem;
}
.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;
}
}
}
// LLM avatar
.llm-avatar {
background: white;
width: 24px;
height: 24px;
border-radius: 8px;
background-size: contain;
margin-right: 10px;
}

View file

@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ConversationItem } from './conversation-item';
import { moduleMetadata } from '@storybook/angular';
import { NgClass, NgIf, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChatbotConversation } from '../../services/conversations.service';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<ConversationItem> = {
title: 'Chatbot/Conversation/ConversationItem',
component: ConversationItem,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [NgClass, NgIf, DatePipe, FormsModule],
providers: []
})
],
argTypes: {
isActive: { control: 'boolean' },
onSelect: { action: 'selected' },
onShare: { action: 'shared' },
onRename: { action: 'renamed' },
onPin: { action: 'pinned' },
onDelete: { action: 'deleted' }
},
};
export default meta;
type Story = StoryObj<ConversationItem>;
// Create a mock conversation
const createMockConversation = (name: string, description: string, pinned: boolean = false): ChatbotConversation => {
const conversation = new ChatbotConversation();
conversation.name = name;
conversation.description = description;
conversation.pinned = pinned;
conversation.lastMessageDate = new Date();
return conversation;
};
// Default story
export const Default: Story = {
args: {
conversation: createMockConversation('Project Discussion', 'Latest updates on the project timeline'),
isActive: false
},
};
// Active conversation
export const Active: Story = {
args: {
conversation: createMockConversation('Project Discussion', 'Latest updates on the project timeline'),
isActive: true
},
};
// Pinned conversation
export const Pinned: Story = {
args: {
conversation: createMockConversation('Important Meeting', 'CEO quarterly update', true),
isActive: false
},
};
// Long name and description
export const LongContent: Story = {
args: {
conversation: createMockConversation(
'Very Long Conversation Name That Should Truncate',
'This is a very long description that should demonstrate how the component handles overflow text in the description area of the conversation item component',
),
isActive: false
},
};

View file

@ -0,0 +1,52 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { NgClass, NgIf, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChatbotConversation } from '../../services/conversations.service';
@Component({
selector: 'app-conversation-item',
standalone: true,
imports: [NgClass, NgIf, DatePipe, FormsModule],
templateUrl: './conversation-item.html',
styleUrl: './conversation-item.scss'
})
export class ConversationItem {
@Input() conversation: ChatbotConversation = new ChatbotConversation();
@Input() isActive: boolean = false;
@Output() onSelect = new EventEmitter<ChatbotConversation>();
@Output() onShare = new EventEmitter<ChatbotConversation>();
@Output() onRename = new EventEmitter<ChatbotConversation>();
@Output() onPin = new EventEmitter<ChatbotConversation>();
@Output() onDelete = new EventEmitter<ChatbotConversation>();
isDropdownActive: boolean = false;
toggleDropdown() {
this.isDropdownActive = !this.isDropdownActive;
}
selectConversation() {
this.onSelect.emit(this.conversation);
}
shareConversation(event: Event) {
event.stopPropagation();
this.onShare.emit(this.conversation);
}
renameConversation(event: Event) {
event.stopPropagation();
this.onRename.emit(this.conversation);
}
pinConversation(event: Event) {
event.stopPropagation();
this.onPin.emit(this.conversation);
}
deleteConversation(event: Event) {
event.stopPropagation();
this.onDelete.emit(this.conversation);
}
}

View file

@ -0,0 +1,23 @@
<div class="dropdown" [ngClass]="{'is-active': isDropdownActive}">
<div class="dropdown-trigger">
<div class="export-chat-button is-clickable" (click)="toggleDropdown()">
<i class="ri-download-2-line"></i>
<span class="label">
Export chat
</span>
</div>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" (click)="exportAs('csv', $event)">
CSV
</a>
<a class="dropdown-item" (click)="exportAs('excel', $event)">
Excel
</a>
<a class="dropdown-item" (click)="exportAs('txt', $event)">
TXT
</a>
</div>
</div>
</div>

View file

@ -0,0 +1,85 @@
.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% */
&:hover {
background: #005AA2;
color: white;
}
.label {
font-size: 14px;
line-height: normal;
}
}
// Dropdown styles
.dropdown {
position: relative;
display: inline-block;
&.is-active,
&.is-hoverable:hover {
.dropdown-menu {
display: block;
}
}
.dropdown-trigger {
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
z-index: 20;
top: 100%;
right: 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;
margin-top: 0.25rem;
}
.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;
}
}
}

View file

@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ExportChatButton } from './export-chat-button';
import { moduleMetadata } from '@storybook/angular';
import { NgClass } from '@angular/common';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<ExportChatButton> = {
title: 'Chatbot/Actions/ExportChatButton',
component: ExportChatButton,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [NgClass],
providers: []
})
],
argTypes: {
onExport: { action: 'exported' }
},
};
export default meta;
type Story = StoryObj<ExportChatButton>;
// Default state
export const Default: Story = {
args: {},
};
// Active dropdown
export const ActiveDropdown: Story = {
args: {},
play: async ({ canvasElement, component }) => {
// Simulate clicking the button to open the dropdown
component.isDropdownActive = true;
},
};
// Custom story to demonstrate hover state
export const HoverState: Story = {
args: {},
parameters: {
docs: {
description: {
story: 'Hover over the button to see the hover state. The button background changes to blue and text becomes white.'
}
}
}
};
// Custom story to demonstrate the export functionality
export const ExportFunctionality: Story = {
args: {},
parameters: {
docs: {
description: {
story: 'Click on the button to open the dropdown, then select a format to export. The onExport event will be emitted with the selected format.'
}
}
}
};

View file

@ -0,0 +1,27 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { NgClass } from '@angular/common';
export type ExportFormat = 'csv' | 'excel' | 'txt';
@Component({
selector: 'app-export-chat-button',
standalone: true,
imports: [NgClass],
templateUrl: './export-chat-button.html',
styleUrl: './export-chat-button.scss'
})
export class ExportChatButton {
@Output() onExport = new EventEmitter<ExportFormat>();
isDropdownActive: boolean = false;
toggleDropdown() {
this.isDropdownActive = !this.isDropdownActive;
}
exportAs(format: ExportFormat, event: Event) {
event.stopPropagation();
this.onExport.emit(format);
this.isDropdownActive = false;
}
}

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: 197px;
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,131 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { FeedbackButton } from './feedback-button';
import { moduleMetadata } from '@storybook/angular';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store, StoreModule } from '@ngrx/store';
import { ApiService } from '../../services/api-service';
import { reducers } from '../../reducers';
// Mock ApiService
class MockApiService {
async sendUserFeedback(feedback: string): Promise<any> {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
resolve({ success: true });
}, 1000);
});
}
}
const meta: Meta<FeedbackButton> = {
title: 'UI/Feedback/FeedbackButton',
component: FeedbackButton,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
CommonModule,
FormsModule,
StoreModule.forRoot(reducers)
],
providers: [
Store,
{ provide: ApiService, useClass: MockApiService }
]
})
],
argTypes: {
isModalOpen: {
control: 'boolean',
description: 'Whether the feedback modal is open'
},
feedbackText: {
control: 'text',
description: 'The feedback text entered by the user'
},
isSubmitting: {
control: 'boolean',
description: 'Whether the feedback is being submitted'
},
submitSuccess: {
control: 'boolean',
description: 'Whether the feedback was submitted successfully'
},
submitError: {
control: 'boolean',
description: 'Whether there was an error submitting the feedback'
}
}
};
export default meta;
type Story = StoryObj<FeedbackButton>;
// Default state - just the button
export const Default: Story = {
args: {
isModalOpen: false,
feedbackText: '',
isSubmitting: false,
submitSuccess: false,
submitError: false
}
};
// Modal open state
export const ModalOpen: Story = {
args: {
isModalOpen: true,
feedbackText: '',
isSubmitting: false,
submitSuccess: false,
submitError: false
}
};
// Modal with text entered
export const WithFeedbackText: Story = {
args: {
isModalOpen: true,
feedbackText: 'This is some feedback text that the user has entered.',
isSubmitting: false,
submitSuccess: false,
submitError: false
}
};
// Submitting state
export const Submitting: Story = {
args: {
isModalOpen: true,
feedbackText: 'This is some feedback text that the user has entered.',
isSubmitting: true,
submitSuccess: false,
submitError: false
}
};
// Success state
export const SubmitSuccess: Story = {
args: {
isModalOpen: true,
feedbackText: 'This is some feedback text that the user has entered.',
isSubmitting: false,
submitSuccess: true,
submitError: false
}
};
// Error state
export const SubmitError: Story = {
args: {
isModalOpen: true,
feedbackText: 'This is some feedback text that the user has entered.',
isSubmitting: false,
submitSuccess: false,
submitError: true
}
};

View file

@ -0,0 +1,89 @@
import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {Store} from '@ngrx/store';
import {ApiService} from '../../services/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,84 @@
<div class="message message-{{kind}} {{kind}}" id="message_{{id}}">
<div class="actions top-actions">
@if (kind === 'user') {
<button (click)="editMessage()" class="button edit">
<i class="ri-edit-box-line"></i>
</button>
}
<!-- @else {-->
<!-- <button (click)="toggleFullScreen()" class="button fullscreen">-->
<!-- <i class="ri-fullscreen-line"></i>-->
<!-- </button>-->
<!-- }-->
</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 class="actions bottom-actions is-{{kind}}">
@if (kind === 'llm') {
<div class="action-feedback">
<app-feedback-button></app-feedback-button>
</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>
<button (click)="toggleSources()" class="button sources">
<i class="ri-book-2-fill"></i>
@if (displaySourcesPanelLarge) {
hide sources
} @else {
see sources
}
</button>
</div>
}
</div>
<!-- <div [ngClass]="{'is-expanded': expanded}" class="expanded-message-fullscreen">-->
<!-- @if (content) {-->
<!-- <div [innerHTML]="sanitizedContent"></div>-->
<!-- }-->
<!-- </div>-->
</div>

View file

@ -0,0 +1,160 @@
@use 'sae-lib/src/styles/variables' as variables;
:host {
width: 100%;
}
.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');
}
}
.message {
.user-more-infos {
margin-top: -35px;
margin-left: 39px;
}
&.user {
background: white;
color: #000;
}
&.llm {
.message-content {
color: #000;
}
.actions {
.button {
background: rgba(59, 135, 204, 0.7);
color: black;
}
}
}
.actions {
.fullscreen {
float: right;
}
}
.app-theme-light & {
background: #F5F5F5;
}
.app-theme-dark & {
background: #2c2c2c;
color: #d5d5d5;
.message {
&.user {
background: #232432;
color: #8b8ecf;
}
&.llm {
background: #232432;
.message-content {
color: #8b8ecf;
}
.actions {
.button {
background: #0d0e15;
color: grey;
}
}
}
}
}
.app-theme-funky & {
color: #1B1D27;
background: #ffe8e8;
.message {
.message-content {
color: #fff3f3;
}
&.user {
background: #d6a3a3;
color: #1B1D27;
}
&.llm {
background: #9f36bc;
color: #bba7d6;
.actions {
.button {
background: #b08eba;
color: #d4b4ff;
}
}
}
}
}
.message {
&.user {
background: variables.$neutral-white;
}
&.llm {
background: rgba(#3B87CC1A, 10%);
}
}
}
.expanded-message-fullscreen {
display: none;
width: 50%;
&.is-visible {
display: block;
padding: 20px;
background: #ccc;
border-radius: 3px;
position: relative;
top: 0;
left: 0;
z-index: 100;
}
}

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,91 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { MessageBox } from './message-box';
import { moduleMetadata } from '@storybook/angular';
import { DomSanitizer, BrowserModule } 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';
const meta: Meta<MessageBox> = {
title: 'Chatbot/Messages/MessageBox',
component: MessageBox,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [Copy, FeedbackButton, BrowserModule],
providers: []
})
],
argTypes: {
kind: {
control: 'select',
options: ['user', 'llm'],
description: "Type de message (utilisateur ou IA)"
},
content: {
control: 'text',
description: "Contenu du message"
}
}
};
export default meta;
type Story = StoryObj<MessageBox>;
export const UserMessage: Story = {
args: {
kind: 'user',
content: 'Voici un message de l\'utilisateur pour tester l\'affichage.',
message: new ChatbotMessage({
kind: 'user',
user: {},
content: 'Voici un message de l\'utilisateur pour tester l\'affichage.',
name: 'User'
})
}
};
export const AIMessage: Story = {
args: {
kind: 'llm',
content: 'Je suis un assistant IA qui répond à vos questions. Voici une réponse détaillée qui peut contenir du <strong>HTML</strong> et des listes:<ul><li>Point 1</li><li>Point 2</li></ul>',
message: new ChatbotMessage({
kind: 'llm',
user: {},
content: 'Je suis un assistant IA qui répond à vos questions. Voici une réponse détaillée qui peut contenir du <strong>HTML</strong> et des listes:<ul><li>Point 1</li><li>Point 2</li></ul>',
name: 'Assistant'
})
}
};
export const AIMessageExpanded: Story = {
args: {
kind: 'llm',
content: 'Je suis un assistant IA qui répond à vos questions. Voici une réponse détaillée qui peut contenir du <strong>HTML</strong> et des listes:<ul><li>Point 1</li><li>Point 2</li></ul>',
message: new ChatbotMessage({
kind: 'llm',
user: {},
content: 'Je suis un assistant IA qui répond à vos questions. Voici une réponse détaillée qui peut contenir du <strong>HTML</strong> et des listes:<ul><li>Point 1</li><li>Point 2</li></ul>',
name: 'Assistant'
}),
expanded: true,
}
};
export const LongAIMessage: Story = {
args: {
kind: 'llm',
content: `<p>Voici une réponse longue avec beaucoup de contenu pour tester comment le composant gère les grands blocs de texte.</p>
<p>Les documents mettent également en évidence diverses mesures de sécurité post-incident mises en œuvre, notamment des procédures de formation améliorées, des protocoles de maintenance renforcés et des modifications de la conception des aéronefs. Des organismes de réglementation tels que le NTSB, le BEA, l'AAIB et d'autres ont participé à l'enquête sur ces incidents et ont formulé des recommandations de sécurité.</p>
<p>Ce résumé donne un aperçu des incidents aéronautiques importants, de leurs causes et de leurs conséquences, ce qui peut être utile pour comprendre les schémas d'accidents d'aéronefs et les domaines à améliorer en matière de sécurité aérienne.</p>
<br/>
<p>Pas de recherche Internet activée.<br/>
Aucune recherche Internet n'a é activée pour cette requête</p>
<br/>
<p>Résultats de recherche.</p>`,
message: new ChatbotMessage({
kind: 'llm',
user: {},
content: 'Contenu long...',
name: 'Assistant'
})
}
};

View file

@ -0,0 +1,80 @@
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';
import {NgClass} from '@angular/common';
import {Store} from '@ngrx/store';
import {ActionTypes, StateInterface} from '../../reducers';
type MessageKind = "user" | "llm";
@Component({
selector: 'app-message-box',
imports: [
Copy,
FeedbackButton,
NgClass
],
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 = "";
expanded: boolean = true;
displaySourcesPanelLarge: boolean = false;
constructor(private sanitizer: DomSanitizer,
public store: Store<StateInterface>) {
this.store.select(state => state.app.displaySourcesPanelLarge).subscribe(value => {
this.displaySourcesPanelLarge = value;
});
}
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")
}
toggleSources() {
console.log("TODO toggle sources")
this.store.dispatch({
type: ActionTypes.UPDATE_APP,
payload: {
displaySourcesPanelLarge: !this.displaySourcesPanelLarge
}
})
}
toggleFullScreen() {
console.log("TODO toggle fullscreen")
this.expanded = !this.expanded;
}
}

View file

@ -0,0 +1,16 @@
<div class="new-input">
<div class="main-conversation-container">
<div class="welcome-text">
<div class="welcome-icon">
<img alt="chatbot image" src="/chatbot.png">
</div>
How can we
<span class="emphasis">
assist
</span>
you today?
</div>
<app-tools-options [hideDisabledButtons]="false"></app-tools-options>
<app-prompt-input></app-prompt-input>
</div>
</div>

View file

@ -0,0 +1,25 @@
.new-input {
padding: 0 200px;
background: #f5f5f5;
border-radius: 10px;
min-height: 100vh;
.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,72 @@
import type {Meta, StoryObj} from '@storybook/angular';
import {moduleMetadata} from '@storybook/angular';
import {NewInput} from './new-input';
import {CommonModule} from '@angular/common';
import {PromptInput} from '../prompt-input/prompt-input';
import {ToolsOptions} from '../tools-options/tools-options';
import {Store, StoreModule} from '@ngrx/store';
import {reducers} from '../../reducers';
import {HttpClientModule} from '@angular/common/http';
import {ApiService} from '../../services/api-service';
import {FormsModule} from '@angular/forms';
const appReducer = reducers.app;
const meta: Meta<NewInput> = {
title: 'Chatbot/Input/NewInput',
component: NewInput,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
CommonModule,
FormsModule,
PromptInput,
ToolsOptions,
HttpClientModule,
StoreModule.forRoot({app: appReducer as any})
],
providers: [
ApiService,
Store
]
})
],
parameters: {
backgrounds: {
default: 'light',
values: [
{name: 'light', value: '#f5f5f5'},
{name: 'dark', value: '#333'},
],
},
}
};
export default meta;
type Story = StoryObj<NewInput>;
export const Default: Story = {
args: {
// Propriétés par défaut
}
};
export const DarkTheme: Story = {
args: {
// Propriétés par défaut
},
parameters: {
backgrounds: {default: 'dark'},
store: {
init: (store: Store) => {
store.dispatch({
type: 'UPDATE_APP',
payload: {
theme: 'dark'
}
});
}
}
}
};

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,33 @@
<div class="options-dropdown is-mode-{{mode}}">
<div class="button">
<i class="ri-settings-2-line "></i>
<div class="indicator"></div>
</div>
<div class="dropwdown">
<div class="dropdown-item" (click)="changeModeOption('auto')" [ngClass]="{
'is-active' : 'auto' == mode
}">
<span class="radio"></span>
<div class="label">
Auto mode
</div>
</div>
<div class="dropdown-item" (click)="changeModeOption('structured')" [ngClass]="{
'is-active' : 'structured' == mode
}">
<span class="radio"></span>
<div class="label">
Structured list
</div>
</div>
<div class="dropdown-item" (click)="changeModeOption('specific')" [ngClass]="{
'is-active' : 'specific' == mode
}">
<span class="radio"></span>
<div class="label">
Specific question
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,59 @@
.options-dropdown {
text-align: left;
position: relative;
top: 0.15em;
right: 0;
float: right;
margin-right: -6em;
.dropwdown {
visibility: hidden;
}
&:hover {
.button {
background: grey;
}
.dropwdown {
visibility: visible;
}
}
.button {
padding: 6px;
width: 2em;
border-radius: 6px;
text-align: center;
}
.is-mode-structured,
.is-mode-other,
{
background: red;
border-radius: 100px;
width: 20px;
position: relative;
left: 10px;
top: 10px;
}
.dropdown-item {
background: white;
padding: 6px;
cursor: pointer;
padding-left: 12px;
&.is-active {
color: cornflowerblue;
}
&:hover {
background: cornflowerblue;
color: white;
}
}
}

View file

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

View file

@ -0,0 +1,23 @@
import {Component, Input} from '@angular/core';
import {NgClass} from '@angular/common';
export type optionsDropdownOptions = 'auto' | 'structured' | 'specific';
@Component({
selector: 'app-options-dropdown',
templateUrl: './options-dropdown.html',
imports: [
NgClass
],
styleUrl: './options-dropdown.scss'
})
export class OptionsDropdown {
@Input() public mode: optionsDropdownOptions = 'auto'
changeModeOption(mode: optionsDropdownOptions) {
this.mode = mode;
}
}

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,100 @@
: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 or on hover
.dropdown.is-active .dropdown-menu,
.dropdown.is-hoverable:hover .dropdown-menu {
display: block;
}
.dropdown:not(.is-active):not(:hover) .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 '../../services/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,16 @@
<div class="prompt-input">
<!-- bottom input-->
<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">
<button (click)="submitMessage()" class="submit">
<i class="ri-send-plane-2-line"></i>
</button>
<app-options-dropdown></app-options-dropdown>
</div>

View file

@ -0,0 +1,136 @@
@use 'sae-lib/src/styles/_variables.scss' as variables;
.prompt-input {
margin: 20px auto;
background: variables.$neutral-white;
border-radius: 0.5rem;
padding: 10px 10px;
box-shadow: 0 5px 10px variables.$shadow-color;
height: 3.8em;
.column {
padding-top: 10px;
}
input {
width: 326px;
max-width: 100%;
border: 0;
padding: 10px;
@extend .is-shadowed !optional;
float: left;
.is-expanded-right & {
width: 968px;
}
&:focus {
outline: none;
}
}
button {
background: variables.$neutral-white;
padding: 10px;
border: 1px solid transparent;
cursor: pointer;
border-radius: 10px;
&:hover {
color: variables.$main-color-active;
fill: variables.$main-color-active;
background: 1px solid variables.$bg-color-button;
}
&.submit {
position: relative;
top: 0;
right: 0;
float: right;
display: inline-block;
z-index: 10;
}
}
.magic {
padding: 10px;
border: 1px solid transparent;
border-radius: 10px;
background: variables.$bg-color-chat;
width: 40px;
height: 40px;
float: left;
}
// Dropdown styles
.dropdown {
position: relative;
display: inline-block;
&.is-active,
&.is-hoverable:hover {
.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,70 @@
import type {Meta, StoryObj} from '@storybook/angular';
import {moduleMetadata} from '@storybook/angular';
import {PromptInput} from './prompt-input';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {Store, StoreModule} from '@ngrx/store';
import {reducers} from '../../reducers';
import {HttpClientModule} from '@angular/common/http';
import {ApiService} from '../../services/api-service';
const appReducer = reducers.app;
const meta: Meta<PromptInput> = {
title: 'Chatbot/Input/PromptInput',
component: PromptInput,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
CommonModule,
FormsModule,
HttpClientModule,
StoreModule.forRoot({app: appReducer as any})
],
providers: [
ApiService,
Store
]
})
],
argTypes: {
// Vous pouvez définir des contrôles pour les propriétés ici
}
};
export default meta;
type Story = StoryObj<PromptInput>;
export const Default: Story = {
args: {
// Les propriétés par défaut
}
};
export const WithPlaceholder: Story = {
args: {
// Les propriétés avec un placeholder personnalisé
},
parameters: {
// Vous pouvez surcharger certains paramètres ici
}
};
export const LoadingState: Story = {
args: {
// Les propriétés de base
},
parameters: {
store: {
init: (store: Store) => {
store.dispatch({
type: 'UPDATE_APP',
payload: {
loading: true
}
});
}
}
}
};

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 '../../services/api-service';
import {interval, Subscription} from 'rxjs';
import {OptionsDropdown} from '../options-dropdown/options-dropdown';
@Component({
selector: 'app-prompt-input',
imports: [CommonModule, FormsModule, OptionsDropdown],
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 [ngClass]="{'expanded': expanded}" class="source">
<i class="ri-file-3-line"></i>
<a class="title">{{ source.title || 'Source sans nom' }}</a>
<div class="actions has-text-right">
<i class="ri-file-add-line"></i>
<i (click)="toggleExpanded()" class="ri-arrow-down-s-line"></i>
</div>
<div (click)="toggleExpanded()" class="description">
{{ source.description || description }}
</div>
</div>

View file

@ -0,0 +1,53 @@
//@use "remixicon/fonts/remixicon.scss";
//@use "sae-lib/src/styles/variables";
.source {
padding: 10px;
margin-bottom: 10px;
background: white;
color: #000;
font-size: 14px;
font-weight: 400;
line-height: 20px;
cursor: pointer;
border-radius: 4px;
height: 100px;
overflow: hidden;
text-overflow: ellipsis;
&.expanded {
height: auto;
}
.title {
color: #1E1F22;
font-size: 14px;
font-weight: 600;
margin-left: 3px;
cursor: pointer;
&:hover {
color: #005AA2;
text-decoration: underline;
}
}
.actions {
color: #005AA2;
float: right;
i {
margin-left: 10px;
}
}
&: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,65 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { SourceBlock } from './source-block';
import { ChatbotSource } from '../../services/conversations.service';
import { moduleMetadata } from '@storybook/angular';
import { CommonModule } from '@angular/common';
const meta: Meta<SourceBlock> = {
title: 'Chatbot/Sources/SourceBlock',
component: SourceBlock,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [CommonModule],
providers: []
})
],
argTypes: {
source: {
control: 'object',
description: 'Objet contenant les informations de la source'
}
},
};
export default meta;
type Story = StoryObj<SourceBlock>;
export const Default: Story = {
args: {
source: new ChatbotSource({
title: 'Document technique.pdf',
url: 'https://example.com/document-technique.pdf',
description: 'Ce document contient des informations techniques sur les avions de ligne modernes et leur maintenance.'
})
},
};
export const ShortDescription: Story = {
args: {
source: new ChatbotSource({
title: 'Rapport d\'accident.pdf',
url: 'https://example.com/rapport-accident.pdf',
description: 'Rapport concernant l\'incident de vol 123.'
})
},
};
export const LongDescription: Story = {
args: {
source: new ChatbotSource({
title: 'Documentation complète.pdf',
url: 'https://example.com/documentation-complete.pdf',
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.'
})
},
};
export const WithoutUrl: Story = {
args: {
source: new ChatbotSource({
title: 'Document interne.pdf',
description: 'Document à usage interne uniquement, sans lien externe.'
})
},
};

View file

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

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,35 @@
.time-separator {
padding: 0 16px;
font-size: 0.8rem;
color: grey;
margin-top: 2rem;
margin-bottom: 2rem;
display: block;
.column {
padding: 0;
width: auto;
&.time-ago {
color: #1E1F22;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: 8px;
margin-top: -32px;
margin-bottom: 16px;
text-align: center;
width: 100px !important;
padding: 16px 0;
}
}
.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,11 @@
<button (click)="toggleState()"
[ngClass]="{
'is-hoverable' : tooltip?.length
}"
[title]="tooltip"
class="togglable-button {{kind}} {{active ? 'is-active' : ''}}">
@if (icon) {
<span class="icon ri-{{icon}}"></span>
}
{{ label }}
</button>

View file

@ -0,0 +1,87 @@
.togglable-button {
cursor: pointer;
font-size: 12px;
line-height: 8px;
font-weight: 600;
color: #005AA2;
text-align: center;
font-style: normal;
display: inline-flex;
padding: 10px 10px 12px 10px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 20px;
background: transparent;
// Custom tooltip styling
&[title] {
position: relative;
}
.is-hoverable {
&:hover::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: black;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 100;
margin-bottom: 5px;
}
}
&:first-letter {
text-transform: capitalize;
}
&.local {
border: solid 1px #005aa2;
color: #005aa2;
&.is-active {
background: #083b7d;
color: white;
&:hover {
color: #7FB8E7;
border: solid 1px #7FB8E7;
}
}
}
&.outside {
border: solid 1px #50d494;
color: #50d494;
&:hover {
color: #159256;
border: solid 1px #159256;
}
&.is-active {
background: #159256;
color: white;
}
}
&:hover {
border: solid 1px black;
color: black;
&.is-active {
color: black;
}
}
}

View file

@ -0,0 +1,51 @@
import type {Meta, StoryObj} from '@storybook/angular';
import {ToggleButton} from './toggle-button';
// More on how to set up stories at: https://storybook.js.org/docs/angular/writing-stories/introduction
const meta: Meta<ToggleButton> = {
title: 'UI/Buttons/ToggleButton',
component: ToggleButton,
tags: ['autodocs'],
argTypes: {
label: {control: 'text'},
icon: {control: 'text'},
tooltip: {control: 'text'},
kind: {control: 'text'},
},
};
export default meta;
type Story = StoryObj<ToggleButton>;
// More on writing stories with args: https://storybook.js.org/docs/angular/writing-stories/args
export const LocalButton: Story = {
args: {
label: 'Button local',
kind: 'local',
icon: 'arrow-right-circle-fill',
tooltip: 'un tooltip détaillant l\'action ',
},
};
export const LocalButtonActive: Story = {
args: {
label: 'Button local',
kind: 'local',
icon: 'award-line',
active: true,
},
};
export const OutsideButton: Story = {
args: {
label: 'Button outside',
kind: 'outside',
active: false,
},
};
export const OutsideButtonActive: Story = {
args: {
label: 'Button outside',
kind: 'outside',
active: true,
},
};

View file

@ -0,0 +1,26 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {NgClass} from '@angular/common';
type ToggleButtonKind = "local" | "outside";
@Component({
selector: 'app-toggle-button',
imports: [
NgClass
],
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 = "";
@Input() tooltip: string = "";
@Output() stateChanged = new EventEmitter<boolean>();
toggleState() {
this.active = !this.active;
this.stateChanged.emit(this.active);
}
}

View file

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

View file

@ -0,0 +1,16 @@
.tools-options {
margin-top: 1rem;
padding-top: 1rem;
border-top: solid 0.5px #b7b7b7;
app-toggle-button {
margin-bottom: 10px;
display: inline-block;
+ app-toggle-button {
margin-left: 10px;
}
}
}

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,55 @@
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 => {
console.log("filters", 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
}
});
}
}
getTooltipForFilter(filter: filterType): string {
// Generate descriptive tooltips based on filter name and kind
const kindDescription = filter.kind === 'local' ? 'Données internes' : 'Données externes';
return `${filter.name} - ${kindDescription}`;
}
}

View file

@ -0,0 +1 @@
<p class="version">Version as of {{ 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="<strong>AI can make mistakes</strong> - 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,83 @@
import type {Meta, StoryObj} from '@storybook/angular';
import {moduleMetadata} from '@storybook/angular';
import {WarningBugs} from './warning-bugs';
import {CommonModule} from '@angular/common';
import {AlertBox} from 'sae-lib/alert-box/alert-box';
import {Store} from '@ngrx/store';
// import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
// Mock Store
const storeMock = {};
const meta: Meta<WarningBugs> = {
title: 'Chatbot/Feedback/WarningBugs',
component: WarningBugs,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
CommonModule,
AlertBox,
// BrowserAnimationsModule
],
providers: [
{
provide: Store,
useValue: storeMock
}
]
})
],
parameters: {
docs: {
description: {
component: 'The WarningBugs component displays either a warning or an error message based on the application state.'
}
}
}
};
export default meta;
type Story = StoryObj<WarningBugs>;
// Default state: Warning about AI making mistakes
export const Warning: Story = {
parameters: {
docs: {
description: {
story: 'Default state showing a warning about AI potentially making mistakes.'
}
}
},
decorators: [
moduleMetadata({
providers: [
{
provide: Store,
useValue: false
}
]
})
]
};
// Error state: System error message
export const ErrorState: Story = {
parameters: {
docs: {
description: {
story: 'Error state showing a system error message when the request cannot be processed.'
}
}
},
decorators: [
moduleMetadata({
providers: [
{
provide: Store,
useValue: true
}
]
})
]
};

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';
import {AlertBox} from 'sae-lib/alert-box/alert-box';
@Component({
selector: 'app-warning-bugs',
standalone: true,
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,97 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ColorDisplay } from './color-display';
import { moduleMetadata } from '@storybook/angular';
import { NgStyle } from '@angular/common';
const meta: Meta<ColorDisplay> = {
title: 'Design/Colors/ColorDisplay',
component: ColorDisplay,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [NgStyle],
providers: []
})
],
argTypes: {
hexaCode: {
control: 'text',
description: "Code hexadécimal de la couleur"
},
name: {
control: 'text',
description: "Nom de la couleur"
},
backgroundColor: {
control: 'text',
description: "Couleur de fond (si différente du code hexa)"
}
}
};
export default meta;
type Story = StoryObj<ColorDisplay>;
export const Red: Story = {
args: {
hexaCode: '#cc0000',
name: 'Rouge',
backgroundColor: ''
}
};
export const Blue: Story = {
args: {
hexaCode: '#0000cc',
name: 'Bleu',
backgroundColor: ''
}
};
export const Green: Story = {
args: {
hexaCode: '#00cc00',
name: 'Vert',
backgroundColor: ''
}
};
export const Yellow: Story = {
args: {
hexaCode: '#ffcc00',
name: 'Jaune',
backgroundColor: ''
}
};
export const Purple: Story = {
args: {
hexaCode: '#9900cc',
name: 'Violet',
backgroundColor: ''
}
};
export const Orange: Story = {
args: {
hexaCode: '#ff6600',
name: 'Orange',
backgroundColor: ''
}
};
export const Black: Story = {
args: {
hexaCode: '#000000',
name: 'Noir',
backgroundColor: ''
}
};
export const White: Story = {
args: {
hexaCode: '#ffffff',
name: 'Blanc',
backgroundColor: ''
}
};

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
}
}
}

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