From 26e54dfcceab6102a1835d425bbdc5cf6b416d80 Mon Sep 17 00:00:00 2001 From: Raushen Date: Fri, 16 Oct 2026 13:14:56 +0300 Subject: [PATCH 1/5] Chat: Add tool call localization messages --- packages/devextreme/js/localization/messages/ar.json | 8 ++++++++ packages/devextreme/js/localization/messages/bg.json | 8 ++++++++ packages/devextreme/js/localization/messages/ca.json | 8 ++++++++ packages/devextreme/js/localization/messages/cs.json | 8 ++++++++ packages/devextreme/js/localization/messages/da.json | 8 ++++++++ packages/devextreme/js/localization/messages/de.json | 8 ++++++++ packages/devextreme/js/localization/messages/el.json | 8 ++++++++ packages/devextreme/js/localization/messages/en.json | 8 ++++++++ packages/devextreme/js/localization/messages/es.json | 8 ++++++++ packages/devextreme/js/localization/messages/fa.json | 8 ++++++++ packages/devextreme/js/localization/messages/fi.json | 8 ++++++++ packages/devextreme/js/localization/messages/fr.json | 8 ++++++++ packages/devextreme/js/localization/messages/hi.json | 8 ++++++++ packages/devextreme/js/localization/messages/hu.json | 8 ++++++++ packages/devextreme/js/localization/messages/it.json | 8 ++++++++ packages/devextreme/js/localization/messages/ja.json | 8 ++++++++ packages/devextreme/js/localization/messages/ko.json | 8 ++++++++ packages/devextreme/js/localization/messages/lt.json | 8 ++++++++ packages/devextreme/js/localization/messages/lv.json | 8 ++++++++ packages/devextreme/js/localization/messages/nb.json | 8 ++++++++ packages/devextreme/js/localization/messages/nl.json | 8 ++++++++ packages/devextreme/js/localization/messages/pl.json | 8 ++++++++ packages/devextreme/js/localization/messages/pt-BR.json | 8 ++++++++ packages/devextreme/js/localization/messages/pt-PT.json | 8 ++++++++ packages/devextreme/js/localization/messages/pt.json | 8 ++++++++ packages/devextreme/js/localization/messages/ro.json | 8 ++++++++ packages/devextreme/js/localization/messages/ru.json | 8 ++++++++ packages/devextreme/js/localization/messages/sl.json | 8 ++++++++ packages/devextreme/js/localization/messages/sv.json | 8 ++++++++ packages/devextreme/js/localization/messages/tr.json | 8 ++++++++ packages/devextreme/js/localization/messages/uk.json | 8 ++++++++ packages/devextreme/js/localization/messages/vi.json | 8 ++++++++ packages/devextreme/js/localization/messages/zh-CN.json | 8 ++++++++ packages/devextreme/js/localization/messages/zh-Hans.json | 8 ++++++++ packages/devextreme/js/localization/messages/zh-tw.json | 8 ++++++++ packages/devextreme/js/localization/messages/zh.json | 8 ++++++++ 36 files changed, 288 insertions(+) diff --git a/packages/devextreme/js/localization/messages/ar.json b/packages/devextreme/js/localization/messages/ar.json index 85e543638afd..f7b11a7c87e6 100644 --- a/packages/devextreme/js/localization/messages/ar.json +++ b/packages/devextreme/js/localization/messages/ar.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "قائمة الملفات", "dxChat-downloadButtonLabel": "تنزيل الملف {0}", "dxChat-fileLimitReachedWarning": "لقد حددت عددًا كبيرًا جدًا من الملفات. حدد {0} ملفات كحد أقصى وأعد المحاولة.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "أحمر", "dxColorView-ariaGreen": "أخضر", diff --git a/packages/devextreme/js/localization/messages/bg.json b/packages/devextreme/js/localization/messages/bg.json index 2c592eb99684..28dfbb1cf166 100644 --- a/packages/devextreme/js/localization/messages/bg.json +++ b/packages/devextreme/js/localization/messages/bg.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Списък с файлове", "dxChat-downloadButtonLabel": "Изтегляне на файл {0}", "dxChat-fileLimitReachedWarning": "Избрахте прекалено много файлове. Изберете не повече от {0} файла и опитайте отново.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Червен", "dxColorView-ariaGreen": "Зелен", diff --git a/packages/devextreme/js/localization/messages/ca.json b/packages/devextreme/js/localization/messages/ca.json index eab201bbff3c..a4bdadcc5396 100644 --- a/packages/devextreme/js/localization/messages/ca.json +++ b/packages/devextreme/js/localization/messages/ca.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Llista de fitxers", "dxChat-downloadButtonLabel": "Descarrega el fitxer {0}", "dxChat-fileLimitReachedWarning": "Heu seleccionat massa fitxers. Seleccioneu-ne com a màxim {0} i torneu-ho a provar.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Vermell", "dxColorView-ariaGreen": "Verd", diff --git a/packages/devextreme/js/localization/messages/cs.json b/packages/devextreme/js/localization/messages/cs.json index ca19654c7b24..5609e565f558 100644 --- a/packages/devextreme/js/localization/messages/cs.json +++ b/packages/devextreme/js/localization/messages/cs.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Seznam souborů", "dxChat-downloadButtonLabel": "Stáhnout soubor {0}", "dxChat-fileLimitReachedWarning": "Vybrali jste příliš mnoho souborů. Vyberte nejvýše {0} souborů a opakujte akci.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Červená", "dxColorView-ariaGreen": "Zelená", diff --git a/packages/devextreme/js/localization/messages/da.json b/packages/devextreme/js/localization/messages/da.json index c5989fb232c1..1465f4e61ec6 100644 --- a/packages/devextreme/js/localization/messages/da.json +++ b/packages/devextreme/js/localization/messages/da.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Filliste", "dxChat-downloadButtonLabel": "Download fil {0}", "dxChat-fileLimitReachedWarning": "Du valgte for mange filer. Vælg højst {0} filer og prøv igen.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rød", "dxColorView-ariaGreen": "Grøn", diff --git a/packages/devextreme/js/localization/messages/de.json b/packages/devextreme/js/localization/messages/de.json index c83c12789c94..d8756e0bb903 100644 --- a/packages/devextreme/js/localization/messages/de.json +++ b/packages/devextreme/js/localization/messages/de.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Dateiliste", "dxChat-downloadButtonLabel": "Datei {0} herunterladen", "dxChat-fileLimitReachedWarning": "Sie haben zu viele Dateien ausgewählt. Wählen Sie höchstens {0} Dateien aus und versuchen Sie es erneut.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rot", "dxColorView-ariaGreen": "Grün", diff --git a/packages/devextreme/js/localization/messages/el.json b/packages/devextreme/js/localization/messages/el.json index f3f3352632cc..9cb5ccf81f12 100644 --- a/packages/devextreme/js/localization/messages/el.json +++ b/packages/devextreme/js/localization/messages/el.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Λίστα αρχείων", "dxChat-downloadButtonLabel": "Λήψη αρχείου {0}", "dxChat-fileLimitReachedWarning": "Επιλέξατε πολλά αρχεία. Επιλέξτε έως {0} αρχεία και δοκιμάστε ξανά.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Κόκκινο", "dxColorView-ariaGreen": "Πράσινο", diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index 2c52bead08c9..7cf8fb5d46f3 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "File list", "dxChat-downloadButtonLabel": "Download file {0}", "dxChat-fileLimitReachedWarning": "You selected too many files. Select no more than {0} files and retry.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Red", "dxColorView-ariaGreen": "Green", diff --git a/packages/devextreme/js/localization/messages/es.json b/packages/devextreme/js/localization/messages/es.json index c2afe3faaebd..5db135a98051 100644 --- a/packages/devextreme/js/localization/messages/es.json +++ b/packages/devextreme/js/localization/messages/es.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Lista de archivos", "dxChat-downloadButtonLabel": "Descargar archivo {0}", "dxChat-fileLimitReachedWarning": "Ha seleccionado demasiados archivos. Seleccione no más de {0} archivos y reintente.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rojo", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/fa.json b/packages/devextreme/js/localization/messages/fa.json index 707b01fa7fbe..ff817e3fdb89 100644 --- a/packages/devextreme/js/localization/messages/fa.json +++ b/packages/devextreme/js/localization/messages/fa.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "لیست فایل", "dxChat-downloadButtonLabel": "دانلود فایل {0}", "dxChat-fileLimitReachedWarning": "تعداد فایل‌های انتخاب شده زیاد است. حداکثر {0} فایل انتخاب کنید و دوباره امتحان کنید.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "قرمز", "dxColorView-ariaGreen": "سبز", diff --git a/packages/devextreme/js/localization/messages/fi.json b/packages/devextreme/js/localization/messages/fi.json index c06a696c0653..2a8482466c27 100644 --- a/packages/devextreme/js/localization/messages/fi.json +++ b/packages/devextreme/js/localization/messages/fi.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Tiedostoluettelo", "dxChat-downloadButtonLabel": "Lataa tiedosto {0}", "dxChat-fileLimitReachedWarning": "Valitsit liian monta tiedostoa. Valitse enintään {0} tiedostoa ja yritä uudelleen.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Punainen", "dxColorView-ariaGreen": "Vihreä", diff --git a/packages/devextreme/js/localization/messages/fr.json b/packages/devextreme/js/localization/messages/fr.json index 333c53c5f317..9eacdca362f2 100644 --- a/packages/devextreme/js/localization/messages/fr.json +++ b/packages/devextreme/js/localization/messages/fr.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Liste des fichiers", "dxChat-downloadButtonLabel": "Télécharger le fichier {0}", "dxChat-fileLimitReachedWarning": "Vous avez sélectionné trop de fichiers. Sélectionnez au maximum {0} fichiers et réessayez.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rouge", "dxColorView-ariaGreen": "Vert", diff --git a/packages/devextreme/js/localization/messages/hi.json b/packages/devextreme/js/localization/messages/hi.json index 7e6d68262c36..daa25df60f0b 100644 --- a/packages/devextreme/js/localization/messages/hi.json +++ b/packages/devextreme/js/localization/messages/hi.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "फ़ाइल सूची", "dxChat-downloadButtonLabel": "फ़ाइल डाउनलोड करें {0}", "dxChat-fileLimitReachedWarning": "आपने बहुत सारी फ़ाइलें चुनीं. {0} से अधिक फ़ाइलें न चुनें और पुनः प्रयास करें।", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "लाल", "dxColorView-ariaGreen": "हरा", diff --git a/packages/devextreme/js/localization/messages/hu.json b/packages/devextreme/js/localization/messages/hu.json index d02011086aff..7c5a2400a290 100644 --- a/packages/devextreme/js/localization/messages/hu.json +++ b/packages/devextreme/js/localization/messages/hu.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Fájllista", "dxChat-downloadButtonLabel": "{0} fájl letöltése", "dxChat-fileLimitReachedWarning": "Túl sok fájlt választott. Válasszon legfeljebb {0} fájlt, és próbálja újra.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Piros", "dxColorView-ariaGreen": "Zöld", diff --git a/packages/devextreme/js/localization/messages/it.json b/packages/devextreme/js/localization/messages/it.json index 71f471dd89c5..945844973fb3 100644 --- a/packages/devextreme/js/localization/messages/it.json +++ b/packages/devextreme/js/localization/messages/it.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Lista file", "dxChat-downloadButtonLabel": "Scarica file {0}", "dxChat-fileLimitReachedWarning": "Hai selezionato troppi file. Seleziona al massimo {0} file e riprova.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rosso", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/ja.json b/packages/devextreme/js/localization/messages/ja.json index 2ea88450230a..33a84e3f8a61 100644 --- a/packages/devextreme/js/localization/messages/ja.json +++ b/packages/devextreme/js/localization/messages/ja.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "ファイル リスト", "dxChat-downloadButtonLabel": "ファイル {0} をダウンロード", "dxChat-fileLimitReachedWarning": "ファイルが多すぎます。{0} 件以下のファイルを選択してやり直してください。", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "赤", "dxColorView-ariaGreen": "緑", diff --git a/packages/devextreme/js/localization/messages/ko.json b/packages/devextreme/js/localization/messages/ko.json index daa2bbfa3c8a..4df01a3dcdad 100644 --- a/packages/devextreme/js/localization/messages/ko.json +++ b/packages/devextreme/js/localization/messages/ko.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "파일 목록", "dxChat-downloadButtonLabel": "파일 {0} 다운로드", "dxChat-fileLimitReachedWarning": "파일을 너무 많이 선택했습니다. {0}개 이하의 파일을 선택하고 다시 시도하세요.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "빨강", "dxColorView-ariaGreen": "초록", diff --git a/packages/devextreme/js/localization/messages/lt.json b/packages/devextreme/js/localization/messages/lt.json index 8e87c3a3e888..c99ed4b9504e 100644 --- a/packages/devextreme/js/localization/messages/lt.json +++ b/packages/devextreme/js/localization/messages/lt.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Failų sąrašas", "dxChat-downloadButtonLabel": "Atsisiųsti failą {0}", "dxChat-fileLimitReachedWarning": "Pasirinkote per daug failų. Pasirinkite ne daugiau kaip {0} failų ir bandykite dar kartą.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Raudona", "dxColorView-ariaGreen": "Žalia", diff --git a/packages/devextreme/js/localization/messages/lv.json b/packages/devextreme/js/localization/messages/lv.json index ed7ee7f7fb79..3b7796b6ab9b 100644 --- a/packages/devextreme/js/localization/messages/lv.json +++ b/packages/devextreme/js/localization/messages/lv.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Failu saraksts", "dxChat-downloadButtonLabel": "Lejupielādēt failu {0}", "dxChat-fileLimitReachedWarning": "Jūs izvēlējāties pārāk daudz failu. Izvēlieties ne vairāk kā {0} failus un mēģinājiet vēlreiz.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Sarkans", "dxColorView-ariaGreen": "Zaļš", diff --git a/packages/devextreme/js/localization/messages/nb.json b/packages/devextreme/js/localization/messages/nb.json index 1d44b60b895d..a263b05a2f93 100644 --- a/packages/devextreme/js/localization/messages/nb.json +++ b/packages/devextreme/js/localization/messages/nb.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Filliste", "dxChat-downloadButtonLabel": "Last ned fil {0}", "dxChat-fileLimitReachedWarning": "Du valgte for mange filer. Velg ikke mer enn {0} filer og prøv igjen.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rød", "dxColorView-ariaGreen": "Grønn", diff --git a/packages/devextreme/js/localization/messages/nl.json b/packages/devextreme/js/localization/messages/nl.json index 705874ff0bb7..f0eafc332f4d 100644 --- a/packages/devextreme/js/localization/messages/nl.json +++ b/packages/devextreme/js/localization/messages/nl.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Bestandslijst", "dxChat-downloadButtonLabel": "Bestand {0} downloaden", "dxChat-fileLimitReachedWarning": "U heeft te veel bestanden geselecteerd. Selecteer maximaal {0} bestanden en probeer opnieuw.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rood", "dxColorView-ariaGreen": "Groen", diff --git a/packages/devextreme/js/localization/messages/pl.json b/packages/devextreme/js/localization/messages/pl.json index a84c0ad03c99..922e64fa7a18 100644 --- a/packages/devextreme/js/localization/messages/pl.json +++ b/packages/devextreme/js/localization/messages/pl.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Lista plików", "dxChat-downloadButtonLabel": "Pobierz plik {0}", "dxChat-fileLimitReachedWarning": "Wybrano zbyt wiele plików. Wybierz maksymalnie {0} plików i spróbuj ponownie.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "czerwony", "dxColorView-ariaGreen": "zielony", diff --git a/packages/devextreme/js/localization/messages/pt-BR.json b/packages/devextreme/js/localization/messages/pt-BR.json index 2133cf60263e..536fa2531499 100644 --- a/packages/devextreme/js/localization/messages/pt-BR.json +++ b/packages/devextreme/js/localization/messages/pt-BR.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Lista de arquivos", "dxChat-downloadButtonLabel": "Baixar arquivo {0}", "dxChat-fileLimitReachedWarning": "Você selecionou arquivos demais. Selecione no máximo {0} arquivos e tente novamente.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Vermelho", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/pt-PT.json b/packages/devextreme/js/localization/messages/pt-PT.json index 5eb4b0c08b7b..bcaf434c3a7d 100644 --- a/packages/devextreme/js/localization/messages/pt-PT.json +++ b/packages/devextreme/js/localization/messages/pt-PT.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Lista de ficheiros", "dxChat-downloadButtonLabel": "Transferir ficheiro {0}", "dxChat-fileLimitReachedWarning": "Selecionou demasiados ficheiros. Selecione, no máximo, {0} ficheiros e tente novamente.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Vermelho", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/pt.json b/packages/devextreme/js/localization/messages/pt.json index 061f5c59aea5..b5fe5be240fb 100644 --- a/packages/devextreme/js/localization/messages/pt.json +++ b/packages/devextreme/js/localization/messages/pt.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Lista de arquivos", "dxChat-downloadButtonLabel": "Baixar arquivo {0}", "dxChat-fileLimitReachedWarning": "Você selecionou arquivos demais. Selecione no máximo {0} arquivos e tente novamente.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Vermelho", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/ro.json b/packages/devextreme/js/localization/messages/ro.json index bca83c34a9b6..4dd6fe0084de 100644 --- a/packages/devextreme/js/localization/messages/ro.json +++ b/packages/devextreme/js/localization/messages/ro.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Listă fișiere", "dxChat-downloadButtonLabel": "Descărcați fișierul {0}", "dxChat-fileLimitReachedWarning": "Ați selectat prea multe fișiere. Selectați cel mult {0} fișiere și încercați din nou.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Roșu", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/ru.json b/packages/devextreme/js/localization/messages/ru.json index 6405bc5e41ab..26636a92e373 100644 --- a/packages/devextreme/js/localization/messages/ru.json +++ b/packages/devextreme/js/localization/messages/ru.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Список файлов", "dxChat-downloadButtonLabel": "Скачать файл {0}", "dxChat-fileLimitReachedWarning": "Выбрано слишком много файлов. Выберите не более {0} файлов и повторите попытку.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Красный", "dxColorView-ariaGreen": "Зеленый", diff --git a/packages/devextreme/js/localization/messages/sl.json b/packages/devextreme/js/localization/messages/sl.json index 8ea1c40a8026..ea78aad1ee25 100644 --- a/packages/devextreme/js/localization/messages/sl.json +++ b/packages/devextreme/js/localization/messages/sl.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Seznam datotek", "dxChat-downloadButtonLabel": "Prenesi datoteko {0}", "dxChat-fileLimitReachedWarning": "Izbrali ste preveč datotek. Izberite največ {0} datotek in poskusite znova.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Rdeča", "dxColorView-ariaGreen": "Zelena", diff --git a/packages/devextreme/js/localization/messages/sv.json b/packages/devextreme/js/localization/messages/sv.json index 5aafe586fbe7..8313feb00f67 100644 --- a/packages/devextreme/js/localization/messages/sv.json +++ b/packages/devextreme/js/localization/messages/sv.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Fillista", "dxChat-downloadButtonLabel": "Ladda ner fil {0}", "dxChat-fileLimitReachedWarning": "Du valde för många filer. Välj högst {0} filer och försök igen.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Röd", "dxColorView-ariaGreen": "Grön", diff --git a/packages/devextreme/js/localization/messages/tr.json b/packages/devextreme/js/localization/messages/tr.json index c7f6be4d4d2b..12c94e300b84 100644 --- a/packages/devextreme/js/localization/messages/tr.json +++ b/packages/devextreme/js/localization/messages/tr.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Dosya listesi", "dxChat-downloadButtonLabel": "{0} dosyasını indir", "dxChat-fileLimitReachedWarning": "Çok fazla dosya seçtiniz. En fazla {0} dosya seçip tekrar deneyin.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Kırmızı", "dxColorView-ariaGreen": "Yeşil", diff --git a/packages/devextreme/js/localization/messages/uk.json b/packages/devextreme/js/localization/messages/uk.json index 1c665ab796a9..73f1ae2023ae 100644 --- a/packages/devextreme/js/localization/messages/uk.json +++ b/packages/devextreme/js/localization/messages/uk.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Список файлів", "dxChat-downloadButtonLabel": "Завантажити файл {0}", "dxChat-fileLimitReachedWarning": "Ви вибрали забагато файлів. Виберіть не більше {0} файлів і повторіть спробу.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Червоний", "dxColorView-ariaGreen": "Зелений", diff --git a/packages/devextreme/js/localization/messages/vi.json b/packages/devextreme/js/localization/messages/vi.json index 9ee7e755fcad..2faa7f6e5eba 100644 --- a/packages/devextreme/js/localization/messages/vi.json +++ b/packages/devextreme/js/localization/messages/vi.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "Danh sách tập tin", "dxChat-downloadButtonLabel": "Tải xuống tập tin {0}", "dxChat-fileLimitReachedWarning": "Bạn đã chọn quá nhiều tập tin. Chọn không quá {0} tập tin và thử lại.", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "Đỏ", "dxColorView-ariaGreen": "Xanh lá", diff --git a/packages/devextreme/js/localization/messages/zh-CN.json b/packages/devextreme/js/localization/messages/zh-CN.json index ebafca4e1d3e..7822d2210c22 100644 --- a/packages/devextreme/js/localization/messages/zh-CN.json +++ b/packages/devextreme/js/localization/messages/zh-CN.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "文件列表", "dxChat-downloadButtonLabel": "下载文件 {0}", "dxChat-fileLimitReachedWarning": "您选择的文件太多。请选择不超过 {0} 个文件后重试。", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "红色", "dxColorView-ariaGreen": "绿色", diff --git a/packages/devextreme/js/localization/messages/zh-Hans.json b/packages/devextreme/js/localization/messages/zh-Hans.json index 4bc731c5ee5a..8f8f1e5ef8c0 100644 --- a/packages/devextreme/js/localization/messages/zh-Hans.json +++ b/packages/devextreme/js/localization/messages/zh-Hans.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "文件列表", "dxChat-downloadButtonLabel": "下载文件 {0}", "dxChat-fileLimitReachedWarning": "您选择的文件太多。请选择不超过 {0} 个文件后重试。", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "红色", "dxColorView-ariaGreen": "绿色", diff --git a/packages/devextreme/js/localization/messages/zh-tw.json b/packages/devextreme/js/localization/messages/zh-tw.json index d5377dd10953..076e53eb5c3c 100644 --- a/packages/devextreme/js/localization/messages/zh-tw.json +++ b/packages/devextreme/js/localization/messages/zh-tw.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "檔案清單", "dxChat-downloadButtonLabel": "下載檔案 {0}", "dxChat-fileLimitReachedWarning": "您選擇的檔案過多。請選擇不超過 {0} 個檔案後重試。", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "紅色", "dxColorView-ariaGreen": "綠色", diff --git a/packages/devextreme/js/localization/messages/zh.json b/packages/devextreme/js/localization/messages/zh.json index 18e073177317..716acabcf9f2 100644 --- a/packages/devextreme/js/localization/messages/zh.json +++ b/packages/devextreme/js/localization/messages/zh.json @@ -448,6 +448,14 @@ "dxChat-fileViewLabel": "文件列表", "dxChat-downloadButtonLabel": "下载文件 {0}", "dxChat-fileLimitReachedWarning": "您选择的文件太多。请选择不超过 {0} 个文件后重试。", + "dxChat-toolCallsTitle": "{0} tools", + "dxChat-toolCallsSingleErrorText": "1 Error", + "dxChat-toolCallsMultipleErrorsText": "{0} Errors", + "dxChat-toolCallArgumentsLabel": "Arguments", + "dxChat-toolCallResultLabel": "Result", + "dxChat-toolCallErrorText": "Error", + "dxChat-copyButtonLabel": "Copy", + "dxChat-copiedButtonLabel": "Copied", "dxColorView-ariaRed": "红色", "dxColorView-ariaGreen": "绿色", From a91deda9058fd417a79a9e20dd68d1040f2e6a6b Mon Sep 17 00:00:00 2001 From: Raushen Date: Fri, 16 Oct 2026 13:15:02 +0300 Subject: [PATCH 2/5] Chat: Render tool calls in the message bubble --- .../devextreme/js/__internal/ui/chat/chat.ts | 9 + .../js/__internal/ui/chat/messagebubble.ts | 54 +++- .../js/__internal/ui/chat/messagegroup.ts | 12 +- .../js/__internal/ui/chat/messagelist.ts | 6 +- .../ui/chat/tool_call_view/tool_call_view.ts | 297 ++++++++++++++++++ 5 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/chat/tool_call_view/tool_call_view.ts diff --git a/packages/devextreme/js/__internal/ui/chat/chat.ts b/packages/devextreme/js/__internal/ui/chat/chat.ts index b8559be2593b..1fdcbb68279f 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat.ts @@ -117,6 +117,10 @@ class Chat extends Widget { showUserName: true, speechToTextEnabled: false, speechToTextOptions: undefined, + toolCallOptions: { + showInMessageTemplate: false, + expandAll: false, + }, typingUsers: [], user: { id: new Guid().toString() }, sendButtonOptions: { @@ -225,6 +229,7 @@ class Chat extends Widget { dayHeaderFormat, messageTimestampFormat, typingUsers = [], + toolCallOptions, } = this.option(); // @ts-expect-error @@ -248,6 +253,7 @@ class Chat extends Widget { messageTimestampFormat, typingUsers, isLoading, + toolCallOptions, onMessageEditingStart: (e) => { this._messageEditingStartHandler(e); @@ -814,6 +820,9 @@ class Chat extends Widget { case 'messageTemplate': this._messageList.option(name, this._getMessageTemplate()); break; + case 'toolCallOptions': + this._messageList.option(fullName, value); + break; case 'emptyViewTemplate': this._messageList.option(name, this._getEmptyViewTemplate()); break; diff --git a/packages/devextreme/js/__internal/ui/chat/messagebubble.ts b/packages/devextreme/js/__internal/ui/chat/messagebubble.ts index 057437de38cc..f49bd2370590 100644 --- a/packages/devextreme/js/__internal/ui/chat/messagebubble.ts +++ b/packages/devextreme/js/__internal/ui/chat/messagebubble.ts @@ -2,12 +2,15 @@ import messageLocalization from '@js/common/core/localization/message'; import { getPublicElement } from '@js/core/element'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; -import type { Attachment, AttachmentDownloadClickEvent, Message } from '@js/ui/chat'; +import type { + Attachment, AttachmentDownloadClickEvent, Message, ToolCall, ToolCallOptions, +} from '@js/ui/chat'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; import { ICON_CLASS } from '@ts/core/utils/icon'; import type { OptionChanged } from '@ts/core/widget/types'; import Widget from '@ts/core/widget/widget'; import FileView from '@ts/ui/chat/file_view/file_view'; +import ToolCallView from '@ts/ui/chat/tool_call_view/tool_call_view'; export const CHAT_MESSAGEBUBBLE_CLASS = 'dx-chat-messagebubble'; export const CHAT_MESSAGEBUBBLE_DELETED_CLASS = 'dx-chat-messagebubble-deleted'; @@ -26,6 +29,8 @@ export interface Properties extends WidgetOptions { src?: string; alt?: string; attachments?: Attachment[]; + toolCalls?: ToolCall[]; + toolCallOptions?: ToolCallOptions; onAttachmentDownloadClick?: (e: AttachmentDownloadClickEvent) => void; template?: ((message: Message, container: Element) => void) | null; } @@ -35,6 +40,8 @@ class MessageBubble extends Widget { _$attachments?: dxElementWrapper; + _$toolCalls?: dxElementWrapper; + _getDefaultOptions(): Properties { return { ...super._getDefaultOptions(), @@ -53,9 +60,11 @@ class MessageBubble extends Widget { super._initMarkup(); this._renderContentContainer(); + this._renderToolCallsElement(); this._renderAttachmentsElement(); this._updateContent(); + this._renderToolCalls(); this._renderAttachments(); } @@ -65,6 +74,20 @@ class MessageBubble extends Widget { .appendTo(this.$element()); } + _renderToolCallsElement(): void { + const { + toolCalls, toolCallOptions, isDeleted, template, + } = this.option(); + const isReplacedByTemplate = Boolean(template) && !toolCallOptions?.showInMessageTemplate; + + this._$toolCalls?.remove(); + this._$toolCalls = undefined; + + if (toolCalls?.length && !isDeleted && !isReplacedByTemplate) { + this._$toolCalls = $('
').prependTo(this.$element()); + } + } + _renderAttachmentsElement(): void { const { attachments, isDeleted } = this.option(); @@ -132,6 +155,19 @@ class MessageBubble extends Widget { } } + _renderToolCalls(): void { + const { toolCalls, toolCallOptions } = this.option(); + + if (!this._$toolCalls) { + return; + } + + this._createComponent(this._$toolCalls, ToolCallView, { + toolCalls, + expandAll: toolCallOptions?.expandAll, + }); + } + _renderAttachments(): void { const { attachments, @@ -163,10 +199,17 @@ class MessageBubble extends Widget { const { name, value } = args; switch (name) { + case 'isDeleted': + this._updateMessageData(name, value); + this._updateContent(); + this._renderToolCallsElement(); + this._renderToolCalls(); + this._renderAttachmentsElement(); + this._renderAttachments(); + break; case 'text': case 'src': case 'alt': - case 'isDeleted': this._updateMessageData(name, value); this._updateContent(); this._renderAttachmentsElement(); @@ -179,10 +222,17 @@ class MessageBubble extends Widget { break; case 'template': this._updateContent(); + this._renderToolCallsElement(); + this._renderToolCalls(); break; case 'isEdited': this._updateMessageData(name, value); break; + case 'toolCalls': + case 'toolCallOptions': + this._renderToolCallsElement(); + this._renderToolCalls(); + break; case 'onAttachmentDownloadClick': case 'attachments': this._renderAttachmentsElement(); diff --git a/packages/devextreme/js/__internal/ui/chat/messagegroup.ts b/packages/devextreme/js/__internal/ui/chat/messagegroup.ts index d4bef516f829..6bb828e278ce 100644 --- a/packages/devextreme/js/__internal/ui/chat/messagegroup.ts +++ b/packages/devextreme/js/__internal/ui/chat/messagegroup.ts @@ -6,7 +6,7 @@ import $ from '@js/core/renderer'; import dateSerialization from '@js/core/utils/date_serialization'; import { isDate, isDefined } from '@js/core/utils/type'; import type { - AttachmentDownloadClickEvent, ImageMessage, Message, TextMessage, + AttachmentDownloadClickEvent, ImageMessage, Message, TextMessage, ToolCallOptions, } from '@js/ui/chat'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; import type { OptionChanged } from '@ts/core/widget/types'; @@ -42,6 +42,7 @@ export interface Properties extends WidgetOptions { showMessageTimestamp: boolean; messageTimestampFormat?: Format; messageTemplate?: MessageTemplate; + toolCallOptions?: ToolCallOptions; onAttachmentDownloadClick?: (e: AttachmentDownloadClickEvent) => void; } @@ -123,14 +124,18 @@ class MessageGroup extends Widget { } _getMessageBubbleOptions(message: Message): MessageBubbleProperties { - const { messageTemplate, onAttachmentDownloadClick } = this.option(); + const { messageTemplate, toolCallOptions, onAttachmentDownloadClick } = this.option(); - const { isDeleted, type, attachments } = message; + const { + isDeleted, type, attachments, toolCalls, + } = message; const options: MessageBubbleProperties = { isDeleted, type, attachments, + toolCalls, + toolCallOptions, onAttachmentDownloadClick, }; @@ -313,6 +318,7 @@ class MessageGroup extends Widget { case 'showMessageTimestamp': case 'messageTemplate': case 'messageTimestampFormat': + case 'toolCallOptions': case 'onAttachmentDownloadClick': this._invalidate(); break; diff --git a/packages/devextreme/js/__internal/ui/chat/messagelist.ts b/packages/devextreme/js/__internal/ui/chat/messagelist.ts index b4635624f17b..ca743849deda 100644 --- a/packages/devextreme/js/__internal/ui/chat/messagelist.ts +++ b/packages/devextreme/js/__internal/ui/chat/messagelist.ts @@ -14,7 +14,7 @@ import { getHeight } from '@js/core/utils/size'; import { isDate, isDefined } from '@js/core/utils/type'; import type { DxEvent } from '@js/events'; import type { - AttachmentDownloadClickEvent, Message, TextMessage, User, + AttachmentDownloadClickEvent, Message, TextMessage, ToolCallOptions, User, } from '@js/ui/chat'; import type { Item as ContextMenuItem } from '@js/ui/context_menu'; import type dxContextMenu from '@js/ui/context_menu'; @@ -104,6 +104,7 @@ export interface Properties extends WidgetOptions { showAvatar: boolean; showUserName: boolean; showMessageTimestamp: boolean; + toolCallOptions?: ToolCallOptions; onAttachmentDownloadClick?: (e: AttachmentDownloadClickEvent) => void; onMessageEditingStart?: (e: MessageEditingEvent) => () => void; onMessageDeleting?: (e: MessageDeletingEvent) => void; @@ -278,6 +279,7 @@ class MessageList extends Widget { showMessageTimestamp, messageTimestampFormat, messageTemplate, + toolCallOptions, onAttachmentDownloadClick, } = this.option(); @@ -291,6 +293,7 @@ class MessageList extends Widget { showMessageTimestamp, messageTimestampFormat, messageTemplate, + toolCallOptions, onAttachmentDownloadClick, }); } @@ -850,6 +853,7 @@ class MessageList extends Widget { case 'emptyViewTemplate': case 'dayHeaderFormat': case 'messageTimestampFormat': + case 'toolCallOptions': case 'onAttachmentDownloadClick': this._invalidate(); break; diff --git a/packages/devextreme/js/__internal/ui/chat/tool_call_view/tool_call_view.ts b/packages/devextreme/js/__internal/ui/chat/tool_call_view/tool_call_view.ts new file mode 100644 index 000000000000..dcae6fd3aa8c --- /dev/null +++ b/packages/devextreme/js/__internal/ui/chat/tool_call_view/tool_call_view.ts @@ -0,0 +1,297 @@ +import { name as clickEventName } from '@js/common/core/events/click'; +import eventsEngine from '@js/common/core/events/core/events_engine'; +import { addNamespace } from '@js/common/core/events/utils'; +import messageLocalization from '@js/common/core/localization/message'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { noop } from '@js/core/utils/common'; +import { isString } from '@js/core/utils/type'; +import type { Item as AccordionItem } from '@js/ui/accordion'; +import type { ButtonType } from '@js/ui/button'; +import type { ToolCall } from '@js/ui/chat'; +import { ICON_CLASS } from '@ts/core/utils/icon'; +import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; +import DOMComponent from '@ts/core/widget/dom_component'; +import type { OptionChanged } from '@ts/core/widget/types'; +import Accordion from '@ts/ui/accordion'; +import TextArea from '@ts/ui/text_area'; + +export interface ToolCallViewProperties extends DOMComponentProperties { + toolCalls?: ToolCall[]; + expandAll?: boolean; +} + +interface FieldConfig { + label: string; + value: string; + maxHeight: number; + isError?: boolean; +} + +interface CopyButtonState { + icon: string; + hint: string; + type: ButtonType; +} + +export const CHAT_TOOL_CALL_VIEW_CLASS = 'dx-chat-tool-call-view'; +export const CHAT_TOOL_CALL_HEADER_CLASS = 'dx-chat-tool-call-header'; +export const CHAT_TOOL_CALL_HEADER_TEXT_CLASS = 'dx-chat-tool-call-header-text'; +export const CHAT_TOOL_CALL_TITLE_CLASS = 'dx-chat-tool-call-title'; +export const CHAT_TOOL_CALL_ICON_CLASS = `${ICON_CLASS}-toolbox`; +export const CHAT_TOOL_CALL_NAME_CLASS = 'dx-chat-tool-call-name'; +export const CHAT_TOOL_CALL_ERROR_BADGE_CLASS = 'dx-chat-tool-call-error-badge'; +export const CHAT_TOOL_CALL_CONTENT_CLASS = 'dx-chat-tool-call-content'; +export const CHAT_TOOL_CALL_FIELD_CLASS = 'dx-chat-tool-call-field'; +export const CHAT_TOOL_CALL_FIELD_LABEL_CLASS = 'dx-chat-tool-call-field-label'; +export const CHAT_TOOL_CALL_FIELD_ERROR_CLASS = 'dx-chat-tool-call-field-error'; + +export const COPY_BUTTON_NAME = 'copy'; +export const COPIED_STATE_DURATION = 2000; +const ARGUMENTS_MAX_HEIGHT = 320; +const RESULT_MAX_HEIGHT = 240; +const FIELD_CLICK_EVENT_NAME = addNamespace(clickEventName, 'dxChatToolCallView'); + +const formatValue = (value: unknown): string => (Array.isArray(value) + ? `[${value.map(formatValue).join(', ')}]` + : JSON.stringify(value)); + +const formatArguments = (args: ToolCall['arguments'] = {}): string => Object.entries(args) + .map(([key, value]) => `${key}: ${formatValue(value)}`) + .join(', '); + +const formatResult = (result: unknown): string => { + if (result === undefined) { + return ''; + } + + return isString(result) ? result : JSON.stringify(result, null, 2); +}; + +const formatErrorCount = (errorCount: number): string => (errorCount === 1 + ? messageLocalization.format('dxChat-toolCallsSingleErrorText') + : messageLocalization.format('dxChat-toolCallsMultipleErrorsText', errorCount)); + +class ToolCallView extends DOMComponent { + _getDefaultOptions(): ToolCallViewProperties { + return { + ...super._getDefaultOptions(), + toolCalls: [], + expandAll: false, + }; + } + + _initMarkup(): void { + this.$element().addClass(CHAT_TOOL_CALL_VIEW_CLASS); + + super._initMarkup(); + + this._renderToolCalls(); + } + + _renderToolCalls(): void { + const { toolCalls = [], expandAll = false } = this.option(); + + if (!toolCalls.length) { + return; + } + + if (toolCalls.length > 1) { + this._renderHeader(toolCalls); + } + + const items = toolCalls.map((toolCall): AccordionItem => ({ + titleTemplate: (): Element => this._createTitle(toolCall), + template: (_data, _index, element): Element => { + const $content = this._createContent(element); + + this._renderToolCallFields($content, toolCall); + + return $content.get(0); + }, + })); + const selectedItems = items.filter((_item, index) => toolCalls[index].expanded ?? expandAll); + + this._createComponent($('
').appendTo(this.$element()), Accordion, { + items, + collapsible: true, + multiple: true, + selectedItems, + }); + } + + _renderHeader(toolCalls: ToolCall[]): void { + const $header = $('
') + .addClass(CHAT_TOOL_CALL_HEADER_CLASS) + .appendTo(this.$element()); + + $('
') + .addClass(CHAT_TOOL_CALL_HEADER_TEXT_CLASS) + .text(messageLocalization.format('dxChat-toolCallsTitle', toolCalls.length)) + .appendTo($header); + + const errorCount = toolCalls.filter(({ status }) => status === 'error').length; + + if (errorCount) { + this._createErrorBadge(formatErrorCount(errorCount)).appendTo($header); + } + } + + _createTitle(toolCall: ToolCall): Element { + const { name, status } = toolCall; + const $title = $('
').addClass(CHAT_TOOL_CALL_TITLE_CLASS); + + $('
') + .addClass(ICON_CLASS) + .addClass(CHAT_TOOL_CALL_ICON_CLASS) + .appendTo($title); + + $('
') + .addClass(CHAT_TOOL_CALL_NAME_CLASS) + .text(name) + .attr('title', name) + .appendTo($title); + + if (status === 'error') { + this._createErrorBadge(messageLocalization.format('dxChat-toolCallErrorText')).appendTo($title); + } + + return $title.get(0); + } + + _createErrorBadge(text: string): dxElementWrapper { + return $('
') + .addClass(CHAT_TOOL_CALL_ERROR_BADGE_CLASS) + .text(text); + } + + _createContent(container: Element): dxElementWrapper { + return $('
') + .addClass(CHAT_TOOL_CALL_CONTENT_CLASS) + .appendTo(container); + } + + _renderToolCallFields($container: dxElementWrapper, toolCall: ToolCall): void { + const argumentsText = formatArguments(toolCall.arguments); + const resultText = formatResult(toolCall.result); + + if (argumentsText) { + this._renderField($container, { + label: messageLocalization.format('dxChat-toolCallArgumentsLabel'), + value: argumentsText, + maxHeight: ARGUMENTS_MAX_HEIGHT, + }); + } + + if (resultText) { + this._renderField($container, { + label: messageLocalization.format('dxChat-toolCallResultLabel'), + value: resultText, + maxHeight: RESULT_MAX_HEIGHT, + isError: toolCall.status === 'error', + }); + } + } + + _renderField($container: dxElementWrapper, config: FieldConfig): void { + const { + label, value, maxHeight, isError = false, + } = config; + + const $field = $('
') + .addClass(CHAT_TOOL_CALL_FIELD_CLASS) + .toggleClass(CHAT_TOOL_CALL_FIELD_ERROR_CLASS, isError) + .appendTo($container); + + $('
') + .addClass(CHAT_TOOL_CALL_FIELD_LABEL_CLASS) + .text(label) + .appendTo($field); + + const $editor = $('
').appendTo($field); + + const copyState: CopyButtonState = { + icon: 'copy', + hint: messageLocalization.format('dxChat-copyButtonLabel'), + type: 'normal', + }; + const copiedState: CopyButtonState = { + icon: 'check', + hint: messageLocalization.format('dxChat-copiedButtonLabel'), + type: 'success', + }; + + const textArea = this._createComponent($editor, TextArea, { + value, + readOnly: true, + stylingMode: 'outlined', + autoResizeEnabled: true, + maxHeight, + hoverStateEnabled: false, + focusStateEnabled: false, + inputAttr: { 'aria-label': label }, + buttons: [{ + name: COPY_BUTTON_NAME, + location: 'after', + options: { + ...copyState, + elementAttr: { 'aria-label': copyState.hint }, + stylingMode: 'text', + disabled: false, + }, + }], + }); + const copyButton = textArea.getButton(COPY_BUTTON_NAME); + const copiedStateTimer: { id?: ReturnType } = {}; + + const setCopyButtonState = (state: CopyButtonState): void => { + copyButton?.option({ + ...state, + elementAttr: { 'aria-label': state.hint }, + }); + }; + + const copyValue = (): void => { + navigator.clipboard?.writeText(value) + .then(() => { + setCopyButtonState(copiedState); + + clearTimeout(copiedStateTimer.id); + // eslint-disable-next-line no-restricted-globals + copiedStateTimer.id = setTimeout(() => { + setCopyButtonState(copyState); + }, COPIED_STATE_DURATION); + }) + .catch(noop); + }; + + copyButton?.option('onClick', copyValue); + + textArea.on('disposing', () => { + clearTimeout(copiedStateTimer.id); + }); + + eventsEngine.on($editor, FIELD_CLICK_EVENT_NAME, copyValue); + } + + _clean(): void { + this.$element().empty(); + + super._clean(); + } + + _optionChanged(args: OptionChanged): void { + const { name } = args; + + switch (name) { + case 'toolCalls': + case 'expandAll': + this._invalidate(); + break; + default: + super._optionChanged(args); + } + } +} + +export default ToolCallView; From 366c30ed58a5584aa65580b201f154cff3d39325 Mon Sep 17 00:00:00 2001 From: Raushen Date: Tue, 27 Oct 2026 02:05:09 +0200 Subject: [PATCH 3/5] Chat: Cover the tool call API with QUnit tests --- .../chat.markup.tests.js | 1 + .../tests/DevExpress.ui.widgets/chat.tests.js | 1 + .../chatParts/chat.markup.tests.js | 86 +++ .../chatParts/chat.tests.js | 442 +++++++++++++++ .../chatParts/messageBubble.markup.tests.js | 9 + .../chatParts/messageBubble.tests.js | 164 ++++++ .../chatParts/toolCallView.markup.tests.js | 78 +++ .../chatParts/toolCallView.tests.js | 518 ++++++++++++++++++ 8 files changed, 1299 insertions(+) create mode 100644 packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.markup.tests.js create mode 100644 packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.tests.js diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js index f1374c9cfdf8..40ad637d2bb7 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js @@ -21,3 +21,4 @@ import './chatParts/editingPreview.markup.tests.js'; import './chatParts/chatTextArea.markup.tests.js'; import './chatParts/chat.markup.tests.js'; import './chatParts/fileView.markup.tests.js'; +import './chatParts/toolCallView.markup.tests.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js index 3f1520ae2c8c..fc4d630a1888 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js @@ -21,3 +21,4 @@ import './chatParts/editingPreview.tests.js'; import './chatParts/chatTextArea.tests.js'; import './chatParts/chat.tests.js'; import './chatParts/fileView.tests.js'; +import './chatParts/toolCallView.tests.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.markup.tests.js index 5a3cc0f7e316..326df5652b02 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.markup.tests.js @@ -8,6 +8,14 @@ const CHAT_MESSAGELIST_CLASS = 'dx-chat-messagelist'; const CHAT_ALERTLIST_CLASS = 'dx-chat-alertlist'; const CHAT_MESSAGELIST_EMPTY_VIEW_CLASS = 'dx-chat-messagelist-empty-view'; const CHAT_TYPINGINDICATOR_CLASS = 'dx-chat-typingindicator'; +const CHAT_MESSAGEBUBBLE_CLASS = 'dx-chat-messagebubble'; +const CHAT_MESSAGEBUBBLE_CONTENT_CLASS = 'dx-chat-messagebubble-content'; +const CHAT_FILE_VIEW_CLASS = 'dx-chat-file-view'; +const CHAT_TOOL_CALL_VIEW_CLASS = 'dx-chat-tool-call-view'; +const CHAT_TOOL_CALL_HEADER_CLASS = 'dx-chat-tool-call-header'; +const CHAT_TOOL_CALL_NAME_CLASS = 'dx-chat-tool-call-name'; +const ACCORDION_CLASS = 'dx-accordion'; +const ACCORDION_ITEM_CLASS = 'dx-accordion-item'; const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; const moduleConfig = { @@ -60,6 +68,84 @@ QUnit.module('Chat', moduleConfig, () => { }); }); + QUnit.module('Tool calls', { + beforeEach() { + this.getBubbleChildren = () => this.$element.find(`.${CHAT_MESSAGEBUBBLE_CLASS}`).children(); + this.getToolCallView = () => this.$element.find(`.${CHAT_TOOL_CALL_VIEW_CLASS}`); + this.getToolCallNames = () => this.$element + .find(`.${CHAT_TOOL_CALL_NAME_CLASS}`) + .map((_, element) => $(element).text()) + .get(); + }, + }, () => { + QUnit.test('bubble of a message without tool calls should contain only the content element', function(assert) { + this.reinit({ items: [{ text: 'answer' }] }); + + const $children = this.getBubbleChildren(); + + assert.strictEqual($children.length, 1, 'bubble has one child'); + assert.strictEqual($children.eq(0).hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true, 'the child is the content'); + }); + + QUnit.test('bubble should contain the tool calls, the content and the attachments in this order', function(assert) { + this.reinit({ + items: [{ + text: 'answer', + toolCalls: [{ name: 'getWeather' }], + attachments: [{ name: 'report.pdf', size: 1024 }], + }], + }); + + const $children = this.getBubbleChildren(); + + assert.strictEqual($children.eq(0).hasClass(CHAT_TOOL_CALL_VIEW_CLASS), true, 'tool calls are first'); + assert.strictEqual($children.eq(1).hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true, 'content is second'); + assert.strictEqual($children.eq(2).hasClass(CHAT_FILE_VIEW_CLASS), true, 'attachments are third'); + }); + + QUnit.test('should render one accordion without the header for a single tool call', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ name: 'getWeather' }] }] }); + + const $view = this.getToolCallView(); + + assert.strictEqual($view.children(`.${ACCORDION_CLASS}`).length, 1, 'one accordion is rendered'); + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'accordion has an item for the tool call'); + assert.strictEqual($view.children(`.${CHAT_TOOL_CALL_HEADER_CLASS}`).length, 0, 'header is not rendered'); + }); + + QUnit.test('should render the header above one accordion with an item for every tool call for several tool calls', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ name: 'getWeather' }, { name: 'getCurrentTime' }] }] }); + + const $children = this.getToolCallView().children(); + + assert.strictEqual($children.length, 2, 'block has two children'); + assert.strictEqual($children.eq(0).hasClass(CHAT_TOOL_CALL_HEADER_CLASS), true, 'header is first'); + assert.strictEqual($children.eq(0).text(), '2 tools', 'header contains the count'); + assert.strictEqual($children.eq(1).hasClass(ACCORDION_CLASS), true, 'accordion is second'); + assert.strictEqual($children.eq(1).find(`.${ACCORDION_ITEM_CLASS}`).length, 2, 'accordion has an item for every tool call'); + assert.deepEqual(this.getToolCallNames(), ['getWeather', 'getCurrentTime'], 'items are titled with the tool names'); + }); + + QUnit.test('bubble of a message with tool calls and without text should contain the tool calls and the empty content', function(assert) { + this.reinit({ items: [{ toolCalls: [{ name: 'getWeather' }] }] }); + + const $children = this.getBubbleChildren(); + + assert.strictEqual($children.eq(0).hasClass(CHAT_TOOL_CALL_VIEW_CLASS), true, 'tool calls are rendered'); + assert.strictEqual($children.eq(1).hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true, 'content follows the tool calls'); + assert.strictEqual($children.eq(1).text(), '', 'content is empty'); + }); + + QUnit.test('should render a tool call name as text', function(assert) { + const name = 'getWeather'; + + this.reinit({ items: [{ text: 'answer', toolCalls: [{ name }] }] }); + + assert.deepEqual(this.getToolCallNames(), [name], 'name is rendered as text'); + assert.strictEqual(this.getToolCallView().find('b').length, 0, 'name is not parsed as markup'); + }); + }); + QUnit.module('Accessibility', () => { [ { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js index b778eccb8a15..41b4f38fddd2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js @@ -41,6 +41,16 @@ import { CHAT_CONFIRMATION_POPUP_WRAPPER_CLASS } from '__internal/ui/chat/confir import { POPUP_CLASS } from '__internal/ui/popup/popup'; import { BUTTON_CLASS } from '__internal/ui/button/button'; import { CHAT_FILE_CLASS } from '__internal/ui/chat/file_view/file'; +import { + CHAT_TOOL_CALL_ERROR_BADGE_CLASS, + CHAT_TOOL_CALL_FIELD_CLASS, + CHAT_TOOL_CALL_FIELD_ERROR_CLASS, + CHAT_TOOL_CALL_FIELD_LABEL_CLASS, + CHAT_TOOL_CALL_HEADER_CLASS, + CHAT_TOOL_CALL_HEADER_TEXT_CLASS, + CHAT_TOOL_CALL_NAME_CLASS, + CHAT_TOOL_CALL_VIEW_CLASS, +} from '__internal/ui/chat/tool_call_view/tool_call_view'; import MessageBubble from '__internal/ui/chat/messagebubble'; import ChatTextArea, { @@ -67,6 +77,10 @@ const CHAT_LAST_MESSAGEGROUP_ALIGNMENT_START_CLASS = 'dx-chat-last-messagegroup- const CHAT_LAST_MESSAGEGROUP_ALIGNMENT_END_CLASS = 'dx-chat-last-messagegroup-alignment-end'; const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; +const ACCORDION_CLASS = 'dx-accordion'; +const ACCORDION_ITEM_CLASS = 'dx-accordion-item'; +const ACCORDION_ITEM_OPENED_CLASS = 'dx-accordion-item-opened'; +const ACCORDION_ITEM_TITLE_CLASS = 'dx-accordion-item-title'; const RTL_CLASS = 'dx-rtl'; const ANIMATION_TIMEOUT = 250; @@ -157,6 +171,26 @@ const moduleConfig = { this.getSuggestionsElement = () => this.instance._suggestions._$element; this.getSuggestionItems = () => this.$element.find(`.${BUTTON_GROUP_ITEM_CLASS}`); this.getSuggestionButtonGroup = () => this.instance._suggestions._buttonGroup; + this.getToolCallView = () => this.$element.find(`.${CHAT_TOOL_CALL_VIEW_CLASS}`); + this.getToolCallNames = () => this.$element + .find(`.${CHAT_TOOL_CALL_NAME_CLASS}`) + .map((_, element) => $(element).text()) + .get(); + this.getToolCallTitles = () => this.$element.find(`.${CHAT_TOOL_CALL_VIEW_CLASS} .${ACCORDION_ITEM_TITLE_CLASS}`); + this.getOpenedToolCallIndexes = () => this.$element + .find(`.${CHAT_TOOL_CALL_VIEW_CLASS} .${ACCORDION_ITEM_CLASS}`) + .map((index, element) => ($(element).hasClass(ACCORDION_ITEM_OPENED_CLASS) ? index : null)) + .get(); + this.getToolCallHeaderText = () => this.$element.find(`.${CHAT_TOOL_CALL_HEADER_TEXT_CLASS}`).text(); + this.getToolCallHeaderBadgeText = () => this.$element.find(`.${CHAT_TOOL_CALL_HEADER_CLASS} .${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).text(); + this.getToolCallFields = () => this.$element.find(`.${CHAT_TOOL_CALL_FIELD_CLASS}`); + this.getToolCallFieldLabels = () => this.getToolCallFields() + .find(`.${CHAT_TOOL_CALL_FIELD_LABEL_CLASS}`) + .map((_, element) => $(element).text()) + .get(); + this.getToolCallField = (label) => this.getToolCallFields() + .filter((_, element) => $(element).find(`.${CHAT_TOOL_CALL_FIELD_LABEL_CLASS}`).text() === label); + this.getToolCallFieldValue = (label) => this.getToolCallField(label).find(`.${TEXTEDITOR_INPUT_CLASS}`).val(); init(); }, @@ -663,6 +697,300 @@ QUnit.module('Chat', () => { }); }); + QUnit.module('Tool calls', () => { + const weatherCall = { name: 'getWeather', arguments: { city: 'London' }, result: '15°C', status: 'success' }; + const currentTimeCall = { name: 'getCurrentTime', arguments: { locations: ['Barcelona'] }, result: '10:18' }; + const stockPriceCall = { name: 'getStockPrice', arguments: { symbol: 'CNTS' }, result: 'Failed to execute the tool', status: 'error' }; + const renderTemplate = (data, container) => { + $('

').text(`text: ${data.message.text}`).appendTo(container); + }; + + QUnit.module('Rendering', () => { + QUnit.test('should render tool calls of messages passed in items', function(assert) { + this.reinit({ + items: [ + { text: 'question' }, + { text: 'answer', toolCalls: [weatherCall, currentTimeCall] }, + ], + }); + + const $bubbles = this.getBubbles(); + + assert.strictEqual($bubbles.eq(0).find(`.${CHAT_TOOL_CALL_VIEW_CLASS}`).length, 0, 'message without tool calls has none'); + assert.deepEqual(this.getToolCallNames(), ['getWeather', 'getCurrentTime'], 'message with tool calls renders them'); + }); + + QUnit.test('should render tool calls of a message added with renderMessage', function(assert) { + this.reinit({ items: [{ text: 'question' }] }); + + this.instance.renderMessage({ text: 'answer', toolCalls: [weatherCall] }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather']); + }); + + QUnit.test('should render the header with the tool call count for several tool calls', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [weatherCall, currentTimeCall, stockPriceCall] }] }); + + assert.strictEqual(this.getToolCallHeaderText(), '3 tools'); + }); + + QUnit.test('should not render tool calls for an empty toolCalls array', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [] }] }); + + assert.strictEqual(this.getBubblesContents().text(), 'answer', 'message is rendered'); + assert.strictEqual(this.getToolCallView().length, 0, 'tool calls are not rendered'); + }); + + QUnit.test('should not render tool calls of a deleted message', function(assert) { + this.reinit({ items: [{ text: 'answer', isDeleted: true, toolCalls: [weatherCall] }] }); + + assert.strictEqual(this.getBubblesContents().text(), 'This message was deleted', 'deleted message is rendered'); + assert.strictEqual(this.getToolCallView().length, 0, 'tool calls are not rendered'); + }); + }); + + QUnit.module('Content', () => { + QUnit.test('should render the arguments and the result of a tool call', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ ...weatherCall, expanded: true }] }] }); + + assert.strictEqual(this.getToolCallFieldValue('Arguments'), 'city: "London"', 'arguments are rendered'); + assert.strictEqual(this.getToolCallFieldValue('Result'), '15°C', 'result is rendered'); + }); + + QUnit.test('should render only the result of a tool call without arguments', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ name: 'getCurrentUtcTime', result: '09:18', expanded: true }] }] }); + + assert.deepEqual(this.getToolCallFieldLabels(), ['Result']); + }); + + QUnit.test('should render only the arguments of a tool call without a result', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ name: 'getWeather', arguments: { city: 'London' }, expanded: true }] }] }); + + assert.deepEqual(this.getToolCallFieldLabels(), ['Arguments']); + }); + + QUnit.test('should render an object result as indented JSON', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ name: 'getExchangeRate', result: { base: 'USD', rate: 0.92 }, expanded: true }] }] }); + + assert.strictEqual(this.getToolCallFieldValue('Result'), '{\n "base": "USD",\n "rate": 0.92\n}'); + }); + + QUnit.test('should render the name, the arguments and the result as text', function(assert) { + const name = ''; + + this.reinit({ + items: [{ + text: 'answer', + toolCalls: [{ name, arguments: { query: 'London' }, result: '15°C', expanded: true }], + }], + }); + + assert.deepEqual(this.getToolCallNames(), [name], 'name is rendered as text'); + assert.strictEqual(this.getToolCallFieldValue('Arguments'), 'query: "London"', 'arguments are rendered as text'); + assert.strictEqual(this.getToolCallFieldValue('Result'), '15°C', 'result is rendered as text'); + assert.strictEqual(this.getToolCallView().find('img, b, u').length, 0, 'values are not parsed as markup'); + }); + }); + + QUnit.module('Status', () => { + QUnit.test('should mark a failed tool call with the error tag and the highlighted result', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ ...stockPriceCall, expanded: true }] }] }); + + assert.strictEqual(this.getToolCallTitles().find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).text(), 'Error', 'title has the error tag'); + assert.strictEqual(this.getToolCallField('Result').hasClass(CHAT_TOOL_CALL_FIELD_ERROR_CLASS), true, 'result is highlighted'); + }); + + QUnit.test('should not mark tool calls with the success status or without a status', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ ...weatherCall, expanded: true }, { ...currentTimeCall, expanded: true }] }] }); + + assert.strictEqual(this.getToolCallField('Result').length, 2, 'results are rendered'); + assert.strictEqual(this.getToolCallView().find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).length, 0, 'no error tags'); + assert.strictEqual(this.getToolCallView().find(`.${CHAT_TOOL_CALL_FIELD_ERROR_CLASS}`).length, 0, 'no highlighted results'); + }); + + QUnit.test('should render the count of one failed tool call in the header', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [weatherCall, stockPriceCall] }] }); + + assert.strictEqual(this.getToolCallHeaderBadgeText(), '1 Error'); + }); + + QUnit.test('should render the count of several failed tool calls in the header', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [stockPriceCall, weatherCall, { ...stockPriceCall, name: 'getExchangeRate' }] }] }); + + assert.strictEqual(this.getToolCallHeaderBadgeText(), '2 Errors'); + }); + }); + + QUnit.module('Expanded state', () => { + QUnit.test('should render collapsed tool calls by default', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [weatherCall, currentTimeCall] }] }); + + assert.deepEqual(this.getOpenedToolCallIndexes(), []); + }); + + QUnit.test('should expand a tool call with expanded set to true', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [weatherCall, { ...currentTimeCall, expanded: true }] }] }); + + assert.deepEqual(this.getOpenedToolCallIndexes(), [1]); + }); + + QUnit.test('should keep several tool calls expanded by the user at once', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [weatherCall, currentTimeCall] }] }); + + this.getToolCallTitles().eq(0).trigger('dxclick'); + this.getToolCallTitles().eq(1).trigger('dxclick'); + + assert.deepEqual(this.getOpenedToolCallIndexes(), [0, 1]); + }); + + QUnit.test('should keep a tool call with expanded set to false collapsed when toolCallOptions.expandAll is true', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [{ ...weatherCall, expanded: false }, currentTimeCall, stockPriceCall] }], + toolCallOptions: { expandAll: true }, + }); + + assert.deepEqual(this.getOpenedToolCallIndexes(), [1, 2]); + }); + + QUnit.test('should expand only a tool call with expanded set to true when toolCallOptions.expandAll is false', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [weatherCall, { ...currentTimeCall, expanded: true }, stockPriceCall] }], + toolCallOptions: { expandAll: false }, + }); + + assert.deepEqual(this.getOpenedToolCallIndexes(), [1]); + }); + + QUnit.test('should keep a tool call with expanded set to false collapsed after toolCallOptions.expandAll is set to true at runtime', function(assert) { + this.reinit({ items: [{ text: 'answer', toolCalls: [{ ...weatherCall, expanded: false }, currentTimeCall, stockPriceCall] }] }); + + this.instance.option('toolCallOptions.expandAll', true); + + assert.deepEqual(this.getOpenedToolCallIndexes(), [1, 2]); + }); + + QUnit.test('should keep a tool call with expanded set to true expanded after toolCallOptions.expandAll is set to false at runtime', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [weatherCall, { ...currentTimeCall, expanded: true }, stockPriceCall] }], + toolCallOptions: { expandAll: true }, + }); + + this.instance.option('toolCallOptions.expandAll', false); + + assert.deepEqual(this.getOpenedToolCallIndexes(), [1]); + }); + }); + + QUnit.module('toolCallOptions', () => { + QUnit.test('should have both flags set to false by default', function(assert) { + assert.deepEqual(this.instance.option('toolCallOptions'), { showInMessageTemplate: false, expandAll: false }); + }); + + QUnit.test('should keep showInMessageTemplate false when toolCallOptions is set partially', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [weatherCall] }], + messageTemplate: renderTemplate, + toolCallOptions: { expandAll: true }, + }); + + assert.strictEqual(this.instance.option('toolCallOptions.showInMessageTemplate'), false, 'showInMessageTemplate keeps its default'); + assert.strictEqual(this.getBubblesContents().text(), 'text: answer', 'template is rendered'); + assert.strictEqual(this.getToolCallView().length, 0, 'tool calls are not rendered with the template'); + }); + + QUnit.test('should render tool calls as usual when showInMessageTemplate is true and there is no messageTemplate', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [weatherCall] }], + toolCallOptions: { showInMessageTemplate: true }, + }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'tool calls are rendered'); + assert.strictEqual(this.getBubblesContents().text(), 'answer', 'text is rendered'); + }); + }); + + QUnit.module('messageTemplate', () => { + QUnit.test('should pass tool calls with their own fields to messageTemplate instead of rendering them', function(assert) { + const messageTemplate = sinon.stub(); + const toolCalls = [{ ...weatherCall, id: 'call_abc123' }]; + + this.reinit({ + items: [{ text: 'answer', toolCalls }], + messageTemplate, + }); + + assert.deepEqual(messageTemplate.lastCall.args[0].message.toolCalls, toolCalls, 'template gets the tool calls'); + assert.strictEqual(this.getToolCallView().length, 0, 'tool calls are not rendered'); + }); + + QUnit.test('should render tool calls above the template content when toolCallOptions.showInMessageTemplate is true', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [weatherCall] }], + messageTemplate: renderTemplate, + toolCallOptions: { showInMessageTemplate: true }, + }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'tool calls are rendered'); + assert.strictEqual(this.getToolCallView().next().hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true, 'tool calls are above the content'); + assert.strictEqual(this.getBubblesContents().text(), 'text: answer', 'template is rendered'); + }); + + QUnit.test('should render tool calls when toolCallOptions.showInMessageTemplate is set to true at runtime', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [weatherCall] }], + messageTemplate: renderTemplate, + }); + + this.instance.option('toolCallOptions.showInMessageTemplate', true); + + assert.deepEqual(this.getToolCallNames(), ['getWeather']); + }); + + QUnit.test('should remove tool calls when toolCallOptions.showInMessageTemplate is set to false at runtime', function(assert) { + this.reinit({ + items: [{ text: 'answer', toolCalls: [weatherCall] }], + messageTemplate: renderTemplate, + toolCallOptions: { showInMessageTemplate: true }, + }); + + this.instance.option('toolCallOptions.showInMessageTemplate', false); + + assert.strictEqual(this.getBubblesContents().text(), 'text: answer', 'template is rendered'); + assert.strictEqual(this.getToolCallView().length, 0, 'tool calls are removed'); + }); + }); + + QUnit.module('Localization', () => { + QUnit.test('should render the texts overridden with loadMessages', function(assert) { + const defaultLocale = localization.locale(); + + try { + localization.loadMessages({ + 'ee': { + 'dxChat-toolCallsTitle': '{0} tööriista', + 'dxChat-toolCallsSingleErrorText': '1 viga', + 'dxChat-toolCallArgumentsLabel': 'Argumendid', + 'dxChat-toolCallResultLabel': 'Tulemus', + 'dxChat-toolCallErrorText': 'Viga', + 'dxChat-copyButtonLabel': 'Kopeeri', + }, + }); + localization.locale('ee'); + + this.reinit({ items: [{ text: 'answer', toolCalls: [weatherCall, { ...stockPriceCall, expanded: true }] }] }); + + assert.strictEqual(this.getToolCallHeaderText(), '2 tööriista', 'header is localized'); + assert.strictEqual(this.getToolCallHeaderBadgeText(), '1 viga', 'error count is localized'); + assert.strictEqual(this.getToolCallTitles().eq(1).find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).text(), 'Viga', 'error tag is localized'); + assert.deepEqual(this.getToolCallFieldLabels(), ['Argumendid', 'Tulemus'], 'field labels are localized'); + assert.strictEqual(this.getToolCallField('Tulemus').find(`.${BUTTON_CLASS}`).attr('aria-label'), 'Kopeeri', 'copy button label is localized'); + } finally { + localization.locale(defaultLocale); + } + }); + }); + }); + QUnit.module('messageTemplate', () => { QUnit.test('messageTemplate should set bubble content on init', function(assert) { const messageTemplate = (data, container) => { @@ -3474,6 +3802,120 @@ QUnit.module('Chat', () => { assert.strictEqual(this.getBubbles().eq(0).text(), 'updated text', 'message bubble text was updated'); }); + QUnit.module('Tool calls', { + beforeEach() { + const timeout = 100; + + this.loadFromRemoteStore = (messages, options = {}) => { + const store = new CustomStore({ + key: 'id', + load: function() { + const d = $.Deferred(); + setTimeout(function() { + d.resolve([...messages]); + }, timeout); + return d.promise(); + }, + }); + + this.reinit({ + dataSource: store, + reloadOnChange: false, + ...options, + }); + this.clock.tick(timeout); + + return store; + }; + + this.pushUpdate = (store, key, data) => { + store.push([{ type: 'update', key, data }]); + this.clock.tick(timeout * 2); + }; + }, + }, () => { + const weatherCall = { name: 'getWeather', arguments: { city: 'London' }, result: '15°C' }; + const currentTimeCall = { name: 'getCurrentTime', arguments: { locations: ['Barcelona'] }, result: '10:18' }; + + QUnit.test('should render tool calls of messages loaded from a remote store', function(assert) { + this.loadFromRemoteStore([{ id: 0, text: 'question' }, { id: 1, text: 'answer', toolCalls: [weatherCall] }]); + + assert.deepEqual(this.getToolCallNames(), ['getWeather']); + }); + + QUnit.test('it should be possible to update tool calls of a message using push api', function(assert) { + const store = this.loadFromRemoteStore([{ id: 0, text: 'message_0' }, { id: 1, text: 'message_1' }]); + + this.pushUpdate(store, 1, { toolCalls: [weatherCall] }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'tool calls are rendered in the updated bubble'); + assert.strictEqual(this.getBubbles().eq(0).find(`.${CHAT_TOOL_CALL_VIEW_CLASS}`).length, 0, 'other bubbles have no tool calls'); + }); + + QUnit.test('it should be possible to remove tool calls of a message using push api', function(assert) { + const store = this.loadFromRemoteStore([{ id: 0, text: 'answer', toolCalls: [weatherCall] }]); + + this.pushUpdate(store, 0, { toolCalls: [] }); + + assert.strictEqual(this.getBubblesContents().text(), 'answer', 'message is rendered'); + assert.strictEqual(this.getToolCallView().length, 0, 'tool calls are removed'); + }); + + QUnit.test('should apply expanded of tool calls updated using push api', function(assert) { + const store = this.loadFromRemoteStore([{ id: 0, text: 'answer', toolCalls: [weatherCall, currentTimeCall] }]); + + this.pushUpdate(store, 0, { toolCalls: [weatherCall, { ...currentTimeCall, expanded: true }] }); + + assert.deepEqual(this.getOpenedToolCallIndexes(), [1]); + }); + + QUnit.test('should render the tool calls block again when the same tool calls are pushed', function(assert) { + const toolCalls = [weatherCall]; + const store = this.loadFromRemoteStore([{ id: 0, text: 'answer', toolCalls }]); + const toolCallViewBeforePush = this.getToolCallView().get(0); + + this.pushUpdate(store, 0, { toolCalls }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'tool calls are rendered'); + assert.notStrictEqual(this.getToolCallView().get(0), toolCallViewBeforePush, 'block is rendered again'); + }); + + QUnit.test('should keep the tool calls block and the items expanded by the user when only the text is pushed', function(assert) { + const store = this.loadFromRemoteStore([{ id: 0, text: 'answer', toolCalls: [weatherCall, currentTimeCall] }]); + const toolCallView = this.getToolCallView().get(0); + const accordion = this.getToolCallView().find(`.${ACCORDION_CLASS}`).get(0); + + this.getToolCallTitles().eq(1).trigger('dxclick'); + this.pushUpdate(store, 0, { text: 'updated answer' }); + + assert.strictEqual(this.getBubblesContents().text(), 'updated answer', 'text is updated'); + assert.strictEqual(this.getToolCallView().get(0), toolCallView, 'block is the same element'); + assert.strictEqual(this.getToolCallView().find(`.${ACCORDION_CLASS}`).get(0), accordion, 'accordion is the same element'); + assert.deepEqual(this.getOpenedToolCallIndexes(), [1], 'item expanded by the user stays expanded'); + }); + + QUnit.test('should keep the tool calls block of a templated message while the text is streamed', function(assert) { + const store = this.loadFromRemoteStore([{ id: 0, text: '', toolCalls: [weatherCall] }], { + messageTemplate: (data, container) => { + $('
').text(data.message.text).appendTo(container); + }, + toolCallOptions: { showInMessageTemplate: true }, + }); + const toolCallView = this.getToolCallView().get(0); + const accordion = this.getToolCallView().find(`.${ACCORDION_CLASS}`).get(0); + + this.getToolCallTitles().eq(0).trigger('dxclick'); + ['The', 'The weather', 'The weather in London is 15°C.'].forEach((text) => { + this.pushUpdate(store, 0, { text }); + }); + + assert.strictEqual(this.getBubblesContents().text(), 'The weather in London is 15°C.', 'template renders the last chunk'); + assert.strictEqual(this.getToolCallView().get(0), toolCallView, 'block is the same element'); + assert.strictEqual(this.getToolCallView().find(`.${ACCORDION_CLASS}`).get(0), accordion, 'accordion is the same element'); + assert.deepEqual(this.getOpenedToolCallIndexes(), [0], 'item expanded by the user stays expanded'); + }); + }); + QUnit.test('it should be possible to update isEdited state of first message in group using push api', function(assert) { const messages = [{ id: 0, text: 'message_0' }, { id: 1, text: 'message_1' }]; const timeout = 100; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.markup.tests.js index 4a95d0a003e5..a14c75b04992 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.markup.tests.js @@ -10,6 +10,7 @@ import MessageBubble, { } from '__internal/ui/chat/messagebubble'; import { CHAT_FILE_VIEW_CLASS } from '__internal/ui/chat/file_view/file_view'; +import { CHAT_TOOL_CALL_VIEW_CLASS } from '__internal/ui/chat/tool_call_view/tool_call_view'; const moduleConfig = { beforeEach: function() { @@ -49,6 +50,14 @@ QUnit.module('MessageBubble', moduleConfig, () => { assert.strictEqual($content.parent().is(this.$element), true, 'attachments element is direct child of root element'); }); + QUnit.test('root element should have a first child tool calls element with correct class if tool calls provided', function(assert) { + this.reinit({ text: 'answer', toolCalls: [{ name: 'getWeather' }] }); + const $toolCalls = this.$element.find(`.${CHAT_TOOL_CALL_VIEW_CLASS}`); + + assert.strictEqual($toolCalls.length, 1, 'tool calls element exist'); + assert.strictEqual(this.$element.children().first().is($toolCalls), true, 'tool calls element is the first child of root element'); + }); + QUnit.test('root element should switch classes when deletion and undo happens', function(assert) { this.instance.option('isDeleted', true); assert.strictEqual(this.$element.hasClass(CHAT_MESSAGEBUBBLE_DELETED_CLASS), true, 'root element has deleted class'); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.tests.js index 507cc884c31b..5ff1d8520f8f 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.tests.js @@ -9,6 +9,21 @@ import MessageBubble, { import { BUTTON_CLASS } from '__internal/ui/button/button'; import FileView, { CHAT_FILE_VIEW_CLASS } from '__internal/ui/chat/file_view/file_view'; import { CHAT_FILE_CLASS, CHAT_FILE_NAME_CLASS, CHAT_FILE_SIZE_CLASS } from '__internal/ui/chat/file_view/file'; +import { + CHAT_TOOL_CALL_VIEW_CLASS, + CHAT_TOOL_CALL_NAME_CLASS, +} from '__internal/ui/chat/tool_call_view/tool_call_view'; +import fx from 'common/core/animation/fx'; + +const ACCORDION_ITEM_CLASS = 'dx-accordion-item'; +const ACCORDION_ITEM_TITLE_CLASS = 'dx-accordion-item-title'; +const ACCORDION_ITEM_OPENED_CLASS = 'dx-accordion-item-opened'; + +const toolCalls = [{ + name: 'getWeather', + arguments: { city: 'London' }, + result: '15°C', +}]; const moduleConfig = { beforeEach: function() { @@ -22,6 +37,11 @@ const moduleConfig = { const $fileView = this.$getAttachments(); return $fileView.length ? FileView.getInstance($fileView) : null; }; + this.$getToolCallView = () => this.$element.find(`.${CHAT_TOOL_CALL_VIEW_CLASS}`); + this.getToolCallNames = () => this.$getToolCallView() + .find(`.${CHAT_TOOL_CALL_NAME_CLASS}`) + .map((_, element) => $(element).text()) + .get(); }; this.reinit = (options) => { @@ -262,6 +282,150 @@ QUnit.module('MessageBubble', moduleConfig, () => { }); }); + QUnit.module('Tool calls', { + beforeEach() { + fx.off = true; + }, + afterEach() { + fx.off = false; + }, + }, () => { + QUnit.test('should render tool calls when toolCalls passed', function(assert) { + this.reinit({ text: 'answer', toolCalls }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather']); + }); + + QUnit.test('should render tool calls before the message content', function(assert) { + this.reinit({ text: 'answer', toolCalls }); + + assert.strictEqual(this.$getToolCallView().next().hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true); + }); + + QUnit.test('should not render tool calls when toolCalls is empty', function(assert) { + this.reinit({ text: 'answer', toolCalls: [] }); + + assert.strictEqual(this.$getToolCallView().length, 0); + }); + + QUnit.test('should not render tool calls when isDeleted is true', function(assert) { + this.reinit({ isDeleted: true, toolCalls }); + + assert.strictEqual(this.$getToolCallView().length, 0); + }); + + QUnit.test('should render tool calls when toolCalls is set at runtime', function(assert) { + this.reinit({ text: 'answer' }); + + this.instance.option('toolCalls', toolCalls); + + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'tool calls are rendered'); + assert.strictEqual(this.$getToolCallView().next().hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true, 'tool calls are rendered before the content'); + }); + + QUnit.test('should remove tool calls when toolCalls is reset at runtime', function(assert) { + this.reinit({ text: 'answer', toolCalls }); + + this.instance.option('toolCalls', []); + + assert.strictEqual(this.$getToolCallView().length, 0); + }); + + QUnit.test('should remove tool calls when isDeleted is set at runtime', function(assert) { + this.reinit({ text: 'answer', toolCalls }); + + this.instance.option('isDeleted', true); + + assert.strictEqual(this.$getToolCallView().length, 0); + }); + + QUnit.test('should keep an expanded tool call expanded when the text changes at runtime', function(assert) { + this.reinit({ text: 'answer', toolCalls }); + + this.$getToolCallView().find(`.${ACCORDION_ITEM_TITLE_CLASS}`).trigger('dxclick'); + this.instance.option('text', 'updated answer'); + + assert.strictEqual(this.$content.text(), 'updated answer', 'text is updated'); + assert.strictEqual(this.$getToolCallView().find(`.${ACCORDION_ITEM_CLASS}`).hasClass(ACCORDION_ITEM_OPENED_CLASS), true, 'tool call stays expanded'); + }); + + QUnit.test('should expand the tool calls when expandAll is true', function(assert) { + this.reinit({ text: 'answer', toolCalls, toolCallOptions: { expandAll: true } }); + + assert.strictEqual(this.$getToolCallView().find(`.${ACCORDION_ITEM_CLASS}`).hasClass(ACCORDION_ITEM_OPENED_CLASS), true); + }); + + QUnit.test('should not render tool calls when the message template is set', function(assert) { + this.reinit({ + text: 'answer', + toolCalls, + template: (data, container) => { + $('

').text(`template text: ${data.text}`).appendTo(container); + }, + }); + + assert.strictEqual(this.$getToolCallView().length, 0, 'tool calls are not rendered'); + assert.strictEqual(this.$content.text(), 'template text: answer', 'template is rendered'); + }); + + QUnit.test('should render tool calls along with the message template when showInMessageTemplate is true', function(assert) { + this.reinit({ + text: 'answer', + toolCalls, + toolCallOptions: { showInMessageTemplate: true }, + template: (data, container) => { + $('

').text(`template text: ${data.text}`).appendTo(container); + }, + }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'tool calls are rendered'); + assert.strictEqual(this.$getToolCallView().next().hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true, 'tool calls are rendered before the template content'); + assert.strictEqual(this.$content.text(), 'template text: answer', 'template is rendered'); + }); + + QUnit.test('should keep an expanded tool call expanded when the text of a templated message changes at runtime', function(assert) { + this.reinit({ + text: 'answer', + toolCalls, + toolCallOptions: { showInMessageTemplate: true }, + template: (data, container) => { + $('

').text(data.text).appendTo(container); + }, + }); + + this.$getToolCallView().find(`.${ACCORDION_ITEM_TITLE_CLASS}`).trigger('dxclick'); + this.instance.option('text', 'updated answer'); + + assert.strictEqual(this.$content.text(), 'updated answer', 'template is rendered with the new text'); + assert.strictEqual(this.$getToolCallView().find(`.${ACCORDION_ITEM_CLASS}`).hasClass(ACCORDION_ITEM_OPENED_CLASS), true, 'tool call stays expanded'); + }); + + QUnit.test('should render tool calls when showInMessageTemplate is set at runtime', function(assert) { + this.reinit({ + text: 'answer', + toolCalls, + template: (data, container) => { + $('

').text(data.text).appendTo(container); + }, + }); + + this.instance.option('toolCallOptions', { showInMessageTemplate: true }); + + assert.deepEqual(this.getToolCallNames(), ['getWeather'], 'tool calls are rendered'); + assert.strictEqual(this.$getToolCallView().next().hasClass(CHAT_MESSAGEBUBBLE_CONTENT_CLASS), true, 'tool calls are rendered before the content'); + }); + + QUnit.test('should remove tool calls when the message template is set at runtime', function(assert) { + this.reinit({ text: 'answer', toolCalls }); + + this.instance.option('template', (data, container) => { + $('

').text(data.text).appendTo(container); + }); + + assert.strictEqual(this.$getToolCallView().length, 0); + }); + }); + QUnit.module('Proxy state options', () => { QUnit.test('MessageBubble state options should not affect FileView state options', function(assert) { const options = { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.markup.tests.js new file mode 100644 index 000000000000..fde10fc95086 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.markup.tests.js @@ -0,0 +1,78 @@ +import $ from 'jquery'; +import ToolCallView, { + CHAT_TOOL_CALL_VIEW_CLASS, + CHAT_TOOL_CALL_HEADER_CLASS, + CHAT_TOOL_CALL_HEADER_TEXT_CLASS, + CHAT_TOOL_CALL_TITLE_CLASS, + CHAT_TOOL_CALL_ICON_CLASS, + CHAT_TOOL_CALL_NAME_CLASS, + CHAT_TOOL_CALL_ERROR_BADGE_CLASS, +} from '__internal/ui/chat/tool_call_view/tool_call_view'; + +const { test } = QUnit; + +const ACCORDION_CLASS = 'dx-accordion'; +const ACCORDION_ITEM_TITLE_CLASS = 'dx-accordion-item-title'; +const ICON_CLASS = 'dx-icon'; + +const toolCall = { + name: 'getWeather', + arguments: { city: 'London' }, + result: '15°C', +}; + +const failedToolCall = { + name: 'getStockPrice', + result: 'Failed to execute the tool', + status: 'error', +}; + +QUnit.module('ToolCallView', { + beforeEach() { + this.$element = $('
').appendTo('#qunit-fixture'); + this.instance = new ToolCallView(this.$element, { toolCalls: [toolCall] }); + }, + afterEach() { + this.instance.dispose(); + this.$element.remove(); + }, +}, () => { + test('root element should have correct class', function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_TOOL_CALL_VIEW_CLASS), true); + }); + + test('should render an accordion as a direct child of the root element', function(assert) { + const $accordion = this.$element.children(`.${ACCORDION_CLASS}`); + + assert.strictEqual($accordion.length, 1); + }); + + test('item title should contain the icon and the name elements', function(assert) { + const $title = this.$element.find(`.${ACCORDION_ITEM_TITLE_CLASS} > .${CHAT_TOOL_CALL_TITLE_CLASS}`); + const $children = $title.children(); + + assert.strictEqual($title.length, 1, 'title element exists'); + assert.strictEqual($children.eq(0).hasClass(ICON_CLASS), true, 'first child is an icon'); + assert.strictEqual($children.eq(0).hasClass(CHAT_TOOL_CALL_ICON_CLASS), true, 'icon is the toolbox icon'); + assert.strictEqual($children.eq(1).hasClass(CHAT_TOOL_CALL_NAME_CLASS), true, 'second child is the name'); + assert.strictEqual($children.eq(1).attr('title'), 'getWeather', 'name has a title attribute'); + }); + + test('should render the header before the accordion for several tool calls', function(assert) { + this.instance.dispose(); + this.instance = new ToolCallView(this.$element, { toolCalls: [toolCall, failedToolCall] }); + + const $children = this.$element.children(); + const $headerChildren = $children.eq(0).children(); + + assert.strictEqual($children.length, 2, 'root has two children'); + assert.strictEqual($children.eq(0).hasClass(CHAT_TOOL_CALL_HEADER_CLASS), true, 'first child is the header'); + assert.strictEqual($children.eq(1).hasClass(ACCORDION_CLASS), true, 'second child is the accordion'); + assert.strictEqual($headerChildren.eq(0).hasClass(CHAT_TOOL_CALL_HEADER_TEXT_CLASS), true, 'header starts with the text'); + assert.strictEqual($headerChildren.eq(1).hasClass(CHAT_TOOL_CALL_ERROR_BADGE_CLASS), true, 'header ends with the error badge'); + }); + + test('should not render the header for a single tool call', function(assert) { + assert.strictEqual(this.$element.children(`.${CHAT_TOOL_CALL_HEADER_CLASS}`).length, 0); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.tests.js new file mode 100644 index 000000000000..f483efccc98c --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/toolCallView.tests.js @@ -0,0 +1,518 @@ +import $ from 'jquery'; +import fx from 'common/core/animation/fx'; +import localization from 'localization'; + +import ToolCallView, { + CHAT_TOOL_CALL_CONTENT_CLASS, + CHAT_TOOL_CALL_ERROR_BADGE_CLASS, + CHAT_TOOL_CALL_FIELD_CLASS, + CHAT_TOOL_CALL_FIELD_ERROR_CLASS, + CHAT_TOOL_CALL_FIELD_LABEL_CLASS, + CHAT_TOOL_CALL_HEADER_CLASS, + CHAT_TOOL_CALL_HEADER_TEXT_CLASS, + CHAT_TOOL_CALL_NAME_CLASS, + COPIED_STATE_DURATION, + COPY_BUTTON_NAME, +} from '__internal/ui/chat/tool_call_view/tool_call_view'; +import TextArea from '__internal/ui/text_area'; + +const ACCORDION_ITEM_CLASS = 'dx-accordion-item'; +const ACCORDION_ITEM_BODY_CLASS = 'dx-accordion-item-body'; +const ACCORDION_ITEM_TITLE_CLASS = 'dx-accordion-item-title'; +const ACCORDION_ITEM_OPENED_CLASS = 'dx-accordion-item-opened'; +const TEXTAREA_CLASS = 'dx-textarea'; +const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; + +const updateUserCall = { + name: 'updateUser', + arguments: { + name: 'John Doe', + email: 'john@example.com', + active: true, + count: 10, + }, + result: 'Completed', + status: 'success', +}; + +const currentTimeCall = { + name: 'getCurrentTime', + arguments: { locations: ['Barcelona', 'Los Angeles'] }, + result: 'Barcelona: 10:18', +}; + +const stockPriceCall = { + name: 'getStockPrice', + arguments: { symbol: 'CNTS' }, + result: 'Failed to execute the tool', + status: 'error', +}; + +const weatherCall = { + name: 'getWeather', + arguments: { city: 'Poseidonis' }, + result: 'Failed to execute the tool', + status: 'error', +}; + +const moduleConfig = { + beforeEach() { + fx.off = true; + + const init = (options = {}) => { + this.instance = new ToolCallView($('#component'), { + toolCalls: [updateUserCall], + ...options, + }); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + init(options); + }; + + this.getItems = () => this.$element.find(`.${ACCORDION_ITEM_CLASS}`); + this.getOpenedItemIndexes = () => this.getItems() + .map((index, element) => ($(element).hasClass(ACCORDION_ITEM_OPENED_CLASS) ? index : null)) + .get(); + this.getHeader = () => this.$element.find(`.${CHAT_TOOL_CALL_HEADER_CLASS}`); + this.getHeaderText = () => this.getHeader().find(`.${CHAT_TOOL_CALL_HEADER_TEXT_CLASS}`).text(); + this.getHeaderBadgeText = () => this.getHeader().find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).text(); + this.getTitles = () => this.$element.find(`.${ACCORDION_ITEM_TITLE_CLASS}`); + this.getNames = () => this.$element.find(`.${CHAT_TOOL_CALL_NAME_CLASS}`) + .map((_, element) => $(element).text()) + .get(); + this.expand = (index = 0) => { + this.getTitles().eq(index).trigger('dxclick'); + }; + this.getFields = () => this.$element.find(`.${CHAT_TOOL_CALL_FIELD_CLASS}`); + this.getField = (label) => this.getFields() + .filter((_, element) => $(element).find(`.${CHAT_TOOL_CALL_FIELD_LABEL_CLASS}`).text() === label); + this.getFieldValue = (label) => this.getField(label).find(`.${TEXTEDITOR_INPUT_CLASS}`).val(); + this.getCopyButton = (label) => TextArea + .getInstance(this.getField(label).find(`.${TEXTAREA_CLASS}`)) + .getButton(COPY_BUTTON_NAME); + + init(); + }, + afterEach() { + fx.off = false; + }, +}; + +QUnit.module('ToolCallView', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should render a collapsed item titled with the tool name for a single tool call', function(assert) { + assert.strictEqual(this.getItems().length, 1, 'one accordion item'); + assert.deepEqual(this.getNames(), ['updateUser'], 'title contains the tool name'); + assert.strictEqual(this.getItems().hasClass(ACCORDION_ITEM_OPENED_CLASS), false, 'item is collapsed'); + assert.strictEqual(this.getHeader().length, 0, 'header is not rendered'); + }); + + QUnit.test('should render an error badge in the title of a failed tool call', function(assert) { + this.reinit({ toolCalls: [stockPriceCall] }); + + const $badge = this.getTitles().find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`); + + assert.strictEqual($badge.length, 1, 'badge is rendered'); + assert.strictEqual($badge.text(), 'Error', 'badge text is correct'); + }); + + QUnit.test('should not render an error badge for a tool call without the error status', function(assert) { + this.reinit({ toolCalls: [currentTimeCall] }); + + assert.strictEqual(this.$element.find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).length, 0); + }); + + QUnit.test('should render the header with the tool call count and a collapsed item for every tool call', function(assert) { + this.reinit({ toolCalls: [currentTimeCall, stockPriceCall] }); + + assert.strictEqual(this.getHeaderText(), '2 tools', 'header contains the count'); + assert.deepEqual(this.getNames(), ['getCurrentTime', 'getStockPrice'], 'every tool call has its own item'); + assert.deepEqual(this.getOpenedItemIndexes(), [], 'items are collapsed'); + assert.strictEqual(this.getTitles().eq(1).find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).text(), 'Error', 'failed tool call title has an error badge'); + }); + + QUnit.test('should render the error count in the header when one tool call failed', function(assert) { + this.reinit({ toolCalls: [currentTimeCall, stockPriceCall] }); + + assert.strictEqual(this.getHeaderBadgeText(), '1 Error'); + }); + + QUnit.test('should render the error count in the header when several tool calls failed', function(assert) { + this.reinit({ toolCalls: [stockPriceCall, currentTimeCall, weatherCall] }); + + assert.strictEqual(this.getHeaderBadgeText(), '2 Errors'); + }); + + QUnit.test('should not render the error count in the header when no tool call failed', function(assert) { + this.reinit({ toolCalls: [currentTimeCall, updateUserCall] }); + + assert.strictEqual(this.getHeader().find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).length, 0); + }); + + QUnit.test('should not render items and the header for an empty toolCalls array', function(assert) { + this.reinit({ toolCalls: [] }); + + assert.strictEqual(this.getItems().length, 0, 'items are not rendered'); + assert.strictEqual(this.getHeader().length, 0, 'header is not rendered'); + }); + }); + + QUnit.module('Structure', () => { + QUnit.test('expanded item body should contain the content element with fields', function(assert) { + this.expand(); + + const $content = this.$element.find(`.${ACCORDION_ITEM_BODY_CLASS} > .${CHAT_TOOL_CALL_CONTENT_CLASS}`); + const $fields = $content.children(`.${CHAT_TOOL_CALL_FIELD_CLASS}`); + + assert.strictEqual($content.length, 1, 'content element exists'); + assert.strictEqual($fields.length, 2, 'two fields are rendered'); + + $fields.each((_, field) => { + const $fieldChildren = $(field).children(); + + assert.strictEqual($fieldChildren.eq(0).hasClass(CHAT_TOOL_CALL_FIELD_LABEL_CLASS), true, 'first child is the label'); + assert.strictEqual($fieldChildren.eq(1).hasClass(TEXTAREA_CLASS), true, 'second child is the text area'); + }); + }); + }); + + QUnit.module('Fields', () => { + QUnit.test('should not render fields until the item is expanded', function(assert) { + assert.strictEqual(this.getFields().length, 0); + }); + + QUnit.test('should render arguments as comma-separated key-value pairs', function(assert) { + this.expand(); + + assert.strictEqual( + this.getFieldValue('Arguments'), + 'name: "John Doe", email: "john@example.com", active: true, count: 10', + ); + }); + + QUnit.test('should separate array items with a comma and a space', function(assert) { + this.reinit({ toolCalls: [currentTimeCall] }); + this.expand(); + + assert.strictEqual(this.getFieldValue('Arguments'), 'locations: ["Barcelona", "Los Angeles"]'); + }); + + QUnit.test('should render a string result as is', function(assert) { + this.expand(); + + assert.strictEqual(this.getFieldValue('Result'), 'Completed'); + }); + + QUnit.test('should render an object result as indented JSON', function(assert) { + this.reinit({ + toolCalls: [{ name: 'getExchangeRate', result: { base: 'USD', rates: { EUR: 0.92 } } }], + }); + this.expand(); + + assert.strictEqual( + this.getFieldValue('Result'), + '{\n "base": "USD",\n "rates": {\n "EUR": 0.92\n }\n}', + ); + }); + + QUnit.test('should render a non-string primitive result as text', function(assert) { + this.reinit({ toolCalls: [{ name: 'getCount', result: 0 }] }); + this.expand(); + + assert.strictEqual(this.getFieldValue('Result'), '0'); + }); + + QUnit.test('should render only the result field when there are no arguments', function(assert) { + this.reinit({ toolCalls: [{ name: 'getTime', arguments: {}, result: '10:18' }] }); + this.expand(); + + const labels = this.getFields().find(`.${CHAT_TOOL_CALL_FIELD_LABEL_CLASS}`).map((_, element) => $(element).text()).get(); + + assert.deepEqual(labels, ['Result']); + }); + + QUnit.test('should render only the arguments field when there is no result', function(assert) { + this.reinit({ toolCalls: [{ name: 'getWeather', arguments: { city: 'London' } }] }); + this.expand(); + + const labels = this.getFields().find(`.${CHAT_TOOL_CALL_FIELD_LABEL_CLASS}`).map((_, element) => $(element).text()).get(); + + assert.deepEqual(labels, ['Arguments']); + }); + + QUnit.test('should mark only the result field of a failed tool call as an error', function(assert) { + this.reinit({ toolCalls: [stockPriceCall] }); + this.expand(); + + assert.strictEqual(this.getField('Result').hasClass(CHAT_TOOL_CALL_FIELD_ERROR_CLASS), true, 'result field is an error'); + assert.strictEqual(this.getField('Arguments').hasClass(CHAT_TOOL_CALL_FIELD_ERROR_CLASS), false, 'arguments field is not an error'); + }); + + QUnit.test('should not mark the result field of a successful tool call as an error', function(assert) { + this.expand(); + + assert.strictEqual(this.getField('Result').hasClass(CHAT_TOOL_CALL_FIELD_ERROR_CLASS), false); + }); + + QUnit.test('should render read-only fields', function(assert) { + this.expand(); + + const $inputs = this.getFields().find(`.${TEXTEDITOR_INPUT_CLASS}`); + + assert.strictEqual($inputs.length, 2, 'two fields are rendered'); + assert.strictEqual($inputs.filter('[readonly]').length, 2, 'both fields are read-only'); + }); + }); + + QUnit.module('Copy', { + beforeEach() { + this.clock = sinon.useFakeTimers(); + this.writeText = sinon.stub(navigator.clipboard, 'writeText').returns(Promise.resolve()); + this.waitForWrite = () => this.writeText.lastCall.returnValue; + }, + afterEach() { + this.writeText.restore(); + this.clock.restore(); + }, + }, () => { + QUnit.test('should copy the field value to the clipboard on a field click', function(assert) { + this.expand(); + + this.getField('Arguments').find(`.${TEXTEDITOR_INPUT_CLASS}`).trigger('dxclick'); + + assert.strictEqual(this.writeText.callCount, 1, 'clipboard is written once'); + assert.strictEqual( + this.writeText.firstCall.args[0], + 'name: "John Doe", email: "john@example.com", active: true, count: 10', + 'formatted arguments are copied', + ); + }); + + QUnit.test('should copy the field value once on a copy button click', function(assert) { + this.expand(); + + $(this.getCopyButton('Result').element()).trigger('dxclick'); + + assert.strictEqual(this.writeText.callCount, 1, 'clipboard is written once'); + assert.strictEqual(this.writeText.firstCall.args[0], 'Completed', 'result is copied'); + }); + + QUnit.test('should switch the copy button to the copied state after the value is copied', async function(assert) { + this.expand(); + + const copyButton = this.getCopyButton('Result'); + + this.getField('Result').find(`.${TEXTEDITOR_INPUT_CLASS}`).trigger('dxclick'); + await this.waitForWrite(); + + assert.strictEqual(copyButton.option('icon'), 'check', 'icon is correct'); + assert.strictEqual(copyButton.option('type'), 'success', 'type is correct'); + assert.strictEqual(copyButton.option('hint'), 'Copied', 'hint is correct'); + assert.strictEqual($(copyButton.element()).attr('aria-label'), 'Copied', 'aria-label is correct'); + }); + + QUnit.test('should restore the copy state of the button after the copied state duration', async function(assert) { + this.expand(); + + const copyButton = this.getCopyButton('Result'); + + this.getField('Result').find(`.${TEXTEDITOR_INPUT_CLASS}`).trigger('dxclick'); + await this.waitForWrite(); + + this.clock.tick(COPIED_STATE_DURATION - 1); + assert.strictEqual(copyButton.option('icon'), 'check', 'copied state is shown before the duration ends'); + + this.clock.tick(1); + assert.strictEqual(copyButton.option('icon'), 'copy', 'icon is restored'); + assert.strictEqual(copyButton.option('type'), 'normal', 'type is restored'); + assert.strictEqual(copyButton.option('hint'), 'Copy', 'hint is restored'); + assert.strictEqual($(copyButton.element()).attr('aria-label'), 'Copy', 'aria-label is restored'); + }); + + QUnit.test('should restart the copied state duration on a repeated copy', async function(assert) { + this.expand(); + + const copyButton = this.getCopyButton('Result'); + const $input = this.getField('Result').find(`.${TEXTEDITOR_INPUT_CLASS}`); + + $input.trigger('dxclick'); + await this.waitForWrite(); + this.clock.tick(COPIED_STATE_DURATION - 1); + + $input.trigger('dxclick'); + await this.waitForWrite(); + this.clock.tick(COPIED_STATE_DURATION - 1); + + assert.strictEqual(copyButton.option('icon'), 'check'); + }); + + QUnit.test('should keep the copy state of the button when writing to the clipboard fails', async function(assert) { + this.writeText.returns(Promise.reject(new Error('Write permission denied'))); + this.expand(); + + const copyButton = this.getCopyButton('Result'); + + this.getField('Result').find(`.${TEXTEDITOR_INPUT_CLASS}`).trigger('dxclick'); + await this.waitForWrite().catch(() => {}); + + assert.strictEqual(copyButton.option('icon'), 'copy'); + }); + + QUnit.test('should render the copy and copied labels overridden with loadMessages', async function(assert) { + const defaultLocale = localization.locale(); + + try { + localization.loadMessages({ + 'fi': { + 'dxChat-copyButtonLabel': 'Kopioi', + 'dxChat-copiedButtonLabel': 'Kopioitu', + }, + }); + localization.locale('fi'); + + this.reinit({ toolCalls: [updateUserCall] }); + this.expand(); + + const copyButton = this.getCopyButton('Result'); + + assert.strictEqual(copyButton.option('hint'), 'Kopioi', 'copy label is localized'); + + this.getField('Result').find(`.${TEXTEDITOR_INPUT_CLASS}`).trigger('dxclick'); + await this.waitForWrite(); + + assert.strictEqual(copyButton.option('hint'), 'Kopioitu', 'copied label is localized'); + } finally { + localization.locale(defaultLocale); + } + }); + + QUnit.test('should clear the copied state timer on dispose', async function(assert) { + this.expand(); + + this.getField('Result').find(`.${TEXTEDITOR_INPUT_CLASS}`).trigger('dxclick'); + await this.waitForWrite(); + + const timerCountWithCopiedState = this.clock.countTimers(); + + this.instance.dispose(); + + assert.strictEqual(this.clock.countTimers(), timerCountWithCopiedState - 1); + }); + }); + + QUnit.module('Expanded state', () => { + QUnit.test('should expand every tool call when expandAll is true', function(assert) { + this.reinit({ toolCalls: [currentTimeCall, stockPriceCall], expandAll: true }); + + assert.deepEqual(this.getOpenedItemIndexes(), [0, 1], 'items are expanded'); + assert.strictEqual(this.getFields().length, 4, 'fields of both tool calls are rendered'); + }); + + QUnit.test('should expand only a tool call with expanded set to true', function(assert) { + this.reinit({ toolCalls: [currentTimeCall, { ...stockPriceCall, expanded: true }] }); + + assert.deepEqual(this.getOpenedItemIndexes(), [1]); + }); + + QUnit.test('should keep a tool call with expanded set to false collapsed when expandAll is true', function(assert) { + this.reinit({ toolCalls: [{ ...currentTimeCall, expanded: false }, stockPriceCall], expandAll: true }); + + assert.deepEqual(this.getOpenedItemIndexes(), [1]); + }); + + QUnit.test('should collapse an initially expanded tool call on a title click', function(assert) { + this.reinit({ toolCalls: [currentTimeCall], expandAll: true }); + + this.expand(); + + assert.deepEqual(this.getOpenedItemIndexes(), []); + }); + }); + + QUnit.module('Options', () => { + QUnit.test('should render new tool calls after the toolCalls option is changed at runtime', function(assert) { + this.instance.option('toolCalls', [currentTimeCall, stockPriceCall]); + + assert.strictEqual(this.getHeaderText(), '2 tools', 'header is rendered'); + assert.deepEqual(this.getNames(), ['getCurrentTime', 'getStockPrice'], 'items are rendered'); + }); + + QUnit.test('should expand the tool calls after the expandAll option is changed at runtime', function(assert) { + this.instance.option('expandAll', true); + + assert.deepEqual(this.getOpenedItemIndexes(), [0]); + }); + }); + + QUnit.module('Accessibility', () => { + QUnit.test('field inputs should be labelled with the field labels', function(assert) { + this.expand(); + + const labels = this.getFields().find(`.${TEXTEDITOR_INPUT_CLASS}`).map((_, element) => $(element).attr('aria-label')).get(); + + assert.deepEqual(labels, ['Arguments', 'Result']); + }); + + QUnit.test('copy buttons should have a localized label', function(assert) { + this.expand(); + + const copyButton = this.getCopyButton('Arguments'); + + assert.strictEqual($(copyButton.element()).attr('aria-label'), 'Copy', 'aria-label is correct'); + assert.strictEqual(copyButton.option('hint'), 'Copy', 'hint is correct'); + }); + }); + + QUnit.module('Localization', () => { + QUnit.test('should localize the header, the field labels and the error badges', function(assert) { + const defaultLocale = localization.locale(); + + try { + localization.loadMessages({ + 'ee': { + 'dxChat-toolCallsTitle': '{0} tööriista', + 'dxChat-toolCallsSingleErrorText': '1 viga', + 'dxChat-toolCallArgumentsLabel': 'Argumendid', + 'dxChat-toolCallResultLabel': 'Tulemus', + 'dxChat-toolCallErrorText': 'Viga', + }, + }); + localization.locale('ee'); + + this.reinit({ toolCalls: [currentTimeCall, stockPriceCall] }); + this.expand(1); + + const labels = this.getFields().find(`.${CHAT_TOOL_CALL_FIELD_LABEL_CLASS}`).map((_, element) => $(element).text()).get(); + + assert.strictEqual(this.getHeaderText(), '2 tööriista', 'header is localized'); + assert.strictEqual(this.getHeaderBadgeText(), '1 viga', 'header error count is localized'); + assert.deepEqual(labels, ['Argumendid', 'Tulemus'], 'field labels are localized'); + assert.strictEqual(this.getTitles().eq(1).find(`.${CHAT_TOOL_CALL_ERROR_BADGE_CLASS}`).text(), 'Viga', 'error badge is localized'); + } finally { + localization.locale(defaultLocale); + } + }); + + QUnit.test('should localize the header error count of several failed tool calls', function(assert) { + const defaultLocale = localization.locale(); + + try { + localization.loadMessages({ + 'ee': { + 'dxChat-toolCallsMultipleErrorsText': '{0} viga', + }, + }); + localization.locale('ee'); + + this.reinit({ toolCalls: [stockPriceCall, weatherCall] }); + + assert.strictEqual(this.getHeaderBadgeText(), '2 viga'); + } finally { + localization.locale(defaultLocale); + } + }); + }); +}); From 6461f9f844f891b47810092f8bf9eec97ce1ddf7 Mon Sep 17 00:00:00 2001 From: Raushen Date: Fri, 16 Oct 2026 13:15:35 +0300 Subject: [PATCH 4/5] Chat: Add tool call layout and legacy theme styles --- .../scss/widgets/base/chat/_index.scss | 4 + .../scss/widgets/base/chat/_mixins.scss | 1 + .../chat/layout/chat-toolcallview/_index.scss | 96 +++++++++++++++++++ .../layout/chat-toolcallview/_mixins.scss | 80 ++++++++++++++++ .../scss/widgets/fluent/chat/_colors.scss | 5 + .../scss/widgets/fluent/chat/_index.scss | 21 ++++ .../scss/widgets/fluent/chat/_sizes.scss | 26 +++++ .../scss/widgets/generic/chat/_colors.scss | 5 + .../scss/widgets/generic/chat/_index.scss | 21 ++++ .../scss/widgets/generic/chat/_sizes.scss | 27 ++++++ .../scss/widgets/material/chat/_colors.scss | 5 + .../scss/widgets/material/chat/_index.scss | 22 +++++ .../scss/widgets/material/chat/_sizes.scss | 26 +++++ 13 files changed, 339 insertions(+) create mode 100644 packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_index.scss create mode 100644 packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_mixins.scss diff --git a/packages/devextreme-scss/scss/widgets/base/chat/_index.scss b/packages/devextreme-scss/scss/widgets/base/chat/_index.scss index 46256faab87d..00758964ea2a 100644 --- a/packages/devextreme-scss/scss/widgets/base/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/chat/_index.scss @@ -1,6 +1,7 @@ $chat-disabled-opacity: null !default; $chat-file-name-font-weight: 600 !default; $chat-editing-preview-caption-font-weight: 500 !default; +$chat-tool-call-header-font-weight: 600 !default; $chat-messagebox-border-block-start-width: 1px !default; $chat-messagebubble-padding-image: 4px !default; @@ -33,3 +34,6 @@ $chat-messagebubble-max-height-image: 400px !default; @use "layout/chat-messagelist" as *; @use "layout/chat-suggestions" as *; @use "layout/chat-typingindicator" as *; +@use "layout/chat-toolcallview" as * with ( + $header-font-weight: $chat-tool-call-header-font-weight, +); diff --git a/packages/devextreme-scss/scss/widgets/base/chat/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/chat/_mixins.scss index 0cabd4e5533d..c014c82a5c5e 100644 --- a/packages/devextreme-scss/scss/widgets/base/chat/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/chat/_mixins.scss @@ -9,4 +9,5 @@ @forward "layout/chat-messagelist/mixins"; @forward "layout/chat-suggestions/mixins"; @forward "layout/chat-typingindicator/mixins"; +@forward "layout/chat-toolcallview/mixins"; @forward "layout/chat-confirmationpopup/mixins"; diff --git a/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_index.scss b/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_index.scss new file mode 100644 index 000000000000..538d2ac934e7 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_index.scss @@ -0,0 +1,96 @@ +$header-font-weight: null !default; + +@use "sass:map"; +@use "../../../mixins" as *; +@use "../../../icon_fonts" as *; + +.dx-chat-messagegroup-alignment-start .dx-chat-messagegroup-content:has(.dx-chat-tool-call-view) { + justify-self: stretch; +} + +.dx-chat-tool-call-view { + .dx-accordion { + margin: 0; + background-color: transparent; + } + + .dx-accordion-item { + margin-block: 0; + box-shadow: none; + background-color: transparent; + } + + .dx-accordion-item:not(.dx-state-focused) { + border-color: transparent; + } + + .dx-accordion-item-title { + justify-content: flex-start; + font-size: inherit; + background-color: transparent; + + &::before { + position: static; + order: 1; + } + } + + .dx-accordion-item-closed > .dx-accordion-item-title::before { + content: map.get($icons, "chevronright"); + } + + .dx-accordion-item-opened > .dx-accordion-item-title::before { + content: map.get($icons, "chevrondown"); + } + + .dx-accordion-item-body { + padding: 0; + } +} + +.dx-rtl .dx-chat-tool-call-view .dx-accordion-item-closed > .dx-accordion-item-title::before { + content: map.get($icons, "chevronleft"); +} + +.dx-chat-tool-call-header { + display: flex; + align-items: center; + font-weight: $header-font-weight; +} + +.dx-chat-tool-call-title { + display: flex; + align-items: center; + min-width: 0; +} + +.dx-chat-tool-call-name { + min-width: 0; + + @include dx-overflow(); +} + +.dx-chat-tool-call-error-badge { + flex-shrink: 0; +} + +.dx-chat-tool-call-content { + display: flex; + flex-direction: column; + border-inline-start-style: solid; +} + +.dx-chat-tool-call-field { + display: flex; + flex-direction: column; + + .dx-textarea, + .dx-texteditor-input, + .dx-button { + cursor: copy; + } + + .dx-button.dx-button-mode-text:not(:focus-visible) { + background-color: transparent; + } +} diff --git a/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_mixins.scss new file mode 100644 index 000000000000..723455d9c704 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-toolcallview/_mixins.scss @@ -0,0 +1,80 @@ +@mixin chat-toolcallview( + $min-width, + $header-padding-block, + $header-padding-inline, + $title-gap, + $title-color, + $icon-size, + $badge-padding-block, + $badge-padding-inline, + $badge-border-radius, + $content-gap, + $content-padding-block, + $content-margin-inline-start, + $content-padding-inline-start, + $content-border-width, + $content-border-color, + $field-gap, + $field-font-family, + $error-color, + $error-background-color, +) { + .dx-chat-messagebubble:has(> .dx-chat-tool-call-view) { + min-width: min($min-width, 100%); + } + + .dx-chat-tool-call-header { + gap: $title-gap; + padding: $header-padding-block $header-padding-inline; + } + + .dx-chat-tool-call-view .dx-accordion-item-title::before { + margin-inline: $title-gap 0; + color: $title-color; + } + + .dx-chat-tool-call-title { + gap: $title-gap; + color: $title-color; + + .dx-icon { + flex-shrink: 0; + font-size: $icon-size; + } + } + + .dx-chat-tool-call-error-badge { + padding: $badge-padding-block $badge-padding-inline; + border-radius: $badge-border-radius; + color: $error-color; + background-color: $error-background-color; + } + + .dx-chat-tool-call-content { + gap: $content-gap; + padding-block: $content-padding-block; + margin-inline-start: $content-margin-inline-start; + padding-inline-start: $content-padding-inline-start; + border-inline-start-width: $content-border-width; + border-inline-start-color: $content-border-color; + } + + .dx-chat-tool-call-field { + gap: $field-gap; + + .dx-textarea { + font-family: $field-font-family; + } + } + + .dx-chat-tool-call-field-error { + .dx-texteditor.dx-editor-outlined.dx-state-readonly { + border-color: transparent; + background-color: $error-background-color; + } + + .dx-texteditor-input { + color: $error-color; + } + } +} diff --git a/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss index 7f53705ade4e..bf5317d013f7 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss @@ -188,3 +188,8 @@ $chat-typingindicator-bubble-bg-color: $chat-bubble-background-color-secondary ! $chat-messagelist-contextmenu-delete-button-color: $base-danger !default; $chat-messagelist-contextmenu-delete-button-focused-color: $base-danger !default; $chat-messagelist-contextmenu-delete-button-focused-bg: $base-hover-bg !default; + +$chat-tool-call-title-color: $base-icon-color !default; +$chat-tool-call-content-border-color: $base-border-color !default; +$chat-tool-call-error-color: $base-danger !default; +$chat-tool-call-error-background-color: color.change($base-danger, $alpha: 0.1) !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss index 3f8905151866..ff93a3f12d81 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss @@ -65,6 +65,27 @@ $chat-bubble-delete-icon-size, $chat-bubble-gap, ); +@include chat-toolcallview( + $chat-tool-call-min-width, + $chat-tool-call-header-padding-block, + $chat-tool-call-header-padding-inline, + $chat-tool-call-title-gap, + $chat-tool-call-title-color, + $chat-tool-call-icon-size, + $chat-tool-call-badge-padding-block, + $chat-tool-call-badge-padding-inline, + $chat-tool-call-badge-border-radius, + $chat-tool-call-content-gap, + $chat-tool-call-content-padding-block, + $chat-tool-call-content-margin-inline-start, + $chat-tool-call-content-padding-inline-start, + $chat-tool-call-content-border-width, + $chat-tool-call-content-border-color, + $chat-tool-call-field-gap, + $chat-tool-call-field-font-family, + $chat-tool-call-error-color, + $chat-tool-call-error-background-color, +); @include chat-messagegroup( $chat-messagegroup-gap, $chat-messagegroup-alignment-start-template, diff --git a/packages/devextreme-scss/scss/widgets/fluent/chat/_sizes.scss b/packages/devextreme-scss/scss/widgets/fluent/chat/_sizes.scss index e34e11d75886..37fa789e079a 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/chat/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/chat/_sizes.scss @@ -3,6 +3,7 @@ @use "../typography/sizes" as *; @use "../popup/sizes" as *; @use "../button/colors" as *; +@use "../accordion/sizes" as accordionSizes; $chat-border-radius: $base-border-radius * 3 !default; $chat-bubble-border-radius: null !default; @@ -80,6 +81,10 @@ $chat-file-icon-font-size: null !default; $chat-file-view-items-gap: 8px !default; $chat-bubble-gap: 8px !default; +$chat-tool-call-badge-padding-inline: null !default; +$chat-tool-call-content-gap: null !default; +$chat-tool-call-content-padding-block: null !default; + @if $size == "default" { $chat-bubble-border-radius: $fluent-base-border-radius * 3 !default; $chat-bubble-padding: 8px 12px !default; @@ -114,6 +119,10 @@ $chat-bubble-gap: 8px !default; $chat-confirmation-popup-toolbar-gap: 8px !default; $chat-file-icon-font-size: 20px !default; + + $chat-tool-call-badge-padding-inline: 8px !default; + $chat-tool-call-content-gap: 16px !default; + $chat-tool-call-content-padding-block: 8px !default; } @else if $size == "compact" { @@ -150,7 +159,24 @@ $chat-bubble-gap: 8px !default; $chat-confirmation-popup-toolbar-gap: 4px !default; $chat-file-icon-font-size: 16px !default; + + $chat-tool-call-badge-padding-inline: 6px !default; + $chat-tool-call-content-gap: 12px !default; + $chat-tool-call-content-padding-block: 6px !default; } $chat-typingindicator-padding: $chat-messagegroup-padding !default; $chat-typingindicator-bubble-border-radius: $chat-bubble-border-radius !default; + +$chat-tool-call-min-width: 360px !default; +$chat-tool-call-header-padding-block: accordionSizes.$fluent-accordion-title-vertical-padding !default; +$chat-tool-call-header-padding-inline: accordionSizes.$fluent-accordion-title-horizontal-padding !default; +$chat-tool-call-icon-size: accordionSizes.$accordion-title-icon-size !default; +$chat-tool-call-title-gap: accordionSizes.$accordion-title-icon-margin !default; +$chat-tool-call-badge-padding-block: 2px !default; +$chat-tool-call-badge-border-radius: $base-border-radius !default; +$chat-tool-call-content-border-width: 1px !default; +$chat-tool-call-content-margin-inline-start: accordionSizes.$fluent-accordion-title-horizontal-padding + math.div($chat-tool-call-icon-size, 2) !default; +$chat-tool-call-content-padding-inline-start: math.div($chat-tool-call-icon-size, 2) + $chat-tool-call-title-gap - $chat-tool-call-content-border-width !default; +$chat-tool-call-field-gap: 4px !default; +$chat-tool-call-field-font-family: Consolas, "Cascadia Mono", Menlo, monospace !default; diff --git a/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss b/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss index 73d08b5ebadb..75cdb73dce0a 100644 --- a/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss @@ -329,3 +329,8 @@ $chat-typingindicator-bubble-bg-color: $chat-bubble-background-color-secondary ! $chat-messagelist-contextmenu-delete-button-color: $base-danger !default; $chat-messagelist-contextmenu-delete-button-focused-color: $base-inverted-text-color !default; $chat-messagelist-contextmenu-delete-button-focused-bg: $base-danger !default; + +$chat-tool-call-title-color: $base-icon-color !default; +$chat-tool-call-content-border-color: $base-border-color !default; +$chat-tool-call-error-color: $base-danger !default; +$chat-tool-call-error-background-color: color.change($base-danger, $alpha: 0.1) !default; diff --git a/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss b/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss index 66b7c36452a2..84336afee6ca 100644 --- a/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss @@ -65,6 +65,27 @@ $chat-bubble-delete-icon-size, $chat-bubble-gap, ); +@include chat-toolcallview( + $chat-tool-call-min-width, + $chat-tool-call-header-padding-block, + $chat-tool-call-header-padding-inline, + $chat-tool-call-title-gap, + $chat-tool-call-title-color, + $chat-tool-call-icon-size, + $chat-tool-call-badge-padding-block, + $chat-tool-call-badge-padding-inline, + $chat-tool-call-badge-border-radius, + $chat-tool-call-content-gap, + $chat-tool-call-content-padding-block, + $chat-tool-call-content-margin-inline-start, + $chat-tool-call-content-padding-inline-start, + $chat-tool-call-content-border-width, + $chat-tool-call-content-border-color, + $chat-tool-call-field-gap, + $chat-tool-call-field-font-family, + $chat-tool-call-error-color, + $chat-tool-call-error-background-color, +); @include chat-messagegroup( $chat-messagegroup-gap, $chat-messagegroup-alignment-start-template, diff --git a/packages/devextreme-scss/scss/widgets/generic/chat/_sizes.scss b/packages/devextreme-scss/scss/widgets/generic/chat/_sizes.scss index c1037584a25e..247e99152e10 100644 --- a/packages/devextreme-scss/scss/widgets/generic/chat/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/generic/chat/_sizes.scss @@ -4,6 +4,7 @@ @use "../sizes" as *; @use "../popup/sizes" as *; @use "../button/colors" as *; +@use "../accordion/sizes" as accordionSizes; $chat-border-radius: $base-border-radius !default; $chat-bubble-border-radius: $base-border-radius !default; @@ -84,6 +85,10 @@ $chat-file-icon-font-size: null !default; $chat-file-view-items-gap: 8px !default; $chat-bubble-gap: 8px !default; +$chat-tool-call-badge-padding-inline: null !default; +$chat-tool-call-content-gap: null !default; +$chat-tool-call-content-padding-block: null !default; + @if $size == "default" { $chat-bubble-padding: 8px 12px !default; $chat-bubble-delete-gap: 4px !default; @@ -122,6 +127,10 @@ $chat-bubble-gap: 8px !default; $chat-confirmation-popup-toolbar-gap: 8px !default; $chat-file-icon-font-size: 18px !default; + + $chat-tool-call-badge-padding-inline: 8px !default; + $chat-tool-call-content-gap: 16px !default; + $chat-tool-call-content-padding-block: 8px !default; } @else if $size == "compact" { @@ -162,6 +171,24 @@ $chat-bubble-gap: 8px !default; $chat-confirmation-popup-toolbar-gap: 4px !default; $chat-file-icon-font-size: 14 !default; + + $chat-tool-call-badge-padding-inline: 6px !default; + $chat-tool-call-content-gap: 12px !default; + $chat-tool-call-content-padding-block: 6px !default; } $chat-typingindicator-padding: $chat-messagegroup-padding !default; + +$chat-tool-call-min-width: 360px !default; +$chat-tool-call-header-padding-block: accordionSizes.$generic-accordion-title-vertical-padding !default; +$chat-tool-call-accordion-item-border-width: 1px !default; +$chat-tool-call-header-padding-inline: accordionSizes.$generic-accordion-title-horizontal-padding + $chat-tool-call-accordion-item-border-width !default; +$chat-tool-call-icon-size: accordionSizes.$generic-accordion-title-icon-size !default; +$chat-tool-call-title-gap: accordionSizes.$accordion-title-icon-margin !default; +$chat-tool-call-badge-padding-block: 2px !default; +$chat-tool-call-badge-border-radius: $base-border-radius !default; +$chat-tool-call-content-border-width: 1px !default; +$chat-tool-call-content-margin-inline-start: accordionSizes.$generic-accordion-title-horizontal-padding + math.div($chat-tool-call-icon-size, 2) !default; +$chat-tool-call-content-padding-inline-start: math.div($chat-tool-call-icon-size, 2) + $chat-tool-call-title-gap - $chat-tool-call-content-border-width !default; +$chat-tool-call-field-gap: 4px !default; +$chat-tool-call-field-font-family: Consolas, "Cascadia Mono", Menlo, monospace !default; diff --git a/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss b/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss index 625265b3ee6d..858a3f028fcd 100644 --- a/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss @@ -149,3 +149,8 @@ $chat-suggestions-box-shadow: null !default; $chat-messagelist-contextmenu-delete-button-color: $base-danger !default; $chat-messagelist-contextmenu-delete-button-focused-color: $base-danger !default; $chat-messagelist-contextmenu-delete-button-focused-bg: $base-hover-bg !default; + +$chat-tool-call-title-color: $base-icon-color !default; +$chat-tool-call-content-border-color: $base-border-color !default; +$chat-tool-call-error-color: $base-danger !default; +$chat-tool-call-error-background-color: color.change($base-danger, $alpha: 0.1) !default; diff --git a/packages/devextreme-scss/scss/widgets/material/chat/_index.scss b/packages/devextreme-scss/scss/widgets/material/chat/_index.scss index afe41c08361f..b6779f6b1852 100644 --- a/packages/devextreme-scss/scss/widgets/material/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/chat/_index.scss @@ -1,5 +1,6 @@ @use "../../base/chat" with ( $chat-disabled-opacity: 0.5, + $chat-tool-call-header-font-weight: 500, ); @use "colors" as *; @use "sizes" as *; @@ -65,6 +66,27 @@ $chat-bubble-delete-icon-size, $chat-bubble-gap, ); +@include chat-toolcallview( + $chat-tool-call-min-width, + $chat-tool-call-header-padding-block, + $chat-tool-call-header-padding-inline, + $chat-tool-call-title-gap, + $chat-tool-call-title-color, + $chat-tool-call-icon-size, + $chat-tool-call-badge-padding-block, + $chat-tool-call-badge-padding-inline, + $chat-tool-call-badge-border-radius, + $chat-tool-call-content-gap, + $chat-tool-call-content-padding-block, + $chat-tool-call-content-margin-inline-start, + $chat-tool-call-content-padding-inline-start, + $chat-tool-call-content-border-width, + $chat-tool-call-content-border-color, + $chat-tool-call-field-gap, + $chat-tool-call-field-font-family, + $chat-tool-call-error-color, + $chat-tool-call-error-background-color, +); @include chat-messagegroup( $chat-messagegroup-gap, $chat-messagegroup-alignment-start-template, diff --git a/packages/devextreme-scss/scss/widgets/material/chat/_sizes.scss b/packages/devextreme-scss/scss/widgets/material/chat/_sizes.scss index 5ced22e92a53..0e557daaa62e 100644 --- a/packages/devextreme-scss/scss/widgets/material/chat/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/material/chat/_sizes.scss @@ -2,6 +2,7 @@ @use "../sizes" as *; @use "../typography/sizes" as *; @use "../button/colors" as *; +@use "../accordion/sizes" as accordionSizes; $chat-border-radius: $base-border-radius * 2 !default; // 8px $chat-bubble-border-radius: $base-border-radius * 2 !default; @@ -82,6 +83,10 @@ $chat-file-icon-font-size: null !default; $chat-file-view-items-gap: 8px !default; $chat-bubble-gap: 8px !default; +$chat-tool-call-badge-padding-inline: null !default; +$chat-tool-call-content-gap: null !default; +$chat-tool-call-content-padding-block: null !default; + @if $size == "default" { $chat-bubble-border-radius: 8px !default; $chat-bubble-padding: 8px 16px !default; @@ -120,6 +125,10 @@ $chat-bubble-gap: 8px !default; $chat-confirmation-popup-toolbar-gap: 8px !default; $chat-file-icon-font-size: 18px !default; + + $chat-tool-call-badge-padding-inline: 8px !default; + $chat-tool-call-content-gap: 16px !default; + $chat-tool-call-content-padding-block: 8px !default; } @else if $size == "compact" { @@ -160,6 +169,23 @@ $chat-bubble-gap: 8px !default; $chat-confirmation-popup-toolbar-gap: 4px !default; $chat-file-icon-font-size: 16px !default; + + $chat-tool-call-badge-padding-inline: 6px !default; + $chat-tool-call-content-gap: 12px !default; + $chat-tool-call-content-padding-block: 6px !default; } $chat-typingindicator-padding: $chat-messagegroup-padding !default; + +$chat-tool-call-min-width: 360px !default; +$chat-tool-call-header-padding-block: accordionSizes.$material-accordion-title-vertical-padding !default; +$chat-tool-call-header-padding-inline: accordionSizes.$material-accordion-title-horizontal-padding !default; +$chat-tool-call-icon-size: accordionSizes.$accordion-title-icon-size !default; +$chat-tool-call-title-gap: accordionSizes.$accordion-title-icon-margin !default; +$chat-tool-call-badge-padding-block: 2px !default; +$chat-tool-call-badge-border-radius: $base-border-radius !default; +$chat-tool-call-content-border-width: 1px !default; +$chat-tool-call-content-margin-inline-start: accordionSizes.$material-accordion-title-horizontal-padding + math.div($chat-tool-call-icon-size, 2) !default; +$chat-tool-call-content-padding-inline-start: math.div($chat-tool-call-icon-size, 2) + $chat-tool-call-title-gap - $chat-tool-call-content-border-width !default; +$chat-tool-call-field-gap: 4px !default; +$chat-tool-call-field-font-family: Consolas, "Cascadia Mono", Menlo, monospace !default; From 2cf1f1a806d42cac5b6c4c3293a610e292368c15 Mon Sep 17 00:00:00 2001 From: Raushen Date: Fri, 16 Oct 2026 13:15:51 +0300 Subject: [PATCH 5/5] Chat: Style tool calls with Fluent Next tokens --- .../widgets/fluent-next/chat/_colors.scss | 5 +++ .../scss/widgets/fluent-next/chat/_index.scss | 28 ++++++++++++++- .../widgets/fluent-next/chat/_public.scss | 20 +++++++++++ .../scss/widgets/fluent-next/chat/_sizes.scss | 35 +++++++++++++++++++ .../tests/public-surface.baseline.json | 20 +++++++++++ .../tools/naming/derive-registries.ts | 2 +- .../tools/naming/registries.json | 7 +++- .../tools/review/nested-scopes.json | 7 ++++ 8 files changed, 121 insertions(+), 3 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_colors.scss index 0da5aaa57b34..fa4b5b87cc2b 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_colors.scss @@ -59,3 +59,8 @@ $chat-suggestions-box-shadow: ds.$box-shadow-md !default; $chat-message-list-context-menu-delete-button-content: ds.$color-content-danger !default; $chat-message-list-context-menu-delete-button-content-focused: ds.$color-content-danger !default; $chat-message-list-context-menu-delete-button-bg-focused: ds.$color-bg-hovered !default; + +$chat-tool-call-title-content: ds.$color-content-subtle !default; +$chat-tool-call-content-border: ds.$color-border !default; +$chat-tool-call-error-content: ds.$color-content-danger !default; +$chat-tool-call-error-bg: ds.$color-bg-danger-subtler !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_index.scss index 09dc5e708198..1c012d44a9f8 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_index.scss @@ -3,6 +3,7 @@ @use "../../base/chat" with ( $chat-file-name-font-weight: var(--dx-chat-file-name-font-weight), $chat-editing-preview-caption-font-weight: var(--dx-chat-editing-preview-caption-font-weight), + $chat-tool-call-header-font-weight: var(--dx-chat-tool-call-header-font-weight), $chat-messagebox-border-block-start-width: var(--dx-chat-message-box-border-block-start-width), $chat-messagebubble-padding-image: var(--dx-chat-bubble-image-padding), $chat-messagebubble-border-radius-image: var(--dx-chat-bubble-image-border-radius), @@ -72,6 +73,27 @@ var(--dx-chat-bubble-delete-icon-size), var(--dx-chat-bubble-gap), ); +@include chat-toolcallview( + var(--dx-chat-tool-call-min-width), + var(--dx-chat-tool-call-header-padding-block), + var(--dx-chat-tool-call-header-padding-inline), + var(--dx-chat-tool-call-title-gap), + var(--dx-chat-tool-call-title-content), + var(--dx-chat-tool-call-icon-size), + var(--dx-chat-tool-call-badge-padding-block), + var(--dx-chat-tool-call-badge-padding-inline), + var(--dx-chat-tool-call-badge-border-radius), + var(--dx-chat-tool-call-content-gap), + var(--dx-chat-tool-call-content-padding-block), + var(--dx-chat-tool-call-content-margin-inline-start), + var(--dx-chat-tool-call-content-padding-inline-start), + var(--dx-chat-tool-call-content-border-width), + var(--dx-chat-tool-call-content-border), + var(--dx-chat-tool-call-field-gap), + var(--dx-chat-tool-call-field-font-family), + var(--dx-chat-tool-call-error-content), + var(--dx-chat-tool-call-error-bg), +); @include chat-messagegroup( var(--dx-chat-message-group-gap), var(--dx-chat-message-group-start-grid-template-columns), @@ -179,7 +201,11 @@ .dx-chat-message-edited, .dx-chat-messagelist-empty-message, .dx-chat-messagelist-empty-prompt, - .dx-chat-messagelist-empty-image { + .dx-chat-messagelist-empty-image, + .dx-chat-tool-call-title, + .dx-chat-tool-call-error-badge, + .dx-chat-tool-call-field-error .dx-texteditor-input, + .dx-chat-tool-call-view .dx-accordion-item-title::before { color: var(--dx-chat-information-content-disabled); } } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_public.scss b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_public.scss index dc842297914b..3ead6b6051b0 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_public.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_public.scss @@ -101,6 +101,26 @@ --dx-chat-suggestions-button-max-width: #{$chat-suggestions-button-max-width}; --dx-chat-suggestions-gap: #{$chat-suggestions-gap}; --dx-chat-suggestions-padding: #{$chat-suggestions-padding}; + --dx-chat-tool-call-badge-border-radius: #{$chat-tool-call-badge-border-radius}; + --dx-chat-tool-call-badge-padding-block: #{$chat-tool-call-badge-padding-block}; + --dx-chat-tool-call-badge-padding-inline: #{$chat-tool-call-badge-padding-inline}; + --dx-chat-tool-call-content-border: #{$chat-tool-call-content-border}; + --dx-chat-tool-call-content-border-width: #{$chat-tool-call-content-border-width}; + --dx-chat-tool-call-content-gap: #{$chat-tool-call-content-gap}; + --dx-chat-tool-call-content-margin-inline-start: #{$chat-tool-call-content-margin-inline-start}; + --dx-chat-tool-call-content-padding-block: #{$chat-tool-call-content-padding-block}; + --dx-chat-tool-call-content-padding-inline-start: #{$chat-tool-call-content-padding-inline-start}; + --dx-chat-tool-call-error-bg: #{$chat-tool-call-error-bg}; + --dx-chat-tool-call-error-content: #{$chat-tool-call-error-content}; + --dx-chat-tool-call-field-font-family: #{$chat-tool-call-field-font-family}; + --dx-chat-tool-call-field-gap: #{$chat-tool-call-field-gap}; + --dx-chat-tool-call-header-font-weight: #{$chat-tool-call-header-font-weight}; + --dx-chat-tool-call-header-padding-block: #{$chat-tool-call-header-padding-block}; + --dx-chat-tool-call-header-padding-inline: #{$chat-tool-call-header-padding-inline}; + --dx-chat-tool-call-icon-size: #{$chat-tool-call-icon-size}; + --dx-chat-tool-call-min-width: #{$chat-tool-call-min-width}; + --dx-chat-tool-call-title-content: #{$chat-tool-call-title-content}; + --dx-chat-tool-call-title-gap: #{$chat-tool-call-title-gap}; --dx-chat-typing-indicator-bubble-column-gap: #{$chat-typing-indicator-bubble-column-gap}; --dx-chat-typing-indicator-bubble-padding-block: #{$chat-typing-indicator-bubble-padding-block}; --dx-chat-typing-indicator-bubble-padding-inline: #{$chat-typing-indicator-bubble-padding-inline}; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_sizes.scss b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_sizes.scss index 2cb3d2b84a33..d48752b860d6 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/chat/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/chat/_sizes.scss @@ -76,6 +76,23 @@ $chat-file-icon-font-size: null !default; $chat-file-view-items-gap: ds.$spacing-80 !default; $chat-bubble-gap: ds.$spacing-80 !default; +$chat-tool-call-min-width: 360px !default; +$chat-tool-call-header-padding-block: ds.$spacing-60 !default; +$chat-tool-call-header-padding-inline: null !default; +$chat-tool-call-header-font-weight: ds.$font-weight-base-strong !default; +$chat-tool-call-title-gap: null !default; +$chat-tool-call-icon-size: null !default; +$chat-tool-call-badge-padding-block: ds.$spacing-20 !default; +$chat-tool-call-badge-padding-inline: null !default; +$chat-tool-call-badge-border-radius: ds.$border-radius-40 !default; +$chat-tool-call-content-gap: null !default; +$chat-tool-call-content-padding-block: null !default; +$chat-tool-call-content-margin-inline-start: null !default; +$chat-tool-call-content-padding-inline-start: null !default; +$chat-tool-call-content-border-width: ds.$border-width-10 !default; +$chat-tool-call-field-gap: ds.$spacing-40 !default; +$chat-tool-call-field-font-family: ds.$font-family-system-mono !default; + @if $size == "default" { $chat-bubble-border-radius: ds.$border-radius-120 !default; $chat-bubble-padding: ds.$spacing-80 ds.$spacing-120 !default; @@ -110,6 +127,15 @@ $chat-bubble-gap: ds.$spacing-80 !default; $chat-confirmation-popup-toolbar-gap: ds.$spacing-80 !default; $chat-file-icon-font-size: ds.$spacing-200 !default; + + $chat-tool-call-header-padding-inline: ds.$spacing-120 !default; + $chat-tool-call-title-gap: ds.$spacing-80 !default; + $chat-tool-call-icon-size: ds.$spacing-200 !default; + $chat-tool-call-badge-padding-inline: ds.$spacing-80 !default; + $chat-tool-call-content-gap: ds.$spacing-160 !default; + $chat-tool-call-content-padding-block: ds.$spacing-80 !default; + $chat-tool-call-content-margin-inline-start: ds.$spacing-220 !default; + $chat-tool-call-content-padding-inline-start: ds.$spacing-170 !default; } @else if $size == "compact" { @@ -146,6 +172,15 @@ $chat-bubble-gap: ds.$spacing-80 !default; $chat-confirmation-popup-toolbar-gap: ds.$spacing-40 !default; $chat-file-icon-font-size: ds.$spacing-160 !default; + + $chat-tool-call-header-padding-inline: ds.$spacing-80 !default; + $chat-tool-call-title-gap: ds.$spacing-60 !default; + $chat-tool-call-icon-size: ds.$spacing-160 !default; + $chat-tool-call-badge-padding-inline: ds.$spacing-60 !default; + $chat-tool-call-content-gap: ds.$spacing-120 !default; + $chat-tool-call-content-padding-block: ds.$spacing-60 !default; + $chat-tool-call-content-margin-inline-start: ds.$spacing-160 !default; + $chat-tool-call-content-padding-inline-start: ds.$spacing-130 !default; } $chat-bubble-image-padding: ds.$spacing-40 !default; diff --git a/packages/devextreme-scss/tests/public-surface.baseline.json b/packages/devextreme-scss/tests/public-surface.baseline.json index b0824c633357..8679b1b195da 100644 --- a/packages/devextreme-scss/tests/public-surface.baseline.json +++ b/packages/devextreme-scss/tests/public-surface.baseline.json @@ -474,6 +474,26 @@ "--dx-chat-suggestions-button-max-width", "--dx-chat-suggestions-gap", "--dx-chat-suggestions-padding", + "--dx-chat-tool-call-badge-border-radius", + "--dx-chat-tool-call-badge-padding-block", + "--dx-chat-tool-call-badge-padding-inline", + "--dx-chat-tool-call-content-border", + "--dx-chat-tool-call-content-border-width", + "--dx-chat-tool-call-content-gap", + "--dx-chat-tool-call-content-margin-inline-start", + "--dx-chat-tool-call-content-padding-block", + "--dx-chat-tool-call-content-padding-inline-start", + "--dx-chat-tool-call-error-bg", + "--dx-chat-tool-call-error-content", + "--dx-chat-tool-call-field-font-family", + "--dx-chat-tool-call-field-gap", + "--dx-chat-tool-call-header-font-weight", + "--dx-chat-tool-call-header-padding-block", + "--dx-chat-tool-call-header-padding-inline", + "--dx-chat-tool-call-icon-size", + "--dx-chat-tool-call-min-width", + "--dx-chat-tool-call-title-content", + "--dx-chat-tool-call-title-gap", "--dx-chat-typing-indicator-bubble-bg", "--dx-chat-typing-indicator-bubble-border-radius", "--dx-chat-typing-indicator-bubble-column-gap", diff --git a/packages/devextreme-scss/tools/naming/derive-registries.ts b/packages/devextreme-scss/tools/naming/derive-registries.ts index d9292f319069..280d64e8b3aa 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.ts +++ b/packages/devextreme-scss/tools/naming/derive-registries.ts @@ -794,7 +794,7 @@ const OVERRIDES: Overrides = { 'alert-list', 'prompt', 'textarea', 'toolbar', 'information', 'author-name', 'timestamp', 'typing-indicator', 'circle', 'bubble', 'group', // .dx-chat-messagebubble-image is a real box inside a bubble - 'image'], + 'image', 'tool-call', 'header', 'title', 'badge', 'field'], /* * `alpha` alone was doing three jobs — the alpha row, the alpha cell and the alpha scale are * three different elements with three different sizes, so each gets its own segment and the diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index e43b87d18bba..8eae3b797b5c 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -1266,7 +1266,12 @@ "circle", "bubble", "group", - "image" + "image", + "tool-call", + "header", + "title", + "badge", + "field" ], "color-view": [ "palette", diff --git a/packages/devextreme-scss/tools/review/nested-scopes.json b/packages/devextreme-scss/tools/review/nested-scopes.json index 9c6b4b53728c..ff196fdcb8c2 100644 --- a/packages/devextreme-scss/tools/review/nested-scopes.json +++ b/packages/devextreme-scss/tools/review/nested-scopes.json @@ -94,6 +94,13 @@ "dx-chat-suggestions", "dx-chat-textarea", "dx-chat-textarea-toolbar", + "dx-chat-tool-call-content", + "dx-chat-tool-call-error-badge", + "dx-chat-tool-call-field", + "dx-chat-tool-call-field-error", + "dx-chat-tool-call-header", + "dx-chat-tool-call-title", + "dx-chat-tool-call-view", "dx-chat-typingindicator-bubble", "dx-chat-typingindicator-circle", "dx-chat-typingindicator-content",