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/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/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": "Непідтримуваний тип файлу", diff --git a/components/chat_widget/src/components.d.ts b/components/chat_widget/src/components.d.ts index af7ed401e4..14b8aad590 100644 --- a/components/chat_widget/src/components.d.ts +++ b/components/chat_widget/src/components.d.ts @@ -51,6 +51,10 @@ export namespace Components { * The language code for the widget UI (e.g., 'en', 'es', 'fr'). Defaults to en */ "language"?: 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. + */ + "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' @@ -170,6 +174,10 @@ declare namespace LocalJSX { * The language code for the widget UI (e.g., 'en', 'es', 'fr'). Defaults to en */ "language"?: 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. + */ + "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' 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 e7a4791f5d..f045c1a18a 100644 --- a/components/chat_widget/src/components/ocs-chat/ocs-chat.css +++ b/components/chat_widget/src/components/ocs-chat/ocs-chat.css @@ -582,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.spec.tsx b/components/chat_widget/src/components/ocs-chat/ocs-chat.spec.tsx index 8c8ca3c589..4cc5232a55 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 42c3303b7d..b775e63e18 100644 --- a/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx +++ b/components/chat_widget/src/components/ocs-chat/ocs-chat.tsx @@ -206,10 +206,18 @@ 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; + /** + * 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; @@ -495,8 +503,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 @@ -1748,7 +1765,7 @@ export class OcsChat {