import { ChangeDetectionStrategy, Component, DestroyRef, Inject, NgZone, afterNextRender, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { merge } from 'lodash-es';
import { combineLatest } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { THEME_COLOR } from 'ish-core/configurations/injection-keys';
import { AccountFacade } from 'ish-core/facades/account.facade';
import { AppFacade } from 'ish-core/facades/app.facade';
import { CheckoutFacade } from 'ish-core/facades/checkout.facade';
import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { InjectSingle } from 'ish-core/utils/injection';
import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator';
import { ScriptLoaderService } from 'ish-core/utils/script-loader/script-loader.service';
import { CompareFacade } from '../../../compare/facades/compare.facade';
import { OrderTemplatesFacade } from '../../../order-templates/facades/order-templates.facade';
import { CopilotFacade } from '../../facades/copilot.facade';
import { ChatbotMessage, ChatbotToolCall } from '../../models/copilot-chatbot/copilot-chatbot.model';
import { CopilotConfig } from '../../models/copilot-config/copilot-config.model';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- allows access to Flowise Chatbot js functionality */
declare let Chatbot: any;
/**
* The Copilot Component
*
* This component initializes the Flowise Chatbot and provides the necessary configuration and localization data.
* It also handles the communication between the Chatbot and the PWA.
*
* @example
*
*/
@Component({
selector: 'ish-copilot',
standalone: false,
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
@GenerateLazyComponent()
export class CopilotComponent {
private destroyRef = inject(DestroyRef);
constructor(
private copilotFacade: CopilotFacade,
private scriptLoader: ScriptLoaderService,
private translateService: TranslateService,
private ngZone: NgZone,
private router: Router,
private appFacade: AppFacade,
private shoppingFacade: ShoppingFacade,
private checkoutFacade: CheckoutFacade,
private compareFacade: CompareFacade,
private accountFacade: AccountFacade,
private orderTemplatesFacade: OrderTemplatesFacade,
@Inject(THEME_COLOR) private themeColor: InjectSingle
) {
// afterNextRender = only rendered in browser
afterNextRender(() => {
this.loadCopilot();
});
}
/**
* Load the Flowise Chatbot functionality and initialize it with the fetched configuration and localization data.
*/
private loadCopilot() {
combineLatest([
this.copilotFacade.copilotConfiguration$.pipe(
switchMap(config => this.scriptLoader.load(config.copilotUIFile, { type: 'module' }).pipe(map(() => config)))
),
this.translateService.get([
'copilot.disclaimer.buttonText',
'copilot.disclaimer.message',
'copilot.disclaimer.title',
'copilot.footer.company_link',
'copilot.footer.company',
'copilot.footer.text',
'copilot.max_chars_warning_message',
'copilot.placeholder',
'copilot.starter_prompt_1',
'copilot.starter_prompt_2',
'copilot.title',
'copilot.welcome_message',
]),
this.appFacade.getRestEndpointWithContext$,
this.appFacade.currentLocale$,
])
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe(([config, localizations, restEndpoint, currentLocale]) => {
this.initializeCopilot(config, localizations, restEndpoint, currentLocale, this.getPrimaryColor());
});
}
/**
* Initialize the Flowise Chatbot with the provided configuration and localization data.
*/
initializeCopilot(
copilotConfig: CopilotConfig,
localizations: Record,
restEndpoint: string,
currentLocale: string,
primaryColor: string
) {
let lastMessage: ChatbotMessage;
let previousLoading = false;
Chatbot.init({
// general configuration
chatflowid: copilotConfig.chatflowid,
apiHost: copilotConfig.apiHost,
chatflowConfig: {
vars: {
restEndpoint,
currentLocale,
// TODO: provide additional personalization context information if it can be handled by the chatbot
// user_token: localStorage.getItem('icm_access_token'),
// customer: this.accountFacade.userEmail$,
// override/extend the chatflowConfig vars via deployment copilotConfig adaptions
...copilotConfig.chatflowConfig?.vars,
},
},
// theme/localization configuration
theme: merge(
{
button: {
backgroundColor: primaryColor,
dragAndDrop: true,
right: 15,
bottom: 60,
size: 38,
},
chatWindow: {
showTitle: true,
showAgentMessages: false,
title: localizations['copilot.title'],
welcomeMessage: localizations['copilot.welcome_message'],
backgroundColor: '#f8f9fa',
fontSize: 16,
starterPrompts: [localizations['copilot.starter_prompt_1'], localizations['copilot.starter_prompt_2']],
clearChatOnReload: false, // If set to true, the chat will be cleared when the page reloads
botMessage: {
backgroundColor: '#ffffff',
},
userMessage: {
backgroundColor: primaryColor,
},
textInput: {
placeholder: localizations['copilot.placeholder'],
maxCharsWarningMessage: localizations['copilot.max_chars_warning_message'],
maxChars: 200,
sendButtonColor: primaryColor,
},
footer: {
text: localizations['copilot.footer.text'],
company: localizations['copilot.footer.company'],
companyLink: localizations['copilot.footer.company_link'],
},
},
disclaimer: {
title: localizations['copilot.disclaimer.title'],
message: localizations['copilot.disclaimer.message'],
buttonText: localizations['copilot.disclaimer.buttonText'],
},
// Add custom CSS styles. Use !important to override default styles
customCSS: `
.chatbot-container {
font-family: 'robotoregular' !important;
}
input, textarea {
font-family: 'robotoregular' !important;
}
`,
},
// override theme configuration via deployment copilotConfig adaptions
copilotConfig.theme
),
// behavior configuration
observersConfig: {
// The bot message stack has changed
observeMessages: (messages: ChatbotMessage[]) => {
// Save the lastMessage for later processing (once loading is finished)
lastMessage = messages[messages?.length - 1];
},
// The bot loading signal changed
observeLoading: (loading: boolean) => {
// Detect transition from true to false
if (previousLoading && !loading) {
// Ensure there is a message to process
if (!lastMessage) {
console.warn('No message available to process.');
return;
}
// Check if the last message is an 'apiMessage' and ensure at least one usedTool exists
if (lastMessage.type === 'apiMessage' && lastMessage.usedTools?.length > 0) {
// Iterate over all used tools to handle multiple tool calls in one message
lastMessage.usedTools.forEach(toolCall => this.handleToolCall(toolCall));
}
}
// Update previousLoading for the next change
previousLoading = loading;
},
},
});
}
/**
* Handle the selected tool call from the chatbot and trigger the corresponding action in the PWA.
*
* @param toolCall The chatbot tool call information
*/
private handleToolCall(toolCall: ChatbotToolCall) {
switch (toolCall?.tool) {
case 'PWA_basket':
this.handlePWABasketToolCall(toolCall.toolInput);
break;
case 'PWA_compare_products':
// Note: this will only work if the 'compare' feature is enabled in the PWA
this.compareFacade.compareProducts(toolCall.toolInput?.SKUs?.split(';'));
this.navigate(`/compare`);
break;
case 'PWA_navigate_to_page':
this.handlePWANavigateToPageToolCall(toolCall.toolInput);
break;
case 'PWA_order_template_actions':
this.handlePWAOrderTemplateToolCall(toolCall.toolInput);
break;
case 'icmSearch':
// Navigate to search results page based on icmSearch tool call
this.handleIcmSearchToolCall(toolCall.toolOutput);
break;
default:
break;
}
}
/**
* Make sure the navigation trigged by the Chatbot is executed within the Angular zone.
*
* @param url An absolute path for a defined route
*/
private navigate(url: string) {
this.ngZone.run(() => this.router.navigateByUrl(url));
}
/**
* Retrieve color for copilot button from CSS custom properties or the theme color as fallback.
*/
private getPrimaryColor(): string {
return (
getComputedStyle(document.documentElement).getPropertyValue('--color-copilot') ||
getComputedStyle(document.documentElement).getPropertyValue('--corporate-primary') ||
this.themeColor // fallback to the theme color defined in environment.ts
);
}
/**
* Triggers the corresponding basket action in the PWA based on the PWA_basket tool call from the copilot.
* @param toolInput The copilot tool call input.
*/
private handlePWABasketToolCall(toolInput: Record) {
const { operation, items } = toolInput || {};
const skusAndQty = (items?.split(';') ?? []).map(item => {
const [sku, param] = item.split(':');
const isAllQty = param?.toLowerCase() === 'all';
return { sku, qty: isAllQty ? 1 : Number(param) || 1, isAllQty };
});
switch (operation) {
case 'add':
this.shoppingFacade.addProductsToBasket(skusAndQty.map(({ sku, qty }) => ({ sku, quantity: qty })));
break;
case 'update':
case 'remove':
this.checkoutFacade.basket$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(basket => {
skusAndQty.forEach(({ sku, qty, isAllQty }) => {
basket?.lineItems
?.filter(li => li?.productSKU === sku)
?.forEach(item => {
if (operation === 'update') {
this.checkoutFacade.updateBasketItem({ itemId: item.id, quantity: qty });
} else if (isAllQty) {
this.checkoutFacade.deleteBasketItem(item.id);
} else {
const newQty = item?.quantity?.value - qty;
if (newQty >= 0) {
this.checkoutFacade.updateBasketItem({ itemId: item.id, quantity: newQty });
}
}
});
});
});
break;
case 'clear':
this.checkoutFacade.deleteBasketItems();
break;
case 'view':
this.navigate('/basket');
break;
default:
break;
}
}
/**
* Triggers the corresponding navigation route in the PWA based on the PWA_navigate_to_page tool call from the copilot.
* @param toolInput The copilot tool call input information.
*/
private handlePWANavigateToPageToolCall(toolInput: Record) {
const { page, sku, categoryId, orderId, orderTemplateId } = toolInput || {};
const navigationMap: Record void> = {
home: () => this.navigate('/'),
basket: () => this.navigate('/basket'),
product: () => this.navigate(`/product/${sku}`),
category: () => this.navigate(`/category/${categoryId}`),
order: () => this.navigate(`/account/orders/${orderId}`),
orderHistory: () => this.navigate('/account/orders'),
orderTemplates: () => this.navigate('/account/order-templates'),
orderTemplate: () => this.navigate(`/account/order-templates/${orderTemplateId}`),
myAccount: () => this.navigate('/account'),
contact: () => this.navigate('/contact'),
imprint: () => this.navigate('/page/page.legalnotice'),
login: () => this.navigate('/login'),
logout: () => this.accountFacade.logoutUser(),
};
if (navigationMap[page]) {
navigationMap[page]();
}
}
/**
* Triggers the corresponding order template action in the PWA based on the PWA_order_template_actions tool call from the copilot.
* @param toolInput The copilot tool call input information.
*/
private handlePWAOrderTemplateToolCall(toolInput: Record) {
const { operation, sku, orderTemplateId, title, quantity } = toolInput || {};
switch (operation) {
case 'create':
this.orderTemplatesFacade.addOrderTemplate({ title });
break;
case 'add':
if (sku && orderTemplateId) {
this.orderTemplatesFacade.addProductToOrderTemplate(orderTemplateId, sku, quantity ? Number(quantity) : 1);
}
break;
case 'remove':
if (sku && orderTemplateId) {
this.orderTemplatesFacade.removeProductFromOrderTemplate(orderTemplateId, sku);
}
break;
case 'delete':
if (orderTemplateId) {
this.orderTemplatesFacade.deleteOrderTemplate(orderTemplateId);
}
break;
default:
break;
}
}
/**
* Processes the copilot's icmSearch tool call and triggers the corresponding search action in the PWA.
* @param toolOutput - The copilot tool call output information
* @param toolOutput.showOnPWA - Indicates if the search results should be displayed in the PWA
* @param toolOutput.query - The search query executed by the copilot to be displayed in the PWA
*/
private handleIcmSearchToolCall(toolOutput: string) {
try {
const parsed = JSON.parse(toolOutput);
if (typeof parsed === 'object' && parsed?.showOnPWA !== undefined && parsed.query) {
const query = parsed.query;
if (query?.trim()) {
this.navigate(`/search/${encodeURIComponent(query)}`);
}
}
} catch {
// intentionally ignore JSON parse errors
}
}
}