diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 624e4bd243..4e304947ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,12 @@ repos: files: ^assets/javascript/.*\.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx types: [ file ] args: [ --fix ] + - repo: https://github.com/rbubley/mirrors-prettier + rev: 'v3.8.3' + hooks: + - id: prettier + files: ^components/chat_widget/src/.*\.(ts|tsx|css)$ + types: [ file ] - repo: https://github.com/allganize/ty-pre-commit # Ty version. rev: v0.0.32 diff --git a/components/chat_widget/.prettierignore b/components/chat_widget/.prettierignore new file mode 100644 index 0000000000..8622a6466a --- /dev/null +++ b/components/chat_widget/.prettierignore @@ -0,0 +1,8 @@ +# Stencil-generated files +src/components.d.ts +src/components/**/readme.md + +# Build output +dist/ +www/ +loader/ diff --git a/components/chat_widget/.prettierrc.json b/components/chat_widget/.prettierrc.json index 7ca3a28a9c..e32b2f5e1d 100644 --- a/components/chat_widget/.prettierrc.json +++ b/components/chat_widget/.prettierrc.json @@ -1,7 +1,7 @@ { "arrowParens": "avoid", "bracketSpacing": true, - "jsxBracketSameLine": false, + "bracketSameLine": false, "jsxSingleQuote": false, "quoteProps": "consistent", "printWidth": 180, diff --git a/components/chat_widget/package-lock.json b/components/chat_widget/package-lock.json index 3b9c6bc3d6..7e02a9a530 100644 --- a/components/chat_widget/package-lock.json +++ b/components/chat_widget/package-lock.json @@ -27,6 +27,7 @@ "jest-cli": "^29.7.0", "npm-run-all": "^4.1.5", "postcss-import": "^16.1.0", + "prettier": "^3.8.3", "puppeteer": "^24.2.0", "stencil-tailwind-plugin": "^2.0.5", "tailwindcss": "^4.1.12", @@ -91,6 +92,7 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1155,6 +1157,7 @@ "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.36.3.tgz", "integrity": "sha512-C9DOaAjm+hSYRuVoUuYWG/lrYT8+4DG0AL0m1Ea9+G5v2Y6ApVpNJLbXvFlRZIdDMGecH86s6v0Gp39uockLxg==", "license": "MIT", + "peer": true, "bin": { "stencil": "bin/stencil" }, @@ -1501,6 +1504,7 @@ "integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.12", @@ -2160,6 +2164,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", @@ -2914,7 +2919,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1402036.tgz", "integrity": "sha512-JwAYQgEvm3yD45CHB+RmF5kMbWtXBaOGwuxa87sZogHcLCv8c/IqnThaoQ1y60d7pXWjSKWQphPEc+1rAScVdg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff-sequences": { "version": "29.6.3", @@ -6516,6 +6522,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7095,6 +7102,22 @@ "dev": true, "license": "MIT" }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -8044,7 +8067,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.2.3", @@ -8280,6 +8304,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/components/chat_widget/package.json b/components/chat_widget/package.json index 96a01b3bbf..814db88f45 100644 --- a/components/chat_widget/package.json +++ b/components/chat_widget/package.json @@ -30,7 +30,9 @@ "prepublishOnly": "run-s build use:npmReadme", "postpublish": "npm run use:gitReadme", "type-check": "tsc --noEmit", - "lint": "eslint --fix src" + "lint": "eslint --fix src", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"" }, "dependencies": { "@stencil/core": "^4.27.0", @@ -51,6 +53,7 @@ "jest-cli": "^29.7.0", "npm-run-all": "^4.1.5", "postcss-import": "^16.1.0", + "prettier": "^3.8.3", "puppeteer": "^24.2.0", "stencil-tailwind-plugin": "^2.0.5", "tailwindcss": "^4.1.12", diff --git a/components/chat_widget/src/components/ocs-chat/icons.tsx b/components/chat_widget/src/components/ocs-chat/icons.tsx index 110e6dd131..d87eb99dba 100644 --- a/components/chat_widget/src/components/ocs-chat/icons.tsx +++ b/components/chat_widget/src/components/ocs-chat/icons.tsx @@ -1,46 +1,41 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -import {h} from "@stencil/core"; +import { h } from '@stencil/core'; export const OcsWidgetAvatar = () => { - return - - - - - - ; -} + return ( + + + + + + + + ); +}; /** * Heroicon: x-mark */ export const XMarkIcon = () => { - return - - ; -} + return ( + + + + ); +}; export const GripDotsVerticalIcon = () => { return ( - + {/* Left column of dots */} - - - + + + {/* Right column of dots */} - - - + + + ); }; @@ -51,10 +46,10 @@ export const GripDotsVerticalIcon = () => { export const PlusWithCircleIcon = () => { return ( - + - ) -} + ); +}; /** * Heroicon: arrows-pointing-out @@ -62,11 +57,14 @@ export const PlusWithCircleIcon = () => { export const ArrowsPointingOutIcon = () => { return ( - + - ) -} + ); +}; /** * Heroicon: arrows-pointing-in @@ -74,11 +72,14 @@ export const ArrowsPointingOutIcon = () => { export const ArrowsPointingInIcon = () => { return ( - + - ) -} + ); +}; /** * Heroicon: paper-clip @@ -86,11 +87,14 @@ export const ArrowsPointingInIcon = () => { export const PaperClipIcon = () => { return ( - + - ) -} + ); +}; /** * Heroicon: document-check @@ -98,17 +102,19 @@ export const PaperClipIcon = () => { export const CheckDocumentIcon = () => { return ( - + - - ) -} + ); +}; export const XIcon = () => { return ( - + - ) -} + ); +}; diff --git a/components/chat_widget/src/components/ocs-chat/ocs-chat.css b/components/chat_widget/src/components/ocs-chat/ocs-chat.css index dbca312fc9..e7a4791f5d 100644 --- a/components/chat_widget/src/components/ocs-chat/ocs-chat.css +++ b/components/chat_widget/src/components/ocs-chat/ocs-chat.css @@ -207,28 +207,12 @@ --success-text-color: #10b981; /* Complementary green to existing blue palette */ /* Markdown code variables */ - --code-bg-user-color: color-mix( - in srgb, - var(--message-user-bg-color) 80%, - white 20% - ); + --code-bg-user-color: color-mix(in srgb, var(--message-user-bg-color) 80%, white 20%); --code-text-user-color: var(--message-user-text-color); - --code-border-user-color: color-mix( - in srgb, - var(--message-user-bg-color) 90%, - black 10% - ); - --code-bg-assistant-color: color-mix( - in srgb, - var(--message-assistant-bg-color) 50%, - white 50% - ); + --code-border-user-color: color-mix(in srgb, var(--message-user-bg-color) 90%, black 10%); + --code-bg-assistant-color: color-mix(in srgb, var(--message-assistant-bg-color) 50%, white 50%); --code-text-assistant-color: var(--message-assistant-text-color); - --code-border-assistant-color: color-mix( - in srgb, - var(--message-assistant-bg-color) 90%, - black 10% - ); + --code-border-assistant-color: color-mix(in srgb, var(--message-assistant-bg-color) 90%, black 10%); --confirmation-overlay-bg-color: rgba(0, 0, 0, 0.5); --confirmation-dialog-bg-color: var(--chat-window-bg-color); @@ -247,15 +231,9 @@ /* File upload variables */ --file-attachment-button-bg-color: transparent; - --file-attachment-button-bg-hover-color: var( - --header-button-bg-hover-color - ); /* #f3f4f6 */ - --file-attachment-button-text-color: var( - --header-button-text-color - ); /* #6b7280 */ - --file-attachment-button-text-disabled-color: var( - --send-button-text-disabled-color - ); /* #6b7280 */ + --file-attachment-button-bg-hover-color: var(--header-button-bg-hover-color); /* #f3f4f6 */ + --file-attachment-button-text-color: var(--header-button-text-color); /* #6b7280 */ + --file-attachment-button-text-disabled-color: var(--send-button-text-disabled-color); /* #6b7280 */ /* Selected files variables */ --selected-files-bg-color: var(--chat-window-bg-color); /* transparent */ @@ -266,9 +244,7 @@ /* Selected file text variables */ --selected-file-font-size: var(--chat-window-font-size-sm); - --selected-file-name-color: var( - --message-assistant-text-color - ); /* #1f2937 */ + --selected-file-name-color: var(--message-assistant-text-color); /* #1f2937 */ --selected-file-size-color: var(--input-placeholder-color); /* #6b7280 */ /* Selected file icon variables */ @@ -285,7 +261,7 @@ bottom: 30px; } - :host([mode="kiosk"]) { + :host([mode='kiosk']) { position: absolute; inset: 0; right: auto; diff --git a/components/chat_widget/src/components/ocs-chat/ocs-chat.spec.tsx b/components/chat_widget/src/components/ocs-chat/ocs-chat.spec.tsx index e99d7b866d..8c8ca3c589 100644 --- a/components/chat_widget/src/components/ocs-chat/ocs-chat.spec.tsx +++ b/components/chat_widget/src/components/ocs-chat/ocs-chat.spec.tsx @@ -14,7 +14,7 @@ describe('ocs-chat', () => { // Mock translation manager to return welcome messages component.translationManager = new TranslationManager('en', { - 'content.welcomeMessages': ['Hello from translations!', 'Welcome to our chat.'] + 'content.welcomeMessages': ['Hello from translations!', 'Welcome to our chat.'], }); // Ensure no messages exist yet @@ -69,7 +69,7 @@ describe('ocs-chat', () => { // Translation messages should override attribute messages component.translationManager = new TranslationManager('en', { - 'content.welcomeMessages': ['Translation message 1', 'Translation message 2'] + 'content.welcomeMessages': ['Translation message 1', 'Translation message 2'], }); component.messages = []; @@ -93,16 +93,18 @@ describe('ocs-chat', () => { const component = page.rootInstance as OcsChat; component.translationManager = new TranslationManager('en', { - 'content.welcomeMessages': ['Hello from translations!'] + 'content.welcomeMessages': ['Hello from translations!'], }); // Add a message to the chat - component.messages = [{ - created_at: new Date().toISOString(), - role: 'user', - content: 'Hello', - attachments: [] - }]; + component.messages = [ + { + created_at: new Date().toISOString(), + role: 'user', + content: 'Hello', + attachments: [], + }, + ]; component.sessionId = 'test-session'; await page.waitForChanges(); @@ -124,7 +126,7 @@ describe('ocs-chat', () => { // Mock translation manager to return starter questions component.translationManager = new TranslationManager('en', { - 'content.starterQuestions': ['What can you help me with?', 'How does this work?'] + 'content.starterQuestions': ['What can you help me with?', 'How does this work?'], }); component.messages = []; @@ -178,7 +180,7 @@ describe('ocs-chat', () => { // Translation questions should override attribute questions component.translationManager = new TranslationManager('en', { - 'content.starterQuestions': ['Translation question 1?', 'Translation question 2?', 'Translation question 3?'] + 'content.starterQuestions': ['Translation question 1?', 'Translation question 2?', 'Translation question 3?'], }); component.messages = []; @@ -202,16 +204,18 @@ describe('ocs-chat', () => { const component = page.rootInstance as OcsChat; component.translationManager = new TranslationManager('en', { - 'content.starterQuestions': ['Question 1?', 'Question 2?'] + 'content.starterQuestions': ['Question 1?', 'Question 2?'], }); // Add a message to the chat - component.messages = [{ - created_at: new Date().toISOString(), - role: 'user', - content: 'Hello', - attachments: [] - }]; + component.messages = [ + { + created_at: new Date().toISOString(), + role: 'user', + content: 'Hello', + attachments: [], + }, + ]; component.sessionId = 'test-session'; await page.waitForChanges(); @@ -375,9 +379,7 @@ describe('ocs-chat', () => { const component = page.rootInstance as OcsChat; component.sessionId = 'session-123'; - component.messages = [ - { created_at: new Date().toISOString(), role: 'user', content: 'Hello', attachments: [] } - ]; + component.messages = [{ created_at: new Date().toISOString(), role: 'user', content: 'Hello', attachments: [] }]; // Change chatbotId page.root!.setAttribute('chatbot-id', 'bot-2'); @@ -397,9 +399,7 @@ describe('ocs-chat', () => { const component = page.rootInstance as OcsChat; component.sessionId = 'session-123'; - component.messages = [ - { created_at: new Date().toISOString(), role: 'user', content: 'Hello', attachments: [] } - ]; + component.messages = [{ created_at: new Date().toISOString(), role: 'user', content: 'Hello', attachments: [] }]; // Change versionNumber page.root!.setAttribute('version-number', '2'); @@ -438,7 +438,7 @@ describe('ocs-chat', () => { component.translationManager = new TranslationManager('en', { 'content.welcomeMessages': ['Welcome!'], - 'content.starterQuestions': ['How can I help?'] + 'content.starterQuestions': ['How can I help?'], }); component.messages = []; diff --git a/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx b/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx index 7e37915fc9..42c3303b7d 100644 --- a/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx +++ b/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx @@ -1,23 +1,21 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -import {Component, Host, h, Prop, State, Element, Watch, Env} from '@stencil/core'; +import { Component, Host, h, Prop, State, Element, Watch, Env } from '@stencil/core'; import { XMarkIcon, - GripDotsVerticalIcon, PlusWithCircleIcon, ArrowsPointingOutIcon, ArrowsPointingInIcon, - PaperClipIcon, CheckDocumentIcon, XIcon, OcsWidgetAvatar + GripDotsVerticalIcon, + PlusWithCircleIcon, + ArrowsPointingOutIcon, + ArrowsPointingInIcon, + PaperClipIcon, + CheckDocumentIcon, + XIcon, + OcsWidgetAvatar, } from './icons'; import { renderMarkdownSync as renderMarkdownComplete } from '../../utils/markdown'; import { varToPixels } from '../../utils/utils'; -import {TranslationStrings, TranslationManager, defaultTranslations} from '../../utils/translations'; -import { - ChatSessionService, - ChatMessage, - MessagePollingHandle, - TaskPollingHandle -} from '../../services/chat-session-service'; -import { - FileAttachmentManager, - SelectedFile -} from '../../services/file-attachment-manager'; +import { TranslationStrings, TranslationManager, defaultTranslations } from '../../utils/translations'; +import { ChatSessionService, ChatMessage, MessagePollingHandle, TaskPollingHandle } from '../../services/chat-session-service'; +import { FileAttachmentManager, SelectedFile } from '../../services/file-attachment-manager'; interface PointerEvent { clientX: number; @@ -35,7 +33,6 @@ interface SessionStorageData { shadow: true, }) export class OcsChat { - private static readonly TASK_POLLING_MAX_ATTEMPTS = 120; private static readonly TASK_POLLING_INTERVAL_MS = 1000; private static readonly MESSAGE_POLLING_INTERVAL_MS = 30000; @@ -50,9 +47,41 @@ export class OcsChat { private static readonly MAX_FILE_SIZE_MB = 50; private static readonly MAX_TOTAL_SIZE_MB = 50; - private static readonly SUPPORTED_FILE_EXTENSIONS = ['.txt', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.csv', '.jpg', - '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.mp4', '.mov', '.avi', '.mp3', '.wav', '.html', '.htm', '.css', - '.js', '.xml', '.md', '.ics', '.vcf', '.rtf', '.tsv', '.yaml', '.yml', '.py', '.c']; + private static readonly SUPPORTED_FILE_EXTENSIONS = [ + '.txt', + '.pdf', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.csv', + '.jpg', + '.jpeg', + '.png', + '.gif', + '.bmp', + '.webp', + '.svg', + '.mp4', + '.mov', + '.avi', + '.mp3', + '.wav', + '.html', + '.htm', + '.css', + '.js', + '.xml', + '.md', + '.ics', + '.vcf', + '.rtf', + '.tsv', + '.yaml', + '.yml', + '.py', + '.c', + ]; /** * The ID of the chatbot to connect to. @@ -62,7 +91,7 @@ export class OcsChat { /** * The base URL for the API. */ - @Prop() apiBaseUrl?: string = "https://www.openchatstudio.com"; + @Prop() apiBaseUrl?: string = 'https://www.openchatstudio.com'; /** * The text to display on the button. @@ -129,7 +158,7 @@ export class OcsChat { @Prop() starterQuestions?: string; /** - * Used to associate chat sessions with a specific user across multiple visits/sessions + * Used to associate chat sessions with a specific user across multiple visits/sessions */ @Prop() userId?: string; /** @@ -181,14 +210,14 @@ export class OcsChat { */ @Prop() versionNumber?: number; - @State() error: string = ""; + @State() error: string = ''; @State() messages: ChatMessage[] = []; @State() sessionId?: string; @State() isLoading: boolean = false; @State() isTyping: boolean = false; @State() typingProgressMessage: string = ''; - @State() messageInput: string = ""; - @State() currentPollTaskId: string = ""; + @State() messageInput: string = ''; + @State() currentPollTaskId: string = ''; @State() isDragging: boolean = false; @State() dragOffset: { x: number; y: number } = { x: 0, y: 0 }; @State() windowPosition: { x: number; y: number } = { x: 0, y: 0 }; @@ -233,7 +262,6 @@ export class OcsChat { private sessionEpoch: number = 0; @Element() host: HTMLElement; - async componentWillLoad() { if (!this.chatbotId) { this.error = 'Chatbot ID is required'; @@ -321,7 +349,7 @@ export class OcsChat { created_at: new Date().toISOString(), role: 'system', content: `**Error:** ${errorText}\nPlease try again.`, - attachments: [] + attachments: [], }; this.messages = [...this.messages, errorMessage]; @@ -368,7 +396,7 @@ export class OcsChat { let customTranslationsObj: Partial | undefined; if (this.translationsUrl) { - customTranslationsObj = await this.loadTranslationsFromUrl(this.translationsUrl); + customTranslationsObj = await this.loadTranslationsFromUrl(this.translationsUrl); } this.translationManager = new TranslationManager(this.language, customTranslationsObj); } @@ -379,7 +407,7 @@ export class OcsChat { } if (typeof this.pageContext !== 'object' || Array.isArray(this.pageContext)) { - console.error("pageContext is expected to be a plain JavaScript object."); + console.error('pageContext is expected to be a plain JavaScript object.'); return; } @@ -396,7 +424,7 @@ export class OcsChat { return translations as Partial; } catch (error) { console.error('Error loading translations from URL:', error); - return defaultTranslations + return defaultTranslations; } } @@ -420,9 +448,9 @@ export class OcsChat { chatbot_id: this.chatbotId, session_data: { source: 'widget', - page_url: window.location.href + page_url: window.location.href, }, - participant_remote_id: userId + participant_remote_id: userId, }; if (this.userName) { @@ -507,7 +535,7 @@ export class OcsChat { created_at: new Date(now.getTime() - (welcomeMessagesToAdd.length - index) * 1000).toISOString(), role: 'assistant' as const, content: welcomeMsg, - attachments: [] + attachments: [], })); this.messages = [...this.messages, ...welcomeMessages]; } @@ -517,13 +545,15 @@ export class OcsChat { created_at: new Date().toISOString(), role: 'user', content: message.trim(), - attachments: this.allowAttachments ? this.selectedFiles - .filter(sf => !sf.error && sf.uploaded) - .map(sf => ({ - name: sf.file.name, - content_type: sf.file.type, - size: sf.file.size, - })) : [] + attachments: this.allowAttachments + ? this.selectedFiles + .filter(sf => !sf.error && sf.uploaded) + .map(sf => ({ + name: sf.file.name, + content_type: sf.file.type, + size: sf.file.size, + })) + : [], }; this.messages = [...this.messages, userMessage]; this.saveSessionToStorage(); @@ -569,7 +599,7 @@ export class OcsChat { * @param forceEnd When `false`, scroll the top of the last message into view. * When `true`, scroll all the way to the end of the last message. */ - private scrollToBottom(forceEnd: boolean =false): void { + private scrollToBottom(forceEnd: boolean = false): void { setTimeout(() => { if (this.messageListRef) { const lastChild = this.messageListRef.lastElementChild; @@ -579,10 +609,10 @@ export class OcsChat { const childRect = lastChild.getBoundingClientRect(); const currentScrollTop = this.messageListRef.scrollTop; const childTopRelativeToParent = childRect.top - parentRect.top; - const targetScroll = currentScrollTop + childTopRelativeToParent - (parentRect.height / 2); + const targetScroll = currentScrollTop + childTopRelativeToParent - parentRect.height / 2; this.messageListRef.scrollTo({ - top: targetScroll, - behavior: 'smooth' + top: targetScroll, + behavior: 'smooth', }); } else { this.messageListRef.scrollTop = this.messageListRef.scrollHeight; @@ -631,9 +661,9 @@ export class OcsChat { if (bytes < k * k) { // Less than 1MB, show in KB - return Math.round(bytes / k * 100) / 100 + ' KB'; + return Math.round((bytes / k) * 100) / 100 + ' KB'; } else { - return Math.round(bytes / (k * k) * 100) / 100 + ' MB'; + return Math.round((bytes / (k * k)) * 100) / 100 + ' MB'; } } @@ -653,7 +683,7 @@ export class OcsChat { */ @Watch('pageContext') pageContextHandler() { - this.loadInternalPageContext() + this.loadInternalPageContext(); } @Watch('chatbotId') @@ -710,7 +740,7 @@ export class OcsChat { } this.taskPollingHandle = this.getChatService().pollTask(this.sessionId, taskId, { - onMessage: (message) => { + onMessage: message => { this.messages = [...this.messages, message]; this.saveSessionToStorage(); this.scrollToBottom(); @@ -721,7 +751,7 @@ export class OcsChat { this.startMessagePolling(); this.focusInput(); }, - onProgress: (message) => { + onProgress: message => { this.typingProgressMessage = message; }, onTimeout: () => { @@ -729,7 +759,7 @@ export class OcsChat { created_at: new Date().toISOString(), role: 'system', content: 'The response is taking longer than expected. The system may be experiencing delays. Please try sending your message again.', - attachments: [] + attachments: [], }; this.messages = [...this.messages, timeoutMessage]; this.saveSessionToStorage(); @@ -741,12 +771,12 @@ export class OcsChat { this.startMessagePolling(); this.focusInput(); }, - onError: (error) => { + onError: error => { this.typingProgressMessage = ''; this.handleError(error.message); this.taskPollingHandle = undefined; this.startMessagePolling(); - } + }, }); } @@ -760,8 +790,8 @@ export class OcsChat { } this.messagePollingHandle = this.getChatService().startMessagePolling(this.sessionId, { - getSince: () => this.messages.length > 0 ? this.messages.at(-1)?.created_at : undefined, - onMessages: (messages) => { + getSince: () => (this.messages.length > 0 ? this.messages.at(-1)?.created_at : undefined), + onMessages: messages => { if (messages.length === 0) return; this.messages = [...this.messages, ...messages]; this.saveSessionToStorage(); @@ -770,7 +800,7 @@ export class OcsChat { }, onError: () => { // Silently ignore polling errors to match previous behaviour - } + }, }); } @@ -849,19 +879,19 @@ export class OcsChat { case 'left': this.windowPosition = { x: OcsChat.WINDOW_MARGIN, - y: windowHeight - this.chatWindowHeight - OcsChat.WINDOW_MARGIN + y: windowHeight - this.chatWindowHeight - OcsChat.WINDOW_MARGIN, }; break; case 'right': this.windowPosition = { x: windowWidth - chatWidth - OcsChat.WINDOW_MARGIN, - y: windowHeight - this.chatWindowHeight - OcsChat.WINDOW_MARGIN + y: windowHeight - this.chatWindowHeight - OcsChat.WINDOW_MARGIN, }; break; case 'center': this.windowPosition = { x: (windowWidth - chatWidth) / 2, - y: (windowHeight - this.chatWindowHeight) / 2 + y: (windowHeight - this.chatWindowHeight) / 2, }; break; } @@ -886,13 +916,13 @@ export class OcsChat { // For fullscreen, track relative to current position this.dragOffset = { x: pointer.clientX, - y: pointer.clientY + y: pointer.clientY, }; } else { const rect = this.chatWindowRef.getBoundingClientRect(); this.dragOffset = { x: pointer.clientX - rect.left, - y: pointer.clientY - rect.top + y: pointer.clientY - rect.top, }; } } @@ -906,7 +936,7 @@ export class OcsChat { const deltaX = pointer.clientX - this.dragOffset.x; this.fullscreenPosition = { - x: Math.max(-maxOffset, Math.min(maxOffset, deltaX)) + x: Math.max(-maxOffset, Math.min(maxOffset, deltaX)), }; } else { const newX = pointer.clientX - this.dragOffset.x; @@ -920,7 +950,7 @@ export class OcsChat { this.windowPosition = { x: Math.max(0, Math.min(newX, windowWidth - chatWidth)), - y: Math.max(0, Math.min(newY, windowHeight - chatHeight)) + y: Math.max(0, Math.min(newY, windowHeight - chatHeight)), }; } } @@ -1006,7 +1036,7 @@ export class OcsChat { this.buttonPosition = { x: Math.max(minPadding, Math.min(this.buttonPosition.x, windowWidth - buttonWidth - minPadding)), - y: Math.max(minPadding, Math.min(this.buttonPosition.y, windowHeight - buttonHeight - minPadding)) + y: Math.max(minPadding, Math.min(this.buttonPosition.y, windowHeight - buttonHeight - minPadding)), }; this.updateHostPosition(); @@ -1048,7 +1078,7 @@ export class OcsChat { this.buttonPosition = { x: horizontalValue, - y: verticalValue + y: verticalValue, }; // Apply the position to the host @@ -1093,7 +1123,7 @@ export class OcsChat { const rect = this.host.getBoundingClientRect(); this.buttonDragOffset = { x: pointer.clientX - rect.left, - y: pointer.clientY - rect.top + y: pointer.clientY - rect.top, }; this.addButtonEventListeners(); @@ -1113,7 +1143,7 @@ export class OcsChat { const rect = this.host.getBoundingClientRect(); this.buttonDragOffset = { x: pointer.clientX - rect.left, - y: pointer.clientY - rect.top + y: pointer.clientY - rect.top, }; this.addButtonEventListeners(); @@ -1158,12 +1188,8 @@ export class OcsChat { const constrainedLeft = Math.max(minLeft, Math.min(candidateLeft, maxLeft)); const constrainedTop = Math.max(minTop, Math.min(candidateTop, maxTop)); - const newHorizontalValue = this.buttonHorizontalSide === 'left' - ? constrainedLeft - : Math.max(minPadding, windowWidth - (constrainedLeft + buttonWidth)); - const newVerticalValue = this.buttonVerticalSide === 'top' - ? constrainedTop - : Math.max(minPadding, windowHeight - (constrainedTop + buttonHeight)); + const newHorizontalValue = this.buttonHorizontalSide === 'left' ? constrainedLeft : Math.max(minPadding, windowWidth - (constrainedLeft + buttonWidth)); + const newVerticalValue = this.buttonVerticalSide === 'top' ? constrainedTop : Math.max(minPadding, windowHeight - (constrainedTop + buttonHeight)); if (newHorizontalValue !== this.buttonPosition.x || newVerticalValue !== this.buttonPosition.y) { this.buttonWasDragged = true; @@ -1263,17 +1289,13 @@ export class OcsChat { } private getWelcomeMessages(): string[] { - const translated = this.translationManager.getArray("content.welcomeMessages"); - return translated && translated.length > 0 - ? translated - : this.parsedWelcomeMessages; + const translated = this.translationManager.getArray('content.welcomeMessages'); + return translated && translated.length > 0 ? translated : this.parsedWelcomeMessages; } private getStarterQuestions(): string[] { - const translated = this.translationManager.getArray("content.starterQuestions"); - return translated && translated.length > 0 - ? translated - : this.parsedStarterQuestions; + const translated = this.translationManager.getArray('content.starterQuestions'); + return translated && translated.length > 0 ? translated : this.parsedStarterQuestions; } private getButtonClasses(): string { @@ -1298,23 +1320,25 @@ export class OcsChat { // Only show drag cursor if button is draggable const isDraggable = this.isButtonDraggable(); - const buttonStyle = isDraggable ? { - cursor: this.isButtonDragging ? 'grabbing' : 'grab', - } : {}; + const buttonStyle = isDraggable + ? { + cursor: this.isButtonDragging ? 'grabbing' : 'grab', + } + : {}; if (hasText) { return ( : } {finalButtonText} @@ -1328,16 +1352,16 @@ export class OcsChat { } else { return ( )} {/* Fullscreen toggle button */} - {this.allowFullScreen && } + {this.allowFullScreen && ( + + )} - @@ -1605,20 +1624,12 @@ export class OcsChat {

{this.translationManager.get('modal.newChatTitle')}

-

- {this.translationManager.get('modal.newChatBody', this.newChatConfirmationMessage)} -

+

{this.translationManager.get('modal.newChatBody', this.newChatConfirmationMessage)}

- -
@@ -1638,20 +1649,14 @@ export class OcsChat { )} {/* Messages */} - {( -
this.messageListRef = el} - class="messages-container" - > + { +
(this.messageListRef = el)} class="messages-container"> {this.messages.length === 0 && this.getWelcomeMessages().length > 0 && (
{this.getWelcomeMessages().map((message, index) => (
-
+
))} @@ -1659,25 +1664,13 @@ export class OcsChat { )} {/* Regular Chat Messages */} {this.messages.map((message, index) => ( -
+
-
+
{message.attachments && message.attachments.length > 0 && (
{message.attachments.map((attachment, attachmentIndex) => ( @@ -1690,9 +1683,7 @@ export class OcsChat { ))}
)} -
- {this.formatTime(message.created_at)} -
+
{this.formatTime(message.created_at)}
))} @@ -1709,17 +1700,14 @@ export class OcsChat {
)}
- )} + } {/* Starter Questions */} {this.messages.length === 0 && this.getStarterQuestions().length > 0 && (
{this.getStarterQuestions().map((question, index) => (
-
@@ -1735,22 +1723,19 @@ export class OcsChat {
- + {selectedFile.file.name} ({this.formatFileSize(selectedFile.file.size)}) - {selectedFile.error && ( - {selectedFile.error} - )} + {selectedFile.error && {selectedFile.error}} {selectedFile.uploaded && ( - + + + )}
-
))} @@ -1762,57 +1747,59 @@ export class OcsChat {
- {/* File Upload Button */} - {this.allowAttachments && ( - { - // Unclear why but after removing all attachments this is being set to `null`. - if (el) {this.fileInputRef = el} - } + {/* File Upload Button */} + {this.allowAttachments && ( + { + // Unclear why but after removing all attachments this is being set to `null`. + if (el) { + this.fileInputRef = el; } - id="ocs-file-input" - type="file" - multiple - accept={OcsChat.SUPPORTED_FILE_EXTENSIONS.join(',') + ',text/*'} - onChange={(e) => this.handleFileSelect(e)} - class="hidden" - /> - )} - {this.allowAttachments && ( - - )} + }} + id="ocs-file-input" + type="file" + multiple + accept={OcsChat.SUPPORTED_FILE_EXTENSIONS.join(',') + ',text/*'} + onChange={e => this.handleFileSelect(e)} + class="hidden" + /> + )} + {this.allowAttachments && ( -
+ )} +
+
-

{this.translationManager.get('branding.poweredBy')}{' '} Dimagi

+

+ {this.translationManager.get('branding.poweredBy')}{' '} + + Dimagi + +

diff --git a/components/chat_widget/src/components/ocs-chat/ocs-chat_session_handling.spec.tsx b/components/chat_widget/src/components/ocs-chat/ocs-chat_session_handling.spec.tsx index e090bb2a59..985a52324a 100644 --- a/components/chat_widget/src/components/ocs-chat/ocs-chat_session_handling.spec.tsx +++ b/components/chat_widget/src/components/ocs-chat/ocs-chat_session_handling.spec.tsx @@ -1,5 +1,5 @@ -import {newSpecPage} from '@stencil/core/testing'; -import {OcsChat} from './ocs-chat'; +import { newSpecPage } from '@stencil/core/testing'; +import { OcsChat } from './ocs-chat'; // Create mock functions at the module level const mockStartSession = jest.fn(); @@ -27,20 +27,22 @@ function setupFetchMock(sessionId = 'test-session-id', taskId = 'test-task-id') if (url.includes('/api/chat/start/')) { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ - session_id: sessionId, - chatbot: {}, - participant: {}, - }), + json: () => + Promise.resolve({ + session_id: sessionId, + chatbot: {}, + participant: {}, + }), } as Response); } if (url.includes('/api/chat/send/')) { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ - task_id: taskId, - status: 'processing', - }), + json: () => + Promise.resolve({ + task_id: taskId, + status: 'processing', + }), } as Response); } return Promise.reject(new Error('Unexpected fetch call')); @@ -456,10 +458,10 @@ describe('ocs-chat localStorage blocked (SecurityError)', () => { beforeEach(() => { jest.clearAllMocks(); - mockStartSession.mockResolvedValue({session_id: 'test-session-id'}); - mockSendMessage.mockResolvedValue({status: 'success', task_id: 'test-task-id'}); - mockPollTask.mockReturnValue({cancel: jest.fn()}); - mockStartMessagePolling.mockReturnValue({stop: jest.fn()}); + mockStartSession.mockResolvedValue({ session_id: 'test-session-id' }); + mockSendMessage.mockResolvedValue({ status: 'success', task_id: 'test-task-id' }); + mockPollTask.mockReturnValue({ cancel: jest.fn() }); + mockStartMessagePolling.mockReturnValue({ stop: jest.fn() }); global.fetch = setupFetchMock(); @@ -525,9 +527,11 @@ describe('ocs-chat localStorage blocked (SecurityError)', () => { }); it('does not throw during componentWillLoad when localStorage is blocked', async () => { - await expect(newSpecPage({ - components: [OcsChat], - html: '', - })).resolves.toBeDefined(); + await expect( + newSpecPage({ + components: [OcsChat], + html: '', + }), + ).resolves.toBeDefined(); }); }); diff --git a/components/chat_widget/src/services/chat-session-service.ts b/components/chat_widget/src/services/chat-session-service.ts index c888d6fc5c..da6abfdfcb 100644 --- a/components/chat_widget/src/services/chat-session-service.ts +++ b/components/chat_widget/src/services/chat-session-service.ts @@ -207,10 +207,7 @@ export class ChatSessionService { return response.json() as Promise; } - startMessagePolling( - sessionId: string, - callbacks: MessagePollingCallbacks, - ): MessagePollingHandle { + startMessagePolling(sessionId: string, callbacks: MessagePollingCallbacks): MessagePollingHandle { const poll = async () => { try { const since = callbacks.getSince(); @@ -246,7 +243,7 @@ export class ChatSessionService { private getJsonHeaders(): Record { const headers = this.getCommonHeaders(); - headers['Content-Type'] = 'application/json' + headers['Content-Type'] = 'application/json'; const csrfToken = this.csrfTokenProvider(this.apiBaseUrl); if (csrfToken) { diff --git a/components/chat_widget/src/services/file-attachment-manager.ts b/components/chat_widget/src/services/file-attachment-manager.ts index e1f21af80e..daeeafed56 100644 --- a/components/chat_widget/src/services/file-attachment-manager.ts +++ b/components/chat_widget/src/services/file-attachment-manager.ts @@ -48,8 +48,8 @@ export class FileAttachmentManager { for (const file of fileArray) { const extension = this.getFileExtension(file.name); - const contentType = file.type.split("/")[0]; - if (contentType != "text" && !this.supportedExtensions.includes(extension)) { + const contentType = file.type.split('/')[0]; + if (contentType != 'text' && !this.supportedExtensions.includes(extension)) { newSelected.push({ file, error: `File type ${extension} not supported` }); continue; } @@ -86,18 +86,13 @@ export class FileAttachmentManager { }); } - async uploadPendingFiles( - existingFiles: SelectedFile[], - context: UploadContext - ): Promise { + async uploadPendingFiles(existingFiles: SelectedFile[], context: UploadContext): Promise { if (existingFiles.length === 0) { return { selectedFiles: existingFiles, uploadedIds: [] }; } const uploadCandidates = existingFiles.filter(file => !file.error && !file.uploaded); - const uploadedIds: number[] = existingFiles - .filter(file => file.uploaded) - .map(file => file.uploaded!.id); + const uploadedIds: number[] = existingFiles.filter(file => file.uploaded).map(file => file.uploaded!.id); if (uploadCandidates.length === 0) { return { selectedFiles: existingFiles, uploadedIds }; @@ -120,9 +115,7 @@ export class FileAttachmentManager { if (!response.ok) { const errorData = await this.safeJson(response); - const errorMessage = - (errorData && typeof errorData === 'object' && 'error' in errorData && (errorData as { error?: string }).error) || - 'Failed to upload files'; + const errorMessage = (errorData && typeof errorData === 'object' && 'error' in errorData && (errorData as { error?: string }).error) || 'Failed to upload files'; return { selectedFiles: this.markPendingFilesWithError(existingFiles, errorMessage), uploadedIds, diff --git a/components/chat_widget/src/utils/cookies.ts b/components/chat_widget/src/utils/cookies.ts index 89447ff9bb..94051e92d1 100644 --- a/components/chat_widget/src/utils/cookies.ts +++ b/components/chat_widget/src/utils/cookies.ts @@ -1,5 +1,4 @@ -import Cookies from "js-cookie"; - +import Cookies from 'js-cookie'; /** * Get CSRF token from cookies if the current domain matches the API base URL @@ -9,7 +8,7 @@ export function getCSRFToken(apiBaseUrl: string): string | undefined { return undefined; } - return Cookies.get('csrftoken') + return Cookies.get('csrftoken'); } function currentDomainMatchesApiBaseUrl(apiBaseUrl: string): boolean { diff --git a/components/chat_widget/src/utils/markdown.ts b/components/chat_widget/src/utils/markdown.ts index 6ff4e4452f..1520bc7f52 100644 --- a/components/chat_widget/src/utils/markdown.ts +++ b/components/chat_widget/src/utils/markdown.ts @@ -22,7 +22,7 @@ export function postProcessMarkdownHTML(html: string): string { // Add target="_blank" and rel="noopener noreferrer" to external links const links = tempDiv.querySelectorAll('a[href]'); - links.forEach((link) => { + links.forEach(link => { const href = link.getAttribute('href'); if (href && (href.startsWith('http://') || href.startsWith('https://'))) { link.setAttribute('target', '_blank'); @@ -37,18 +37,42 @@ export function postProcessMarkdownHTML(html: string): string { } } - export const SANITIZE_CONFIG = { ALLOWED_TAGS: [ - 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'code', 'pre', - 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'blockquote', 'a', 'img', 'hr', 'table', 'thead', 'tbody', - 'tr', 'td', 'th', 'del', 'ins', 'sub', 'sup' - ], - ALLOWED_ATTR: [ - 'href', 'target', 'rel', 'class', 'src', 'alt', 'title', - 'width', 'height', 'align', 'colspan', 'rowspan' + 'p', + 'br', + 'strong', + 'b', + 'em', + 'i', + 'u', + 'code', + 'pre', + 'ul', + 'ol', + 'li', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'a', + 'img', + 'hr', + 'table', + 'thead', + 'tbody', + 'tr', + 'td', + 'th', + 'del', + 'ins', + 'sub', + 'sup', ], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'src', 'alt', 'title', 'width', 'height', 'align', 'colspan', 'rowspan'], ALLOWED_URI_REGEXP: /^(?:(?:https?):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, ADD_ATTR: ['target'], FORBID_TAGS: ['script', 'style', 'form', 'input', 'button', 'iframe', 'object', 'embed', 'svg', 'math'], diff --git a/components/chat_widget/src/utils/translations.spec.ts b/components/chat_widget/src/utils/translations.spec.ts index 9cf9f80446..032aff171a 100644 --- a/components/chat_widget/src/utils/translations.spec.ts +++ b/components/chat_widget/src/utils/translations.spec.ts @@ -88,7 +88,7 @@ describe('mergeTranslations', () => { }); describe('TranslationManager', () => { - const waitForAsyncLoad = () => new Promise((resolve) => setTimeout(resolve, 0)); + const waitForAsyncLoad = () => new Promise(resolve => setTimeout(resolve, 0)); it('initializes with resolved language', async () => { const manager = new translations.TranslationManager('FR'); @@ -119,12 +119,12 @@ describe('TranslationManager', () => { it('translations return keys if value is blank', async () => { const manager = new translations.TranslationManager('de', { - "window.close": "", + 'window.close': '', }); await waitForAsyncLoad(); - expect(manager.get('window.close')).toBe(""); + expect(manager.get('window.close')).toBe(''); }); it('returns arrays from getArray and wraps strings', async () => { diff --git a/components/chat_widget/src/utils/translations.ts b/components/chat_widget/src/utils/translations.ts index 4f9c990e50..9928841a5c 100644 --- a/components/chat_widget/src/utils/translations.ts +++ b/components/chat_widget/src/utils/translations.ts @@ -30,7 +30,6 @@ const translationFiles: Record = { uk: uk as TranslationStrings, }; - export function getBrowserLanguage(): string { if (typeof navigator !== 'undefined') { const lang = navigator.language || (navigator as any).userLanguage; @@ -55,10 +54,7 @@ export async function loadTranslations(language: string): Promise -): TranslationStrings { +export function mergeTranslations(baseTranslations: TranslationStrings, customTranslations: Partial): TranslationStrings { return { ...baseTranslations, ...customTranslations }; } @@ -80,9 +76,7 @@ export class TranslationManager { baseTranslations = defaultTranslations; } - this.translations = customTranslations - ? mergeTranslations(baseTranslations, customTranslations) - : baseTranslations; + this.translations = customTranslations ? mergeTranslations(baseTranslations, customTranslations) : baseTranslations; } get(key: keyof TranslationStrings, override?: string | null): string | undefined { diff --git a/components/chat_widget/src/utils/utils.ts b/components/chat_widget/src/utils/utils.ts index f84753bb45..f1799df6b5 100644 --- a/components/chat_widget/src/utils/utils.ts +++ b/components/chat_widget/src/utils/utils.ts @@ -5,20 +5,20 @@ * @param defaultValue The default value if the CSS value is neither a percentage nor a pixel value. */ export const varToPixels = (value: string, maxValue: number, defaultValue: number) => { - value = value.trim() - if (value.includes("%")) { - const percent = percentToFloat(value); - if (!isNaN(percent)) { - return maxValue * percent; - } - } else if (value.includes("px")) { - const pixels = parseFloat(value); - if (!isNaN(pixels)) { - return pixels; - } + value = value.trim(); + if (value.includes('%')) { + const percent = percentToFloat(value); + if (!isNaN(percent)) { + return maxValue * percent; } - return defaultValue; -} + } else if (value.includes('px')) { + const pixels = parseFloat(value); + if (!isNaN(pixels)) { + return pixels; + } + } + return defaultValue; +}; const percentToFloat = (percentageString: string) => { const numericValue = parseFloat(percentageString);