diff --git a/angular.json b/angular.json index bff0c15fc..a2495a798 100644 --- a/angular.json +++ b/angular.json @@ -42,7 +42,7 @@ "stylePreprocessorOptions": { "includePaths": ["node_modules/"] }, - "styles": ["src/styles.scss"], + "styles": ["src/styles.scss", "node_modules/katex/dist/katex.min.css"], "scripts": [], "allowedCommonJsDependencies": [ "@babel/standalone", diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index c6876cdf9..b602b1718 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -7,24 +7,41 @@ import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import * as _angular_core from '@angular/core'; +import { BackgroundColorVariant } from '@siemens/element-ng/common'; import { ElementRef } from '@angular/core'; import { FileUploadError } from '@siemens/element-ng/file-uploader'; import * as i1 from '@siemens/element-ng/resize-observer'; +import { isSignal } from '@angular/core'; import { MenuItem } from '@siemens/element-ng/menu'; import { OnDestroy } from '@angular/core'; import * as _siemens_element_translate_ng_translate from '@siemens/element-translate-ng/translate'; +import { Signal } from '@angular/core'; import { SiModalService } from '@siemens/element-ng/modal'; import { TemplateRef } from '@angular/core'; import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; import { TranslatableString as TranslatableString_2 } from '@siemens/element-translate-ng/translate'; import { UploadFile } from '@siemens/element-ng/file-uploader'; +// @public +export interface AiChatMessage extends BaseChatMessage { + actions?: MessageAction[]; + content: string | Signal; + type: 'ai'; +} + // @public export interface Attachment { name: string; previewTemplate?: TemplateRef | (() => TemplateRef); } +// @public +export interface BaseChatMessage { + content?: string | Signal; + loading?: boolean | Signal; + type: 'user' | 'ai' | 'tool'; +} + // @public export interface ChatInputAttachment extends Attachment { file: File; @@ -32,6 +49,9 @@ export interface ChatInputAttachment extends Attachment { type: string; } +// @public +export type ChatMessage = UserChatMessage | AiChatMessage | ToolChatMessage | TemplateChatMessage; + // @public export interface MessageAction { action: (actionParam: any, source: this) => void; @@ -40,6 +60,57 @@ export interface MessageAction { label: TranslatableString; } +// @public (undocumented) +export interface PromptCategory { + // (undocumented) + label: string; +} + +// @public (undocumented) +export interface PromptSuggestion { + // (undocumented) + text: string; +} + +// @public +export class SiAiChatContainerComponent { + constructor(); + readonly aiIcon: _angular_core.InputSignal; + readonly colorVariant: _angular_core.InputSignal; + readonly copyCodeButtonLabel: _angular_core.InputSignal; + readonly disableCopyCodeButton: _angular_core.InputSignal; + readonly disableDownloadTableButton: _angular_core.InputSignal; + readonly disableInterrupt: _angular_core.InputSignalWithTransform; + readonly downloadTableButtonLabel: _angular_core.InputSignal; + focus(): void; + readonly greeting: _angular_core.InputSignal; + readonly interrupting: _angular_core.InputSignalWithTransform; + readonly latexRenderer: _angular_core.InputSignal<((latex: string, displayMode: boolean) => string | undefined) | undefined>; + readonly loading: _angular_core.InputSignalWithTransform; + readonly messages: _angular_core.InputSignal; + readonly messageSent: _angular_core.OutputEmitterRef<{ + content: string; + attachments: ChatInputAttachment[]; + }>; + readonly noAutoScroll: _angular_core.InputSignalWithTransform; + readonly promptSuggestions: _angular_core.InputSignal>; + scrollToBottom(): void; + readonly secondaryActionsLabel: _angular_core.InputSignal; + readonly sending: _angular_core.InputSignalWithTransform; + readonly statusAction: _angular_core.InputSignal<{ + title: string; + href: string; + target?: string; + } | undefined>; + readonly statusHeading: _angular_core.InputSignal; + readonly statusMessage: _angular_core.InputSignal; + readonly statusSeverity: _angular_core.InputSignal<"info" | "success" | "warning" | "danger" | "caution" | "critical" | undefined>; + readonly syntaxHighlighter: _angular_core.InputSignal<((code: string, language?: string) => string | undefined) | undefined>; + readonly toolInputArgumentsLabel: _angular_core.InputSignal; + readonly toolOutputLabel: _angular_core.InputSignal; + readonly welcomeMessage: _angular_core.InputSignal; +} + // @public export class SiAiMessageComponent { constructor(); @@ -52,6 +123,14 @@ export class SiAiMessageComponent { readonly secondaryActionsLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; } +// @public +export class SiAiWelcomeScreenComponent { + readonly categories: _angular_core.InputSignal; + readonly promptSelected: _angular_core.OutputEmitterRef; + readonly promptSuggestions: _angular_core.InputSignal>; + readonly selectedCategory: _angular_core.ModelSignal; +} + // @public export class SiAttachmentListComponent { readonly alignment: _angular_core.InputSignal<"start" | "end">; @@ -67,6 +146,8 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { readonly colorVariant: _angular_core.InputSignal; focus(): void; readonly noAutoScroll: _angular_core.InputSignalWithTransform; + scrollToBottom(): void; + scrollToTop(): void; } // @public @@ -74,7 +155,8 @@ export class SiChatContainerInputDirective { } // @public -export class SiChatInputComponent implements AfterViewInit { +export class SiChatInputComponent implements AfterViewInit, OnDestroy { + constructor(); readonly accept: _angular_core.InputSignal; readonly actionParam: _angular_core.InputSignal; readonly actions: _angular_core.InputSignal; @@ -88,11 +170,13 @@ export class SiChatInputComponent implements AfterViewInit { focus(): void; readonly interrupt: _angular_core.OutputEmitterRef; readonly interruptButtonLabel: _angular_core.InputSignal; - readonly interruptible: _angular_core.InputSignalWithTransform; + readonly interruptible: _angular_core.ModelSignal; readonly label: _angular_core.InputSignal; readonly maxFileSize: _angular_core.InputSignal; readonly maxLength: _angular_core.InputSignal; readonly placeholder: _angular_core.InputSignal; + // (undocumented) + registerParent(sending: Signal, interruptible: Signal, sendListener?: () => void): void; readonly removeAttachmentLabel: _angular_core.InputSignal; readonly secondaryActions: _angular_core.InputSignal; readonly secondaryActionsLabel: _angular_core.InputSignal; @@ -102,7 +186,7 @@ export class SiChatInputComponent implements AfterViewInit { }>; readonly sendButtonIcon: _angular_core.InputSignal; readonly sendButtonLabel: _angular_core.InputSignal; - readonly sending: _angular_core.InputSignalWithTransform; + readonly sending: _angular_core.ModelSignal; readonly value: _angular_core.ModelSignal; } @@ -121,6 +205,19 @@ export class SiChatMessageComponent { readonly loading: _angular_core.InputSignal; } +// @public +export class SiToolMessageComponent { + readonly expandInputArguments: _angular_core.InputSignalWithTransform; + readonly expandOutput: _angular_core.InputSignalWithTransform; + readonly inputArguments: _angular_core.InputSignal; + readonly inputArgumentsLabel: _angular_core.InputSignal; + readonly loading: _angular_core.InputSignalWithTransform; + readonly name: _angular_core.InputSignal; + readonly output: _angular_core.InputSignal; + readonly outputLabel: _angular_core.InputSignal; + readonly toolIcon: _angular_core.InputSignal; +} + // @public export class SiUserMessageComponent { constructor(); @@ -133,6 +230,31 @@ export class SiUserMessageComponent { readonly secondaryActionsLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; } +// @public +export interface TemplateChatMessage { + template: TemplateRef; + templateContext?: any; +} + +// @public +export interface ToolChatMessage extends BaseChatMessage { + autoExpandInputArguments?: boolean; + autoExpandOutput?: boolean; + icon?: string; + inputArguments?: string | object; + name: string; + output?: string | object | Signal; + type: 'tool'; +} + +// @public +export interface UserChatMessage extends BaseChatMessage { + actions?: MessageAction[]; + attachments?: Attachment[]; + content: string; + type: 'user'; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index e47c3c773..6aad95cf6 100644 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ b/api-goldens/element-ng/markdown-renderer/index.api.md @@ -4,16 +4,34 @@ ```ts +import * as _angular_core from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import * as i0 from '@angular/core'; +import * as _siemens_element_translate_ng_translate from '@siemens/element-translate-ng/translate'; +import { SiTranslateService } from '@siemens/element-translate-ng/translate'; +import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; // @public -export const getMarkdownRenderer: (sanitizer: DomSanitizer) => ((text: string) => Node); +export const getMarkdownRenderer: (sanitizer: DomSanitizer, options?: MarkdownRendererOptions, doc?: Document, isBrowser?: boolean) => ((text: string) => Node); + +// @public (undocumented) +export interface MarkdownRendererOptions { + copyCodeButton?: TranslatableString; + downloadTableButton?: TranslatableString; + latexRenderer?: (latex: string, displayMode: boolean) => string | undefined; + syntaxHighlighter?: (code: string, language?: string) => string | undefined; + translateSync?: SiTranslateService['translateSync']; +} // @public export class SiMarkdownRendererComponent { constructor(); - readonly text: i0.InputSignal; + readonly copyButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; + readonly disableCopyButton: _angular_core.InputSignal; + readonly disableDownloadButton: _angular_core.InputSignal; + readonly downloadButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; + readonly latexRenderer: _angular_core.InputSignal<((latex: string, displayMode: boolean) => string | undefined) | undefined>; + readonly syntaxHighlighter: _angular_core.InputSignal<((code: string, language?: string) => string | undefined) | undefined>; + readonly text: _angular_core.InputSignal; } // (No @packageDocumentation comment for this package) diff --git a/api-goldens/element-ng/translate/index.api.md b/api-goldens/element-ng/translate/index.api.md index d7e5013e3..7221e069c 100644 --- a/api-goldens/element-ng/translate/index.api.md +++ b/api-goldens/element-ng/translate/index.api.md @@ -11,6 +11,12 @@ export const provideSiTranslatableOverrides: (values: SiTranslatableKeys) => Pro // @public (undocumented) export interface SiTranslatableKeys { + // (undocumented) + 'SI_AI_CHAT_CONTAINER.SECONDARY_ACTIONS'?: string; + // (undocumented) + 'SI_AI_CHAT_CONTAINER.WELCOME_GREETING'?: string; + // (undocumented) + 'SI_AI_CHAT_CONTAINER.WELCOME_MESSAGE'?: string; // (undocumented) 'SI_AI_MESSAGE.SECONDARY_ACTIONS'?: string; // (undocumented) @@ -334,6 +340,10 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_MAIN_DETAIL_CONTAINER.BACK'?: string; // (undocumented) + 'SI_MARKDOWN_RENDERER.COPY_CODE'?: string; + // (undocumented) + 'SI_MARKDOWN_RENDERER.DOWNLOAD'?: string; + // (undocumented) 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; // (undocumented) 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; @@ -432,6 +442,10 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_TOAST.CLOSE'?: string; // (undocumented) + 'SI_TOOL_MESSAGE.INPUT_ARGUMENTS'?: string; + // (undocumented) + 'SI_TOOL_MESSAGE.OUTPUT'?: string; + // (undocumented) 'SI_TOUR.BACK'?: string; // (undocumented) 'SI_TOUR.CLOSE'?: string; diff --git a/docs/components/chat-messages/chat-message.md b/docs/components/chat-messages/chat-message.md index 8db5df0c1..e663a9706 100644 --- a/docs/components/chat-messages/chat-message.md +++ b/docs/components/chat-messages/chat-message.md @@ -116,4 +116,14 @@ The slots are: +## Tool Message Component + +> **Note:** The tool message is currently experimental and may undergo changes in future releases. + +The **si-tool-message** component is similar to the AI message component, but it includes a tool icon instead of an AI icon and is used to display tool calls and their results. + + + + + diff --git a/docs/patterns/ai/ai-chat.md b/docs/patterns/ai/ai-chat.md index b61531b1f..5b724150d 100644 --- a/docs/patterns/ai/ai-chat.md +++ b/docs/patterns/ai/ai-chat.md @@ -86,7 +86,46 @@ These are not treated as errors and do not require a separate notification. ## Code --- -Use the chat container with the chat messages to build chat message interfaces. +The **si-ai-chat-container** component provides a complete AI chat interface, it should be inserted into a layout with fixed height, if no fixed height is provided, please set a height in pixels. + + + + + +### Initial Screen + +The AI chat container includes a built-in initial welcome screen component that displays when there are no messages. It can be slotted into the **si-chat-container** component. It accepts prompt suggestions as an input. + + + + + +#### Prompt Suggestions + +Prompt suggestions can be provided as either: + +- A simple array of suggestions (no categories) +- A record mapping category labels to arrays of suggestions + +When using a record, categories are automatically displayed as filter pills. + +```typescript +// Simple array (no categories) +promptSuggestions = [ + { text: 'How do I optimize performance?' }, + { text: 'What are best practices?' } +]; + +// Record with categories +promptSuggestions = { + 'All prompts': [{ text: 'How do I optimize performance?' }, { text: 'What are best practices?' }], + 'Maintenance': [{ text: 'Schedule preventive maintenance' }, { text: 'Track equipment downtime' }] +}; +``` + +### Base Chat Container + +Use this base container with the chat messages to build custom chat message interfaces. The **si-chat-container** component is a wrapper component, it has slots for [chat messages](../../components/chat-messages/chat-message.md) and a @@ -94,7 +133,7 @@ The **si-chat-container** component is a wrapper component, it has slots for The slots are: -- default -> chat messages or empty state +- default -> chat messages or initial screen (`si-welcome-screen`) - `si-chat-input/siChatContainerInput (helper directive)` -> For the input (whether default or custom). - `si-inline-notification` -> Slotted above the input for displaying the status. @@ -102,4 +141,12 @@ The slots are: +### Initial Screen + +When initially displaying a chat interface use the initial **si-welcome-screen** component that displays when there are no messages. It can be slotted into the **si-chat-container** component. It accepts prompt suggestions as an input. + + + + + diff --git a/package-lock.json b/package-lock.json index bd321a72b..9b8168aca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@types/geojson": "7946.0.16", "@types/google-libphonenumber": "7.4.30", "@types/jasmine": "5.1.15", + "@types/katex": "0.16.7", "@types/node": "24.10.9", "angular-eslint": "21.1.0", "axe-html-reporter": "2.2.11", @@ -83,6 +84,7 @@ "eslint-plugin-perfectionist": "5.3.1", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-tsdoc": "0.5.0", + "highlight.js": "^11.11.1", "http-server": "14.1.1", "husky": "9.1.7", "jasmine": "5.13.0", @@ -96,6 +98,7 @@ "karma-jasmine-seed-reporter": "0.2.0", "karma-junit-reporter": "2.0.1", "karma-spec-reporter": "0.0.36", + "katex": "0.16.27", "ng-packagr": "21.0.1", "piscina": "5.1.4", "postcss": "8.5.6", @@ -468,7 +471,6 @@ "integrity": "sha512-My42P8i/FrZgEsTnsCS9IXKMk7ikJwa14i0aBcHg3lMBAPrdpHVzgDS6/1SOO1HsoVYF/SiPjwnlL152xlm8/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2100.5", @@ -771,7 +773,6 @@ "integrity": "sha512-PYVgNbjNtuD5/QOuS6cHR8A7bRqsVqxtUUXGqdv76FYMAajQcAvyfR0QxOkqf3NmYxgNgO3hlUHWq0ILjVbcow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-eslint/bundled-angular-compiler": "21.1.0", "eslint-scope": "^9.0.0" @@ -818,7 +819,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.0.8.tgz", "integrity": "sha512-1YXHZQO/LYiExbg7sZhiqqF5fMcH17iVgK1tI2Gk90Yy0HQAuqnteOv3pPGgUfLowNOWK0sGhCYbB2Lq21LA3w==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -967,7 +967,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.0.6.tgz", "integrity": "sha512-5Gw8mXtKXvcvDMWEciPLRYB6Ja5vsikLAidZsdCEIF6Bc51GmoqT5Tk/Ke+ciCd5Hq9Aco/IcHxT1RC3470lZg==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -984,7 +983,6 @@ "integrity": "sha512-UYFQqn9Ow1wFVSwdB/xfjmZo4Yb7CUNxilbeYDFIybesfxXSdjMJBbXLtV0+icIhjmqfSUm2gTls6WIrG8qv9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/architect": "0.2100.5", "@angular-devkit/core": "21.0.5", @@ -1081,7 +1079,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.0.8.tgz", "integrity": "sha512-on1B4oc/pf7IlkbG08Et/cCDSX8dpZz9iwp3zMFN/0JvorspyL5YOovFJfjdpmjdlrIi+ToGImwyIkY9P8Mblw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1112,7 +1109,6 @@ "integrity": "sha512-+i/wFvi5FTg47Ei+aiFf8j3iYfjQ79ieg8oJM86+Mw4bNwEKQqvWcpmKjoqcfmCescuw0sr2DXU6OEeX+yWeVg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1206,7 +1202,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.0.8.tgz", "integrity": "sha512-8dNolIQn8WHrD3PsqGuPrujxDX5hjpMbioifIByjjX9yaJy9on7AewVGb8m/DHVwWQ1eGVAGmvW9wt+h+nlzLg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1248,7 +1243,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.8.tgz", "integrity": "sha512-H03A50elawXO53xkz0Aytar5kYT14GLeaj6dLKc1kcR5NqvX9Y/R7z3bY52tvypAdIR8CmPT7ad07TlT4O9lkg==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -1279,7 +1273,6 @@ "integrity": "sha512-b4R3lLq32CbRXZrwMct4K7rQ5yzL7EXihg1IfyHNSEcxuuzdtXw/M1xexIkEVtLIfA+SROAThISbYgSgWq6rwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@types/babel__core": "7.20.5", @@ -1365,7 +1358,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.8.tgz", "integrity": "sha512-5rPyrP6n6ClO0ZEUXndS2/Xb7nZrbjjYWOxgfCb+ZTCiU7eyN6zhSmicKk2dLQxE1M15wbTa87dN6/Ytuq2uvg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1388,7 +1380,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.8.tgz", "integrity": "sha512-LPR65wyWBSyR46fGeQtD92+TM635o0lh+N5k9qPZdMacogwViTrtBHWPfKYBtBUXLWEWXXKJfSbXvhh3w3uLxw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1446,7 +1437,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3094,7 +3084,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3136,7 +3125,6 @@ "integrity": "sha512-NtInjSlyev/+SLPvx/ulz8hRE25Wf5S9dLNDcIwazq0JyB4/w1ROF/5nV0ObPTX8YpRaKYeKtXDYWqumBNHWsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@commitlint/format": "^20.3.1", "@commitlint/lint": "^20.3.1", @@ -3159,7 +3147,6 @@ "integrity": "sha512-NCzwvxepstBZbmVXsvg49s+shCxlJDJPWxXqONVcAtJH9wWrOlkMQw/zyl+dJmt8lyVopt5mwQ3mR5M2N2rUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@commitlint/types": "^20.3.1", "conventional-changelog-conventionalcommits": "^7.0.2" @@ -3429,7 +3416,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3473,7 +3459,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4221,7 +4206,6 @@ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4609,7 +4593,6 @@ "integrity": "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", @@ -5435,6 +5418,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-0.9.1.tgz", "integrity": "sha512-znN/Qm6M0U1t3iF10gu1hSxDkk18yz78yvk+AMB34UDzpXHiC1zbpIeV2CQNV5GCeafmCICmcn9y1qh7F54KTg==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/sdk": "0.9.1", "@types/semver": "7.5.8", @@ -5446,6 +5430,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -5458,6 +5443,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/data-prefetch/-/data-prefetch-0.9.1.tgz", "integrity": "sha512-rS1AsgRvIMAWK8oMprEBF0YQ3WvsqnumjinvAZU1Dqut5DICmpQMTPEO1OrAKyjO+PQgEhmq13HggzN6ebGLrQ==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.9.1", "@module-federation/sdk": "0.9.1", @@ -5473,6 +5459,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5488,6 +5475,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-0.9.1.tgz", "integrity": "sha512-DezBrFaIKfDcEY7UhqyO1WbYocERYsR/CDN8AV6OvMnRlQ8u0rgM8qBUJwx0s+K59f+CFQFKEN4C8p7naCiHrw==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.1", "@module-federation/managers": "0.9.1", @@ -5521,6 +5509,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -5536,6 +5525,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5549,6 +5539,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5564,6 +5555,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5621,13 +5613,15 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.9.1.tgz", "integrity": "sha512-q8spCvlwUzW42iX1irnlBTcwcZftRNHyGdlaoFO1z/fW4iphnBIfijzkigWQzOMhdPgzqN/up7XN+g5hjBGBtw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-0.9.1.tgz", "integrity": "sha512-BPfzu1cqDU5BhM493enVF1VfxJWmruen0ktlHrWdJJlcddhZzyFBGaLAGoGc+83fS75aEllvJTEthw4kMViMQQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@module-federation/runtime-tools": "0.9.1" } @@ -5637,6 +5631,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-0.9.1.tgz", "integrity": "sha512-8hpIrvGfiODxS1qelTd7eaLRVF7jrp17RWgeH1DWoprxELANxm5IVvqUryB+7j+BhoQzamog9DL5q4MuNfGgIA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/sdk": "0.9.1", "find-pkg": "2.0.0", @@ -5648,6 +5643,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5663,6 +5659,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-0.9.1.tgz", "integrity": "sha512-+GteKBXrAUkq49i2CSyWZXM4vYa+mEVXxR9Du71R55nXXxgbzAIoZj9gxjRunj9pcE8+YpAOyfHxLEdWngxWdg==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/dts-plugin": "0.9.1", "@module-federation/managers": "0.9.1", @@ -5676,6 +5673,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -5691,6 +5689,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5704,6 +5703,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-0.9.1.tgz", "integrity": "sha512-ZJqG75dWHhyTMa9I0YPJEV2XRt0MFxnDiuMOpI92esdmwWY633CBKyNh1XxcLd629YVeTv03+whr+Fz/f91JEw==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/bridge-react-webpack-plugin": "0.9.1", "@module-federation/dts-plugin": "0.9.1", @@ -5732,6 +5732,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.9.1.tgz", "integrity": "sha512-jp7K06weabM5BF5sruHr/VLyalO+cilvRDy7vdEBqq88O9mjc0RserD8J+AP4WTl3ZzU7/GRqwRsiwjjN913dA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.1", "@module-federation/runtime-core": "0.9.1", @@ -5743,6 +5744,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.6.21.tgz", "integrity": "sha512-CLQiPP3kpcPbgPkiu/A1VURI2v4geFnEdizlB1tq0c6eDZqb5aLzvp87ZCGDVSuwY7DCq6jh1k+CM2WGge/2xA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.0", "@module-federation/sdk": "0.9.0" @@ -5752,13 +5754,15 @@ "version": "0.9.0", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.9.0.tgz", "integrity": "sha512-dNqIs5cQfE4p+WIdiZ64cTSRJ5KjGaV+epvZkGttrNjXW9XAAtE7zgpo7cMQ8GWA3wCGaKnFw7Dn48XcU5ZMNw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@module-federation/runtime-core/node_modules/@module-federation/sdk": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.9.0.tgz", "integrity": "sha512-84MklxE6Z79gCAr+6HCyqOpF95pqSah+fGnhLz+g4ePcWf98J73bWfrdOWFO/UfxMRneXKBZBNbpDVvPLgaFeQ==", "license": "MIT", + "peer": true, "dependencies": { "isomorphic-rslog": "0.0.7" } @@ -5779,6 +5783,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.9.1.tgz", "integrity": "sha512-r61ufhKt5pjl81v7TkmhzeIoSPOaNtLynW6+aCy3KZMa3RfRevFxmygJqv4Nug1L0NhqUeWtdLejh4VIglNy5Q==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.1", "@module-federation/sdk": "0.9.1" @@ -5788,13 +5793,15 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.9.1.tgz", "integrity": "sha512-YQonPTImgnCqZjE/A+3N2g3J5ypR6kx1tbBzc9toUANKr/dw/S63qlh/zHKzWQzxjjNNVMdXRtTMp07g3kgEWg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@module-federation/third-party-dts-extractor": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-0.9.1.tgz", "integrity": "sha512-KeIByP718hHyq+Mc53enZ419pZZ1fh9Ns6+/bYLkc3iCoJr/EDBeiLzkbMwh2AS4Qk57WW0yNC82xzf7r0Zrrw==", "license": "MIT", + "peer": true, "dependencies": { "find-pkg": "2.0.0", "fs-extra": "9.1.0", @@ -5806,6 +5813,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5821,6 +5829,7 @@ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "license": "MIT", + "peer": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -5838,6 +5847,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.9.1.tgz", "integrity": "sha512-CxySX01gT8cBowKl9xZh+voiHvThMZ471icasWnlDIZb14KasZoX1eCh9wpGvwoOdIk9rIRT7h70UvW9nmop6w==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.9.1", "@module-federation/sdk": "0.9.1" @@ -6238,6 +6248,7 @@ "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", @@ -6266,7 +6277,6 @@ "resolved": "https://registry.npmjs.org/@ngx-formly/bootstrap/-/bootstrap-6.3.12.tgz", "integrity": "sha512-2HPqyC7DJjz5mwgNw+hkzXVmyaD4BfykWgUiF9peeNmhmYqF0z1JoGRotbdtzuwGeaGVkX86dPEt2pHJDeS3Pw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -6280,7 +6290,6 @@ "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.12.tgz", "integrity": "sha512-88MOfn9dM1B33t04jl8x0Glh0Ed0lUKMkhYajicRH7ZHTmwIdla1SQjiblp2C+EcCFvsY7XAU2/JUZQdl56aUw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -6294,7 +6303,6 @@ "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-16.0.4.tgz", "integrity": "sha512-s8llTL2SJvROhqttxvEs7Cg+6qSf4kvZPFYO+cTOY1d8DWTjlutRkWAleZcPPoeX927Dm7ALfL07G7oYDJ7z6w==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -6658,7 +6666,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -7865,6 +7872,7 @@ "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.4.6.tgz", "integrity": "sha512-rRc6sbKWxhomxxJeqi4QS3S/2T6pKf4JwC/VHXs7KXw7lHXHa3yxPynmn3xHstL0H6VLaM5xQj87Wh7lQYRAPg==", "license": "MIT", + "peer": true, "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.4.6", "@rspack/binding-darwin-x64": "1.4.6", @@ -7889,7 +7897,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/binding-darwin-x64": { "version": "1.4.6", @@ -7902,7 +7911,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-arm64-gnu": { "version": "1.4.6", @@ -7915,7 +7925,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-arm64-musl": { "version": "1.4.6", @@ -7928,7 +7939,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-x64-gnu": { "version": "1.4.6", @@ -7941,7 +7953,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-x64-musl": { "version": "1.4.6", @@ -7954,7 +7967,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-wasm32-wasi": { "version": "1.4.6", @@ -7965,6 +7979,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" } @@ -7980,7 +7995,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/binding-win32-ia32-msvc": { "version": "1.4.6", @@ -7993,7 +8009,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/binding-win32-x64-msvc": { "version": "1.4.6", @@ -8006,7 +8023,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/core": { "version": "1.4.6", @@ -8035,13 +8053,15 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.15.0.tgz", "integrity": "sha512-CFJSF+XKwTcy0PFZ2l/fSUpR4z247+Uwzp1sXVkdIfJ/ATsnqf0Q01f51qqSEA6MYdQi6FKos9FIcu3dCpQNdg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rspack/core/node_modules/@module-federation/runtime": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.15.0.tgz", "integrity": "sha512-dTPsCNum9Bhu3yPOcrPYq0YnM9eCMMMNB1wuiqf1+sFbQlNApF0vfZxooqz3ln0/MpgE0jerVvFsLVGfqvC9Ug==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.15.0", "@module-federation/runtime-core": "0.15.0", @@ -8053,6 +8073,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.15.0.tgz", "integrity": "sha512-RYzI61fRDrhyhaEOXH3AgIGlHiot0wPFXu7F43cr+ZnTi+VlSYWLdlZ4NBuT9uV6JSmH54/c+tEZm5SXgKR2sQ==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.15.0", "@module-federation/sdk": "0.15.0" @@ -8063,6 +8084,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.15.0.tgz", "integrity": "sha512-kzFn3ObUeBp5vaEtN1WMxhTYBuYEErxugu1RzFUERD21X3BZ+b4cWwdFJuBDlsmVjctIg/QSOoZoPXRKAO0foA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.15.0", "@module-federation/webpack-bundler-runtime": "0.15.0" @@ -8072,13 +8094,15 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.15.0.tgz", "integrity": "sha512-PWiYbGcJrKUD6JZiEPihrXhV3bgXdll4bV7rU+opV7tHaun+Z0CdcawjZ82Xnpb8MCPGmqHwa1MPFeUs66zksw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rspack/core/node_modules/@module-federation/webpack-bundler-runtime": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.15.0.tgz", "integrity": "sha512-i+3wu2Ljh2TmuUpsnjwZVupOVqV50jP0ndA8PSP4gwMKlgdGeaZ4VH5KkHAXGr2eiYUxYLMrJXz1+eILJqeGDg==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.15.0", "@module-federation/sdk": "0.15.0" @@ -8089,6 +8113,7 @@ "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.0.1.tgz", "integrity": "sha512-VynGOEsVw2s8TAlLf/uESfrgfrq2+rcXB1muPJYBWbsm1Oa6r5qVQhjA5ggM6z/coYPrsVMgovl3Ff7Q7OCp1w==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.0.0" } @@ -8269,7 +8294,6 @@ "integrity": "sha512-uNBIilq5bGnln3D7Nbm3/K+Ot++eGj4rygU0DCw//IZiTQU/iSyF3UAsN++iRetu/OMs+97T/RoGPjD22ryiZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "21.0.5", "@angular-devkit/schematics": "21.0.5", @@ -8967,7 +8991,6 @@ "resolved": "https://registry.npmjs.org/@siemens/ngx-datatable/-/ngx-datatable-25.0.0.tgz", "integrity": "sha512-iK1/ESVGApP/V6WHMtwP1YkK0f+7JRbcD8hE/vL8SOrtn+blchiCOrGwPDIzOb8HFRXX5+ptrYpBL4IRnXz0QQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -9396,6 +9419,7 @@ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -9457,7 +9481,8 @@ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/eslint": { "version": "9.6.1", @@ -9569,6 +9594,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -9582,7 +9614,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -9636,7 +9667,6 @@ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -9662,7 +9692,8 @@ "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/send": { "version": "1.2.1", @@ -9923,7 +9954,6 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -10315,7 +10345,6 @@ "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -10359,7 +10388,6 @@ "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.4", @@ -10428,6 +10456,7 @@ "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", @@ -10446,6 +10475,7 @@ "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", @@ -10473,6 +10503,7 @@ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -10483,6 +10514,7 @@ "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -10496,6 +10528,7 @@ "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" @@ -10510,6 +10543,7 @@ "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", @@ -10525,6 +10559,7 @@ "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://opencollective.com/vitest" } @@ -10535,6 +10570,7 @@ "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" @@ -10763,7 +10799,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10841,6 +10876,7 @@ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.0" } @@ -10870,7 +10906,6 @@ "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.3.1.tgz", "integrity": "sha512-PwlrPudsFOzGumphi2y9ihWeaUlIwKhOra/MXu2LjeV2U8DgLLcYS8CartE5Hszhn1poJHawwI9HWrxlKliwdw==", "license": "MIT", - "peer": true, "dependencies": { "ag-charts-types": "12.3.1" } @@ -10905,7 +10940,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10995,7 +11029,6 @@ "integrity": "sha512-qXpIEBNYpfgpBaFblnyFegVSQjWCVUdCXTHvMcvtNtmMgtPwIDKvG8wuJo5BbQ/MNt2d8npmnRUaS2ddzdCzww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": ">= 21.0.0 < 22.0.0", "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", @@ -11173,6 +11206,7 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -11198,13 +11232,15 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "license": "ISC", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -11253,7 +11289,6 @@ "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", "dev": true, "license": "MPL-2.0", - "peer": true, "engines": { "node": ">=4" } @@ -11279,6 +11314,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -11686,7 +11722,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11706,6 +11741,7 @@ "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", "license": "(MIT OR Apache-2.0)", + "peer": true, "bin": { "btoa": "bin/btoa.js" }, @@ -11797,6 +11833,7 @@ "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^2.1.18", "ylru": "^1.2.0" @@ -11903,6 +11940,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -12090,6 +12128,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-highlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cli-highlight/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -12447,6 +12495,7 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "license": "MIT", + "peer": true, "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -12516,6 +12565,7 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", + "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -12856,6 +12906,7 @@ "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~2.0.0", "keygrip": "~1.1.0" @@ -12969,7 +13020,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -13075,6 +13125,7 @@ "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", "license": "MIT", + "peer": true, "dependencies": { "luxon": "^3.2.1" }, @@ -13304,7 +13355,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/deep-extend": { "version": "0.6.0", @@ -13371,6 +13423,7 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.4.0" } @@ -13379,7 +13432,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/depd": { "version": "2.0.0", @@ -13631,7 +13685,6 @@ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" @@ -13689,6 +13742,29 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/engine.io": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", @@ -14022,6 +14098,7 @@ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -14139,7 +14216,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -14213,7 +14289,6 @@ "integrity": "sha512-v8kAP8TarQYqDC4kxr343ZNi++/oOlBnmWovsUZpbJ7A/pq1VHGlgsf/fDh4CdEvEstzkrc8NLvoVKtfpsC4oA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/utils": "^8.52.0", "natural-orderby": "^5.0.0" @@ -14385,7 +14460,6 @@ "integrity": "sha512-J9I5PKCOJretVuiZRGvPQxCbllxGAV/viI20JO3LYblAodofBxyMnZAJ+WGeClHgANnSJberTNoFWWjrWKBuXQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "eslint": ">=2.0.0" } @@ -14807,6 +14881,7 @@ "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", "license": "MIT", + "peer": true, "dependencies": { "homedir-polyfill": "^1.0.1" }, @@ -14820,6 +14895,7 @@ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.0.0" } @@ -15368,6 +15444,7 @@ "resolved": "https://registry.npmjs.org/find-file-up/-/find-file-up-2.0.1.tgz", "integrity": "sha512-qVdaUhYO39zmh28/JLQM5CoYN9byEOKEH4qfa8K1eNV17W0UUMJ9WgbR/hHFH+t5rcl+6RTb5UC7ck/I+uRkpQ==", "license": "MIT", + "peer": true, "dependencies": { "resolve-dir": "^1.0.1" }, @@ -15380,6 +15457,7 @@ "resolved": "https://registry.npmjs.org/find-pkg/-/find-pkg-2.0.0.tgz", "integrity": "sha512-WgZ+nKbELDa6N3i/9nrHeNznm+lY3z4YfhDDWgW+5P0pdmMj26bxaxU11ookgY3NyP9GC7HvZ9etp0jRFqGEeQ==", "license": "MIT", + "peer": true, "dependencies": { "find-file-up": "^2.0.1" }, @@ -15439,8 +15517,7 @@ "version": "7.5.0", "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/flat": { "version": "5.0.2", @@ -15514,6 +15591,7 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", + "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -15680,6 +15758,7 @@ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -16069,7 +16148,6 @@ "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.43.tgz", "integrity": "sha512-TbIX/UC3BFRJwCxbBeCPwuRC4Qws9Jz/CECmfTM1t9RFoI3X6eRThurv6AYr9wSrt640IA9KFIHuAD/vlyjqRw==", "license": "(MIT AND Apache-2.0)", - "peer": true, "engines": { "node": ">=0.10" } @@ -16106,8 +16184,7 @@ "url": "https://www.venmo.com/adumesny" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/handle-thing": { "version": "2.0.1", @@ -16220,13 +16297,13 @@ } }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "dev": true, "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=12.0.0" } }, "node_modules/homedir-polyfill": { @@ -16234,6 +16311,7 @@ "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", "license": "MIT", + "peer": true, "dependencies": { "parse-passwd": "^1.0.0" }, @@ -16412,6 +16490,7 @@ "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", "license": "MIT", + "peer": true, "dependencies": { "deep-equal": "~1.0.1", "http-errors": "~1.8.0" @@ -16425,6 +16504,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -16434,6 +16514,7 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", @@ -17009,6 +17090,7 @@ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", @@ -17216,6 +17298,7 @@ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17274,6 +17357,7 @@ "resolved": "https://registry.npmjs.org/isomorphic-rslog/-/isomorphic-rslog-0.0.7.tgz", "integrity": "sha512-n6/XnKnZ5eLEj6VllG4XmamXG7/F69nls8dcynHyhcTpsPUYgcgx4ifEaCo4lQJ2uzwfmIT+F0KBGwBcMKmt5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.17.6" } @@ -17283,6 +17367,7 @@ "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", "license": "MIT", + "peer": true, "peerDependencies": { "ws": "*" } @@ -17420,8 +17505,7 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.13.0.tgz", "integrity": "sha512-vsYjfh7lyqvZX5QgqKc4YH8phs7g96Z8bsdIFNEU3VqXhlHaq+vov/Fgn/sr6MiUczdZkyXRC3TX369Ll4Nzbw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jasmine/node_modules/glob": { "version": "10.5.0", @@ -17668,7 +17752,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -18163,12 +18246,40 @@ "node": ">=10" } }, + "node_modules/katex": { + "version": "0.16.27", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", + "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", + "peer": true, "dependencies": { "tsscmp": "1.0.6" }, @@ -18208,6 +18319,7 @@ "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", @@ -18241,13 +18353,15 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/koa-convert": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", "license": "MIT", + "peer": true, "dependencies": { "co": "^4.6.0", "koa-compose": "^4.1.0" @@ -18261,6 +18375,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -18273,6 +18388,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -18282,6 +18398,7 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", @@ -18298,6 +18415,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -18325,7 +18443,6 @@ "integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -18491,7 +18608,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -18659,7 +18775,8 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.0.tgz", "integrity": "sha512-QRBRSxhbtsX1nc0baxSkkK5WlVTTm/s48DSukcGcWZwIyI8Zz+lB+kFiELJXtzfH4Aj6kMWQ1VWW4U5uUDgZMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -18827,7 +18944,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -18851,6 +18969,7 @@ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -18964,7 +19083,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -19605,7 +19723,6 @@ "integrity": "sha512-IZGxuF226GF0d8FOZIfPvHsyBl53PrDEg/IB2+CVamsm3r4+gUw3mBp27eygpowBpdVLG0Sm2IbUiH4aSspzyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.3.0", "@rollup/plugin-json": "^6.1.0", @@ -20154,7 +20271,6 @@ "resolved": "https://registry.npmjs.org/ngx-image-cropper/-/ngx-image-cropper-9.1.6.tgz", "integrity": "sha512-b250YJ+jZovfqIj8vdEOrpEFay34be5f1Hpvg6Db68VMlvdyyuzboJdR0gCupbXtVcG6qQ86L7YG+SYxXJwApw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -20321,6 +20437,7 @@ "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.2.0", "long-timeout": "0.1.1", @@ -22936,7 +23053,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23221,14 +23337,14 @@ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ol": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/ol/-/ol-10.7.0.tgz", "integrity": "sha512-122U5gamPqNgLpLOkogFJhgpywvd/5en2kETIDW+Ubfi9lPnZ0G9HWRdG+CX0oP8od2d6u6ky3eewIYYlrVczw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@types/rbush": "4.0.0", "earcut": "^3.0.0", @@ -23246,7 +23362,6 @@ "resolved": "https://registry.npmjs.org/ol-ext/-/ol-ext-4.0.37.tgz", "integrity": "sha512-RxzdgMWnNBDP9VZCza3oS3rl1+OCl+1SJLMjt7ATyDDLZl/zzrsQELfJ25WAL6HIWgjkQ2vYDh3nnHFupxOH4w==", "license": "BSD-3-Clause", - "peer": true, "peerDependencies": { "ol": ">= 5.3.0" } @@ -23256,7 +23371,6 @@ "resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.2.0.tgz", "integrity": "sha512-7jKoejdVMBxdUk97DlaHy/7ZddGslBq8obnW1yGEMD705Eo+khqZiaVbaABpszzDLAf17fKeXn+fm+WWT9OYCQ==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@maplibre/maplibre-gl-style-spec": "^23.1.0", "mapbox-to-css-font": "^3.2.0" @@ -23316,7 +23430,8 @@ "node_modules/only": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", - "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", + "peer": true }, "node_modules/open": { "version": "10.2.0", @@ -23683,6 +23798,7 @@ "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -23882,7 +23998,8 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pbf": { "version": "4.0.1", @@ -24077,7 +24194,6 @@ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -24134,7 +24250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -24300,7 +24415,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=12.0" }, @@ -24314,7 +24428,6 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -24346,7 +24459,6 @@ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -24454,7 +24566,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prr": { "version": "1.0.1", @@ -24569,7 +24682,8 @@ "version": "9.4.2", "resolved": "https://registry.npmjs.org/rambda/-/rambda-9.4.2.tgz", "integrity": "sha512-++euMfxnl7OgaEKwXh9QqThOjMeta2HH001N1v4mYQzBjJBnmXBh2BCK6dZAbICFVXOFUVD3xFG0R3ZPU0mxXw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/randombytes": { "version": "2.1.0", @@ -24921,6 +25035,7 @@ "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", "license": "MIT", + "peer": true, "dependencies": { "expand-tilde": "^2.0.0", "global-modules": "^1.0.0" @@ -24934,6 +25049,7 @@ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", "license": "MIT", + "peer": true, "dependencies": { "global-prefix": "^1.0.1", "is-windows": "^1.0.1", @@ -24948,6 +25064,7 @@ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", "license": "MIT", + "peer": true, "dependencies": { "expand-tilde": "^2.0.2", "homedir-polyfill": "^1.0.1", @@ -24963,13 +25080,15 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/resolve-dir/node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -25210,7 +25329,6 @@ "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -25335,7 +25453,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -25390,7 +25507,6 @@ "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -25459,7 +25575,8 @@ "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/schema-utils": { "version": "4.3.3", @@ -25533,7 +25650,6 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -26340,7 +26456,8 @@ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/signal-exit": { "version": "4.1.0", @@ -26677,7 +26794,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/source-map": { "version": "0.7.6", @@ -26857,7 +26975,8 @@ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/statuses": { "version": "1.5.0", @@ -26873,7 +26992,8 @@ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/stdin-discarder": { "version": "0.2.2", @@ -27173,7 +27293,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -27228,7 +27347,6 @@ "integrity": "sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "css-tree": "^3.0.1", "is-plain-object": "^5.0.0", @@ -27876,7 +27994,8 @@ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyexec": { "version": "1.0.2", @@ -27917,6 +28036,7 @@ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -28076,14 +28196,14 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.6.x" } @@ -28094,7 +28214,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -28663,7 +28782,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -28678,7 +28796,6 @@ "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", @@ -29071,6 +29188,7 @@ "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=4", "yarn": "*" @@ -29224,7 +29342,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -29862,7 +29979,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/void-elements": { "version": "2.0.1", @@ -29924,7 +30042,6 @@ "integrity": "sha512-5DeICTX8BVgNp6afSPYXAFjskIgWGlygQH58bcozPOXgo2r/6xx39Y1+cULZ3gTxUYQP88jmwLj2anu4Xaq84g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -30031,7 +30148,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -30620,6 +30736,7 @@ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -30856,7 +30973,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -31050,6 +31166,7 @@ "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -31109,7 +31226,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -31182,7 +31298,6 @@ "name": "@siemens/element-ng", "version": "48.9.0", "license": "MIT", - "peer": true, "peerDependencies": { "@angular/animations": "21", "@angular/cdk": "21", @@ -31227,8 +31342,7 @@ "projects/element-theme": { "name": "@siemens/element-theme", "version": "48.9.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "projects/element-translate-cli": { "name": "@siemens/element-translate-cli", @@ -31245,7 +31359,6 @@ "name": "@siemens/element-translate-ng", "version": "48.9.0", "license": "MIT", - "peer": true, "peerDependencies": { "@angular/common": "21", "@angular/core": "21", diff --git a/package.json b/package.json index 3bad464cc..82ec76a1e 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@types/geojson": "7946.0.16", "@types/google-libphonenumber": "7.4.30", "@types/jasmine": "5.1.15", + "@types/katex": "0.16.7", "@types/node": "24.10.9", "angular-eslint": "21.1.0", "axe-html-reporter": "2.2.11", @@ -151,10 +152,12 @@ "eslint-plugin-perfectionist": "5.3.1", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-tsdoc": "0.5.0", + "highlight.js": "^11.11.1", "http-server": "14.1.1", "husky": "9.1.7", "jasmine": "5.13.0", "jasmine-core": "5.13.0", + "katex": "0.16.27", "karma": "6.4.4", "karma-chrome-launcher": "3.2.0", "karma-coverage": "2.2.1", diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index af0c0ef63..c9ca8f275 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -110,13 +110,16 @@ test('typography/type-styles', ({ si }) => si.static()); test('typography/display-styles', ({ si }) => si.static()); test('typography/typography', ({ si }) => si.static()); test('si-markdown-renderer/si-markdown-renderer', ({ si }) => - si.static({ disabledA11yRules: ['link-in-text-block'] })); + si.static({ disabledA11yRules: ['link-in-text-block', 'color-contrast'] })); test('si-chat-messages/si-ai-message', ({ si }) => si.static()); test('si-chat-messages/si-user-message', ({ si }) => si.static()); test('si-chat-messages/si-chat-message', ({ si }) => si.static()); test('si-chat-messages/si-attachment-list', ({ si }) => si.static()); test('si-chat-messages/si-chat-input', ({ si }) => si.static()); test('si-chat-messages/si-chat-container', ({ si }) => si.static()); +test('si-chat-messages/si-ai-welcome-screen', ({ si }) => si.static()); +test('si-chat-messages/si-ai-chat-container', ({ si }) => si.static()); +test('si-chat-messages/si-tool-message', ({ si }) => si.static()); test('ag-grid/ag-grid-empty-state', async ({ si }) => { await si.static({ disabledA11yRules: ['aria-required-children'] }); }); diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..a07cdf723 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6138cbedf9fb22bb1954adcd327d015e858f0504fd318e99a8e90fab22959bdd +size 45051 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..730a3e010 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dce4146b77bd3b8b8bcb9468f31c9446bc3769c2e77366dbba10d16e982da0a +size 43721 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container.yaml new file mode 100644 index 000000000..d42fee505 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-chat-container.yaml @@ -0,0 +1,30 @@ +- group: + - button "data-analysis.py" +- group: + - button "dataset.csv" +- paragraph: Can you help me analyze these files? +- paragraph: I'm having trouble understanding the data structure and need assistance with the implementation. +- button "Copy message" +- paragraph: I'd be happy to help you analyze your files! I can see you've shared a Python script and a CSV dataset. +- paragraph: Let me examine the structure and provide guidance. +- button "Good response" +- button "Copy response" +- button "Retry response" +- button "More actions" +- paragraph: Perfect! What should I focus on first +- paragraph: I also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows? +- button "Copy message" +- paragraph: Great question! When analyzing large datasets, it's crucial to focus on... +- alert: Info AI responses are for demonstration purposes. +- group: + - text: requirements.pdf + - button "Remove attachment requirements.pdf" +- group: + - text: mockup.png + - button "Remove attachment mockup.png" +- textbox "Chat message input": + - /placeholder: Enter a command, question or topic… +- button "Attach file" +- button "Clear messages" +- button "Send" +- text: The content is AI generated. Always verify the information for accuracy. \ No newline at end of file diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png index e02e5d25a..b0c9b11a6 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a429c543b99cac0b06944e2ada718f7fe778dfe90b933c588b5a3cb27704323 -size 14027 +oid sha256:8af4683c1e999db6b4b92557c64a8b05bbdf1c9db6b53b05e2248a510bf58b6e +size 13941 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..80b81f07f --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd97f4f374bd5dd61d79ae6d2d464afae647328798c009c7d4fa409d5c49a7b1 +size 47972 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..1e25d8711 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:300c4ee4160d094e2db8839ac2dc2685763da3f08590034f64cbedd7e7bee69d +size 48558 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen.yaml new file mode 100644 index 000000000..f377feca1 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-welcome-screen.yaml @@ -0,0 +1,12 @@ +- paragraph: + - strong: Hello + - text: "," +- paragraph: how can I help you today? +- checkbox "All prompts" +- checkbox "Maintenance" +- checkbox "Analytics" +- checkbox "Troubleshooting" +- button "How do I optimize performance for large datasets?" +- button "What are the best practices for data validation?" +- button "Help me troubleshoot this error message" +- button "Explain the difference between async and sync operations" \ No newline at end of file diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png index 2c5ea0c2a..25039fddd 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d6696befb87b5c6cd435010497600c8be7867e6103c8ef01b8412198fb2e547c -size 45722 +oid sha256:5e8ac26883f565a36ff44a23bc63499714aeb4895038f5c124ab70d5f7c95cd0 +size 46614 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png index 786b30a00..e0ddde630 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b16bc15018cbc13fbfbe34523b95fde06d7a8431034b6f8666fdc423a8b08c56 -size 44368 +oid sha256:4cb77388b66b148c87375b84c78f0d1ab30696f6c969baafb8406e13ae6f9118 +size 45377 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml index 8c06f612f..df112676b 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml @@ -4,16 +4,16 @@ - button "dataset.csv" - paragraph: Can you help me analyze these files? - paragraph: I'm having trouble understanding the data structure and need assistance with the implementation. -- button "Copy message" +- button "Export message" - paragraph: I'd be happy to help you analyze your files! I can see you've shared a Python script and a CSV dataset. - paragraph: Let me examine the structure and provide guidance. -- button "Good response" -- button "Copy response" +- button "Add to list" +- button "Export response" - button "Retry response" - button "More actions" - paragraph: Perfect! What should I focus on first - paragraph: I also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows? -- button "Copy message" +- button "Export message" - paragraph: Great question! When analyzing large datasets, it's crucial to focus on... - alert: Info AI responses are for demonstration purposes. - group: @@ -25,6 +25,6 @@ - textbox "Chat message input": - /placeholder: Enter a command, question or topic… - button "Attach file" -- button "Text formatting" -- button "Message templates" +- button "Clear messages" - button "Send" +- text: The content is AI generated. Always verify the information for accuracy. \ No newline at end of file diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml index 6b45be157..80195857f 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-input.yaml @@ -5,11 +5,11 @@ - text: mockup.png - button "Remove attachment mockup.png" - textbox "Chat message input": - - /placeholder: Enter a command, question or topic... + - /placeholder: Enter a command, question or topic… - button "Attach files" - button "Take photo" - button "Text formatting" - button "More actions" - button "Insert emoji" - button "Send Message" -- text: The content is AI generated. Always verify the information for accuracy. \ No newline at end of file +- text: The content is AI generated. Always verify the information for accuracy. diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..c668e433c --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e729915dc6b34cf08851c2e26c7f68ab872dbcde0e934af62ef1e143e76e8dc0 +size 11939 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..54a97e5dd --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:800231cb68affd0722679e8ff900a2060fcd863acbda39eac7c6fc77f62e8d97 +size 11737 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message.yaml new file mode 100644 index 000000000..4d2a1c6b5 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-tool-message.yaml @@ -0,0 +1,6 @@ +- heading "fetch_weather_data" [level=6] +- button "Input Arguments" +- region "Input Arguments" +- button "Output" +- region "Output" +- heading "processing_data" [level=6] diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png index 68efca860..4561541ec 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1379c882cd0573abc7acb0e82d2c553abafdcbbbea5788e2da40975ee538aedb -size 14586 +oid sha256:5d14d41a205b238d9444bf58126bb08ddec78f082be8c2b6b6f87c98620b667b +size 14587 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png index 1102638f6..e0bbf1654 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fda5fb73f9a2374f211bc1075f757d2ed1ec57ec8a60387cd09d119fe2f68f07 -size 14269 +oid sha256:6928a9ff04d1d305bc344eb449b8bf6ae4277fb7c56855a360559fabcf6c3c7f +size 14296 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml index a4ba54ee1..08a98b568 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml @@ -4,8 +4,7 @@ - text: Can you help me with this - strong: code snippet - text: "?" -- paragraph: - - code: console.log('Hello World') +- code: console.log('Hello World') - paragraph: I'm getting an error when I run it. - button "Edit message" - button "Copy message" diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png index 51092e1d9..4466df14a 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:812619a4f94ad30609fb21818985dd7c84413357890804d08673e6215cbdd499 -size 142176 +oid sha256:d39ee44e92a90228027e1a0088425c275110f89fef718ac6f8e25fbe5ab5b071 +size 178856 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png index 9abc29eea..09d29788e 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93b378bb738fd8100e34380f71d548aaf6cc0244424c403910e28807a3b1be90 -size 137686 +oid sha256:3c628201a0112b677b9f630fad6616ff9850cbbdca37044bcccf6689ed2f431a +size 175289 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml index 8b16fe2e6..1185f2c69 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml @@ -1,5 +1,5 @@ -- heading "AI Assistant Response" [level=2]: - - strong: AI Assistant Response +- heading "Sample Markdown Content" [level=2]: + - strong: Sample Markdown Content - paragraph: - text: Here's a - strong: comprehensive example @@ -10,9 +10,10 @@ - text: You can use inline code like - code: console.log('Hello World') - text: "or multi-line code blocks:" -- paragraph +- text: javascript +- button "Copy code" - code: "function calculateSum(a, b) { return a + b; } const result = calculateSum(5, 3); console.log(`Result: ${result}`);" -- paragraph +- separator - heading "Formatting Options" [level=2] - paragraph: Here's a paragraph explaining the formatting options available. - paragraph: "Another paragraph with different formatting elements:" @@ -33,12 +34,13 @@ - /url: https://element.siemens.io - text: for more information, as well as local links like - link "Internal Page": - - /url: "/#/internal-page" + - /url: /#/internal-page - text: . - paragraph: - text: "Links are also automatically detected:" - link "https://angular.io": - /url: https://angular.io +- separator - heading "Lists and Bullets" [level=2] - paragraph: "Here are the key features:" - list: @@ -47,6 +49,8 @@ - listitem: Inline code highlighting - listitem: Bullet point lists - listitem: Blockquote support + - listitem: Or in the alternate format + - listitem: Another bullet point - paragraph: This paragraph appears after the list to show proper spacing. - heading "Ordered Lists" [level=2] - paragraph: "Step-by-step instructions:" @@ -54,7 +58,9 @@ - listitem: First, analyze the requirements - listitem: Then, implement the solution - listitem: Finally, test the implementation -- blockquote: This is a blockquote that demonstrates how quoted text appears in the markdown content component. +- separator +- blockquote: + - paragraph: This is a blockquote that demonstrates how quoted text appears in the markdown content component. - paragraph: This paragraph follows the blockquotes to demonstrate proper paragraph separation. - paragraph: This is a separate paragraph created by double line breaks. - list: @@ -65,20 +71,24 @@ - listitem: First ordered item - listitem: Second ordered item - paragraph: Final paragraph to show proper spacing. +- separator +- heading "Images" [level=2] +- paragraph: "Images can be included as follows:" +- separator - heading "Tables" [level=2] - paragraph: "Tables are also supported:" -- paragraph - table: - rowgroup: - row "Feature Examples Status Notes": - - cell "Feature": + - columnheader "Feature": - paragraph: Feature - - cell "Examples": + - columnheader "Examples": - paragraph: Examples - - cell "Status": + - columnheader "Status": - paragraph: Status - - cell "Notes": + - columnheader "Notes": - paragraph: Notes + - rowgroup: - row "Basic content Alice Johnson Bob Smith ✓ Complete Simple text and line breaks": - cell "Basic content": - paragraph: @@ -118,11 +128,11 @@ - paragraph: ✓ Complete - cell "Bullet lists work properly": - paragraph: Bullet lists work properly - - 'row "Escaped pipes grep \"text|pattern\" awk ''{print $1|$2}'' ✓ Complete Use | for literal pipes"': + - 'row "Escaped pipes grep \"text|pattern\" awk ''{print 2}'' ✓ Complete Use | for literal pipes"': - cell "Escaped pipes": - paragraph: Escaped pipes - - 'cell "grep \"text|pattern\" awk ''{print $1|$2}''"': - - paragraph: "grep \"text|pattern\" awk '{print $1|$2}'" + - 'cell "grep \"text|pattern\" awk ''{print 2}''"': + - paragraph: "grep \"text|pattern\" awk '{print 2}'" - cell "✓ Complete": - paragraph: ✓ Complete - cell "Use | for literal pipes": @@ -139,5 +149,12 @@ - text: Uses - code:
- text: tags -- text: This paragraph appears after the tables to demonstrate proper spacing. -- paragraph +- button "Download CSV" +- paragraph: This paragraph appears after the tables to demonstrate proper spacing. +- separator +- heading "Math Expressions" [level=2] +- paragraph: LaTeX math expressions are supported for mathematical notation. +- paragraph: "Inline math can be written like this: or the quadratic formula ." +- paragraph: "/You can escape dollar signs with a backslash to show literal prices: \\$\\d+, \\$\\d+, \\$\\d+\\./" +- paragraph: "Display math uses double dollar signs for block equations:" +- paragraph: UML is not supported in this markdown component. \ No newline at end of file diff --git a/projects/element-ng/chat-messages/chat-message.model.ts b/projects/element-ng/chat-messages/chat-message.model.ts new file mode 100644 index 000000000..fafa43f5a --- /dev/null +++ b/projects/element-ng/chat-messages/chat-message.model.ts @@ -0,0 +1,157 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import type { Signal, TemplateRef } from '@angular/core'; +import type { TranslatableString } from '@siemens/element-translate-ng/translate-types'; + +/** + * Actions for messages representing an action with icon, label (for accessibility), and handler. + * Only the icon will be displayed. + * + * @see {@link SiUserMessageComponent} for the user message where this is used + * @see {@link SiAiMessageComponent} for the AI message where this is used + * @see {@link SiAiChatContainerComponent} for the AI chat container where these actions are used + * + * @experimental + */ +export interface MessageAction { + /** Label that is shown to the user. */ + label: TranslatableString; + /** + * Icon used to represent the action + */ + icon: string; + /** + * Action that is called when the item is triggered. + */ + action: (actionParam: any, source: this) => void; + /** Whether the menu item is disabled. */ + disabled?: boolean; +} + +/** + * Attachment item interface for file attachments in chat messages, used by {@link SiAttachmentListComponent} and inside {@link SiUserMessageComponent} as well as {@link SiChatInputComponent}. + * + * @see {@link SiAttachmentListComponent} for the attachment list component + * @see {@link SiUserMessageComponent} for the user message + * @see {@link SiChatInputComponent} for the chat input component + * + * @experimental + */ +export interface Attachment { + /** File name */ + name: string; + /** Optionally show a preview of the attachment by providing a template that is shown in a modal when clicked (optional) */ + previewTemplate?: TemplateRef | (() => TemplateRef); +} + +/** + * Base interface for all chat messages. Messages can be rendered either with content + * or with a custom template for advanced styling and functionality. + * + * @see {@link ChatMessage} for the chat message type union + * @see {@link UserChatMessage} for user messages + * @see {@link AiChatMessage} for AI chat messages + * @see {@link ToolChatMessage} for tool messages + * @see {@link TemplateChatMessage} for template-based messages + * + * @experimental + */ +export interface BaseChatMessage { + /** Type of message */ + type: 'user' | 'ai' | 'tool'; + /** Message content - can be a string or a Signal, empty string shows loading state */ + content?: string | Signal; + /** Whether the message is currently loading/being generated - can be a boolean or Signal */ + loading?: boolean | Signal; +} + +/** + * User chat message for AI chats + * + * @see {@link SiAiChatContainerComponent} for the AI chat container where this is used + * + * @experimental + */ +export interface UserChatMessage extends BaseChatMessage { + /** Type of message */ + type: 'user'; + /** Message content, should be a string, empty string shows loading state */ + content: string; + /** Attachments (for user messages) */ + attachments?: Attachment[]; + /** Actions available for this message */ + actions?: MessageAction[]; +} + +/** + * AI chat message + * + * @see {@link SiAiChatContainerComponent} for the AI chat container where this is used + * + * @experimental + */ +export interface AiChatMessage extends BaseChatMessage { + /** Type of message */ + type: 'ai'; + /** Message content - can be a string or a Signal, empty string shows loading state, set signal back to string to end "streaming" state */ + content: string | Signal; + /** Actions available for this message */ + actions?: MessageAction[]; +} + +/** AI tool call display + * + * @see {@link SiAiChatContainerComponent} for the AI chat container where this is used + * + * @experimental + */ +export interface ToolChatMessage extends BaseChatMessage { + /** Type of message */ + type: 'tool'; + /** Tool name/title */ + name: string; + /** Input arguments for the tool call */ + inputArguments?: string | object; + /** Output result from the tool call - can be a string/object or Signal\, empty does not show loading state. */ + output?: string | object | Signal; + /** Whether the input arguments section should be expanded by default if it's the latest message (and closed after) */ + autoExpandInputArguments?: boolean; + /** Whether the output section should be expanded by default if it's the latest message (and closed after) */ + autoExpandOutput?: boolean; + /** Alternative tool icon, defaults to 'element-maintenance' */ + icon?: string; +} + +/** + * Render custom chat message via template, consider using {@link SiChatMessageComponent} inside the template for consistent styling + * + * @see {@link SiAiChatContainerComponent} for the AI chat container where this is used + * @see {@link SiChatMessageComponent} for the chat message wrapper component which can be used inside the template + * + * @experimental + */ +export interface TemplateChatMessage { + /** + * Template to render the message + */ + template: TemplateRef; + + /** Context data to pass to the template */ + templateContext?: any; +} + +/** + * Chat message type union of all supported message types in the AI chat container. + * + * @see {@link UserChatMessage} for user messages + * @see {@link AiChatMessage} for AI messages + * @see {@link ToolChatMessage} for AI tool calls + * @see {@link TemplateChatMessage} for template-based messages + * @see {@link BaseChatMessage} for the base chat message interface + * @see {@link SiAiChatContainerComponent} for the AI chat container where this is used + * + * @experimental + */ +export type ChatMessage = UserChatMessage | AiChatMessage | ToolChatMessage | TemplateChatMessage; diff --git a/projects/element-ng/chat-messages/index.ts b/projects/element-ng/chat-messages/index.ts index d0442eb14..d4a08d739 100644 --- a/projects/element-ng/chat-messages/index.ts +++ b/projects/element-ng/chat-messages/index.ts @@ -2,6 +2,7 @@ * Copyright (c) Siemens 2016 - 2025 * SPDX-License-Identifier: MIT */ +export * from './chat-message.model'; export * from './si-ai-message.component'; export * from './si-attachment-list.component'; export * from './si-chat-container.component'; @@ -10,5 +11,9 @@ export * from './si-chat-input.component'; export * from './si-chat-input-disclaimer.directive'; export * from './si-chat-message-action.directive'; export * from './si-chat-message.component'; +export * from './si-tool-message.component'; export * from './si-user-message.component'; -export * from './message-action.model'; +export * from './chat-message.model'; +export * from './si-ai-welcome-screen.component'; +export * from './si-ai-chat-container.component'; +export * from './si-ai-welcome-screen.component'; diff --git a/projects/element-ng/chat-messages/message-action.model.ts b/projects/element-ng/chat-messages/message-action.model.ts deleted file mode 100644 index aa7b25239..000000000 --- a/projects/element-ng/chat-messages/message-action.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) Siemens 2016 - 2025 - * SPDX-License-Identifier: MIT - */ -import type { TranslatableString } from '@siemens/element-translate-ng/translate-types'; - -/** - * Actions for messages representing an action with icon, label (for accessibility), and handler, for use within {@link SiAiMessageComponent} and {@link SiUserMessageComponent}. - * Only the icon will be displayed. - * - * @see {@link SiAiMessageComponent} for the AI message - * @see {@link SiUserMessageComponent} for thee user message - * - * @experimental - */ -export interface MessageAction { - /** Label that is shown to the user. */ - label: TranslatableString; - /** - * Icon used to represent the action - */ - icon: string; - /** - * Action that is called when the item is triggered. - */ - action: (actionParam: any, source: this) => void; - /** Whether the menu item is disabled. */ - disabled?: boolean; -} diff --git a/projects/element-ng/chat-messages/si-ai-chat-container.component.html b/projects/element-ng/chat-messages/si-ai-chat-container.component.html new file mode 100644 index 000000000..a964d39b7 --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-chat-container.component.html @@ -0,0 +1,83 @@ + + @if (isEmpty()) { + +

+ {{ greeting() | translate }} +

+

{{ welcomeMessage() | translate }}

+ +
+ } + + @if (displayMessages() !== undefined) { + @for (message of displayMessages(); track $index) { + @if (isTemplateMessage(message)) { + + } @else { + @if (message.type === 'user') { + + } + + @if (message.type === 'ai') { + + } + + @if (message.type === 'tool') { + + } + } + } + } + + @if (statusSeverity() && statusMessage()) { + + } + +
+ +
+
diff --git a/projects/element-ng/chat-messages/si-ai-chat-container.component.spec.ts b/projects/element-ng/chat-messages/si-ai-chat-container.component.spec.ts new file mode 100644 index 000000000..b97dccb80 --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-chat-container.component.spec.ts @@ -0,0 +1,501 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { DebugElement, provideZonelessChangeDetection, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { + ChatMessage, + MessageAction, + SiAiChatContainerComponent +} from '@siemens/element-ng/chat-messages'; + +describe('SiAiChatContainerComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let component: SiAiChatContainerComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SiAiChatContainerComponent], + providers: [provideNoopAnimations(), provideZonelessChangeDetection()] + }).compileComponents(); + + fixture = TestBed.createComponent(SiAiChatContainerComponent); + debugElement = fixture.debugElement; + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have default sending state of false', () => { + expect(component.sending()).toBe(false); + }); + + it('should have default loading state of false', () => { + expect(component.loading()).toBe(false); + }); + + it('should have default disableInterrupt state of false', () => { + expect(component.disableInterrupt()).toBe(false); + }); + + it('should have default interrupting state of false', () => { + expect(component.interrupting()).toBe(false); + }); + + it('should have default noAutoScroll of false', () => { + expect(component.noAutoScroll()).toBe(false); + }); + + it('should have default empty messages array', () => { + expect(component.messages()).toBeUndefined(); + }); + + it('should have default colorVariant of base-0', () => { + expect(component.colorVariant()).toBe('base-0'); + }); + + it('should have default aiIcon of element-ai', () => { + expect(component.aiIcon()).toBe('element-ai'); + }); + + it('should render empty state when no messages', () => { + fixture.componentRef.setInput('messages', []); + fixture.detectChanges(); + + const welcomeScreen = debugElement.query(By.css('si-ai-welcome-screen')); + expect(welcomeScreen).toBeTruthy(); + }); + + it('should not render empty state when messages exist', () => { + const messages: ChatMessage[] = [ + { + type: 'user', + content: 'Hello' + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const welcomeScreen = debugElement.query(By.css('si-ai-welcome-screen')); + expect(welcomeScreen).toBeFalsy(); + }); + + it('should render user messages', () => { + const messages: ChatMessage[] = [ + { + type: 'user', + content: 'Hello' + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const userMessage = debugElement.query(By.css('si-user-message')); + expect(userMessage).toBeTruthy(); + }); + + it('should render AI messages', () => { + const messages: ChatMessage[] = [ + { + type: 'ai', + content: 'Hello there!' + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const aiMessage = debugElement.query(By.css('si-ai-message')); + expect(aiMessage).toBeTruthy(); + }); + + it('should render tool messages', () => { + const messages: ChatMessage[] = [ + { + type: 'tool', + name: 'Calculator', + content: '', + output: '42' + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const toolMessage = debugElement.query(By.css('si-tool-message')); + expect(toolMessage).toBeTruthy(); + }); + + it('should not render status notification when statusSeverity is not set', () => { + fixture.detectChanges(); + + const notification = debugElement.query(By.css('si-inline-notification')); + expect(notification).toBeFalsy(); + }); + + it('should render loading AI message when loading is true', () => { + fixture.componentRef.setInput('messages', []); + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + const aiMessages = debugElement.queryAll(By.css('si-ai-message')); + expect(aiMessages.length).toBeGreaterThan(0); + }); + + it('should handle user message with attachments', () => { + const messages: ChatMessage[] = [ + { + type: 'user', + content: 'Here are some files', + attachments: [{ name: 'file1.txt' }, { name: 'file2.pdf' }] + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const userMessage = debugElement.query(By.css('si-user-message')); + expect(userMessage.componentInstance.attachments().length).toBe(2); + }); + + it('should handle signal content in AI messages', () => { + const contentSignal = signal('Streaming content...'); + const messages: ChatMessage[] = [ + { + type: 'ai', + content: contentSignal + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const aiMessage = debugElement.query(By.css('si-ai-message')); + expect(aiMessage.componentInstance.content()).toBe('Streaming content...'); + }); + + it('should compute inputInterruptible correctly', () => { + fixture.componentRef.setInput('loading', true); + fixture.componentRef.setInput('disableInterrupt', false); + fixture.componentRef.setInput('sending', false); + fixture.componentRef.setInput('interrupting', false); + fixture.detectChanges(); + + expect((component as any).inputInterruptible()).toBe(true); + + fixture.componentRef.setInput('interrupting', true); + fixture.componentRef.setInput('loading', false); + fixture.componentRef.setInput('disableInterrupt', true); + fixture.componentRef.setInput('sending', true); + fixture.detectChanges(); + + expect((component as any).inputInterruptible()).toBe(true); + + fixture.componentRef.setInput('loading', false); + fixture.componentRef.setInput('interrupting', false); + fixture.detectChanges(); + + expect((component as any).inputInterruptible()).toBe(false); + }); + + it('should compute inputSending correctly', () => { + fixture.componentRef.setInput('sending', true); + fixture.componentRef.setInput('interrupting', false); + fixture.detectChanges(); + + expect((component as any).inputSending()).toBe(true); + + fixture.componentRef.setInput('sending', false); + fixture.componentRef.setInput('interrupting', true); + fixture.detectChanges(); + + expect((component as any).inputSending()).toBe(true); + + fixture.componentRef.setInput('sending', false); + fixture.componentRef.setInput('interrupting', false); + fixture.detectChanges(); + + expect((component as any).inputSending()).toBe(false); + }); + + it('should handle multiple message types in sequence', () => { + const messages: ChatMessage[] = [ + { + type: 'user', + content: 'Question' + }, + { + type: 'ai', + content: 'Answer' + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const userMessages = debugElement.queryAll(By.css('si-user-message')); + const aiMessages = debugElement.queryAll(By.css('si-ai-message')); + + expect(userMessages.length).toBe(1); + expect(aiMessages.length).toBe(1); + }); + + it('should render welcome screen with custom greeting', () => { + const customGreeting = 'Hello there!'; + fixture.componentRef.setInput('messages', []); + fixture.componentRef.setInput('greeting', customGreeting); + fixture.detectChanges(); + + const welcomeScreen = debugElement.query(By.css('si-ai-welcome-screen')); + expect(welcomeScreen.nativeElement.textContent).toContain(customGreeting); + }); + + it('should render welcome screen with custom welcome message', () => { + const customMessage = 'How can I help you today?'; + fixture.componentRef.setInput('messages', []); + fixture.componentRef.setInput('welcomeMessage', customMessage); + fixture.detectChanges(); + + const welcomeScreen = debugElement.query(By.css('si-ai-welcome-screen')); + expect(welcomeScreen.nativeElement.textContent).toContain(customMessage); + }); + + it('should display prompt suggestions in welcome screen', () => { + const suggestions = [{ text: 'What can you do?' }]; + fixture.componentRef.setInput('messages', []); + fixture.componentRef.setInput('promptSuggestions', suggestions); + fixture.detectChanges(); + + const welcomeScreen = debugElement.query(By.css('si-ai-welcome-screen')); + expect(welcomeScreen.nativeElement.textContent).toContain('What can you do?'); + }); + + it('should handle AI message with actions', () => { + const actions: MessageAction[] = [ + { + label: 'Copy', + icon: 'element-export', + action: () => {} + } + ]; + + const messages: ChatMessage[] = [ + { + type: 'ai', + content: 'AI response', + actions + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const aiMessage = debugElement.query(By.css('si-ai-message')); + expect(aiMessage.componentInstance.actions()).toEqual(actions); + }); + + it('should render status notification when statusSeverity is set', () => { + fixture.componentRef.setInput('statusSeverity', 'warning'); + fixture.componentRef.setInput('statusMessage', 'Warning message'); + fixture.detectChanges(); + + const notification = debugElement.query(By.css('si-inline-notification')); + expect(notification).toBeTruthy(); + }); + + it('should have focus method', () => { + expect(typeof component.focus).toBe('function'); + }); + + it('should compute inputSending with both true', () => { + fixture.componentRef.setInput('sending', true); + fixture.componentRef.setInput('interrupting', true); + fixture.detectChanges(); + + expect((component as any).inputSending()).toBe(true); + }); + + it('should not be interruptible when disableInterrupt is true', () => { + fixture.componentRef.setInput('loading', true); + fixture.componentRef.setInput('disableInterrupt', true); + fixture.componentRef.setInput('sending', false); + fixture.detectChanges(); + + expect((component as any).inputInterruptible()).toBe(false); + }); + + it('should not be interruptible when sending is true', () => { + fixture.componentRef.setInput('loading', true); + fixture.componentRef.setInput('disableInterrupt', false); + fixture.componentRef.setInput('sending', true); + fixture.detectChanges(); + + expect((component as any).inputInterruptible()).toBe(false); + }); + + it('should handle loading state with existing messages', () => { + const messages: ChatMessage[] = [ + { + type: 'user', + content: 'Question' + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + const aiMessages = debugElement.queryAll(By.css('si-ai-message')); + expect(aiMessages.length).toBeGreaterThan(0); + }); + + it('should handle signal content updates', () => { + const contentSignal = signal('Initial content'); + const messages: ChatMessage[] = [ + { + type: 'ai', + content: contentSignal + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const aiMessage = debugElement.query(By.css('si-ai-message')); + expect(aiMessage.componentInstance.content()).toBe('Initial content'); + + contentSignal.set('Updated content'); + fixture.detectChanges(); + + expect(aiMessage.componentInstance.content()).toBe('Updated content'); + }); + + it('should handle empty content signal', () => { + const contentSignal = signal(''); + const messages: ChatMessage[] = [ + { + type: 'ai', + content: contentSignal + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const aiMessage = debugElement.query(By.css('si-ai-message')); + expect(aiMessage).toBeTruthy(); + }); + + it('should display tool message', () => { + const messages: ChatMessage[] = [ + { + type: 'ai', + content: 'Answer' + }, + { + type: 'tool', + name: 'Tool', + content: '', + output: 'Result' + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const toolMessages = debugElement.queryAll(By.css('si-tool-message')); + + expect(toolMessages.length).toBe(1); + }); + + it('should apply color variant to underlying container', () => { + fixture.componentRef.setInput('colorVariant', 'base-1'); + fixture.detectChanges(); + + const chatContainer = debugElement.query(By.css('si-chat-container')); + expect(chatContainer.componentInstance.colorVariant()).toBe('base-1'); + }); + + it('should pass noAutoScroll to underlying container', () => { + fixture.componentRef.setInput('noAutoScroll', true); + fixture.detectChanges(); + + const chatContainer = debugElement.query(By.css('si-chat-container')); + expect(chatContainer.componentInstance.noAutoScroll()).toBe(true); + }); + + it('should handle user messages with actions', () => { + const actions: MessageAction[] = [ + { + label: 'Edit', + icon: 'element-edit', + action: () => {} + } + ]; + + const messages: ChatMessage[] = [ + { + type: 'user', + content: 'User message', + actions + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const userMessage = debugElement.query(By.css('si-user-message')); + expect(userMessage.componentInstance.actions()).toEqual(actions); + }); + + it('should handle messages with more than 3 actions', () => { + const actions: MessageAction[] = [ + { label: 'Action 1', icon: 'element-icon1', action: () => {} }, + { label: 'Action 2', icon: 'element-icon2', action: () => {} }, + { label: 'Action 3', icon: 'element-icon3', action: () => {} }, + { label: 'Action 4', icon: 'element-icon4', action: () => {} }, + { label: 'Action 5', icon: 'element-icon5', action: () => {} } + ]; + + const messages: ChatMessage[] = [ + { + type: 'ai', + content: 'AI response', + actions + } + ]; + + fixture.componentRef.setInput('messages', messages); + fixture.detectChanges(); + + const primaryActions = (component as any).getMessagePrimaryActions(messages[0]); + const secondaryActions = (component as any).getMessageSecondaryActions(messages[0]); + + expect(primaryActions.length).toBe(3); + expect(secondaryActions.length).toBe(2); + }); + + it('should cache message actions', () => { + const actions: MessageAction[] = [{ label: 'Action', icon: 'element-icon', action: () => {} }]; + + const message: ChatMessage = { + type: 'ai', + content: 'Content', + actions + }; + + const result1 = (component as any).getMessageActions(message); + const result2 = (component as any).getMessageActions(message); + + expect(result1).toBe(result2); + }); +}); diff --git a/projects/element-ng/chat-messages/si-ai-chat-container.component.ts b/projects/element-ng/chat-messages/si-ai-chat-container.component.ts new file mode 100644 index 000000000..cce37a5ae --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-chat-container.component.ts @@ -0,0 +1,605 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common'; +import { + Component, + computed, + input, + output, + booleanAttribute, + inject, + contentChild, + Signal, + isSignal, + effect, + viewChild, + signal, + PLATFORM_ID, + DOCUMENT +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { BackgroundColorVariant } from '@siemens/element-ng/common'; +import { SiInlineNotificationComponent } from '@siemens/element-ng/inline-notification'; +import { + getMarkdownRenderer, + MarkdownRendererOptions +} from '@siemens/element-ng/markdown-renderer'; +import { MenuItem } from '@siemens/element-ng/menu'; +import { + SiTranslatePipe, + TranslatableString, + injectSiTranslateService, + t +} from '@siemens/element-translate-ng/translate'; + +import { + AiChatMessage, + ChatMessage, + MessageAction, + TemplateChatMessage +} from './chat-message.model'; +import { SiAiMessageComponent } from './si-ai-message.component'; +import { PromptSuggestion, SiAiWelcomeScreenComponent } from './si-ai-welcome-screen.component'; +import { SiChatContainerInputDirective } from './si-chat-container-input.directive'; +import { SiChatContainerComponent } from './si-chat-container.component'; +import { ChatInputAttachment, SiChatInputComponent } from './si-chat-input.component'; +import { SiToolMessageComponent } from './si-tool-message.component'; +import { SiUserMessageComponent } from './si-user-message.component'; + +/** + * A model-driven chat container component for displaying an AI chat interface with automatic scroll-to-bottom behavior. + * + * This component provides an AI chat interface, managing scrolling behavior + * to keep the newest messages visible while respecting user scrolling actions. It automatically + * scrolls to the bottom when new content is added, unless the user has scrolled up to view older messages. + * + * Required content projection: + * - `si-chat-input`: Input controls for composing messages + * + * @see {@link ChatMessage} for the chat message model + * @see {@link AiChatMessage} for the AI chat message model + * @see {@link UserChatMessage} for the user chat message model + * @see {@link ToolChatMessage} for the tool chat message model + * @see {@link TemplateChatMessage} for the template chat message model + * @see {@link SiChatInputComponent} for the chat input component + * @see {@link SiChatContainerComponent} for the base chat container component + * @see {@link SiAiMessageComponent} for the used AI message component + * @see {@link SiUserMessageComponent} for the used user message component + * @see {@link SiToolMessageComponent} for the used tool message component + * @see {@link SiChatMessageComponent} for the base wrapper chat message component used by AI and user message components + * + * @experimental + */ +@Component({ + selector: 'si-ai-chat-container', + imports: [ + NgTemplateOutlet, + SiInlineNotificationComponent, + SiAiMessageComponent, + SiToolMessageComponent, + SiUserMessageComponent, + SiChatContainerComponent, + SiChatContainerInputDirective, + SiTranslatePipe, + SiAiWelcomeScreenComponent + ], + templateUrl: './si-ai-chat-container.component.html', + host: { + class: 'd-flex si-layout-inner flex-grow-1 flex-column h-100 w-100' + } +}) +export class SiAiChatContainerComponent { + private readonly chatInput = contentChild(SiChatInputComponent); + private readonly chatContainer = viewChild(SiChatContainerComponent); + private sanitizer = inject(DomSanitizer); + private translateService = injectSiTranslateService(); + private platformId = inject(PLATFORM_ID); + private isBrowser = isPlatformBrowser(this.platformId); + private doc = inject(DOCUMENT); + + constructor() { + effect(() => { + const inputComponent = this.chatInput(); + if (inputComponent) { + inputComponent.registerParent(this.inputSending, this.inputInterruptible, () => { + this.scrollToBottom(); + }); + } + }); + + effect(() => { + if (this.isEmpty()) { + this.chatContainer()?.scrollToTop(); + } + }); + } + + private messageActionsCache = new WeakMap< + ChatMessage, + { primary: MessageAction[]; secondary: MenuItem[]; version: number } + >(); + + private messageVersions = new WeakMap(); + + /** + * List of chat messages to display. Messages can contain either content (string) + * or a template (TemplateRef) for custom rendering. When both content and template + * are provided, the template takes precedence. + * Leave undefined to manage messages via ng-content instead. + */ + readonly messages = input(); + + /** + * Whether a message is currently being sent, disables input and shows a loading state in the input area. + * If you want to show a loading state for the latest AI message, set {@link loading} to true instead. + * @defaultValue false + */ + readonly sending = input(false, { transform: booleanAttribute }); + + /** + * Whether a pending loading AI message should be shown (e.g. while waiting for response). + * If you also want to prevent the user from sending new messages, set {@link sending} to true as well. + * @defaultValue false + */ + readonly loading = input(false, { transform: booleanAttribute }); + + /** + * Whether to disable the interrupt functionality + * @defaultValue false + */ + readonly disableInterrupt = input(false, { transform: booleanAttribute }); + + /** + * Whether the system is currently interrupting an operation. When true, + * forces interruptible mode and shows sending state on the input. + * @defaultValue false + */ + readonly interrupting = input(false, { transform: booleanAttribute }); + + /** + * Disable auto-scroll to bottom when new messages are added + * @defaultValue false + */ + readonly noAutoScroll = input(false, { transform: booleanAttribute }); + + /** + * Custom AI icon name, used for empty state + * @defaultValue 'element-ai' + */ + readonly aiIcon = input('element-ai'); + + /** + * Color to use for component background. + * @defaultValue 'base-0' + */ + readonly colorVariant = input('base-0'); + + /** + * Do not display the copy code button. + * @defaultValue false + */ + readonly disableCopyCodeButton = input(false); + + /** + * Do not display the download CSV button for tables. + * @defaultValue false + */ + readonly disableDownloadTableButton = input(false); + + /** + * Optional syntax highlighter function for code blocks. + * Receives code content and optional language, returns an HTML content string to display inside of the code block or undefined to use default rendering. + * The returned code is sanitized before insertion. + * Make sure that the required styles/scripts for the syntax highlighter are included in your application. + * @defaultValue undefined + */ + readonly syntaxHighlighter = input< + ((code: string, language?: string) => string | undefined) | undefined + >(undefined); + + /** + * Optional LaTeX renderer function for math expressions. + * Receives LaTeX string and display mode flag, returns an HTML content string to display or undefined to use default rendering. + * The returned HTML is sanitized before insertion. + * Make sure that the required styles/scripts for the LaTeX renderer are included in your application. + * @defaultValue undefined + */ + readonly latexRenderer = input< + ((latex: string, displayMode: boolean) => string | undefined) | undefined + >(undefined); + + /** + * Label for the copy button. + * @defaultValue + * ``` + * t(() => $localize`:@@SI_MARKDOWN_RENDERER.COPY_CODE:Copy code`) + * ``` + */ + readonly copyCodeButtonLabel = input( + t(() => $localize`:@@SI_MARKDOWN_RENDERER.COPY_CODE:Copy code`) + ); + + /** + * Label for the download CSV button. + * @defaultValue + * ``` + * t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`) + * ``` + */ + readonly downloadTableButtonLabel = input( + t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`) + ); + + /** + * The greeting text for the welcome screen + * @defaultValue + * ``` + * t(() => $localize`:@@SI_AI_CHAT_CONTAINER.WELCOME_GREETING:Hello,`) + * ``` + */ + readonly greeting = input(t(() => $localize`:@@SI_AI_CHAT_CONTAINER.WELCOME_GREETING:Hello,`)); + + /** + * The welcome message text for the welcome screen + * @defaultValue + * ``` + * t(() => $localize`:@@SI_AI_CHAT_CONTAINER.WELCOME_MESSAGE:how can I help you today?`) + * ``` + */ + readonly welcomeMessage = input( + t(() => $localize`:@@SI_AI_CHAT_CONTAINER.WELCOME_MESSAGE:how can I help you today?`) + ); + + /** + * The list of prompt suggestions for the welcome screen, either as an array or a record mapping category labels to suggestion arrays + * @defaultValue [] + */ + readonly promptSuggestions = input>([]); + + /** + * Internal selected category state + */ + protected readonly selectedCategory = signal(undefined); + + /** + * Computed list of categories derived from promptSuggestions + */ + protected readonly promptCategories = computed(() => { + const suggestions = this.promptSuggestions(); + if (Array.isArray(suggestions)) { + return []; + } + return Object.keys(suggestions).map(label => ({ label })); + }); + + /** + * More actions button aria label + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_AI_CHAT_CONTAINER.SECONDARY_ACTIONS:More actions`) + * ``` + */ + readonly secondaryActionsLabel = input( + t(() => $localize`:@@SI_AI_CHAT_CONTAINER.SECONDARY_ACTIONS:More actions`) + ); + + /** + * Status notification severity + */ + readonly statusSeverity = input< + 'info' | 'success' | 'caution' | 'warning' | 'danger' | 'critical' + >(); + + /** + * Status notification heading + */ + readonly statusHeading = input(); + + /** + * Status notification message + */ + readonly statusMessage = input(); + + /** + * Status notification action link + */ + readonly statusAction = input<{ title: string; href: string; target?: string }>(); + + /** + * Label for tool message input arguments section + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_TOOL_MESSAGE.INPUT_ARGUMENTS:Input Arguments`) + * ``` + */ + readonly toolInputArgumentsLabel = input( + t(() => $localize`:@@SI_TOOL_MESSAGE.INPUT_ARGUMENTS:Input Arguments`) + ); + + /** + * Label for tool message output section + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_TOOL_MESSAGE.OUTPUT:Output`) + * ``` + */ + readonly toolOutputLabel = input( + t(() => $localize`:@@SI_TOOL_MESSAGE.OUTPUT:Output`) + ); + + /** + * Emitted when a new message is sent + */ + readonly messageSent = output<{ + content: string; + attachments: ChatInputAttachment[]; + }>(); + + protected readonly markdownRenderer = computed(() => { + const highlighterFn = this.syntaxHighlighter(); + const latexFn = this.latexRenderer(); + + const options: MarkdownRendererOptions | undefined = { + copyCodeButton: !this.disableCopyCodeButton() ? this.copyCodeButtonLabel() : undefined, + downloadTableButton: !this.disableDownloadTableButton() + ? this.downloadTableButtonLabel() + : undefined, + syntaxHighlighter: highlighterFn, + latexRenderer: latexFn, + translateSync: this.translateService.translateSync.bind(this.translateService) + }; + + return getMarkdownRenderer(this.sanitizer, options, this.doc, this.isBrowser); + }); + + protected readonly isEmpty = computed(() => this.messages()?.length === 0 && !this.loading()); + + protected readonly inputInterruptible = computed(() => { + return this.interrupting() || (this.loading() && !this.disableInterrupt() && !this.sending()); + }); + + protected readonly inputSending = computed(() => { + return this.sending() || this.interrupting(); + }); + + protected readonly displayMessages = computed(() => { + const messages = this.messages(); + if (!messages?.length) { + if (this.loading()) { + // If loading but no messages, show a single loading AI message + const loadingMessage: AiChatMessage = { + type: 'ai', + content: '', + loading: true + }; + return [loadingMessage]; + } + return []; + } + + // If global loading is true, check if we need to add an AI message + if (this.loading() && messages.length > 0) { + const latestMessage = messages[messages.length - 1]; + + if (this.shouldInjectLoadingMessage(latestMessage)) { + const newMessages = [...messages]; + const loadingMessage: AiChatMessage = { + type: 'ai', + content: '', + loading: true + }; + newMessages.push(loadingMessage); + return newMessages; + } + } + + return messages; + }); + + /** + * Determines if a loading AI message should be injected after the given message. + * Returns true unless the latest message is an AI message that's either empty, currently loading, or streaming. + */ + private shouldInjectLoadingMessage(latestMessage: ChatMessage): boolean { + if (this.isTemplateMessage(latestMessage)) return true; + if (latestMessage.type !== 'ai') return true; + + // Don't inject if AI message is empty/loading/streaming + const hasContent = !!this.getContentValue(latestMessage.content); + const isLoading = this.getLoadingState(latestMessage.loading, latestMessage.content, false); + const isStreaming = this.isStreamingContent(latestMessage.content); + + return hasContent && !isLoading && !isStreaming; + } + + private getMessageActions(message: ChatMessage): { + primary: MessageAction[]; + secondary: MenuItem[]; + version: number; + } { + if (this.isTemplateMessage(message) || message.type === 'tool') { + return { primary: [], secondary: [], version: 0 }; + } + + const actions = message.actions ?? []; + + // Get or create version number for this message + const version = this.messageVersions.get(message) ?? 0; + + // Check if we have a cached version that matches + const cached = this.messageActionsCache.get(message); + if (cached?.version === version) { + return cached; + } + + // Build primary and secondary actions + const primary = actions.slice(0, 3); + const secondary = actions.slice(3).map( + action => + ({ + ...action, + action: action.action as unknown as (actionParam: any, source: MenuItem) => void, + type: 'action' + }) as MenuItem + ); + + const result = { primary, secondary, version }; + this.messageActionsCache.set(message, result); + + // Return the cached object to maintain reference equality + return result; + } + + protected isTemplateMessage(message: ChatMessage): message is TemplateChatMessage { + return 'template' in message && message.template !== undefined; + } + + protected getMessagePrimaryActions(message: ChatMessage): MessageAction[] { + return this.getMessageActions(message).primary; + } + + protected getMessageSecondaryActions(message: ChatMessage): MenuItem[] { + return this.getMessageActions(message).secondary; + } + + /** + * Scrolls to the bottom of the messages container immediately. + * This method forces a scroll even if the user has scrolled up. + */ + public scrollToBottom(): void { + this.chatContainer()?.scrollToBottom(); + } + + /** + * Focuses the chat input component if it exists. + */ + public focus(): void { + const inputComponent = this.chatInput(); + if (inputComponent) { + inputComponent.focus(); + } + } + + protected getContentValue(content: T | Signal | undefined): T { + if (!content) return '' as T; + return isSignal(content) ? (content as Signal)() : content; + } + + protected getOutputValue( + outputValue: string | object | Signal | undefined + ): string | object | undefined { + if (outputValue === undefined || outputValue === null) return undefined; + return isSignal(outputValue) ? (outputValue as Signal)() : outputValue; + } + + private isEmptyContent(content: string | object | Signal | undefined): boolean { + const contentValue = this.getContentValue(content); + + return !contentValue; + } + + private getLoadingValue(loading: boolean | Signal | undefined): boolean { + return loading !== undefined ? (isSignal(loading) ? loading() : loading) : false; + } + + private isStreamingContent( + content: string | object | Signal | undefined + ): boolean { + return isSignal(content) && !this.isEmptyContent(content); + } + + /** + * Helper method to get loading state from boolean or signal + * + * Behavior: + * - Shows loading when content is empty and allowEmptyContent is false + * - Shows loading when message.loading is true + * - Shows loading when it's the latest message, globalLoading is true, and content is empty + * + * @param messageLoading - The loading state of the individual message + * @param content - The content of the message + * @param isLatest - Whether this is the latest message in the list + * @param globalLoading - The global loading state for the chat container + * @param allowEmptyContent - Whether to allow empty content without showing loading + * @returns Whether to show the loading state + */ + protected getLoadingState( + messageLoading: boolean | Signal | undefined, + content: string | object | Signal | undefined, + isLatest: boolean, + globalLoading: boolean = false, + allowEmptyContent: boolean = false + ): boolean { + const messageLoadingValue = this.getLoadingValue(messageLoading); + const isEmptyContent = this.isEmptyContent(content); + + // If the content is empty, always show loading (unless allowEmptyContent is true) + if (isEmptyContent && !allowEmptyContent) { + return true; + } + + return messageLoadingValue || (isLatest && globalLoading && isEmptyContent); + } + + protected isLatestMessage(message: ChatMessage): boolean { + const messages = this.displayMessages(); + if (!messages || messages.length === 0) return false; + return messages[messages.length - 1] === message; + } + private isLatestToolMessage(message: ChatMessage): boolean { + const messages = this.displayMessages(); + if (!messages || messages.length === 0) return false; + + const messageIndex = messages.findIndex(m => m === message); + if (messageIndex === -1) return false; + + if (messageIndex === messages.length - 1) return true; + + if (messageIndex < messages.length - 2) return false; + + const nextMessage = messages[messageIndex + 1]; + + // Auto-expand only applies when an AI response is being generated. + // If a user message or another tool message follows, this is no longer the latest tool call. + if (!this.isTemplateMessage(nextMessage) && nextMessage.type === 'ai') return true; + + return false; + } + + protected shouldAutoExpandInputArguments(message: ChatMessage): boolean { + if (this.isTemplateMessage(message) || message.type !== 'tool') return false; + if (!message.autoExpandInputArguments) { + return false; + } + return this.isLatestToolMessage(message); + } + + protected shouldAutoExpandOutput(message: ChatMessage): boolean { + if (this.isTemplateMessage(message) || message.type !== 'tool') return false; + if (!message.autoExpandOutput) { + return false; + } + return this.isLatestToolMessage(message); + } + + protected readonly isSignal = isSignal; + + protected onPromptSelected(suggestion: PromptSuggestion): void { + // Set the input value and emit the send event + const inputComponent = this.chatInput(); + if (inputComponent) { + inputComponent.value.set(suggestion.text); + // Programmatically trigger the send output + inputComponent.send.emit({ + content: suggestion.text, + attachments: [] + }); + inputComponent.value.set(''); + } + } +} diff --git a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts index d97b0456c..04003f9ba 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts @@ -2,12 +2,13 @@ * Copyright (c) Siemens 2016 - 2025 * SPDX-License-Identifier: MIT */ +import { DOCUMENT } from '@angular/common'; import { DebugElement } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By, DomSanitizer } from '@angular/platform-browser'; import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; -import { MessageAction } from './message-action.model'; +import { MessageAction } from './chat-message.model'; import { SiAiMessageComponent as TestComponent } from './si-ai-message.component'; describe('SiAiMessageComponent', () => { @@ -18,7 +19,8 @@ describe('SiAiMessageComponent', () => { fixture = TestBed.createComponent(TestComponent); debugElement = fixture.debugElement; const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + const doc = TestBed.inject(DOCUMENT); + markdownRenderer = getMarkdownRenderer(sanitizer, undefined, doc, true); }); it('should render markdown content', () => { diff --git a/projects/element-ng/chat-messages/si-ai-message.component.ts b/projects/element-ng/chat-messages/si-ai-message.component.ts index 2326963fd..4480769d8 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.ts @@ -16,7 +16,7 @@ import { SiIconComponent } from '@siemens/element-ng/icon'; import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; -import { MessageAction } from './message-action.model'; +import { MessageAction } from './chat-message.model'; import { SiChatMessageActionDirective } from './si-chat-message-action.directive'; import { SiChatMessageComponent } from './si-chat-message.component'; @@ -37,7 +37,8 @@ import { SiChatMessageComponent } from './si-chat-message.component'; * @see {@link SiChatMessageComponent} for the base message wrapper component * @see {@link SiUserMessageComponent} for the user message component * @see {@link getMarkdownRenderer} for markdown formatting support - * @see {@link SiChatContainerComponent} for the chat container to use this within + * @see {@link SiChatContainerComponent} for the base chat container to use this within + * @see {@link SiAiChatContainerComponent} for the AI chat container which uses this component * * @experimental */ diff --git a/projects/element-ng/chat-messages/si-ai-welcome-screen.component.html b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.html new file mode 100644 index 000000000..8a68039b3 --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.html @@ -0,0 +1,34 @@ +
+ +
+ +
+ + +
+ @if (categories().length > 0) { +
+ @for (category of categories(); track $index) { + + } +
+ } + + @if (filteredPromptSuggestions().length > 0) { +
+ @for (suggestion of filteredPromptSuggestions(); track $index) { + + } +
+ } +
+
diff --git a/projects/element-ng/chat-messages/si-ai-welcome-screen.component.scss b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.scss new file mode 100644 index 000000000..1c43e6d33 --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.scss @@ -0,0 +1,53 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ + +@use 'sass:map'; + +@use '@siemens/element-theme/src/styles/variables'; + +.welcome-header { + max-inline-size: 720px; + overflow-x: visible; + position: relative; + + &::before { + position: absolute; + content: ''; + inset-inline-start: -104px; + inset-block-start: 50%; + transform: translateY(-50%); + inline-size: 168px; + block-size: 168px; + background-image: variables.$element-brand-ai-key-visual; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + } +} + +:host { + &.si-container-xs, + &.si-container-sm { + .welcome-header::before { + display: none; + } + } +} + +.prompt-suggestions { + max-inline-size: 720px; + + > div:first-child { + flex-wrap: wrap; + } +} + +:host { + inline-size: 100%; + + ::ng-deep button[si-action-card] { + background-color: variables.$element-base-input-experimental; + } +} diff --git a/projects/element-ng/chat-messages/si-ai-welcome-screen.component.spec.ts b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.spec.ts new file mode 100644 index 000000000..20b0e3dad --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.spec.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { provideZonelessChangeDetection } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SiAiWelcomeScreenComponent } from './si-ai-welcome-screen.component'; + +describe('SiAiWelcomeScreenComponent', () => { + let component: SiAiWelcomeScreenComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SiAiWelcomeScreenComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + + fixture = TestBed.createComponent(SiAiWelcomeScreenComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display categories when provided', () => { + const categories = [{ label: 'Category 1' }, { label: 'Category 2' }]; + fixture.componentRef.setInput('categories', categories); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Category 1'); + expect(compiled.textContent).toContain('Category 2'); + }); + + it('should display prompt suggestions when provided', () => { + const suggestions = [{ text: 'Suggestion 1' }, { text: 'Suggestion 2' }]; + fixture.componentRef.setInput('promptSuggestions', suggestions); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Suggestion 1'); + expect(compiled.textContent).toContain('Suggestion 2'); + }); + + it('should emit categorySelected when category is clicked', () => { + const categories = [{ label: 'Category 1' }]; + fixture.componentRef.setInput('categories', categories); + fixture.detectChanges(); + + const categoryChip = fixture.nativeElement.querySelector('si-summary-chip .chip'); + categoryChip?.click(); + fixture.detectChanges(); + + expect(component.selectedCategory()).toBe('Category 1'); + }); + + it('should emit promptSelected when suggestion is clicked', () => { + const suggestion = { text: 'Suggestion 1' }; + fixture.componentRef.setInput('promptSuggestions', [suggestion]); + fixture.detectChanges(); + + const emitSpy = spyOn(component.promptSelected, 'emit'); + const suggestionButton = fixture.nativeElement.querySelector('button'); + suggestionButton?.click(); + + expect(emitSpy).toHaveBeenCalledWith(suggestion); + }); + + it('should hide categories when no categories provided', () => { + fixture.componentRef.setInput('categories', []); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const categoryChips = compiled.querySelectorAll('si-summary-chip'); + expect(categoryChips.length).toBe(0); + }); + + it('should hide suggestions when no suggestions provided', () => { + fixture.componentRef.setInput('promptSuggestions', []); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const actionCards = compiled.querySelectorAll('[si-action-card]'); + expect(actionCards.length).toBe(0); + }); +}); diff --git a/projects/element-ng/chat-messages/si-ai-welcome-screen.component.ts b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.ts new file mode 100644 index 000000000..8db1d7fe2 --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-welcome-screen.component.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, computed, input, model, output } from '@angular/core'; +import { SiActionCardComponent } from '@siemens/element-ng/card'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { SiResponsiveContainerDirective } from '@siemens/element-ng/resize-observer'; +import { SiSummaryChipComponent } from '@siemens/element-ng/summary-chip'; + +export interface PromptCategory { + label: string; +} + +export interface PromptSuggestion { + text: string; +} + +/** + * AI welcome screen component for displaying initial state in AI chat interfaces. + * + * The AI welcome screen component provides an engaging initial state for AI chat interfaces, + * featuring a welcome message, optional prompt categories for filtering, and suggested prompts + * that users can click to start conversations. + * + * The component includes: + * - Welcome header with AI branding and customizable greeting + * - Optional category pills for filtering prompt suggestions + * - Clickable prompt suggestion cards + * - Optional refresh button to regenerate suggestions + * + * @see {@link SiAiChatContainerComponent} for the AI chat container which uses this component + * @see {@link SiChatContainerComponent} for the base chat container + * + * @experimental + */ +@Component({ + selector: 'si-ai-welcome-screen', + imports: [SiActionCardComponent, SiSummaryChipComponent, SiIconComponent], + templateUrl: './si-ai-welcome-screen.component.html', + styleUrl: './si-ai-welcome-screen.component.scss', + host: { + class: 'd-block' + }, + hostDirectives: [SiResponsiveContainerDirective] +}) +export class SiAiWelcomeScreenComponent { + /** + * The list of prompt categories + * @defaultValue [] + */ + readonly categories = input([]); + + /** + * The currently selected category ID + * @defaultValue undefined + */ + readonly selectedCategory = model(undefined); + + /** + * The list of prompt suggestions, either as an array or a record mapping category labels to suggestion arrays + * @defaultValue [] + */ + readonly promptSuggestions = input>([]); + + /** + * Computed list of filtered prompt suggestions based on selected category + */ + protected readonly filteredPromptSuggestions = computed(() => { + const suggestions = this.promptSuggestions(); + const selected = this.selectedCategory(); + + // If suggestions is an array, return as-is (no filtering) + if (Array.isArray(suggestions)) { + return suggestions; + } + + // If suggestions is a record and a category is selected, return that category's suggestions + if (selected && suggestions[selected]) { + return suggestions[selected]; + } + + // If no category selected, return all suggestions flattened + return Object.values(suggestions).flat(); + }); + + /** + * Emitted when a prompt suggestion is clicked + */ + readonly promptSelected = output(); + + protected onCategoryClick(categoryLabel: string): void { + this.selectedCategory.set( + this.selectedCategory() === categoryLabel ? undefined : categoryLabel + ); + } + + protected onPromptClick(suggestion: PromptSuggestion): void { + this.promptSelected.emit(suggestion); + } +} diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.spec.ts b/projects/element-ng/chat-messages/si-attachment-list.component.spec.ts index a9a1baabd..2e8daf5d3 100644 --- a/projects/element-ng/chat-messages/si-attachment-list.component.spec.ts +++ b/projects/element-ng/chat-messages/si-attachment-list.component.spec.ts @@ -7,10 +7,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SiModalService } from '@siemens/element-ng/modal'; -import { - SiAttachmentListComponent as TestComponent, - Attachment -} from './si-attachment-list.component'; +import { Attachment } from './chat-message.model'; +import { SiAttachmentListComponent as TestComponent } from './si-attachment-list.component'; describe('SiAttachmentListComponent', () => { let fixture: ComponentFixture; diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.ts b/projects/element-ng/chat-messages/si-attachment-list.component.ts index 3dd12dfec..3bd0b1e66 100644 --- a/projects/element-ng/chat-messages/si-attachment-list.component.ts +++ b/projects/element-ng/chat-messages/si-attachment-list.component.ts @@ -2,26 +2,12 @@ * Copyright (c) Siemens 2016 - 2025 * SPDX-License-Identifier: MIT */ -import { booleanAttribute, Component, inject, input, output, TemplateRef } from '@angular/core'; +import { booleanAttribute, Component, inject, input, output } from '@angular/core'; import { SiIconComponent } from '@siemens/element-ng/icon'; import { SiModalService } from '@siemens/element-ng/modal'; import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; -/** - * Attachment item interface for file attachments in chat messages, used by {@link SiAttachmentListComponent} and inside {@link SiUserMessageComponent} as well as {@link SiChatInputComponent}. - * - * @see {@link SiAttachmentListComponent} for the attachment list component - * @see {@link SiUserMessageComponent} for the user message - * @see {@link SiChatInputComponent} for the chat input component - * - * @experimental - */ -export interface Attachment { - /** File name */ - name: string; - /** Optionally show a preview of the attachment by providing a template that is shown in a modal when clicked (optional) */ - previewTemplate?: TemplateRef | (() => TemplateRef); -} +import { Attachment } from './chat-message.model'; /** * Attachment list component for displaying file attachments in chat messages. @@ -103,7 +89,8 @@ export class SiAttachmentListComponent { if (template) { event.preventDefault(); this.modalService.show(template, { - inputValues: { 'attachment': attachment } + inputValues: { 'attachment': attachment }, + ignoreBackdropClick: false }); } } diff --git a/projects/element-ng/chat-messages/si-chat-container.component.scss b/projects/element-ng/chat-messages/si-chat-container.component.scss index b47fdab0a..1af53732b 100644 --- a/projects/element-ng/chat-messages/si-chat-container.component.scss +++ b/projects/element-ng/chat-messages/si-chat-container.component.scss @@ -20,6 +20,20 @@ scroll-behavior: smooth; } + ::ng-deep &:has(si-ai-welcome-screen) { + inline-size: 100%; + // stylelint-disable-next-line declaration-no-important + padding-inline-start: 0 !important; + // stylelint-disable-next-line declaration-no-important + padding-inline-end: 0 !important; + + .chat-input-area, + si-ai-welcome-screen { + padding-inline-start: map.get(variables.$spacers, 6); + padding-inline-end: map.get(variables.$spacers, 6); + } + } + // Ensure the container can grow to fill available space min-block-size: 0; overflow-y: hidden; @@ -27,7 +41,6 @@ } .messages-container { - max-inline-size: 720px; overflow-y: auto; overflow-x: visible; gap: map.get(variables.$spacers, 9) + map.get(variables.$spacers, 4); @@ -36,6 +49,14 @@ ::ng-deep si-user-message:not(.last) { margin-block-end: -1 * (map.get(variables.$spacers, 9) + map.get(variables.$spacers, 4)); } + + ::ng-deep &:not(:has(si-ai-welcome-screen)) { + max-inline-size: 720px; + } + + ::ng-deep &:has(si-ai-welcome-screen) { + inline-size: 100%; + } } .chat-input-area { @@ -43,7 +64,10 @@ align-self: center; overflow: visible; inline-size: 100%; - max-inline-size: 720px; + + > ::ng-deep * { + max-inline-size: 720px; + } &:empty, &:has([sichatcontainerinput]:empty) { @@ -64,13 +88,3 @@ // stylelint-disable-next-line declaration-no-important display: none !important; } - -// In mobile mode the inline-notification is massive compared to the scrollable area -:host-context(.si-container-xs, .si-container-sm) ::ng-deep { - si-inline-notification { - .alert, - .alert-link { - font-size: variables.$si-font-size-body; - } - } -} diff --git a/projects/element-ng/chat-messages/si-chat-container.component.ts b/projects/element-ng/chat-messages/si-chat-container.component.ts index a4f6cab3a..00675463f 100644 --- a/projects/element-ng/chat-messages/si-chat-container.component.ts +++ b/projects/element-ng/chat-messages/si-chat-container.component.ts @@ -23,14 +23,16 @@ import { * scrolls to the bottom when new content is added, unless the user has scrolled up to view older messages. * * Use via content projection: + * * - Default content: Chat messages displayed in the scrollable messages container or something like an empty state. * - `si-inline-notification` selector: Notification component displayed above the input area * - `si-chat-input` or `[siChatContainerInput]` selector: Input controls for composing messages * - * @see {@link SiChatInputComponent} for the chat input wrapper component + * @see {@link SiChatInputComponent} for the chat input component * @see {@link SiChatContainerInputDirective} for other input controls to slot in * @see {@link SiAiMessageComponent} for AI messages to slot in * @see {@link SiUserMessageComponent} for user messages (in AI chats) to slot in + * @see {@link SiToolMessageComponent} for AI tool call displays to slot in * @see {@link SiChatMessageComponent} for the chat message wrapper component to slot in other messages * * @experimental @@ -50,6 +52,9 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { private isUserAtBottom = true; private scrollTimeout: ReturnType | undefined; + private lastScrollTime = 0; + private pendingScroll = false; + private scrollDebounceMs = 7; // ~144fps private resizeObserver: ResizeObserver | undefined; private contentObserver: MutationObserver | undefined; @@ -77,7 +82,7 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { } ngAfterContentInit(): void { - this.scrollToBottom(); + this.scrollToBottomDuringStreaming(); } ngOnDestroy(): void { @@ -92,7 +97,7 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { } } - private scrollToBottom(): void { + private scrollToBottomDuringStreaming(): void { if (this.noAutoScroll() || !this.isUserAtBottom) { return; } @@ -107,13 +112,28 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { } private debouncedScrollToBottom(): void { + const now = Date.now(); + const timeSinceLastScroll = now - this.lastScrollTime; + + if (timeSinceLastScroll >= this.scrollDebounceMs) { + this.lastScrollTime = now; + this.scrollToBottomDuringStreaming(); + this.pendingScroll = false; + } else { + this.pendingScroll = true; + } + if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); } this.scrollTimeout = setTimeout(() => { - this.scrollToBottom(); - }, 100); + if (this.pendingScroll) { + this.lastScrollTime = Date.now(); + this.scrollToBottomDuringStreaming(); + this.pendingScroll = false; + } + }, this.scrollDebounceMs); } private setupResizeObserver(): void { @@ -170,6 +190,29 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { this.checkIfUserAtBottom(); } + /** + * Scrolls to the bottom of the messages container immediately. + * This method forces a scroll even if the user has scrolled up. + */ + public scrollToBottom(): void { + this.isUserAtBottom = true; + this.scrollToBottomDuringStreaming(); + } + + /** + * Scrolls to the top of the messages container immediately. + */ + public scrollToTop(): void { + const container = this.messagesContainer(); + if (!container) { + return; + } + + const element = container.nativeElement; + element.scrollTop = 0; + this.isUserAtBottom = false; + } + /** * Focuses the messages container element. */ diff --git a/projects/element-ng/chat-messages/si-chat-input.component.spec.ts b/projects/element-ng/chat-messages/si-chat-input.component.spec.ts index 2791668ef..ab4a3ef21 100644 --- a/projects/element-ng/chat-messages/si-chat-input.component.spec.ts +++ b/projects/element-ng/chat-messages/si-chat-input.component.spec.ts @@ -8,7 +8,7 @@ import { By } from '@angular/platform-browser'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { FileUploadError, UploadFile } from '@siemens/element-ng/file-uploader'; -import { MessageAction } from './message-action.model'; +import { MessageAction } from './chat-message.model'; import { ChatInputAttachment, SiChatInputComponent as TestComponent diff --git a/projects/element-ng/chat-messages/si-chat-input.component.ts b/projects/element-ng/chat-messages/si-chat-input.component.ts index cfe1724d7..3893695e7 100644 --- a/projects/element-ng/chat-messages/si-chat-input.component.ts +++ b/projects/element-ng/chat-messages/si-chat-input.component.ts @@ -11,8 +11,12 @@ import { ElementRef, input, model, + OnDestroy, output, - viewChild + viewChild, + signal, + Signal, + effect } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { @@ -24,8 +28,8 @@ import { SiIconComponent } from '@siemens/element-ng/icon'; import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; import { SiTranslatePipe, TranslatableString, t } from '@siemens/element-translate-ng/translate'; -import { MessageAction } from './message-action.model'; -import { Attachment, SiAttachmentListComponent } from './si-attachment-list.component'; +import { Attachment, MessageAction } from './chat-message.model'; +import { SiAttachmentListComponent } from './si-attachment-list.component'; /** * Attachment item interface for file attachments in chat messages, extension of {@link Attachment} for {@link SiAttachmentListComponent} to use within {@link SiChatInputComponent}. @@ -35,7 +39,8 @@ import { Attachment, SiAttachmentListComponent } from './si-attachment-list.comp * @see {@link Attachment} for base attachment interface * @see {@link SiAttachmentListComponent} for the attachment list component * @see {@link SiChatInputComponent} for the chat input component - * @see {@link SiChatContainerComponent} for the chat container component + * @see {@link SiChatContainerComponent} for the base chat container component where this can be used + * @see {@link SiAiChatContainerComponent} for the AI chat container where this needs to be used * * @experimental */ @@ -62,6 +67,7 @@ export interface ChatInputAttachment extends Attachment { * - Displaying primary and secondary actions. * * Additionally to the inputs and outputs documented here, the component supports content projection via the following slots: + * * - Default content: Custom action buttons to display inline, prefer using the `actions` input for buttons, can be used in addition. * - `siChatInputDisclaimer` selector: Custom disclaimer content to display below the input area, prefer using the `disclaimer` input for simple text disclaimers. * @@ -84,12 +90,32 @@ export interface ChatInputAttachment extends Attachment { templateUrl: './si-chat-input.component.html', styleUrl: './si-chat-input.component.scss' }) -export class SiChatInputComponent implements AfterViewInit { +export class SiChatInputComponent implements AfterViewInit, OnDestroy { private static idCounter = 0; private readonly textInput = viewChild>('textInput'); private readonly projectedContent = viewChild('projected'); private readonly fileUploadDirective = viewChild(SiFileUploadDirective); + private readonly registeredSending = signal | null>(null); + private readonly registeredInterruptible = signal | null>(null); + private sendSubscription: { unsubscribe: () => void } | null = null; + + constructor() { + effect(() => { + const sendingSignal = this.registeredSending(); + if (sendingSignal) { + this.sending.set(sendingSignal()); + } + }); + + effect(() => { + const interruptibleSignal = this.registeredInterruptible(); + if (interruptibleSignal) { + this.interruptible.set(interruptibleSignal()); + } + }); + } + /** * Current input value * @defaultValue '' @@ -118,7 +144,7 @@ export class SiChatInputComponent implements AfterViewInit { * Whether a message is currently being sent, also prevent the sending of new ones while still allowing the user to type * @defaultValue false */ - readonly sending = input(false, { transform: booleanAttribute }); + readonly sending = model(false); /** * Whether the input supports interrupting ongoing operations. When active, @@ -126,7 +152,7 @@ export class SiChatInputComponent implements AfterViewInit { * If sending is true, the interrupt button will be disabled. * @defaultValue false */ - readonly interruptible = input(false, { transform: booleanAttribute }); + readonly interruptible = model(false); /** * Maximum number of characters allowed @@ -373,6 +399,23 @@ export class SiChatInputComponent implements AfterViewInit { }); } + public registerParent( + sending: Signal, + interruptible: Signal, + sendListener?: () => void + ): void { + this.registeredSending.set(sending); + this.registeredInterruptible.set(interruptible); + + this.sendSubscription?.unsubscribe(); + + if (sendListener) { + this.sendSubscription = this.send.subscribe(() => { + sendListener(); + }); + } + } + protected onContainerClick(event: Event): void { const target = event.target as HTMLElement; @@ -406,6 +449,10 @@ export class SiChatInputComponent implements AfterViewInit { } } + ngOnDestroy(): void { + this.sendSubscription?.unsubscribe(); + } + protected adjustTextareaHeight(event: Event): void { const textarea = event.target as HTMLTextAreaElement; this.setTextareaHeight(textarea); diff --git a/projects/element-ng/chat-messages/si-chat-message.component.scss b/projects/element-ng/chat-messages/si-chat-message.component.scss index b2484bba0..4f34bb870 100644 --- a/projects/element-ng/chat-messages/si-chat-message.component.scss +++ b/projects/element-ng/chat-messages/si-chat-message.component.scss @@ -35,6 +35,7 @@ margin-block-end: auto; background-color: var(--chat-message-bubble-bg); min-inline-size: 0; + max-inline-size: 100%; overflow-wrap: break-word; word-break: break-word; @@ -50,6 +51,7 @@ .message-wrapper { min-inline-size: 0; + max-inline-size: 100%; } .attachment-slot { diff --git a/projects/element-ng/chat-messages/si-chat-message.component.ts b/projects/element-ng/chat-messages/si-chat-message.component.ts index 76d6f14b1..b56381f11 100644 --- a/projects/element-ng/chat-messages/si-chat-message.component.ts +++ b/projects/element-ng/chat-messages/si-chat-message.component.ts @@ -22,17 +22,20 @@ import { SiResponsiveContainerDirective } from '@siemens/element-ng/resize-obser * - Responsive behavior that adapts to container size * * This is a low-level component designed for slotting in custom content, it provides slots via content projection: + * * - Default content: Main message content area (consider using {@link SiMarkdownRendererComponent} for markdown support) * - `si-avatar/si-icon/img` selector: Avatar or icon representing the message sender * - `si-chat-message-action` selector: Action buttons related to the message * - `si-attachment-list` selector: Attachment list component for displaying file attachments * - * @see {@link SiUserMessageComponent} for user message display - * @see {@link SiAiMessageComponent} for AI message display + * @see {@link SiUserMessageComponent} for the user message component + * @see {@link SiAiMessageComponent} for the AI message component + * @see {@link SiToolMessageComponent} for the AI tool call display * @see {@link SiAttachmentListComponent} for attachment list to slot in * @see {@link SiChatMessageActionDirective} for action buttons to slot in * @see {@link SiMarkdownRendererComponent} for markdown content rendering * @see {@link SiChatContainerComponent} for the chat container to use this within + * @see {@link SiAiChatContainerComponent} for the AI chat container which uses this component * * @experimental */ diff --git a/projects/element-ng/chat-messages/si-tool-message.component.html b/projects/element-ng/chat-messages/si-tool-message.component.html new file mode 100644 index 000000000..d048d4a39 --- /dev/null +++ b/projects/element-ng/chat-messages/si-tool-message.component.html @@ -0,0 +1,35 @@ + + @if (toolIcon()) { + + } + +
+ @if (name()) { +
{{ name() }}
+ } + +
+ @if (hasInputArguments()) { + +
{{ formatData(inputArguments()) }}
+
+ } + + @if (hasOutput() && !getLoadingState()) { + +
{{ formatData(getOutputValue()) }}
+
+ } + + @if (getLoadingState()) { + + } +
+
+
diff --git a/projects/element-ng/chat-messages/si-tool-message.component.scss b/projects/element-ng/chat-messages/si-tool-message.component.scss new file mode 100644 index 000000000..616c827cb --- /dev/null +++ b/projects/element-ng/chat-messages/si-tool-message.component.scss @@ -0,0 +1,103 @@ +@use 'sass:map'; +@use '@siemens/element-theme/src/styles/variables'; + +:host { + display: block; +} + +si-chat-message { + --chat-message-bubble-bg: transparent; + // Because of the transparent background we need to remove the padding + --chat-message-bubble-padding: 0; +} + +// Loading spinner size adjustment (inherited from generic component) +:host ::ng-deep si-loading-spinner { + --loading-spinner-size: 1.5em; +} + +.tool-message-content { + display: flex; + flex-direction: column; + inline-size: fit-content; + max-inline-size: 100%; + overflow: hidden; + transition: inline-size variables.$element-default-transition-duration ease-out; + + > * { + inline-size: 100%; + max-inline-size: 100%; + flex-shrink: 0; + } +} + +.tool-name { + color: variables.$element-text-primary; + font-weight: variables.$si-font-weight-semibold; + margin-block-end: map.get(variables.$spacers, 3); + inline-size: 100%; + max-inline-size: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-data { + background-color: variables.$element-ui-4; + padding: map.get(variables.$spacers, 3); + border-radius: variables.$element-radius-1; + margin: 0; + max-block-size: 300px; + max-inline-size: 100%; + overflow: auto; + word-wrap: break-word; + white-space: pre-wrap; + font-family: + 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 0.875rem; + line-height: 1.4; + + code { + // Because different font + font-size: 0.9em; + background: none; + padding: 0; + color: variables.$element-text-primary; + font-family: inherit; + word-wrap: break-word; + white-space: pre-wrap; + } +} + +si-collapsible-panel { + inline-size: 100%; + max-inline-size: 100%; + overflow: hidden; + + ::ng-deep :has(> .loading-panel-header) { + inline-size: 100%; + padding-inline-end: map.get(variables.$spacers, 2); + } + + ::ng-deep .collapsible-header { + inline-size: 100%; + max-inline-size: 100%; + overflow: hidden; + } + + ::ng-deep .collapsible-content { + inline-size: 100%; + max-inline-size: 100%; + overflow: hidden; + } +} + +si-collapsible-panel ::ng-deep .collapsible-header:has(.loading-panel-header) .dropdown-caret { + color: variables.$element-text-disabled; +} + +.loading-panel-header { + inline-size: 100%; + min-inline-size: 75px; + margin-block-start: map.get(variables.$spacers, 1); +} diff --git a/projects/element-ng/chat-messages/si-tool-message.component.spec.ts b/projects/element-ng/chat-messages/si-tool-message.component.spec.ts new file mode 100644 index 000000000..dc21c788d --- /dev/null +++ b/projects/element-ng/chat-messages/si-tool-message.component.spec.ts @@ -0,0 +1,272 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { DebugElement, provideZonelessChangeDetection, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { SiToolMessageComponent as TestComponent } from './si-tool-message.component'; + +describe('SiToolMessageComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let component: TestComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [provideNoopAnimations(), provideZonelessChangeDetection()] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement; + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have default empty name', () => { + fixture.detectChanges(); + expect(component.name()).toBe(''); + }); + + it('should have default loading state of false', () => { + fixture.detectChanges(); + expect(component.loading()).toBe(false); + }); + + it('should have default expandInputArguments of false', () => { + fixture.detectChanges(); + expect(component.expandInputArguments()).toBe(false); + }); + + it('should have default expandOutput of false', () => { + fixture.detectChanges(); + expect(component.expandOutput()).toBe(false); + }); + + it('should have default toolIcon of element-maintenance', () => { + fixture.detectChanges(); + expect(component.toolIcon()).toBe('element-maintenance'); + }); + + it('should render tool name', () => { + const toolName = 'Calculator'; + fixture.componentRef.setInput('name', toolName); + fixture.detectChanges(); + + const nameElement = debugElement.query(By.css('.tool-name')); + expect(nameElement.nativeElement.textContent).toContain(toolName); + }); + + it('should render tool icon', () => { + fixture.detectChanges(); + + const icon = debugElement.query(By.css('si-icon')); + expect(icon).toBeTruthy(); + expect(icon.componentInstance.icon()).toBe('element-maintenance'); + }); + + it('should use custom tool icon', () => { + fixture.componentRef.setInput('toolIcon', 'element-calculator'); + fixture.detectChanges(); + + const icon = debugElement.query(By.css('si-icon')); + expect(icon.componentInstance.icon()).toBe('element-calculator'); + }); + + it('should pass loading state to chat message', () => { + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + const chatMessage = debugElement.query(By.css('si-chat-message')); + // Note: loading is hardcoded to false in template, but getLoadingState() is used internally + expect(chatMessage.componentInstance.loading()).toBe(false); + }); + + it('should use start alignment for chat message', () => { + fixture.detectChanges(); + + const chatMessage = debugElement.query(By.css('si-chat-message')); + expect(chatMessage.componentInstance.alignment()).toBe('start'); + }); + + it('should not render input arguments section when no input arguments', () => { + fixture.componentRef.setInput('inputArguments', undefined); + fixture.detectChanges(); + + const panels = debugElement.queryAll(By.css('si-collapsible-panel')); + expect(panels.length).toBe(0); + }); + + it('should render input arguments section when input arguments provided', () => { + fixture.componentRef.setInput('inputArguments', '{"x": 5, "y": 10}'); + fixture.detectChanges(); + + const inputPanel = debugElement.query(By.css('si-collapsible-panel')); + expect(inputPanel).toBeTruthy(); + }); + + it('should format string input arguments', () => { + const inputArgs = '{"x": 5, "y": 10}'; + fixture.componentRef.setInput('inputArguments', inputArgs); + fixture.detectChanges(); + + expect((component as any).formatData(inputArgs)).toBe(inputArgs); + }); + + it('should format object input arguments as JSON', () => { + const inputArgs = { x: 5, y: 10 }; + fixture.componentRef.setInput('inputArguments', inputArgs); + fixture.detectChanges(); + + const formatted = (component as any).formatData(inputArgs); + expect(formatted).toContain('"x": 5'); + expect(formatted).toContain('"y": 10'); + }); + + it('should not render output section when no output', () => { + fixture.componentRef.setInput('output', undefined); + fixture.detectChanges(); + + const outputPanels = debugElement.queryAll(By.css('si-collapsible-panel')); + expect(outputPanels.length).toBe(0); + }); + + it('should render output section when output provided', () => { + fixture.componentRef.setInput('output', '{"result": 15}'); + fixture.detectChanges(); + + const outputPanel = debugElement.query(By.css('si-collapsible-panel')); + expect(outputPanel).toBeTruthy(); + }); + + it('should format string output', () => { + const output = '{"result": 15}'; + fixture.componentRef.setInput('output', output); + fixture.detectChanges(); + + expect((component as any).formatData(output)).toBe(output); + }); + + it('should format object output as JSON', () => { + const output = { result: 15 }; + fixture.componentRef.setInput('output', output); + fixture.detectChanges(); + + const formatted = (component as any).formatData(output); + expect(formatted).toContain('"result": 15'); + }); + + it('should handle signal output', () => { + const outputSignal = signal('{"result": 20}'); + fixture.componentRef.setInput('output', outputSignal); + fixture.detectChanges(); + + expect((component as any).hasOutput()).toBe(true); + }); + + it('should expand input arguments when expandInputArguments is true', () => { + fixture.componentRef.setInput('inputArguments', '{"x": 5}'); + fixture.componentRef.setInput('expandInputArguments', true); + fixture.detectChanges(); + + const inputPanel = debugElement.query(By.css('si-collapsible-panel')); + expect(inputPanel.componentInstance.opened()).toBe(true); + }); + + it('should collapse input arguments when expandInputArguments is false', () => { + fixture.componentRef.setInput('inputArguments', '{"x": 5}'); + fixture.componentRef.setInput('expandInputArguments', false); + fixture.detectChanges(); + + const inputPanel = debugElement.query(By.css('si-collapsible-panel')); + expect(inputPanel.componentInstance.opened()).toBe(false); + }); + + it('should expand output when expandOutput is true', () => { + fixture.componentRef.setInput('output', '{"result": 15}'); + fixture.componentRef.setInput('expandOutput', true); + fixture.detectChanges(); + + const panels = debugElement.queryAll(By.css('si-collapsible-panel')); + const outputPanel = panels[panels.length - 1]; // Get last panel (output panel) + expect(outputPanel.componentInstance.opened()).toBe(true); + }); + + it('should collapse output when expandOutput is false', () => { + fixture.componentRef.setInput('output', '{"result": 15}'); + fixture.componentRef.setInput('expandOutput', false); + fixture.detectChanges(); + + const panels = debugElement.queryAll(By.css('si-collapsible-panel')); + const outputPanel = panels[panels.length - 1]; // Get last panel (output panel) + expect(outputPanel.componentInstance.opened()).toBe(false); + }); + + it('should use custom inputArgumentsLabel', () => { + const customLabel = 'Custom Input'; + fixture.componentRef.setInput('inputArguments', '{"x": 5}'); + fixture.componentRef.setInput('inputArgumentsLabel', customLabel); + fixture.detectChanges(); + + const inputPanel = debugElement.query(By.css('si-collapsible-panel')); + expect(inputPanel.componentInstance.heading()).toBe(customLabel); + }); + + it('should use custom outputLabel', () => { + const customLabel = 'Custom Output'; + fixture.componentRef.setInput('output', '{"result": 15}'); + fixture.componentRef.setInput('outputLabel', customLabel); + fixture.detectChanges(); + + const panels = debugElement.queryAll(By.css('si-collapsible-panel')); + const outputPanel = panels[panels.length - 1]; // Get last panel (output panel) + expect(outputPanel.componentInstance.heading()).toBe(customLabel); + }); + + it('should handle null input arguments', () => { + fixture.componentRef.setInput('inputArguments', null); + fixture.detectChanges(); + + expect((component as any).hasInputArguments()).toBe(false); + }); + + it('should handle null output', () => { + fixture.componentRef.setInput('output', null); + fixture.detectChanges(); + + expect((component as any).hasOutput()).toBe(false); + }); + + it('should handle empty string in formatData', () => { + expect((component as any).formatData('')).toBe(''); + }); + + it('should handle undefined in formatData', () => { + expect((component as any).formatData(undefined)).toBe(''); + }); + + it('should have si-tool-message host class', () => { + fixture.detectChanges(); + expect(debugElement.nativeElement.classList.contains('si-tool-message')).toBe(true); + }); + + it('should show loading state correctly', () => { + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + expect((component as any).getLoadingState()).toBe(true); + }); + + it('should not show loading when loading is false', () => { + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + + expect((component as any).getLoadingState()).toBe(false); + }); +}); diff --git a/projects/element-ng/chat-messages/si-tool-message.component.ts b/projects/element-ng/chat-messages/si-tool-message.component.ts new file mode 100644 index 000000000..ca079e006 --- /dev/null +++ b/projects/element-ng/chat-messages/si-tool-message.component.ts @@ -0,0 +1,148 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, input, Signal, booleanAttribute } from '@angular/core'; +import { SiCollapsiblePanelComponent } from '@siemens/element-ng/accordion'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { TranslatableString, t } from '@siemens/element-translate-ng/translate'; + +import { SiChatMessageComponent } from './si-chat-message.component'; + +/** + * Tool message component for displaying AI tool calls in conversational interfaces. + * + * The tool message component renders AI tool call information in chat interfaces, + * supporting input/output display with an icon, loading states, and collapsible sections. + * It appears as text (no bubble) aligned to the left side with and icon always shown. + * Can be used within {@link SiChatContainerComponent}. + * + * The component automatically handles: + * - Styling for tool messages distinct from user, AI and generic chat messages + * - Displaying tool name, icon, input arguments, and output result + * - Showing loading states with skeleton UI during generation + * + * @see {@link SiChatMessageComponent} for the base message wrapper component + * @see {@link SiAiMessageComponent} for the AI message component + * @see {@link SiUserMessageComponent} for the user message component + * @see {@link SiChatContainerComponent} for the base chat container to use this within + * @see {@link SiAiChatContainerComponent} for the AI chat container which uses this component + * + * @experimental + */ +@Component({ + selector: 'si-tool-message', + imports: [SiChatMessageComponent, SiIconComponent, SiCollapsiblePanelComponent], + templateUrl: './si-tool-message.component.html', + styleUrl: './si-tool-message.component.scss', + host: { + class: 'si-tool-message' + } +}) +export class SiToolMessageComponent { + /** + * The tool name/title + * @defaultValue '' + */ + readonly name = input(''); + + /** + * Input arguments for the tool call (optional, JSON string or object) + * @defaultValue undefined + */ + readonly inputArguments = input(); + + /** + * Output result from the tool call (optional, JSON string or object) + * @defaultValue undefined + */ + readonly output = input(); + + /** + * Whether to expand the input arguments section by default + * @defaultValue false + */ + readonly expandInputArguments = input(false, { transform: booleanAttribute }); + + /** + * Whether to expand the output section by default + * @defaultValue false + */ + readonly expandOutput = input(false, { transform: booleanAttribute }); + + /** + * Whether the tool call is currently executing (shows skeleton) + * @defaultValue false + */ + readonly loading = input(false, { transform: booleanAttribute }); + + /** + * Custom tool icon name + * @defaultValue 'element-maintenance' + */ + readonly toolIcon = input('element-maintenance'); + + /** + * Label for the input arguments section + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_TOOL_MESSAGE.INPUT_ARGUMENTS:Input Arguments`) + * ``` + */ + readonly inputArgumentsLabel = input( + t(() => $localize`:@@SI_TOOL_MESSAGE.INPUT_ARGUMENTS:Input Arguments`) + ); + + /** + * Label for the output section + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_TOOL_MESSAGE.OUTPUT:Output`) + * ``` + */ + readonly outputLabel = input( + t(() => $localize`:@@SI_TOOL_MESSAGE.OUTPUT:Output`) + ); + + protected formatData(data: string | object | Signal | undefined): string { + if (data === undefined || data === null) return ''; + // Unwrap signal if provided + const unwrappedData = + typeof data === 'function' && 'call' in data ? (data as Signal)() : data; + if (unwrappedData === undefined || unwrappedData === null) return ''; + if (typeof unwrappedData === 'string') return unwrappedData; + try { + return JSON.stringify(unwrappedData, null, 2); + } catch { + return String(unwrappedData); + } + } + + protected hasInputArguments(): boolean { + return this.inputArguments() !== undefined && this.inputArguments() !== null; + } + + protected hasOutput(): boolean { + const outputValue = this.output(); + return outputValue !== undefined && outputValue !== null; + } + + protected getLoadingState(): boolean { + const loadingValue = this.loading(); + + // If explicitly loading + if (loadingValue) { + return true; + } + + // If output is empty string (not undefined), show loading + return false; + } + + protected getOutputValue(): string | object | undefined { + const outputValue = this.output(); + return outputValue as string | object | undefined; + } +} diff --git a/projects/element-ng/chat-messages/si-user-message.component.spec.ts b/projects/element-ng/chat-messages/si-user-message.component.spec.ts index 5a4ab41ae..1f9f71b48 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.spec.ts @@ -2,13 +2,13 @@ * Copyright (c) Siemens 2016 - 2025 * SPDX-License-Identifier: MIT */ +import { DOCUMENT } from '@angular/common'; import { DebugElement } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By, DomSanitizer } from '@angular/platform-browser'; import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; -import { MessageAction } from './message-action.model'; -import { Attachment } from './si-attachment-list.component'; +import { Attachment, MessageAction } from './chat-message.model'; import { SiUserMessageComponent as TestComponent } from './si-user-message.component'; describe('SiUserMessageComponent', () => { @@ -20,7 +20,8 @@ describe('SiUserMessageComponent', () => { fixture = TestBed.createComponent(TestComponent); debugElement = fixture.debugElement; const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + const doc = TestBed.inject(DOCUMENT); + markdownRenderer = getMarkdownRenderer(sanitizer, undefined, doc, true); }); it('should render markdown content', () => { diff --git a/projects/element-ng/chat-messages/si-user-message.component.ts b/projects/element-ng/chat-messages/si-user-message.component.ts index 3c45acd66..6660e6b86 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.ts @@ -8,8 +8,8 @@ import { SiIconComponent } from '@siemens/element-ng/icon'; import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; -import { MessageAction } from './message-action.model'; -import { Attachment, SiAttachmentListComponent } from './si-attachment-list.component'; +import { Attachment, MessageAction } from './chat-message.model'; +import { SiAttachmentListComponent } from './si-attachment-list.component'; import { SiChatMessageActionDirective } from './si-chat-message-action.directive'; import { SiChatMessageComponent } from './si-chat-message.component'; @@ -32,6 +32,7 @@ import { SiChatMessageComponent } from './si-chat-message.component'; * @see {@link SiAttachmentListComponent} for the base attachment component * @see {@link getMarkdownRenderer} for markdown formatting support * @see {@link SiChatContainerComponent} for the chat container to use this within + * @see {@link SiAiChatContainerComponent} for the AI chat container which uses this component * * @experimental */ diff --git a/projects/element-ng/link/si-link.directive.ts b/projects/element-ng/link/si-link.directive.ts index ec01d90a2..46e52960b 100644 --- a/projects/element-ng/link/si-link.directive.ts +++ b/projects/element-ng/link/si-link.directive.ts @@ -2,7 +2,6 @@ * Copyright (c) Siemens 2016 - 2025 * SPDX-License-Identifier: MIT */ -/* eslint-disable @angular-eslint/no-conflicting-lifecycle */ import { LocationStrategy } from '@angular/common'; import { booleanAttribute, diff --git a/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts new file mode 100644 index 000000000..98d2ba0a6 --- /dev/null +++ b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { SecurityContext } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; + +/** + * Sanitizes HTML while preserving inline style attributes. + * Uses class-based placeholders to preserve styles through sanitization. + * This prevents style attributes from being stripped while maintaining security. + */ +export const sanitizeHtmlWithStyles = (html: string, sanitizer: DomSanitizer): string | null => { + const styleMap = new Map(); + let counter = 0; + const styleId = `STYLE-PH-${Math.random().toString(36).substring(2, 15)}`; + + // Step 1: Extract all style attributes and replace with placeholder classes + let processed = html; + + // Match any element with a style attribute + processed = processed.replace( + /(<[a-z][a-z0-9]*[^>]*?)\s+style="([^"]*)"/gi, + (match, tagStart, styleContent) => { + const placeholder = `${styleId}-${counter++}`; + styleMap.set(placeholder, styleContent); + + // Check if tag already has a class attribute + const hasClass = /\sclass="[^"]*"/.test(tagStart); + + if (hasClass) { + // Add placeholder to existing class attribute + return tagStart.replace(/(\sclass=")([^"]*)"/i, `$1$2 ${placeholder}"`); + } else { + // Add new class attribute with placeholder + return `${tagStart} class="${placeholder}"`; + } + } + ); + + // Step 2: Remove any remaining style attributes (safety measure) + processed = processed.replace(/\s+style="[^"]*"/gi, ''); + + // Step 3: Sanitize the HTML structure (now without style attributes) + const sanitized = sanitizer.sanitize(SecurityContext.HTML, processed); + if (!sanitized) { + return null; + } + + // Step 4: Restore style attributes from placeholders + let restored = sanitized; + styleMap.forEach((styleContent, placeholder) => { + // Sanitize individual style content + const sanitizedStyle = sanitizer.sanitize(SecurityContext.STYLE, styleContent); + if (sanitizedStyle) { + // Escape the sanitized style to prevent injection + const escapedStyle = sanitizedStyle + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + + // Find and replace the placeholder in class attributes + restored = restored.replace(new RegExp(`class="([^"]*)"`, 'g'), (match, classContent) => { + if (classContent.includes(placeholder)) { + // Remove the placeholder from class list + const remainingClasses = classContent + .split(/\s+/) + .filter((cls: string) => cls && cls !== placeholder) + .join(' ') + .trim(); + + // Add style attribute and keep remaining classes if any + if (remainingClasses) { + return `class="${remainingClasses}" style="${escapedStyle}"`; + } + return `style="${escapedStyle}"`; + } + return match; + }); + } + }); + + return restored; +}; + +/** + * Gets a cached HTML element or creates a new one if not in cache. + * Implements LRU caching strategy. + */ +export const getCachedOrCreateElement = ( + cache: Map, + cacheOrder: string[], + cacheSize: number, + key: string, + createHtml: () => string, + doc: Document +): HTMLElement => { + const cached = cache.get(key); + if (cached) { + const orderIndex = cacheOrder.indexOf(key); + if (orderIndex > -1) { + cacheOrder.splice(orderIndex, 1); + } + cacheOrder.push(key); + return cached; + } + + const tempDiv = doc.createElement('div'); + tempDiv.innerHTML = createHtml(); + const element = tempDiv.firstElementChild as HTMLElement; + + cache.set(key, element); + cacheOrder.push(key); + + if (cacheOrder.length > cacheSize) { + const oldestKey = cacheOrder.shift(); + if (oldestKey) { + cache.delete(oldestKey); + } + } + + return element; +}; + +/** + * Gets a cached string or creates a new one if not in cache. + * Implements LRU caching strategy. + */ +export const getCachedOrCreateString = ( + cache: Map, + cacheOrder: string[], + cacheSize: number, + key: string, + createString: () => string +): string => { + const cached = cache.get(key); + if (cached) { + const orderIndex = cacheOrder.indexOf(key); + if (orderIndex > -1) { + cacheOrder.splice(orderIndex, 1); + } + cacheOrder.push(key); + return cached; + } + + const result = createString(); + + cache.set(key, result); + cacheOrder.push(key); + + if (cacheOrder.length > cacheSize) { + const oldestKey = cacheOrder.shift(); + if (oldestKey) { + cache.delete(oldestKey); + } + } + + return result; +}; diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index 1f072426d..fe6db8fb9 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -4,260 +4,944 @@ */ import { SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { type SiTranslateService } from '@siemens/element-translate-ng/translate'; +import { type TranslatableString } from '@siemens/element-translate-ng/translate-types'; + +import { + sanitizeHtmlWithStyles, + getCachedOrCreateString, + getCachedOrCreateElement +} from './markdown-renderer-helpers'; + +const CACHE_SIZE = 100; + +export interface MarkdownRendererOptions { + /** + * Provide this to enable the copy code button functionality. + * Label for the copy code button (will be translated internally if translateService is provided). + */ + copyCodeButton?: TranslatableString; + + /** + * Provide this to enable the download table button functionality. + * Label for the download table button (will be translated internally if translateService is provided). + */ + downloadTableButton?: TranslatableString; + + /** + * Optional syntax highlighter function for code blocks. + * Receives code content and optional language, returns highlighted HTML or undefined. + */ + syntaxHighlighter?: (code: string, language?: string) => string | undefined; + + /** + * Optional LaTeX renderer function. + * Receives LaTeX content and display mode, returns rendered HTML or undefined. + */ + latexRenderer?: (latex: string, displayMode: boolean) => string | undefined; + + /** + * Synchronous translation function for button labels. + */ + translateSync?: SiTranslateService['translateSync']; +} + +interface ProcessOptions { + allowCodeBlocks?: boolean; + allowBlockquotes?: boolean; + allowTables?: boolean; + allowLatex?: boolean; + allowInlineCode?: boolean; + allowLinks?: boolean; +} /** - * Returns a markdown renderer function which_ - * - Transforms markdown text into formatted HTML. - * - Returns a DOM node containing the formatted content. + * Returns a function that transforms markdown text into a formatted HTML node. * - * **Warning:** The returned Node is inserted without additional sanitization. - * Input content is sanitized before processing. + * **Important for SSR**: When using this function in an SSR context, you must provide the `doc` and `isBrowser` parameters. + * Call this within an Angular injection context and pass `inject(DOCUMENT)` and `isPlatformBrowser(inject(PLATFORM_ID))`. * * @experimental * @param sanitizer - Angular DomSanitizer instance + * @param options - Optional configuration for the markdown renderer + * @param doc - Document instance (optional for browser-only apps, required for SSR - pass inject(DOCUMENT)) + * @param isBrowser - Whether running in browser (optional for browser-only apps, required for SSR - pass isPlatformBrowser(inject(PLATFORM_ID))) * @returns A function taking the markdown text to transform and returning a DOM div element containing the formatted HTML */ -export const getMarkdownRenderer = (sanitizer: DomSanitizer): ((text: string) => Node) => { - return (text: string): Node => { - const div = document.createElement('div'); - div.className = 'markdown-content text-break'; +export const getMarkdownRenderer = ( + sanitizer: DomSanitizer, + options?: MarkdownRendererOptions, + doc?: Document, + isBrowser?: boolean +): ((text: string) => Node) => { + // Use provided document or fall back to global document for backwards compatibility + const docRef = doc ?? document; + const isInBrowser = isBrowser ?? true; + + // Persistent caches within this renderer instance + const codeBlockCache = new Map(); + const tableCache = new Map(); + const latexCache = new Map(); + const codeBlockCacheOrder: string[] = []; + const tableCacheOrder: string[] = []; + const latexCacheOrder: string[] = []; + + // Store table data for CSV export + const tableCellData = new Map>>(); + let tableCounter = 0; + + // Placeholder maps for cached elements + const codeBlockPlaceholderMap = new Map(); + const tablePlaceholderMap = new Map(); + + // Placeholder maps for inline elements + const inlineLatexPlaceholderMap = new Map(); + const linkPlaceholderMap = new Map(); + + /** + * Main recursive processing function + */ + const processMarkdown = ( + text: string, + processOpts: ProcessOptions = { + allowCodeBlocks: true, + allowBlockquotes: true, + allowTables: true, + allowLatex: true, + allowInlineCode: true, + allowLinks: true + } + ): string => { + let result = text; + + // Step 1: Extract and process code blocks (4+ backticks for nested markdown) + if (processOpts.allowCodeBlocks) { + const codeBlockMap = new Map(); + + // Match code blocks with 4 or more backticks (for displaying nested code blocks) + // Only matches at line start (after optional whitespace), not after > prefix + result = result.replace( + /(^|\n)([\s]*)(````+)([^\n]*)\n?([\s\S]*?)\n?\s*\3/gm, + (match, prefix, indent, backticks, language, content) => { + const placeholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + // Don't process content as markdown - keep it as plain text for code display + const cacheKey = createCodeBlockCacheKey(language.trim(), content, false); + codeBlockPlaceholderMap.set(placeholder, cacheKey); + codeBlockMap.set(placeholder, ``); + return prefix + indent + placeholder; + } + ); + + // Match standard code blocks (3 backticks) + // Only matches at line start (after optional whitespace), not after > prefix + // Add temporary closing marker for incomplete code blocks during streaming + const tempResult = result + '\n```--TEMP-CLOSE--\n'; + result = tempResult.replace( + /(^|\n)([\s]*)(```)([^\n]*)\n?([\s\S]*?)(?:\n\s*```|```$)/gm, + (match, prefix, indent, backticks, language, content) => { + // Skip the temp closing marker + if (content.includes('--TEMP-CLOSE--')) { + return match; + } + const placeholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + const cacheKey = createCodeBlockCacheKey(language.trim(), content, false); + codeBlockPlaceholderMap.set(placeholder, cacheKey); + codeBlockMap.set(placeholder, ``); + return prefix + indent + placeholder; + } + ); + // Remove temp closing marker + result = result.replace(/\n```--TEMP-CLOSE--\n/g, '').replace(/--TEMP-CLOSE--/g, ''); + + // Restore code block placeholders + codeBlockMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + } - if (!text) { - return div; + // Step 2: Extract and process blockquotes (can contain code blocks and inline elements) + if (processOpts.allowBlockquotes) { + const blockquoteMap = new Map(); + const lines = result.split('\n'); + let i = 0; + + while (i < lines.length) { + if (lines[i].match(/^\s*>/)) { + const blockquoteLines: string[] = []; + while (i < lines.length && lines[i].match(/^\s*>/)) { + blockquoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + + const blockquoteContent = blockquoteLines.join('\n'); + const processedContent = processMarkdown(blockquoteContent, { + ...processOpts, + allowBlockquotes: false, // Prevent nested blockquotes for simplicity + allowTables: false + }); + + const placeholder = `--BLOCKQUOTE-${Math.random().toString(36).substring(2, 15)}--`; + blockquoteMap.set(placeholder, `
${processedContent}
`); + lines.splice(i - blockquoteLines.length, blockquoteLines.length, placeholder); + i = i - blockquoteLines.length + 1; + } else { + i++; + } + } + + result = lines.join('\n'); + + // Restore blockquotes + blockquoteMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); } - // Generate a random placeholder for newlines to preserve them during HTML sanitization - const newlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; - - // Replace newlines with placeholder before sanitization - const valueWithPlaceholders = text.replace(/\n/g, newlinePlaceholder); - - // Sanitize the input using Angular's HTML sanitizer - const sanitizedInput = sanitizer.sanitize(SecurityContext.HTML, valueWithPlaceholders) ?? ''; - - // Restore newlines from placeholder for markdown processing. - let html = sanitizedInput.replace(new RegExp(newlinePlaceholder, 'g'), '\n'); - - // Process tables first - html = html - // Remove table separator lines first - .replace(/^\|\s*[-:]+.*\|\s*$/gm, '') - // Process table rows - .replace(/^\|(.+)\|\s*$/gm, (_match, htmlContent) => { - // Handle escaped pipes by temporarily replacing them - const escapedPipePlaceholder = `--ESCAPED-PIPE-${Math.random().toString(36).substring(2, 15)}--`; - const contentWithPlaceholders = htmlContent.replace(/\\\|/g, escapedPipePlaceholder); - const cells = contentWithPlaceholders.split('|').map((cell: string) => { - const trimmedCell = cell.trim(); - // Restore escaped pipes - const cellWithPipes = trimmedCell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|'); - - return cellWithPipes; - }); - // Make cell ready for markdown processing by replacing code blocks with inline code and
with newlines - const cellsWithNewlines = cells.map((cell: string) => { - // Replace multiline code blocks with single line code blocks - const cellWithoutMultilineCode = cell.replace( - /```([\s\S]*?)```/g, - (_innerMatch, inlineCodeContent) => { - return '`' + inlineCodeContent.replace(/`/g, '') + '`'; - } - ); - // Temporarily replace single line code blocks to avoid replacing
inside them - const tableInlineCodeBrPlaceholder = `--INLINE-CODE-BR--${Math.random().toString(36).substring(2, 15)}--`; - const cellWithPlaceholders = cellWithoutMultilineCode.replace( - /(`[^`]*`)/g, - inlineCodeMatch => { - return inlineCodeMatch.replace(/
/g, tableInlineCodeBrPlaceholder); + // Step 3: Extract and process tables (can contain inline code and inline latex) + if (processOpts.allowTables) { + result = processTables(result, processOpts); + } + + // Step 4: Extract and process display LaTeX (multi-line formulas) + if (processOpts.allowLatex) { + const latexDisplayMap = new Map(); + + result = result.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex) => { + const placeholder = `--LATEX-DISPLAY-${Math.random().toString(36).substring(2, 15)}--`; + const rendered = renderLatex(latex.trim(), true); + latexDisplayMap.set(placeholder, rendered); + return placeholder; + }); + + // Restore display LaTeX + latexDisplayMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + } + + // Step 5: Extract and process inline code (must be before inline LaTeX) + if (processOpts.allowInlineCode) { + const inlineCodeMap = new Map(); + + // Handle escaped backticks properly (match odd number of backslashes before backtick) + result = result.replace(/(? { + const placeholder = `--INLINE-CODE-${Math.random().toString(36).substring(2, 15)}--`; + const escaped = content.replace(//g, '>'); + inlineCodeMap.set(placeholder, `${escaped}`); + return placeholder; + }); + + // Restore inline code + inlineCodeMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + } + + // Step 6: Extract and process inline LaTeX (keep as placeholder to protect from line breaks) + if (processOpts.allowLatex) { + // Escape dollar signs (handle properly with even number of backslashes) + result = result.replace(/(? { + const placeholder = `--LATEX-INLINE-${Math.random().toString(36).substring(2, 15)}--`; + inlineLatexPlaceholderMap.set(placeholder, latex.trim()); + return placeholder; + }); + + // Restore escaped dollar signs + result = result.replace(/___ESCAPED_DOLLAR___/g, '$'); + } + + // Step 7: Process links (both formats, can contain inline code) + if (processOpts.allowLinks) { + result = result.replace(/<(https?:\/\/[^\s>]+)>/g, (match, url) => { + const sanitizedUrl = sanitizeUrl(url, sanitizer); + return `${escapeHtml(url)}`; + }); + + // Images: ![alt](url) + result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + const sanitizedUrl = sanitizeUrl(url, sanitizer); + const escapedAlt = escapeHtml(alt); + return `${escapedAlt}`; + }); + + // Links: [text](url) - keep as placeholder to protect from line breaks + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { + const placeholder = `--LINK-${Math.random().toString(36).substring(2, 15)}--`; + linkPlaceholderMap.set(placeholder, { text: linkText, url }); + return placeholder; + }); + + // Auto-detect URLs + result = result.replace(/(? { + const sanitizedUrl = sanitizeUrl(match, sanitizer); + return `${escapeHtml(match)}`; + }); + } + + // Step 8: Process inline formatting only on text segments, not on block elements + result = processTextSegments(result, sanitizer); + + return result; + }; + + /** + * Create cache key for code block + */ + const createCodeBlockCacheKey = ( + language: string, + content: string, + isMarkdown: boolean + ): string => { + return `${language}|||${content}|||${options?.copyCodeButton ?? ''}|||${isMarkdown}`; + }; + + /** + * Create a code block element with optional syntax highlighting (cached) + */ + const createCodeBlockElement = ( + language: string, + content: string, + isMarkdown: boolean + ): HTMLElement => { + const cacheKey = createCodeBlockCacheKey(language, content, isMarkdown); + + return getCachedOrCreateElement( + codeBlockCache, + codeBlockCacheOrder, + CACHE_SIZE, + cacheKey, + () => { + let displayContent = content; + + if (!isMarkdown) { + // Escape HTML for regular code blocks + displayContent = content.replace(//g, '>'); + + // Apply syntax highlighting if available + if (options?.syntaxHighlighter && language) { + const highlighted = options.syntaxHighlighter(content, language); + if (highlighted) { + displayContent = highlighted; } + } + } + + // Sanitize the display content + const sanitized = sanitizer.sanitize(SecurityContext.HTML, displayContent) ?? ''; + + const codeId = `code-${Math.random().toString(36).substring(2, 15)}`; + + // Create copy button if enabled + let copyButton = ''; + if (options?.copyCodeButton) { + const translatedLabel = options.translateSync + ? options.translateSync(options.copyCodeButton) + : options.copyCodeButton; + const buttonLabel = escapeHtml(translatedLabel); + copyButton = ``; + } + + const languageLabel = language + ? `${escapeHtml(language)}` + : ''; + const headerContent = + copyButton || languageLabel + ? `
${languageLabel}${copyButton}
` + : ''; + const wrapperClass = headerContent ? 'code-wrapper has-header' : 'code-wrapper'; + return `
${headerContent}
${sanitized}
`; + }, + docRef + ); + }; + + /** + * Render LaTeX formula + */ + const renderLatex = (latex: string, displayMode: boolean): string => { + const cacheKey = `${displayMode ? 'display' : 'inline'}|||${latex}`; + + return getCachedOrCreateString(latexCache, latexCacheOrder, CACHE_SIZE, cacheKey, () => { + if (!options?.latexRenderer) { + return escapeHtml(displayMode ? `$$${latex}$$` : `$${latex}$`); + } + + let rendered: string | undefined; + try { + rendered = options.latexRenderer(latex, displayMode); + } catch { + return escapeHtml(displayMode ? `$$${latex}$$` : `$${latex}$`); + } + + if (!rendered) { + return escapeHtml(displayMode ? `$$${latex}$$` : `$${latex}$`); + } + + // Preserve HTML entities through sanitization + const withPlaceholders = rendered + .replace(/&/g, '') + .replace(/</g, '') + .replace(/>/g, '') + .replace(/"/g, ''); + + const sanitized = sanitizeHtmlWithStyles(withPlaceholders, sanitizer); + if (!sanitized) { + return escapeHtml(displayMode ? `$$${latex}$$` : `$${latex}$`); + } + + // Restore HTML entities + const restored = sanitized + .replace(//g, '&') + .replace(//g, '<') + .replace(//g, '>') + .replace(//g, '"'); + + return displayMode ? `
${restored}
` : restored; + }); + }; + + /** + * Process tables + */ + const processTables = (input: string, processOpts: ProcessOptions): string => { + const lines = input.split('\n'); + const tableMap = new Map(); + let i = 0; + + while (i < lines.length) { + // Check if line looks like a table row (starts with |, may or may not end with |) + if (lines[i].match(/^\|.+/)) { + const tableLines: string[] = []; + let hasSeparator = false; + + // Collect table lines + while (i < lines.length && lines[i].match(/^\|.+/)) { + const line = lines[i]; + if (line.match(/^\|\s*[-:]+/)) { + hasSeparator = true; + } else { + tableLines.push(line); + } + i++; + } + + if (tableLines.length > 0) { + const currentTableIndex = tableCounter++; + const placeholder = `--TABLE-${Math.random().toString(36).substring(2, 15)}--`; + // Store table index and metadata in placeholder map + tablePlaceholderMap.set( + placeholder, + JSON.stringify({ + tableIndex: currentTableIndex, + hasSeparator, + tableLines + }) ); - // Replace
with newlines - const cellWithNewlines = cellWithPlaceholders.replace(//gi, '\n'); - // Restore
in inline code placeholders - const preProcessedCell = cellWithNewlines.replace( - new RegExp(tableInlineCodeBrPlaceholder, 'g'), - '
' + tableMap.set(placeholder, ``); + + // Initialize table data map for CSV export + tableCellData.set(currentTableIndex, new Map()); + + // Replace table lines with placeholder + lines.splice( + i - tableLines.length - (hasSeparator ? 1 : 0), + tableLines.length + (hasSeparator ? 1 : 0), + placeholder ); - return preProcessedCell; + i = i - tableLines.length - (hasSeparator ? 1 : 0) + 1; + } + } else { + i++; + } + } + + let result = lines.join('\n'); + + // Restore tables + tableMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + + return result; + }; + + /** + * Create cache key for table + */ + const createTableCacheKey = ( + tableLines: string[], + hasSeparator: boolean, + tableIndex: number + ): string => { + return `${tableLines.join('|||')}|||${hasSeparator}|||${tableIndex}|||${options?.downloadTableButton ?? ''}`; + }; + + /** + * Create table HTML element (cached) + */ + const createTableElement = ( + tableLines: string[], + hasSeparator: boolean, + tableIndex: number, + processOpts: ProcessOptions + ): HTMLElement => { + const cacheKey = createTableCacheKey(tableLines, hasSeparator, tableIndex); + + return getCachedOrCreateElement( + tableCache, + tableCacheOrder, + CACHE_SIZE, + cacheKey, + () => { + const rows: string[] = []; + const cellData = tableCellData.get(tableIndex); + + tableLines.forEach((line, rowIndex) => { + if (!line.trim()) { + return; + } + + const escapedPipePlaceholder = `___ESCAPED_PIPE___${Math.random().toString(36).substring(2, 15)}___`; + const contentWithPlaceholders = line.replace(/\\\|/g, escapedPipePlaceholder); + const parts = contentWithPlaceholders.split('|'); + const cells = parts.slice(1, -1); + + const processedCells = cells.map((cell, cellIndex) => { + // Restore escaped pipes and process cell content + let originalCell = cell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|').trim(); + + // Extract inline code to protect
tags within code + const inlineCodeMap = new Map(); + originalCell = originalCell.replace(/(? { + const placeholder = `___INLINE_CODE_${Math.random().toString(36).substring(2, 15)}___`; + inlineCodeMap.set(placeholder, match); + return placeholder; + }); + + // Convert
tags to newlines for table cells (outside of code) + originalCell = originalCell.replace(//gi, '\n'); + + // Restore inline code + inlineCodeMap.forEach((code, placeholder) => { + originalCell = originalCell.replace(placeholder, code); + }); + + // Store in cellData if available + if (cellData) { + const rowData = cellData.get(rowIndex) ?? new Map(); + rowData.set(cellIndex, originalCell); + cellData.set(rowIndex, rowData); + } + + // Process cell content for inline elements + const processedContent = processMarkdown(originalCell, { + allowCodeBlocks: false, + allowBlockquotes: false, + allowTables: false, + allowLatex: true, + allowInlineCode: true, + allowLinks: true + }); + + return processedContent; + }); + + const isHeader = hasSeparator && rowIndex === 0; + const tag = isHeader ? 'th' : 'td'; + const rowHtml = `${processedCells.map(cell => `<${tag}>${cell}`).join('')}`; + rows.push(rowHtml); }); - // Recursively process cell content for markdown formatting - const processedCells = cellsWithNewlines.map((cell: string) => { - return transformMarkdownText(cell, false, sanitizer); + // Filter out empty rows (check for text content OR innerHTML content) + const filteredRows = rows.filter(row => { + // Wrap in proper table structure for correct HTML parsing + const tempTable = docRef.createElement('table'); + tempTable.innerHTML = `${row}`; + const cells = tempTable.querySelectorAll('td, th'); + // Check if any cell has content + return Array.from(cells).some(cell => { + const hasText = !!cell.textContent?.trim(); + const hasHtml = !!cell.innerHTML?.trim(); + return hasText || hasHtml; + }); }); - return `${processedCells.map((cell: string) => `${cell}`).join('')}`; - }) - // Wrap table rows in table elements - .replace(/(.*?<\/tr>)/gs, '$1
') - // Remove duplicate table tags - .replace(/<\/table>\s*/g, ''); + if (filteredRows.length === 0) { + return '
'; + } + + const tableId = `table-${Math.random().toString(36).substring(2, 15)}`; + let tableHtml = '
'; + + if (hasSeparator && filteredRows.length > 0) { + tableHtml += '' + filteredRows[0] + ''; + if (filteredRows.length > 1) { + tableHtml += '' + filteredRows.slice(1).join('') + ''; + } + } else { + tableHtml += '' + filteredRows.join('') + ''; + } + + tableHtml += '
'; + + // Add download button if enabled + let downloadButton = ''; + if (options?.downloadTableButton) { + const translatedLabel = options.translateSync + ? options.translateSync(options.downloadTableButton) + : options.downloadTableButton; + const buttonLabel = escapeHtml(translatedLabel); + downloadButton = ``; + } + + return `
${tableHtml}
${downloadButton}
`; + }, + docRef + ); + }; - html = transformMarkdownText(html, true, sanitizer); + /** + * Process text segments separately from block elements + */ + const processTextSegments = (input: string, domSanitizer: DomSanitizer): string => { + // Split input by known block elements so we don't process text inside them + // This regex matches complete block elements with their content + const blockElementRegex = + /(<(pre|blockquote|ul|ol|hr|h[1-6]|table)[^>]*>[\s\S]*?<\/\2>)|()/g; + + const parts: string[] = []; + let lastIndex = 0; + let blockMatch; + + // Find all block elements and capture text between them + while ((blockMatch = blockElementRegex.exec(input)) !== null) { + // Add text before this block element + if (blockMatch.index > lastIndex) { + parts.push(input.slice(lastIndex, blockMatch.index)); + } + // Add the block element itself + parts.push(blockMatch[0]); + lastIndex = blockMatch.index + blockMatch[0].length; + } + // Add any remaining text after the last block element + if (lastIndex < input.length) { + parts.push(input.slice(lastIndex)); + } - div.innerHTML = html; - return div; + const processedParts = parts.map(part => { + // Keep block elements and placeholders as-is + if (part.match(/^<(pre|blockquote|ul|ol|hr|h[1-6]|table)|^