From 9dab397dc7e9c706fa973c079133e162360646ba Mon Sep 17 00:00:00 2001 From: Ezrqn Kemboi Date: Tue, 21 Apr 2026 17:56:51 +0300 Subject: [PATCH 1/5] feat: add max-char-limit support to chat widget and web chat template Adds character limit enforcement to the OCS chat widget component: - Live character counter with warning/error states - Disabled send button when message exceeds limit - Updated web_chat.html to pass max-char-limit attribute from backend Co-Authored-By: Claude Sonnet 4.6 --- .../src/assets/translations/en.json | 1 + components/chat_widget/src/components.d.ts | 14 +- .../src/components/ocs-chat/ocs-chat.css | 63 ++- .../src/components/ocs-chat/ocs-chat.tsx | 426 +++++++++--------- .../src/components/ocs-chat/readme.md | 53 +-- .../src/services/chat-session-service.ts | 19 +- templates/chatbots/chat/web_chat.html | 1 + 7 files changed, 302 insertions(+), 275 deletions(-) diff --git a/components/chat_widget/src/assets/translations/en.json b/components/chat_widget/src/assets/translations/en.json index f5b7686468..42d5a53c1d 100644 --- a/components/chat_widget/src/assets/translations/en.json +++ b/components/chat_widget/src/assets/translations/en.json @@ -16,6 +16,7 @@ "modal.confirm": "Confirm", "composer.placeholder": "Type a message...", "composer.send": "Send message", + "composer.messageTooLong": "Message is too long", "error.fileTooLarge": "File too large", "error.totalTooLarge": "Total file size too large", "error.unsupportedType": "Unsupported file type", diff --git a/components/chat_widget/src/components.d.ts b/components/chat_widget/src/components.d.ts index 2c37779bbc..c4940a5286 100644 --- a/components/chat_widget/src/components.d.ts +++ b/components/chat_widget/src/components.d.ts @@ -52,7 +52,11 @@ export namespace Components { */ "language"?: string; /** - * The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. + * Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded. + */ + "maxCharLimit"?: number; + /** + * The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`). * @default 'standard' */ "mode": 'standard' | 'kiosk'; @@ -101,6 +105,7 @@ export namespace Components { * Display name for the user. */ "userName"?: string; + "versionNumber"?: number; /** * Whether the chat widget is visible on load. * @default false @@ -170,7 +175,11 @@ declare namespace LocalJSX { */ "language"?: string; /** - * The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. + * Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded. + */ + "maxCharLimit"?: number; + /** + * The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`). * @default 'standard' */ "mode"?: 'standard' | 'kiosk'; @@ -219,6 +228,7 @@ declare namespace LocalJSX { * Display name for the user. */ "userName"?: string; + "versionNumber"?: number; /** * Whether the chat widget is visible on load. * @default false 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..f045c1a18a 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; @@ -606,6 +582,27 @@ } } + .message-textarea-error { + border-color: var(--error-text-color); + &:focus { + outline-color: var(--error-text-color); + } + } + + .char-counter { + @apply text-xs text-right pr-1 pt-0.5; + color: var(--char-counter-color, #9ca3af); + } + + .char-counter-warning { + color: var(--char-counter-warning-color, #f59e0b); + } + + .char-counter-error { + color: var(--error-text-color); + font-weight: 600; + } + .send-button { @apply rounded-md font-medium transition-colors duration-200; padding: 0.5em 1em; 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 9ca6bc3698..5a38e967df 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,20 @@ export class OcsChat { */ @Prop() versionNumber?: number; - @State() error: string = ""; + /** + * Maximum number of characters allowed in a single message (derived from the model's token limit). + * When set, a live counter is shown and the send button is disabled when exceeded. + */ + @Prop() maxCharLimit?: number; + + @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 +268,6 @@ export class OcsChat { private sessionEpoch: number = 0; @Element() host: HTMLElement; - async componentWillLoad() { if (!this.chatbotId) { this.error = 'Chatbot ID is required'; @@ -321,7 +355,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 +402,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 +413,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 +430,7 @@ export class OcsChat { return translations as Partial; } catch (error) { console.error('Error loading translations from URL:', error); - return defaultTranslations + return defaultTranslations; } } @@ -420,9 +454,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) { @@ -467,8 +501,17 @@ export class OcsChat { } } + private get messageTooLong(): boolean { + return this.maxCharLimit != null && this.messageInput.length > this.maxCharLimit; + } + + private get messageNearLimit(): boolean { + return this.maxCharLimit != null && this.messageInput.length > this.maxCharLimit * 0.8; + } + private async sendMessage(message: string): Promise { if (!message.trim()) return; + if (this.messageTooLong) return; const epoch = this.sessionEpoch; // Start session if we don't have one yet @@ -507,7 +550,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 +560,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 +614,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 +624,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 +676,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 +698,7 @@ export class OcsChat { */ @Watch('pageContext') pageContextHandler() { - this.loadInternalPageContext() + this.loadInternalPageContext(); } @Watch('chatbotId') @@ -710,7 +755,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 +766,7 @@ export class OcsChat { this.startMessagePolling(); this.focusInput(); }, - onProgress: (message) => { + onProgress: message => { this.typingProgressMessage = message; }, onTimeout: () => { @@ -729,7 +774,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 +786,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 +805,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 +815,7 @@ export class OcsChat { }, onError: () => { // Silently ignore polling errors to match previous behaviour - } + }, }); } @@ -849,19 +894,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 +931,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 +951,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 +965,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 +1051,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 +1093,7 @@ export class OcsChat { this.buttonPosition = { x: horizontalValue, - y: verticalValue + y: verticalValue, }; // Apply the position to the host @@ -1093,7 +1138,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 +1158,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 +1203,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 +1304,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 +1335,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 ( )} {/* Fullscreen toggle button */} - {this.allowFullScreen && } + {this.allowFullScreen && ( + + )} - @@ -1596,20 +1630,12 @@ export class OcsChat {

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

-

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

+

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

- -
@@ -1629,20 +1655,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) => (
-
+
))} @@ -1650,25 +1670,13 @@ export class OcsChat { )} {/* Regular Chat Messages */} {this.messages.map((message, index) => ( -
+
-
+
{message.attachments && message.attachments.length > 0 && (
{message.attachments.map((attachment, attachmentIndex) => ( @@ -1681,9 +1689,7 @@ export class OcsChat { ))}
)} -
- {this.formatTime(message.created_at)} -
+
{this.formatTime(message.created_at)}
))} @@ -1700,17 +1706,14 @@ export class OcsChat {
)}
- )} + } {/* Starter Questions */} {this.messages.length === 0 && this.getStarterQuestions().length > 0 && (
{this.getStarterQuestions().map((question, index) => (
-
@@ -1726,22 +1729,19 @@ export class OcsChat {
- + {selectedFile.file.name} ({this.formatFileSize(selectedFile.file.size)}) - {selectedFile.error && ( - {selectedFile.error} - )} + {selectedFile.error && {selectedFile.error}} {selectedFile.uploaded && ( - + + + )}
-
))} @@ -1753,57 +1753,67 @@ 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} - } + {this.maxCharLimit != null && ( +
+ {this.messageInput.length} / {this.maxCharLimit} +
+ )} + {/* 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/readme.md b/components/chat_widget/src/components/ocs-chat/readme.md index 24287f974e..36eed82aa5 100644 --- a/components/chat_widget/src/components/ocs-chat/readme.md +++ b/components/chat_widget/src/components/ocs-chat/readme.md @@ -9,32 +9,33 @@ For more information, see the [Open Chat Studio documentation](https://docs.open ## Properties -| Property | Attribute | Description | Type | Default | -| ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | ---------------------------------- | -| `allowAttachments` | `allow-attachments` | Allow the user to attach files to their messages. | `boolean` | `false` | -| `allowFullScreen` | `allow-full-screen` | Allow the user to make the chat window full screen. | `boolean` | `true` | -| `apiBaseUrl` | `api-base-url` | The base URL for the API. | `string` | `"https://www.openchatstudio.com"` | -| `buttonShape` | `button-shape` | The shape of the chat button. 'round' makes it circular, 'square' keeps it rectangular. | `"round" \| "square"` | `'square'` | -| `buttonText` | `button-text` | The text to display on the button. | `string` | `undefined` | -| `chatbotId` _(required)_ | `chatbot-id` | The ID of the chatbot to connect to. | `string` | `undefined` | -| `embedKey` | `embed-key` | Authentication key for embedded channels | `string` | `undefined` | -| `headerText` | `header-text` | The text to place in the header. | `""` | `undefined` | -| `iconUrl` | `icon-url` | URL of the icon to display on the button. If not provided, uses the default OCS logo. | `string` | `undefined` | -| `language` | `language` | The language code for the widget UI (e.g., 'en', 'es', 'fr'). Defaults to en | `string` | `undefined` | -| `mode` | `mode` | The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`). | `"kiosk" \| "standard"` | `'standard'` | -| `newChatConfirmationMessage` | `new-chat-confirmation-message` | The message to display in the new chat confirmation dialog. | `string` | `undefined` | -| `pageContext` | `page-context` | Optional context object to send with each message. This provides page-specific context to the bot. | `{ [x: string]: any; }` | `undefined` | -| `persistentSession` | `persistent-session` | Whether to persist session data to local storage to allow resuming previous conversations after page reload. | `boolean` | `true` | -| `persistentSessionExpire` | `persistent-session-expire` | Minutes since the most recent message after which the session data in local storage will expire. Set this to `0` to never expire. | `number` | `60 * 24` | -| `position` | `position` | The initial position of the chat widget on the screen. | `"center" \| "left" \| "right"` | `'right'` | -| `showButton` | `show-button` | Whether to show the launcher button. Set to false to hide the button and open the chat window programmatically via the `visible` property. | `boolean` | `true` | -| `starterQuestions` | `starter-questions` | Array of starter questions that users can click to send (JSON array of strings) | `string` | `undefined` | -| `translationsUrl` | `translations-url` | | `string` | `undefined` | -| `typingIndicatorText` | `typing-indicator-text` | The text to display while the assistant is typing/preparing a response. | `string` | `undefined` | -| `userId` | `user-id` | Used to associate chat sessions with a specific user across multiple visits/sessions | `string` | `undefined` | -| `userName` | `user-name` | Display name for the user. | `string` | `undefined` | -| `visible` | `visible` | Whether the chat widget is visible on load. | `boolean` | `false` | -| `welcomeMessages` | `welcome-messages` | Welcome messages to display above starter questions (JSON array of strings) | `string` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | ---------------------------------- | +| `allowAttachments` | `allow-attachments` | Allow the user to attach files to their messages. | `boolean` | `false` | +| `allowFullScreen` | `allow-full-screen` | Allow the user to make the chat window full screen. | `boolean` | `true` | +| `apiBaseUrl` | `api-base-url` | The base URL for the API. | `string` | `"https://www.openchatstudio.com"` | +| `buttonShape` | `button-shape` | The shape of the chat button. 'round' makes it circular, 'square' keeps it rectangular. | `"round" \| "square"` | `'square'` | +| `buttonText` | `button-text` | The text to display on the button. | `string` | `undefined` | +| `chatbotId` _(required)_ | `chatbot-id` | The ID of the chatbot to connect to. | `string` | `undefined` | +| `embedKey` | `embed-key` | Authentication key for embedded channels | `string` | `undefined` | +| `headerText` | `header-text` | The text to place in the header. | `""` | `undefined` | +| `iconUrl` | `icon-url` | URL of the icon to display on the button. If not provided, uses the default OCS logo. | `string` | `undefined` | +| `language` | `language` | The language code for the widget UI (e.g., 'en', 'es', 'fr'). Defaults to en | `string` | `undefined` | +| `maxCharLimit` | `max-char-limit` | Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded. | `number` | `undefined` | +| `mode` | `mode` | The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`). | `"kiosk" \| "standard"` | `'standard'` | +| `newChatConfirmationMessage` | `new-chat-confirmation-message` | The message to display in the new chat confirmation dialog. | `string` | `undefined` | +| `pageContext` | `page-context` | Optional context object to send with each message. This provides page-specific context to the bot. | `{ [x: string]: any; }` | `undefined` | +| `persistentSession` | `persistent-session` | Whether to persist session data to local storage to allow resuming previous conversations after page reload. | `boolean` | `true` | +| `persistentSessionExpire` | `persistent-session-expire` | Minutes since the most recent message after which the session data in local storage will expire. Set this to `0` to never expire. | `number` | `60 * 24` | +| `position` | `position` | The initial position of the chat widget on the screen. | `"center" \| "left" \| "right"` | `'right'` | +| `showButton` | `show-button` | Whether to show the launcher button. Set to false to hide the button and open the chat window programmatically via the `visible` property. | `boolean` | `true` | +| `starterQuestions` | `starter-questions` | Array of starter questions that users can click to send (JSON array of strings) | `string` | `undefined` | +| `translationsUrl` | `translations-url` | | `string` | `undefined` | +| `typingIndicatorText` | `typing-indicator-text` | The text to display while the assistant is typing/preparing a response. | `string` | `undefined` | +| `userId` | `user-id` | Used to associate chat sessions with a specific user across multiple visits/sessions | `string` | `undefined` | +| `userName` | `user-name` | Display name for the user. | `string` | `undefined` | +| `visible` | `visible` | Whether the chat widget is visible on load. | `boolean` | `false` | +| `welcomeMessages` | `welcome-messages` | Welcome messages to display above starter questions (JSON array of strings) | `string` | `undefined` | ## CSS Custom Properties diff --git a/components/chat_widget/src/services/chat-session-service.ts b/components/chat_widget/src/services/chat-session-service.ts index c888d6fc5c..755bb6a0b9 100644 --- a/components/chat_widget/src/services/chat-session-service.ts +++ b/components/chat_widget/src/services/chat-session-service.ts @@ -125,7 +125,17 @@ export class ChatSessionService { }); if (!response.ok) { - throw new Error(`Failed to poll task: ${response.statusText}`); + let errorMessage = `Failed to poll task: ${response.statusText}`; + try { + const data = (await response.json()) as { error?: string }; + if (data?.error) { + errorMessage = data.error; + } + } catch { + // non-JSON body; keep statusText fallback + } + + throw new Error(errorMessage); } return response.json() as Promise; @@ -207,10 +217,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 +253,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/templates/chatbots/chat/web_chat.html b/templates/chatbots/chat/web_chat.html index 984e359191..d5ef66967a 100644 --- a/templates/chatbots/chat/web_chat.html +++ b/templates/chatbots/chat/web_chat.html @@ -35,6 +35,7 @@ user-name="{{ request.user.get_full_name }}" {% endif %} persistent-session="true" + {% if max_char_limit %}max-char-limit="{{ max_char_limit }}"{% endif %} >
{% else %} From a3e5cd3b3ccae70505e2ea4933a827d4f01ca97e Mon Sep 17 00:00:00 2001 From: Ezrqn Kemboi Date: Tue, 21 Apr 2026 19:12:06 +0300 Subject: [PATCH 2/5] fix: address coderabbit/sentry review feedback on widget char limit --- .../src/components/ocs-chat/ocs-chat.spec.tsx | 69 +++++++++++++++++++ .../src/components/ocs-chat/ocs-chat.tsx | 14 ++-- 2 files changed, 77 insertions(+), 6 deletions(-) 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..fafa51d8d6 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 @@ -453,4 +453,73 @@ describe('ocs-chat', () => { expect(starterQuestions).toBeTruthy(); }); }); + + describe('Character Limit', () => { + it('should not render counter when maxCharLimit is not set', async () => { + const page = await newSpecPage({ + components: [OcsChat], + html: ``, + }); + const component = page.rootInstance as OcsChat; + component.sessionId = 'test-session'; + await page.waitForChanges(); + + const counter = page.root?.shadowRoot?.querySelector('.char-counter'); + expect(counter).toBeNull(); + }); + + it('should render counter and allow send when message is within limit', async () => { + const page = await newSpecPage({ + components: [OcsChat], + html: ``, + }); + const component = page.rootInstance as OcsChat; + component.sessionId = 'test-session'; + component.messageInput = 'short message'; + await page.waitForChanges(); + + const counter = page.root?.shadowRoot?.querySelector('.char-counter'); + expect(counter).toBeTruthy(); + expect(counter?.classList.contains('char-counter-error')).toBe(false); + expect(counter?.classList.contains('char-counter-warning')).toBe(false); + + const sendBtn = page.root?.shadowRoot?.querySelector('button[title]') as HTMLButtonElement | null; + // send button should not be disabled due to char limit alone (message is short) + expect(component.messageTooLong).toBe(false); + }); + + it('should show warning state when message exceeds 80% of limit', async () => { + const page = await newSpecPage({ + components: [OcsChat], + html: ``, + }); + const component = page.rootInstance as OcsChat; + component.sessionId = 'test-session'; + component.messageInput = '12345678'; // 80% of 10 + await page.waitForChanges(); + + const counter = page.root?.shadowRoot?.querySelector('.char-counter'); + expect(counter?.classList.contains('char-counter-warning')).toBe(true); + expect(counter?.classList.contains('char-counter-error')).toBe(false); + expect(component.messageTooLong).toBe(false); + }); + + it('should show error state and block send when message exceeds limit', async () => { + const page = await newSpecPage({ + components: [OcsChat], + html: ``, + }); + const component = page.rootInstance as OcsChat; + component.sessionId = 'test-session'; + component.messageInput = 'this is too long'; + await page.waitForChanges(); + + const counter = page.root?.shadowRoot?.querySelector('.char-counter'); + expect(counter?.classList.contains('char-counter-error')).toBe(true); + expect(component.messageTooLong).toBe(true); + + const sendButton = page.root?.shadowRoot?.querySelector('button.send-button') as HTMLButtonElement | null; + expect(sendButton?.disabled).toBe(true); + }); + }); }); 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 5a38e967df..f79d3f8d5b 100644 --- a/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx +++ b/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx @@ -206,7 +206,9 @@ export class OcsChat { /** * @internal * Optional version number of the chatbot to use. Requires authentication. - * This is for internal use only and is not intended for public-facing widgets. + * Intentionally declared as @Prop() so the Django host page can pass it as + * an HTML attribute; it is not part of the public widget API and should not + * be used by third-party embedders. */ @Prop() versionNumber?: number; @@ -1762,11 +1764,6 @@ export class OcsChat { onKeyPress={e => this.handleKeyPress(e)} disabled={this.isTyping || this.isUploadingFiles || this.isLoading} > - {this.maxCharLimit != null && ( -
- {this.messageInput.length} / {this.maxCharLimit} -
- )} {/* File Upload Button */} {this.allowAttachments && (
+ {this.maxCharLimit != null && ( +
+ {this.messageInput.length} / {this.maxCharLimit} +
+ )}

From 13a27372b0475f8ee62f1610d62fc733c782f828 Mon Sep 17 00:00:00 2001 From: Ezrqn Kemboi Date: Tue, 5 May 2026 11:00:41 +0300 Subject: [PATCH 3/5] chore: add transaltions --- components/chat_widget/src/assets/translations/ar.json | 1 + components/chat_widget/src/assets/translations/es.json | 1 + components/chat_widget/src/assets/translations/fr.json | 1 + components/chat_widget/src/assets/translations/hi.json | 1 + components/chat_widget/src/assets/translations/it.json | 1 + components/chat_widget/src/assets/translations/pt.json | 1 + components/chat_widget/src/assets/translations/sw.json | 1 + components/chat_widget/src/assets/translations/uk.json | 1 + 8 files changed, 8 insertions(+) diff --git a/components/chat_widget/src/assets/translations/ar.json b/components/chat_widget/src/assets/translations/ar.json index 7a5e23a373..70d0546dfd 100644 --- a/components/chat_widget/src/assets/translations/ar.json +++ b/components/chat_widget/src/assets/translations/ar.json @@ -16,6 +16,7 @@ "modal.confirm": "تأكيد", "composer.placeholder": "اكتب رسالة...", "composer.send": "إرسال الرسالة", + "composer.messageTooLong": "الرسالة طويلة جدًا", "error.fileTooLarge": "الملف كبير جدًا", "error.totalTooLarge": "إجمالي حجم الملفات كبير جدًا", "error.unsupportedType": "نوع الملف غير مدعوم", diff --git a/components/chat_widget/src/assets/translations/es.json b/components/chat_widget/src/assets/translations/es.json index 295bdb66c7..f87df208a1 100644 --- a/components/chat_widget/src/assets/translations/es.json +++ b/components/chat_widget/src/assets/translations/es.json @@ -16,6 +16,7 @@ "modal.confirm": "Confirmar", "composer.placeholder": "Escribe un mensaje...", "composer.send": "Enviar mensaje", + "composer.messageTooLong": "El mensaje es demasiado largo", "error.fileTooLarge": "Archivo demasiado grande", "error.totalTooLarge": "Tamaño total de archivos demasiado grande", "error.unsupportedType": "Tipo de archivo no soportado", diff --git a/components/chat_widget/src/assets/translations/fr.json b/components/chat_widget/src/assets/translations/fr.json index 1bf63e9a8a..41b02f6907 100644 --- a/components/chat_widget/src/assets/translations/fr.json +++ b/components/chat_widget/src/assets/translations/fr.json @@ -16,6 +16,7 @@ "modal.confirm": "Confirmer", "composer.placeholder": "Tapez un message...", "composer.send": "Envoyer le message", + "composer.messageTooLong": "Le message est trop long", "error.fileTooLarge": "Fichier trop volumineux", "error.totalTooLarge": "Taille totale des fichiers trop importante", "error.unsupportedType": "Type de fichier non pris en charge", diff --git a/components/chat_widget/src/assets/translations/hi.json b/components/chat_widget/src/assets/translations/hi.json index 84dd8c32bb..c980afc8b2 100644 --- a/components/chat_widget/src/assets/translations/hi.json +++ b/components/chat_widget/src/assets/translations/hi.json @@ -16,6 +16,7 @@ "modal.confirm": "पुष्टि करें", "composer.placeholder": "संदेश लिखें...", "composer.send": "संदेश भेजें", + "composer.messageTooLong": "संदेश बहुत लंबा है", "error.fileTooLarge": "फ़ाइल बहुत बड़ी है", "error.totalTooLarge": "कुल फ़ाइल आकार बहुत बड़ा है", "error.unsupportedType": "फ़ाइल प्रकार समर्थित नहीं है", diff --git a/components/chat_widget/src/assets/translations/it.json b/components/chat_widget/src/assets/translations/it.json index 9377094ea3..56491737a0 100644 --- a/components/chat_widget/src/assets/translations/it.json +++ b/components/chat_widget/src/assets/translations/it.json @@ -16,6 +16,7 @@ "modal.confirm": "Conferma", "composer.placeholder": "Scrivi un messaggio...", "composer.send": "Invia messaggio", + "composer.messageTooLong": "Il messaggio è troppo lungo", "error.fileTooLarge": "File troppo grande", "error.totalTooLarge": "Dimensione totale del file troppo grande", "error.unsupportedType": "Tipo di file non supportato", diff --git a/components/chat_widget/src/assets/translations/pt.json b/components/chat_widget/src/assets/translations/pt.json index ff0db320ec..4874a843f0 100644 --- a/components/chat_widget/src/assets/translations/pt.json +++ b/components/chat_widget/src/assets/translations/pt.json @@ -16,6 +16,7 @@ "modal.confirm": "Confirmar", "composer.placeholder": "Digite uma mensagem...", "composer.send": "Enviar mensagem", + "composer.messageTooLong": "A mensagem é muito longa", "error.fileTooLarge": "Arquivo muito grande", "error.totalTooLarge": "Tamanho total do arquivo muito grande", "error.unsupportedType": "Tipo de arquivo não suportado", diff --git a/components/chat_widget/src/assets/translations/sw.json b/components/chat_widget/src/assets/translations/sw.json index 1249840e05..e6fe10a52e 100644 --- a/components/chat_widget/src/assets/translations/sw.json +++ b/components/chat_widget/src/assets/translations/sw.json @@ -16,6 +16,7 @@ "modal.confirm": "Thibitisha", "composer.placeholder": "Andika ujumbe...", "composer.send": "Tuma ujumbe", + "composer.messageTooLong": "Ujumbe ni mrefu sana", "error.fileTooLarge": "Faili ni kubwa sana", "error.totalTooLarge": "Jumla ya ukubwa wa faili ni kubwa sana", "error.unsupportedType": "Aina ya faili haitumiki", diff --git a/components/chat_widget/src/assets/translations/uk.json b/components/chat_widget/src/assets/translations/uk.json index d3dbf103b5..710977d33a 100644 --- a/components/chat_widget/src/assets/translations/uk.json +++ b/components/chat_widget/src/assets/translations/uk.json @@ -16,6 +16,7 @@ "modal.confirm": "Підтвердити", "composer.placeholder": "Введіть повідомлення...", "composer.send": "Надіслати повідомлення", + "composer.messageTooLong": "Повідомлення занадто довге", "error.fileTooLarge": "Файл занадто великий", "error.totalTooLarge": "Загальний розмір файлів занадто великий", "error.unsupportedType": "Непідтримуваний тип файлу", From af961e43f085fb4faf07900951be990824379abb Mon Sep 17 00:00:00 2001 From: Simon Kelly Date: Wed, 6 May 2026 12:57:49 +0200 Subject: [PATCH 4/5] Remove maxCharLimit and fix merge conflicts Removed maxCharLimit property and resolved merge conflicts. --- components/chat_widget/src/components.d.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/components/chat_widget/src/components.d.ts b/components/chat_widget/src/components.d.ts index 950d7b0e21..0016a032fc 100644 --- a/components/chat_widget/src/components.d.ts +++ b/components/chat_widget/src/components.d.ts @@ -52,13 +52,10 @@ export namespace Components { */ "language"?: string; /** -<<<<<<< feat/prevent-oversized-message-payloads-widget * Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded. */ "maxCharLimit"?: number; /** -======= ->>>>>>> main * The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`). * @default 'standard' */ From 5742f3ed672913a35333263a5b48d6d315c39a86 Mon Sep 17 00:00:00 2001 From: Simon Kelly Date: Wed, 6 May 2026 12:58:22 +0200 Subject: [PATCH 5/5] Remove maxCharLimit from widget component types Removed the maxCharLimit property from the widget's component type definition. --- components/chat_widget/src/components.d.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/components/chat_widget/src/components.d.ts b/components/chat_widget/src/components.d.ts index 0016a032fc..14b8aad590 100644 --- a/components/chat_widget/src/components.d.ts +++ b/components/chat_widget/src/components.d.ts @@ -175,13 +175,10 @@ declare namespace LocalJSX { */ "language"?: string; /** -<<<<<<< feat/prevent-oversized-message-payloads-widget * Maximum number of characters allowed in a single message (derived from the model's token limit). When set, a live counter is shown and the send button is disabled when exceeded. */ "maxCharLimit"?: number; /** -======= ->>>>>>> main * The operating mode of the widget. - 'standard': Default floating window with launcher button. - 'kiosk': Fills parent container, always visible, no header or launcher button. The parent element must establish a containing block (e.g. `position: relative`). * @default 'standard' */