feat: ChatInput support rich text and support parallel send (#8964)

*  feat: Add LobeEditor

* fix tests

* fix mobile

---------

Co-authored-by: arvinxx <arvinx@foxmail.com>
This commit is contained in:
CanisMinor
2025-09-06 09:51:52 +08:00
committed by GitHub
parent e2448eb091
commit 38d9d98b97
124 changed files with 2985 additions and 1943 deletions
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "إضافة رسالة مستخدم",
"more": "المزيد",
"send": "إرسال",
"sendWithCmdEnter": "اضغط {{meta}} + Enter للإرسال",
"sendWithEnter": "اضغط Enter للإرسال",
"sendWithCmdEnter": "اضغط <key/> للإرسال",
"sendWithEnter": "اضغط <key/> للإرسال",
"stop": "توقف",
"warp": "تغيير السطر"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} رسالة",
"title": "موضوع فرعي"
},
"toggleWideScreen": {
"off": "إيقاف وضع الشاشة العريضة",
"on": "تشغيل وضع الشاشة العريضة"
},
"tokenDetails": {
"chats": "رسائل المحادثة",
"historySummary": "ملخص التاريخ",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "طي",
"on": "توسيع"
},
"typobar": {
"off": "إخفاء شريط أدوات التنسيق",
"on": "إظهار شريط أدوات التنسيق"
}
},
"file": {
"error": "خطأ: {{message}}",
"uploading": "جاري رفع الملف..."
},
"image": {
"broken": "الصورة تالفة"
},
"link": {
"edit": "تعديل الرابط",
"open": "فتح الرابط",
"placeholder": "أدخل عنوان URL للرابط",
"unlink": "إزالة الرابط"
},
"table": {
"delete": "حذف الجدول",
"deleteColumn": "حذف العمود",
"deleteRow": "حذف الصف",
"insertColumnLeft": "إدراج {{count}} عمودًا إلى اليسار",
"insertColumnRight": "إدراج {{count}} عمودًا إلى اليمين",
"insertRowAbove": "إدراج {{count}} صفًا في الأعلى",
"insertRowBelow": "إدراج {{count}} صفًا في الأسفل"
},
"typobar": {
"blockquote": "اقتباس",
"bold": "غامق",
"bulletList": "قائمة نقطية",
"code": "كود مضمن",
"codeblock": "كتلة كود",
"italic": "مائل",
"link": "رابط",
"numberList": "قائمة مرقمة",
"strikethrough": "شطب",
"table": "إدراج جدول",
"underline": "تسطير"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Добави потребителско съобщение",
"more": "още",
"send": "Изпрати",
"sendWithCmdEnter": "Натисни {{meta}} + Enter за да изпратиш",
"sendWithEnter": "Натисни Enter за да изпратиш",
"sendWithCmdEnter": "Натиснете <key/> за изпращане",
"sendWithEnter": "Натиснете <key/> за изпращане",
"stop": "Спри",
"warp": "Нов ред"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} съобщения",
"title": "Подтема"
},
"toggleWideScreen": {
"off": "Изключване на широк екран",
"on": "Включване на широк екран"
},
"tokenDetails": {
"chats": "Чат съобщения",
"historySummary": "Историческо резюме",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Сгъни",
"on": "Разгъни"
},
"typobar": {
"off": "Скрий лентата за форматиране",
"on": "Покажи лентата за форматиране"
}
},
"file": {
"error": "Грешка: {{message}}",
"uploading": "Качване на файл..."
},
"image": {
"broken": "Изображението е повредено"
},
"link": {
"edit": "Редактирай връзката",
"open": "Отвори връзката",
"placeholder": "Въведете URL адрес на връзката",
"unlink": "Премахни връзката"
},
"table": {
"delete": "Премахни таблицата",
"deleteColumn": "Премахни колоната",
"deleteRow": "Премахни реда",
"insertColumnLeft": "Вмъкни {{count}} колони отляво",
"insertColumnRight": "Вмъкни {{count}} колони отдясно",
"insertRowAbove": "Вмъкни {{count}} реда отгоре",
"insertRowBelow": "Вмъкни {{count}} реда отдолу"
},
"typobar": {
"blockquote": "Цитат",
"bold": "Удебели",
"bulletList": "Маркиран списък",
"code": "Код в реда",
"codeblock": "Блок с код",
"italic": "Курсив",
"link": "Връзка",
"numberList": "Номериран списък",
"strikethrough": "Зачеркване",
"table": "Вмъкване на таблица",
"underline": "Подчертаване"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Fügen Sie eine Benutzer-Nachricht hinzu",
"more": "Mehr",
"send": "Senden",
"sendWithCmdEnter": "Mit {{meta}} + Eingabetaste senden",
"sendWithEnter": "Mit Eingabetaste senden",
"sendWithCmdEnter": "Drücken Sie <key/>, um zu senden",
"sendWithEnter": "Drücken Sie <key/>, um zu senden",
"stop": "Stoppen",
"warp": "Zeilenumbruch"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} Nachrichten",
"title": "Unterthema"
},
"toggleWideScreen": {
"off": "Breitbildmodus deaktivieren",
"on": "Breitbildmodus aktivieren"
},
"tokenDetails": {
"chats": "Chats",
"historySummary": "Historische Zusammenfassung",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Einklappen",
"on": "Ausklappen"
},
"typobar": {
"off": "Formatierungsleiste ausblenden",
"on": "Formatierungsleiste anzeigen"
}
},
"file": {
"error": "Fehler: {{message}}",
"uploading": "Datei wird hochgeladen..."
},
"image": {
"broken": "Bild beschädigt"
},
"link": {
"edit": "Link bearbeiten",
"open": "Link öffnen",
"placeholder": "Link-URL eingeben",
"unlink": "Link entfernen"
},
"table": {
"delete": "Tabelle löschen",
"deleteColumn": "Spalte löschen",
"deleteRow": "Zeile löschen",
"insertColumnLeft": "Links {{count}} Spalten einfügen",
"insertColumnRight": "Rechts {{count}} Spalten einfügen",
"insertRowAbove": "Oben {{count}} Zeilen einfügen",
"insertRowBelow": "Unten {{count}} Zeilen einfügen"
},
"typobar": {
"blockquote": "Zitat",
"bold": "Fett",
"bulletList": "Ungeordnete Liste",
"code": "Inline-Code",
"codeblock": "Codeblock",
"italic": "Kursiv",
"link": "Link",
"numberList": "Nummerierte Liste",
"strikethrough": "Durchgestrichen",
"table": "Tabelle einfügen",
"underline": "Unterstrichen"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Add a user message",
"more": "more",
"send": "Send",
"sendWithCmdEnter": "Press {{meta}} + Enter to send",
"sendWithEnter": "Press Enter to send",
"sendWithCmdEnter": "Press <key/> to send",
"sendWithEnter": "Press <key/> to send",
"stop": "Stop",
"warp": "New Line"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} messages",
"title": "Subtopic"
},
"toggleWideScreen": {
"off": "Turn off widescreen mode",
"on": "Turn on widescreen mode"
},
"tokenDetails": {
"chats": "Chat Messages",
"historySummary": "History Summary",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Collapse",
"on": "Expand"
},
"typobar": {
"off": "Hide formatting toolbar",
"on": "Show formatting toolbar"
}
},
"file": {
"error": "Error: {{message}}",
"uploading": "Uploading file..."
},
"image": {
"broken": "Image is corrupted"
},
"link": {
"edit": "Edit link",
"open": "Open link",
"placeholder": "Enter link URL",
"unlink": "Unlink"
},
"table": {
"delete": "Delete table",
"deleteColumn": "Delete column",
"deleteRow": "Delete row",
"insertColumnLeft": "Insert {{count}} column(s) to the left",
"insertColumnRight": "Insert {{count}} column(s) to the right",
"insertRowAbove": "Insert {{count}} row(s) above",
"insertRowBelow": "Insert {{count}} row(s) below"
},
"typobar": {
"blockquote": "Blockquote",
"bold": "Bold",
"bulletList": "Bulleted list",
"code": "Inline code",
"codeblock": "Code block",
"italic": "Italic",
"link": "Link",
"numberList": "Numbered list",
"strikethrough": "Strikethrough",
"table": "Insert table",
"underline": "Underline"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Agregar un mensaje de usuario",
"more": "más",
"send": "Enviar",
"sendWithCmdEnter": "Enviar con {{meta}} + Enter",
"sendWithEnter": "Enviar con Enter",
"sendWithCmdEnter": "Presiona <key/> para enviar",
"sendWithEnter": "Presiona <key/> para enviar",
"stop": "Detener",
"warp": "Salto de línea"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} mensajes",
"title": "Subtema"
},
"toggleWideScreen": {
"off": "Desactivar modo de pantalla ancha",
"on": "Activar modo de pantalla ancha"
},
"tokenDetails": {
"chats": "Mensajes de chat",
"historySummary": "Resumen histórico",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Contraer",
"on": "Expandir"
},
"typobar": {
"off": "Ocultar barra de herramientas de formato",
"on": "Mostrar barra de herramientas de formato"
}
},
"file": {
"error": "Error: {{message}}",
"uploading": "Subiendo archivo..."
},
"image": {
"broken": "Imagen dañada"
},
"link": {
"edit": "Editar enlace",
"open": "Abrir enlace",
"placeholder": "Introduce la URL del enlace",
"unlink": "Quitar enlace"
},
"table": {
"delete": "Eliminar tabla",
"deleteColumn": "Eliminar columna",
"deleteRow": "Eliminar fila",
"insertColumnLeft": "Insertar {{count}} columna(s) a la izquierda",
"insertColumnRight": "Insertar {{count}} columna(s) a la derecha",
"insertRowAbove": "Insertar {{count}} fila(s) encima",
"insertRowBelow": "Insertar {{count}} fila(s) debajo"
},
"typobar": {
"blockquote": "Cita",
"bold": "Negrita",
"bulletList": "Lista desordenada",
"code": "Código en línea",
"codeblock": "Bloque de código",
"italic": "Cursiva",
"link": "Enlace",
"numberList": "Lista ordenada",
"strikethrough": "Tachado",
"table": "Insertar tabla",
"underline": "Subrayado"
}
}
+3 -1
View File
@@ -1505,7 +1505,9 @@
"gpt-4.1-nano": {
"description": "GPT-4.1 mini ofrece un equilibrio entre inteligencia, velocidad y costo, lo que lo convierte en un modelo atractivo para muchos casos de uso."
},
"gpt-4.5-preview": "GPT-4.5-preview es el modelo de propósito general más reciente, con un profundo conocimiento del mundo y una mejor comprensión de las intenciones de los usuarios; destaca en tareas creativas y en la planificación de agentes. El conocimiento de este modelo está actualizado hasta octubre de 2023.",
"gpt-4.5-preview": {
"description": "GPT-4.5-preview es el modelo de propósito general más reciente, con un profundo conocimiento del mundo y una mejor comprensión de las intenciones de los usuarios; destaca en tareas creativas y en la planificación de agentes. El conocimiento de este modelo está actualizado hasta octubre de 2023."
},
"gpt-4o": {
"description": "ChatGPT-4o es un modelo dinámico que se actualiza en tiempo real para mantener la versión más actual. Combina una poderosa comprensión y generación de lenguaje, adecuado para aplicaciones a gran escala, incluyendo servicio al cliente, educación y soporte técnico."
},
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "افزودن یک پیام کاربر",
"more": "بیشتر",
"send": "ارسال",
"sendWithCmdEnter": "فشار دهید {{meta}} + Enter برای ارسال",
"sendWithEnter": "فشار دهید Enter برای ارسال",
"sendWithCmdEnter": "برای ارسال، کلید <key/> را فشار دهید",
"sendWithEnter": "برای ارسال، کلید <key/> را فشار دهید",
"stop": "توقف",
"warp": "خط جدید"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} پیام",
"title": "زیرموضوع"
},
"toggleWideScreen": {
"off": "حالت صفحه‌نمایش عریض را غیرفعال کنید",
"on": "حالت صفحه‌نمایش عریض را فعال کنید"
},
"tokenDetails": {
"chats": "پیام‌های گفتگو",
"historySummary": "خلاصه تاریخ",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "جمع کردن",
"on": "باز کردن"
},
"typobar": {
"off": "مخفی کردن نوار ابزار قالب‌بندی",
"on": "نمایش نوار ابزار قالب‌بندی"
}
},
"file": {
"error": "خطا: {{message}}",
"uploading": "در حال بارگذاری فایل..."
},
"image": {
"broken": "تصویر خراب است"
},
"link": {
"edit": "ویرایش پیوند",
"open": "باز کردن پیوند",
"placeholder": "آدرس URL پیوند را وارد کنید",
"unlink": "حذف پیوند"
},
"table": {
"delete": "حذف جدول",
"deleteColumn": "حذف ستون",
"deleteRow": "حذف ردیف",
"insertColumnLeft": "درج {{count}} ستون در سمت چپ",
"insertColumnRight": "درج {{count}} ستون در سمت راست",
"insertRowAbove": "درج {{count}} ردیف در بالا",
"insertRowBelow": "درج {{count}} ردیف در پایین"
},
"typobar": {
"blockquote": "نقل قول",
"bold": "پررنگ",
"bulletList": "فهرست نشانه‌دار",
"code": "کد درون‌خطی",
"codeblock": "بلوک کد",
"italic": "ایتالیک",
"link": "پیوند",
"numberList": "فهرست شماره‌دار",
"strikethrough": "خط خورده",
"table": "درج جدول",
"underline": "زیرخط"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Ajouter un message utilisateur",
"more": "Plus",
"send": "Envoyer",
"sendWithCmdEnter": "Envoyer avec {{meta}} + Entrée",
"sendWithEnter": "Envoyer avec Entrée",
"sendWithCmdEnter": "Appuyez sur <key/> pour envoyer",
"sendWithEnter": "Appuyez sur <key/> pour envoyer",
"stop": "Arrêter",
"warp": "Saut de ligne"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} messages",
"title": "Sous-sujet"
},
"toggleWideScreen": {
"off": "Désactiver le mode écran large",
"on": "Activer le mode écran large"
},
"tokenDetails": {
"chats": "Messages de discussion",
"historySummary": "Résumé historique",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Réduire",
"on": "Développer"
},
"typobar": {
"off": "Masquer la barre d'outils de formatage",
"on": "Afficher la barre d'outils de formatage"
}
},
"file": {
"error": "Erreur : {{message}}",
"uploading": "Téléversement du fichier en cours..."
},
"image": {
"broken": "Image endommagée"
},
"link": {
"edit": "Modifier le lien",
"open": "Ouvrir le lien",
"placeholder": "Entrez l'URL du lien",
"unlink": "Supprimer le lien"
},
"table": {
"delete": "Supprimer le tableau",
"deleteColumn": "Supprimer la colonne",
"deleteRow": "Supprimer la ligne",
"insertColumnLeft": "Insérer {{count}} colonne(s) à gauche",
"insertColumnRight": "Insérer {{count}} colonne(s) à droite",
"insertRowAbove": "Insérer {{count}} ligne(s) au-dessus",
"insertRowBelow": "Insérer {{count}} ligne(s) en dessous"
},
"typobar": {
"blockquote": "Citation",
"bold": "Gras",
"bulletList": "Liste à puces",
"code": "Code en ligne",
"codeblock": "Bloc de code",
"italic": "Italique",
"link": "Lien",
"numberList": "Liste numérotée",
"strikethrough": "Barré",
"table": "Insérer un tableau",
"underline": "Souligné"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Aggiungi un messaggio utente",
"more": "Ulteriori",
"send": "Invia",
"sendWithCmdEnter": "Invia premendo {{meta}} + Invio",
"sendWithEnter": "Invia premendo Invio",
"sendWithCmdEnter": "Premi <key/> per inviare",
"sendWithEnter": "Premi <key/> per inviare",
"stop": "Ferma",
"warp": "A capo"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} messaggi",
"title": "Sottoargomento"
},
"toggleWideScreen": {
"off": "Disattiva modalità schermo ampio",
"on": "Attiva modalità schermo ampio"
},
"tokenDetails": {
"chats": "Chat",
"historySummary": "Riepilogo storico",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Comprimi",
"on": "Espandi"
},
"typobar": {
"off": "Nascondi barra degli strumenti di formattazione",
"on": "Mostra barra degli strumenti di formattazione"
}
},
"file": {
"error": "Errore: {{message}}",
"uploading": "Caricamento file in corso..."
},
"image": {
"broken": "Immagine danneggiata"
},
"link": {
"edit": "Modifica link",
"open": "Apri link",
"placeholder": "Inserisci URL del link",
"unlink": "Rimuovi link"
},
"table": {
"delete": "Elimina tabella",
"deleteColumn": "Elimina colonna",
"deleteRow": "Elimina riga",
"insertColumnLeft": "Inserisci {{count}} colonne a sinistra",
"insertColumnRight": "Inserisci {{count}} colonne a destra",
"insertRowAbove": "Inserisci {{count}} righe sopra",
"insertRowBelow": "Inserisci {{count}} righe sotto"
},
"typobar": {
"blockquote": "Citazione",
"bold": "Grassetto",
"bulletList": "Elenco puntato",
"code": "Codice in linea",
"codeblock": "Blocco di codice",
"italic": "Corsivo",
"link": "Collegamento",
"numberList": "Elenco numerato",
"strikethrough": "Testo barrato",
"table": "Inserisci tabella",
"underline": "Sottolineato"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "ユーザーメッセージを追加",
"more": "もっと",
"send": "送信",
"sendWithCmdEnter": "{{meta}} + Enter キーで送信",
"sendWithEnter": "Enter キーで送信",
"sendWithCmdEnter": "<key/> キーを押して送信",
"sendWithEnter": "<key/> キーを押して送信",
"stop": "停止",
"warp": "改行"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} 件のメッセージ",
"title": "サブトピック"
},
"toggleWideScreen": {
"off": "ワイドスクリーンモードをオフにする",
"on": "ワイドスクリーンモードをオンにする"
},
"tokenDetails": {
"chats": "チャットメッセージ",
"historySummary": "履歴の要約",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "折りたたむ",
"on": "展開する"
},
"typobar": {
"off": "書式ツールバーを非表示",
"on": "書式ツールバーを表示"
}
},
"file": {
"error": "エラー:{{message}}",
"uploading": "ファイルをアップロードしています..."
},
"image": {
"broken": "画像が破損しています"
},
"link": {
"edit": "リンクを編集",
"open": "リンクを開く",
"placeholder": "リンクの URL を入力",
"unlink": "リンクを解除"
},
"table": {
"delete": "表を削除",
"deleteColumn": "列を削除",
"deleteRow": "行を削除",
"insertColumnLeft": "左側に {{count}} 列を挿入",
"insertColumnRight": "右側に {{count}} 列を挿入",
"insertRowAbove": "上に {{count}} 行を挿入",
"insertRowBelow": "下に {{count}} 行を挿入"
},
"typobar": {
"blockquote": "引用",
"bold": "太字",
"bulletList": "番号なしリスト",
"code": "インラインコード",
"codeblock": "コードブロック",
"italic": "斜体",
"link": "リンク",
"numberList": "番号付きリスト",
"strikethrough": "取り消し線",
"table": "表を挿入",
"underline": "下線"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "사용자 메시지 추가",
"more": "더 많은",
"send": "전송",
"sendWithCmdEnter": "{{meta}} + Enter 키로 전송",
"sendWithEnter": "Enter 키로 전송",
"sendWithCmdEnter": "<key/> 키를 눌러 전송",
"sendWithEnter": "<key/> 키를 눌러 전송",
"stop": "중지",
"warp": "줄바꿈"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}}개의 메시지",
"title": "하위 주제"
},
"toggleWideScreen": {
"off": "와이드 스크린 모드 끄기",
"on": "와이드 스크린 모드 켜기"
},
"tokenDetails": {
"chats": "채팅 메시지",
"historySummary": "역사 요약",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "접기",
"on": "펼치기"
},
"typobar": {
"off": "서식 도구 모음 숨기기",
"on": "서식 도구 모음 표시"
}
},
"file": {
"error": "오류: {{message}}",
"uploading": "파일 업로드 중..."
},
"image": {
"broken": "이미지가 손상되었습니다"
},
"link": {
"edit": "링크 편집",
"open": "링크 열기",
"placeholder": "링크 URL 입력",
"unlink": "링크 제거"
},
"table": {
"delete": "테이블 삭제",
"deleteColumn": "열 삭제",
"deleteRow": "행 삭제",
"insertColumnLeft": "왼쪽에 {{count}}개의 열 삽입",
"insertColumnRight": "오른쪽에 {{count}}개의 열 삽입",
"insertRowAbove": "위에 {{count}}개의 행 삽입",
"insertRowBelow": "아래에 {{count}}개의 행 삽입"
},
"typobar": {
"blockquote": "인용",
"bold": "굵게",
"bulletList": "순서 없는 목록",
"code": "인라인 코드",
"codeblock": "코드 블록",
"italic": "기울임꼴",
"link": "링크",
"numberList": "번호 매긴 목록",
"strikethrough": "취소선",
"table": "표 삽입",
"underline": "밑줄"
}
}
+3 -1
View File
@@ -1505,7 +1505,9 @@
"gpt-4.1-nano": {
"description": "GPT-4.1 mini는 지능, 속도 및 비용 간의 균형을 제공하여 많은 사용 사례에서 매력적인 모델이 됩니다."
},
"gpt-4.5-preview": "GPT-4.5-preview는 최신 범용 모델로, 폭넓은 세계 지식과 사용자 의도에 대한 향상된 이해를 갖추고 있어 창의적 과제와 에이전트 계획에 능숙합니다. 이 모델의 지식은 2023년 10월까지입니다.",
"gpt-4.5-preview": {
"description": "GPT-4.5-preview는 최신 범용 모델로, 폭넓은 세계 지식과 사용자 의도에 대한 향상된 이해를 갖추고 있어 창의적 과제와 에이전트 계획에 능숙합니다. 이 모델의 지식은 2023년 10월까지입니다."
},
"gpt-4o": {
"description": "ChatGPT-4o는 동적 모델로, 최신 버전을 유지하기 위해 실시간으로 업데이트됩니다. 강력한 언어 이해 및 생성 능력을 결합하여 고객 서비스, 교육 및 기술 지원을 포함한 대규모 응용 프로그램에 적합합니다."
},
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Voeg een gebruikersbericht toe",
"more": "Meer",
"send": "Verzenden",
"sendWithCmdEnter": "Verzenden met {{meta}} + Enter",
"sendWithEnter": "Verzenden met Enter",
"sendWithCmdEnter": "Druk op <key/> om te verzenden",
"sendWithEnter": "Druk op <key/> om te verzenden",
"stop": "Stoppen",
"warp": "Nieuwe regel"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} berichten",
"title": "Subonderwerp"
},
"toggleWideScreen": {
"off": "Schakel breedbeeldmodus uit",
"on": "Schakel breedbeeldmodus in"
},
"tokenDetails": {
"chats": "Chats",
"historySummary": "Geschiedenis samenvatting",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Inklappen",
"on": "Uitklappen"
},
"typobar": {
"off": "Verberg opmaakwerkbalk",
"on": "Toon opmaakwerkbalk"
}
},
"file": {
"error": "Fout: {{message}}",
"uploading": "Bestand wordt geüpload..."
},
"image": {
"broken": "Afbeelding beschadigd"
},
"link": {
"edit": "Link bewerken",
"open": "Link openen",
"placeholder": "Voer de URL van de link in",
"unlink": "Link verwijderen"
},
"table": {
"delete": "Tabel verwijderen",
"deleteColumn": "Kolom verwijderen",
"deleteRow": "Rij verwijderen",
"insertColumnLeft": "Voeg {{count}} kolommen links in",
"insertColumnRight": "Voeg {{count}} kolommen rechts in",
"insertRowAbove": "Voeg {{count}} rijen hierboven in",
"insertRowBelow": "Voeg {{count}} rijen hieronder in"
},
"typobar": {
"blockquote": "Citaat",
"bold": "Vet",
"bulletList": "Opsomming",
"code": "Inlinecode",
"codeblock": "Codeblok",
"italic": "Cursief",
"link": "Link",
"numberList": "Genummerde lijst",
"strikethrough": "Doorhalen",
"table": "Tabel invoegen",
"underline": "Onderstrepen"
}
}
+3 -1
View File
@@ -2663,7 +2663,9 @@
"step-r1-v-mini": {
"description": "Dit model is een krachtig redeneringsmodel met sterke beeldbegripcapaciteiten, in staat om beeld- en tekstinformatie te verwerken en tekstinhoud te genereren na diep nadenken. Dit model presteert uitstekend in visuele redenering en heeft eersteklas wiskundige, code- en tekstredeneringscapaciteiten. De contextlengte is 100k."
},
"stepfun-ai/step3": "Step3 is een geavanceerd multimodaal redeneermodel uitgebracht door StepFun (阶跃星辰). Het is gebouwd op een Mixture-of-Experts (MoE)-architectuur met in totaal 321 miljard (321B) parameters en 38 miljard (38B) actieve parameters. Het model heeft een end-to-end ontwerp dat gericht is op het minimaliseren van decodeerkosten, terwijl het topniveau-prestaties levert bij vision-language redenering. Dankzij de synergie tussen Multi-Matrix Factorized Attention (MFA) en Attention-FFN Decoupling (AFD) behoudt Step3 uitstekende efficiëntie zowel op high-end als low-end accelerators. Tijdens de voortraining verwerkte Step3 meer dan 20 biljoen (20T) teksttokens en 4 biljoen (4T) gecombineerde beeld-tekst-tokens, en bestrijkt daarmee meer dan tien talen. Het model behaalt leidende resultaten onder open-sourcemodellen op verschillende benchmarks, waaronder wiskunde, code en multimodaal.",
"stepfun-ai/step3": {
"description": "Step3 is een geavanceerd multimodaal redeneermodel uitgebracht door StepFun (阶跃星辰). Het is gebouwd op een Mixture-of-Experts (MoE)-architectuur met in totaal 321 miljard (321B) parameters en 38 miljard (38B) actieve parameters. Het model heeft een end-to-end ontwerp dat gericht is op het minimaliseren van decodeerkosten, terwijl het topniveau-prestaties levert bij vision-language redenering. Dankzij de synergie tussen Multi-Matrix Factorized Attention (MFA) en Attention-FFN Decoupling (AFD) behoudt Step3 uitstekende efficiëntie zowel op high-end als low-end accelerators. Tijdens de voortraining verwerkte Step3 meer dan 20 biljoen (20T) teksttokens en 4 biljoen (4T) gecombineerde beeld-tekst-tokens, en bestrijkt daarmee meer dan tien talen. Het model behaalt leidende resultaten onder open-sourcemodellen op verschillende benchmarks, waaronder wiskunde, code en multimodaal."
},
"taichu_llm": {
"description": "Het Zido Tai Chu-taalmodel heeft een sterke taalbegripcapaciteit en kan tekstcreatie, kennisvragen, codeprogrammering, wiskundige berekeningen, logische redenering, sentimentanalyse, tekstsamenvattingen en meer aan. Het combineert innovatief grote data voortraining met rijke kennis uit meerdere bronnen, door algoritmische technologie continu te verfijnen en voortdurend nieuwe kennis op te nemen uit enorme tekstdata op het gebied van vocabulaire, structuur, grammatica en semantiek, waardoor de modelprestaties voortdurend evolueren. Het biedt gebruikers gemakkelijkere informatie en diensten en een meer intelligente ervaring."
},
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Dodaj wiadomość użytkownika",
"more": "więcej",
"send": "Wyślij",
"sendWithCmdEnter": "Wyślij za pomocą klawisza {{meta}} + Enter",
"sendWithEnter": "Wyślij za pomocą klawisza Enter",
"sendWithCmdEnter": "Naciśnij <key/>, aby wysłać",
"sendWithEnter": "Naciśnij <key/>, aby wysłać",
"stop": "Zatrzymaj",
"warp": "Złamanie wiersza"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} wiadomości",
"title": "Podwątek"
},
"toggleWideScreen": {
"off": "Wyłącz tryb szerokiego ekranu",
"on": "Włącz tryb szerokiego ekranu"
},
"tokenDetails": {
"chats": "Rozmowy",
"historySummary": "Podsumowanie historii",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Zwiń",
"on": "Rozwiń"
},
"typobar": {
"off": "Ukryj pasek narzędzi formatowania",
"on": "Pokaż pasek narzędzi formatowania"
}
},
"file": {
"error": "Błąd: {{message}}",
"uploading": "Przesyłanie pliku..."
},
"image": {
"broken": "Obraz uszkodzony"
},
"link": {
"edit": "Edytuj link",
"open": "Otwórz link",
"placeholder": "Wpisz adres URL linku",
"unlink": "Usuń link"
},
"table": {
"delete": "Usuń tabelę",
"deleteColumn": "Usuń kolumnę",
"deleteRow": "Usuń wiersz",
"insertColumnLeft": "Wstaw {{count}} kolumn po lewej",
"insertColumnRight": "Wstaw {{count}} kolumn po prawej",
"insertRowAbove": "Wstaw {{count}} wierszy powyżej",
"insertRowBelow": "Wstaw {{count}} wierszy poniżej"
},
"typobar": {
"blockquote": "Cytat",
"bold": "Pogrubienie",
"bulletList": "Lista punktowana",
"code": "Kod w linii",
"codeblock": "Blok kodu",
"italic": "Kursywa",
"link": "Link",
"numberList": "Lista numerowana",
"strikethrough": "Przekreślenie",
"table": "Wstaw tabelę",
"underline": "Podkreślenie"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Adicionar uma mensagem de usuário",
"more": "mais",
"send": "Enviar",
"sendWithCmdEnter": "Pressione {{meta}} + Enter para enviar",
"sendWithEnter": "Pressione Enter para enviar",
"sendWithCmdEnter": "Pressione <key/> para enviar",
"sendWithEnter": "Pressione <key/> para enviar",
"stop": "Parar",
"warp": "Quebrar linha"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} mensagens",
"title": "Subtópico"
},
"toggleWideScreen": {
"off": "Desativar modo de tela ampla",
"on": "Ativar modo de tela ampla"
},
"tokenDetails": {
"chats": "Mensagens de bate-papo",
"historySummary": "Resumo Histórico",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Recolher",
"on": "Expandir"
},
"typobar": {
"off": "Ocultar barra de formatação",
"on": "Mostrar barra de formatação"
}
},
"file": {
"error": "Erro: {{message}}",
"uploading": "Enviando arquivo..."
},
"image": {
"broken": "Imagem corrompida"
},
"link": {
"edit": "Editar link",
"open": "Abrir link",
"placeholder": "Digite a URL do link",
"unlink": "Remover link"
},
"table": {
"delete": "Excluir tabela",
"deleteColumn": "Excluir coluna",
"deleteRow": "Excluir linha",
"insertColumnLeft": "Inserir {{count}} coluna(s) à esquerda",
"insertColumnRight": "Inserir {{count}} coluna(s) à direita",
"insertRowAbove": "Inserir {{count}} linha(s) acima",
"insertRowBelow": "Inserir {{count}} linha(s) abaixo"
},
"typobar": {
"blockquote": "Citação",
"bold": "Negrito",
"bulletList": "Lista não ordenada",
"code": "Código inline",
"codeblock": "Bloco de código",
"italic": "Itálico",
"link": "Link",
"numberList": "Lista numerada",
"strikethrough": "Tachado",
"table": "Inserir tabela",
"underline": "Sublinhado"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Добавить сообщение пользователя",
"more": "больше",
"send": "Отправить",
"sendWithCmdEnter": "Отправить с помощью {{meta}} + Enter",
"sendWithEnter": "Отправить с помощью Enter",
"sendWithCmdEnter": "Нажмите <key/> для отправки",
"sendWithEnter": "Нажмите <key/> для отправки",
"stop": "Остановить",
"warp": "Перенос строки"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} сообщений",
"title": "Подтема"
},
"toggleWideScreen": {
"off": "Отключить режим широкого экрана",
"on": "Включить режим широкого экрана"
},
"tokenDetails": {
"chats": "Чаты",
"historySummary": "Историческое резюме",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Свернуть",
"on": "Развернуть"
},
"typobar": {
"off": "Скрыть панель форматирования",
"on": "Показать панель форматирования"
}
},
"file": {
"error": "Ошибка: {{message}}",
"uploading": "Загрузка файла..."
},
"image": {
"broken": "Изображение повреждено"
},
"link": {
"edit": "Редактировать ссылку",
"open": "Открыть ссылку",
"placeholder": "Введите URL ссылки",
"unlink": "Удалить ссылку"
},
"table": {
"delete": "Удалить таблицу",
"deleteColumn": "Удалить столбец",
"deleteRow": "Удалить строку",
"insertColumnLeft": "{count, plural, one {Вставить # столбец слева} few {Вставить # столбца слева} many {Вставить # столбцов слева} other {Вставить # столбца слева}}",
"insertColumnRight": "{count, plural, one {Вставить # столбец справа} few {Вставить # столбца справа} many {Вставить # столбцов справа} other {Вставить # столбца справа}}",
"insertRowAbove": "{count, plural, one {Вставить # строку сверху} few {Вставить # строки сверху} many {Вставить # строк сверху} other {Вставить # строки сверху}}",
"insertRowBelow": "{count, plural, one {Вставить # строку снизу} few {Вставить # строки снизу} many {Вставить # строк снизу} other {Вставить # строки снизу}}"
},
"typobar": {
"blockquote": "Цитата",
"bold": "Жирный",
"bulletList": "Маркированный список",
"code": "Встроенный код",
"codeblock": "Блок кода",
"italic": "Курсив",
"link": "Ссылка",
"numberList": "Нумерованный список",
"strikethrough": "Зачёркнутый",
"table": "Вставить таблицу",
"underline": "Подчёркнутый"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Bir kullanıcı mesajı ekleyin",
"more": "Daha fazla",
"send": "Gönder",
"sendWithCmdEnter": "{{meta}} + Enter tuşuna basarak gönder",
"sendWithEnter": "Enter tuşuna basarak gönder",
"sendWithCmdEnter": "<key/> tuşuna basarak gönder",
"sendWithEnter": "<key/> tuşuna basarak gönder",
"stop": "Dur",
"warp": "Satır atla"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} mesaj",
"title": "Alt konu"
},
"toggleWideScreen": {
"off": "Geniş ekran modunu kapat",
"on": "Geniş ekran modunu aç"
},
"tokenDetails": {
"chats": "Sohbetler",
"historySummary": "Tarih Özeti",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Daralt",
"on": "Genişlet"
},
"typobar": {
"off": "Biçim araç çubuğunu gizle",
"on": "Biçim araç çubuğunu göster"
}
},
"file": {
"error": "Hata: {{message}}",
"uploading": "Dosya yükleniyor..."
},
"image": {
"broken": "Resim bozuk"
},
"link": {
"edit": "Bağlantıyı düzenle",
"open": "Bağlantıyı aç",
"placeholder": "Bağlantı URL'si girin",
"unlink": "Bağlantıyı kaldır"
},
"table": {
"delete": "Tabloyu sil",
"deleteColumn": "Sütunu sil",
"deleteRow": "Satırı sil",
"insertColumnLeft": "Sol tarafa {{count}} sütun ekle",
"insertColumnRight": "Sağ tarafa {{count}} sütun ekle",
"insertRowAbove": "Üst tarafa {{count}} satır ekle",
"insertRowBelow": "Alt tarafa {{count}} satır ekle"
},
"typobar": {
"blockquote": "Alıntı",
"bold": "Kalın",
"bulletList": "Sırasız liste",
"code": "Satır içi kod",
"codeblock": "Kod bloğu",
"italic": "İtalik",
"link": "Bağlantı",
"numberList": "Numaralı liste",
"strikethrough": "Üstü çizili",
"table": "Tablo ekle",
"underline": "Altı çizili"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "Thêm một tin nhắn người dùng",
"more": "Thêm",
"send": "Gửi",
"sendWithCmdEnter": "Nhấn {{meta}} + Enter để gửi",
"sendWithEnter": "Nhấn Enter để gửi",
"sendWithCmdEnter": "Nhấn <key/> để gửi",
"sendWithEnter": "Nhấn <key/> để gửi",
"stop": "Dừng",
"warp": "Xuống dòng"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} tin nhắn",
"title": "Chủ đề con"
},
"toggleWideScreen": {
"off": "Tắt chế độ màn hình rộng",
"on": "Bật chế độ màn hình rộng"
},
"tokenDetails": {
"chats": "Tin nhắn trò chuyện",
"historySummary": "Tóm tắt lịch sử",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "Thu gọn",
"on": "Mở rộng"
},
"typobar": {
"off": "Ẩn thanh công cụ định dạng",
"on": "Hiển thị thanh công cụ định dạng"
}
},
"file": {
"error": "Lỗi: {{message}}",
"uploading": "Đang tải tệp lên..."
},
"image": {
"broken": "Hình ảnh bị hỏng"
},
"link": {
"edit": "Chỉnh sửa liên kết",
"open": "Mở liên kết",
"placeholder": "Nhập URL liên kết",
"unlink": "Gỡ liên kết"
},
"table": {
"delete": "Xóa bảng",
"deleteColumn": "Xóa cột",
"deleteRow": "Xóa hàng",
"insertColumnLeft": "Chèn {{count}} cột vào bên trái",
"insertColumnRight": "Chèn {{count}} cột vào bên phải",
"insertRowAbove": "Chèn {{count}} hàng vào phía trên",
"insertRowBelow": "Chèn {{count}} hàng vào phía dưới"
},
"typobar": {
"blockquote": "Trích dẫn",
"bold": "Đậm",
"bulletList": "Danh sách gạch đầu dòng",
"code": "Mã nội tuyến",
"codeblock": "Khối mã",
"italic": "Nghiêng",
"link": "Liên kết",
"numberList": "Danh sách có thứ tự",
"strikethrough": "Gạch ngang",
"table": "Chèn bảng",
"underline": "Gạch chân"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "添加一条用户消息",
"more": "更多",
"send": "发送",
"sendWithCmdEnter": "按 {{meta}} + Enter 键发送",
"sendWithEnter": "按 Enter 键发送",
"sendWithCmdEnter": "按 <key/> 键发送",
"sendWithEnter": "按 <key/> 键发送",
"stop": "停止",
"warp": "换行"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} 条消息",
"title": "子话题"
},
"toggleWideScreen": {
"off": "关闭宽屏模式",
"on": "开启宽屏模式"
},
"tokenDetails": {
"chats": "会话消息",
"historySummary": "历史总结",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "收起",
"on": "展开"
},
"typobar": {
"off": "隐藏格式工具栏",
"on": "显示格式工具栏"
}
},
"file": {
"error": "错误:{{message}}",
"uploading": "正在上传文件..."
},
"image": {
"broken": "图片损坏"
},
"link": {
"edit": "编辑链接",
"open": "打开链接",
"placeholder": "输入链接 URL",
"unlink": "取消链接"
},
"table": {
"delete": "删除表格",
"deleteColumn": "删除列",
"deleteRow": "删除行",
"insertColumnLeft": "在左侧插入 {{count}} 列",
"insertColumnRight": "在右侧插入 {{count}} 列",
"insertRowAbove": "在上方插入 {{count}} 行",
"insertRowBelow": "在下方插入 {{count}} 行"
},
"typobar": {
"blockquote": "引用",
"bold": "加粗",
"bulletList": "无序列表",
"code": "行内代码",
"codeblock": "代码块",
"italic": "斜体",
"link": "链接",
"numberList": "有序列表",
"strikethrough": "删除线",
"table": "插入表格",
"underline": "下划线"
}
}
+6 -2
View File
@@ -72,8 +72,8 @@
"addUser": "新增一條使用者訊息",
"more": "更多",
"send": "發送",
"sendWithCmdEnter": "按 {{meta}} + Enter 鍵發送",
"sendWithEnter": "按 Enter 鍵發送",
"sendWithCmdEnter": "按 <key/> 鍵發送",
"sendWithEnter": "按 <key/> 鍵發送",
"stop": "停止",
"warp": "換行"
},
@@ -232,6 +232,10 @@
"threadMessageCount": "{{messageCount}} 條消息",
"title": "子話題"
},
"toggleWideScreen": {
"off": "關閉寬螢幕模式",
"on": "開啟寬螢幕模式"
},
"tokenDetails": {
"chats": "聊天訊息",
"historySummary": "歷史總結",
+47
View File
@@ -0,0 +1,47 @@
{
"actions": {
"expand": {
"off": "收合",
"on": "展開"
},
"typobar": {
"off": "隱藏格式工具列",
"on": "顯示格式工具列"
}
},
"file": {
"error": "錯誤:{{message}}",
"uploading": "正在上傳檔案..."
},
"image": {
"broken": "圖片損毀"
},
"link": {
"edit": "編輯連結",
"open": "開啟連結",
"placeholder": "輸入連結 URL",
"unlink": "取消連結"
},
"table": {
"delete": "刪除表格",
"deleteColumn": "刪除列",
"deleteRow": "刪除行",
"insertColumnLeft": "在左側插入 {{count}} 列",
"insertColumnRight": "在右側插入 {{count}} 列",
"insertRowAbove": "在上方插入 {{count}} 行",
"insertRowBelow": "在下方插入 {{count}} 行"
},
"typobar": {
"blockquote": "引用",
"bold": "粗體",
"bulletList": "無序清單",
"code": "行內程式碼",
"codeblock": "程式碼區塊",
"italic": "斜體",
"link": "連結",
"numberList": "有序清單",
"strikethrough": "刪除線",
"table": "插入表格",
"underline": "底線"
}
}
+3 -1
View File
@@ -1505,7 +1505,9 @@
"gpt-4.1-nano": {
"description": "GPT-4.1 mini 提供了智能、速度和成本之間的平衡,使其成為許多用例中具吸引力的模型。"
},
"gpt-4.5-preview": "GPT-4.5-preview 是最新的通用模型,具備深厚的世界知識並能更好地理解使用者意圖,擅長創意任務與代理規劃。此模型的知識截至 2023 年 10 月。",
"gpt-4.5-preview": {
"description": "GPT-4.5-preview 是最新的通用模型,擁有深厚的世界知識和對使用者意圖的更佳理解,擅長創意任務與代理規劃。該模型的知識截止至2023年10月。"
},
"gpt-4o": {
"description": "ChatGPT-4o是一款動態模型,實時更新以保持當前最新版本。它結合了強大的語言理解與生成能力,適合於大規模應用場景,包括客戶服務、教育和技術支持。"
},
+4
View File
@@ -22,6 +22,9 @@ const standaloneConfig: NextConfig = {
const nextConfig: NextConfig = {
...(isStandaloneMode ? standaloneConfig : {}),
compiler: {
emotion: true,
},
compress: isProd,
experimental: {
optimizePackageImports: [
@@ -30,6 +33,7 @@ const nextConfig: NextConfig = {
'@emoji-mart/data',
'@icons-pack/react-simple-icons',
'@lobehub/ui',
'@lobehub/icons',
'gpt-tokenizer',
],
// oidc provider depend on constructor.name
+3 -1
View File
@@ -134,6 +134,7 @@
"@codesandbox/sandpack-react": "^2.20.0",
"@cyntler/react-doc-viewer": "^1.17.0",
"@electric-sql/pglite": "0.2.17",
"@emotion/react": "^11.14.0",
"@fal-ai/client": "^1.6.1",
"@formkit/auto-animate": "^0.8.2",
"@google/genai": "^1.13.0",
@@ -154,10 +155,11 @@
"@lobehub/charts": "^2.0.0",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",
"@lobehub/editor": "^1.4.4",
"@lobehub/icons": "^2.31.0",
"@lobehub/market-sdk": "^0.22.7",
"@lobehub/tts": "^2.0.1",
"@lobehub/ui": "^2.8.3",
"@lobehub/ui": "^2.11.9",
"@modelcontextprotocol/sdk": "^1.17.1",
"@neondatabase/serverless": "^1.0.1",
"@next/third-parties": "^15.4.6",
+1
View File
@@ -7,6 +7,7 @@ export const CHAT_TEXTAREA_MAX_HEIGHT = 800;
export const CHAT_TEXTAREA_HEIGHT = 160;
export const CHAT_TEXTAREA_HEIGHT_MOBILE = 108;
export const CHAT_SIDEBAR_WIDTH = 280;
export const CONVERSATION_MIN_WIDTH = 850;
export const CHAT_PORTAL_WIDTH = 400;
export const CHAT_PORTAL_MAX_WIDTH = 1280;
+1
View File
@@ -8,6 +8,7 @@ export * from './chunk';
export * from './clientDB';
export * from './eval';
export * from './fetch';
export * from './hotkey';
export * from './knowledgeBase';
export * from './llm';
export * from './message';
+1
View File
@@ -2,6 +2,7 @@ export * from './client/cookie';
export * from './detectChinese';
export * from './format';
export * from './imageToBase64';
export * from './keyboard';
export * from './object';
export * from './parseModels';
export * from './pricing';
@@ -1,104 +0,0 @@
import { Button, Dropdown, Hotkey, Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { BotMessageSquare, LucideCheck, LucideChevronDown, MessageSquarePlus } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { useUserStore } from '@/store/user';
import { preferenceSelectors, settingsSelectors } from '@/store/user/selectors';
import { HotkeyEnum } from '@/types/hotkey';
const useStyles = createStyles(({ css, prefixCls }) => {
return {
arrow: css`
&.${prefixCls}-btn.${prefixCls}-btn-icon-only {
width: 28px;
}
`,
};
});
interface SendMoreProps {
disabled?: boolean;
isMac?: boolean;
}
const SendMore = memo<SendMoreProps>(({ disabled, isMac }) => {
const { t } = useTranslation('chat');
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.AddUserMessage));
const { styles } = useStyles();
const [useCmdEnterToSend, updatePreference] = useUserStore((s) => [
preferenceSelectors.useCmdEnterToSend(s),
s.updatePreference,
]);
const addAIMessage = useChatStore((s) => s.addAIMessage);
const { send: sendMessage } = useSendMessage();
return (
<Dropdown
disabled={disabled}
menu={{
items: [
{
icon: !useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
key: 'sendWithEnter',
label: t('input.sendWithEnter'),
onClick: () => {
updatePreference({ useCmdEnterToSend: false });
},
},
{
icon: useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
key: 'sendWithCmdEnter',
label: t('input.sendWithCmdEnter', {
meta: typeof isMac === 'boolean' ? (isMac ? '⌘' : 'Ctrl') : '…',
}),
onClick: () => {
updatePreference({ useCmdEnterToSend: true });
},
},
{ type: 'divider' },
{
icon: <Icon icon={BotMessageSquare} />,
key: 'addAi',
label: t('input.addAi'),
onClick: () => {
addAIMessage();
},
},
{
icon: <Icon icon={MessageSquarePlus} />,
key: 'addUser',
label: (
<Flexbox align={'center'} gap={24} horizontal>
{t('input.addUser')}
<Hotkey keys={hotkey} />
</Flexbox>
),
onClick: () => {
sendMessage({ onlyAddUserMessage: true });
},
},
],
}}
placement={'topRight'}
trigger={['hover']}
>
<Button
aria-label={t('input.more')}
className={styles.arrow}
icon={LucideChevronDown}
type={'primary'}
/>
</Dropdown>
);
});
SendMore.displayName = 'SendMore';
export default SendMore;
@@ -1,40 +0,0 @@
import { Hotkey, combineKeys } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { KeyEnum } from '@/types/hotkey';
const ShortcutHint = memo(() => {
const { t } = useTranslation('chat');
const theme = useTheme();
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
const sendShortcut = useCmdEnterToSend
? combineKeys([KeyEnum.Mod, KeyEnum.Enter])
: KeyEnum.Enter;
const wrapperShortcut = useCmdEnterToSend
? KeyEnum.Enter
: combineKeys([KeyEnum.Mod, KeyEnum.Enter]);
return (
<Flexbox
align={'center'}
gap={4}
horizontal
style={{ color: theme.colorTextDescription, fontSize: 12, marginRight: 12 }}
>
<Hotkey keys={sendShortcut} style={{ color: 'inherit' }} variant={'borderless'} />
<span>{t('input.send')}</span>
<span>/</span>
<Hotkey keys={wrapperShortcut} style={{ color: 'inherit' }} variant={'borderless'} />
<span>{t('input.warp')}</span>
</Flexbox>
);
});
export default ShortcutHint;
@@ -1,125 +0,0 @@
import { Button } from '@lobehub/ui';
import { Space } from 'antd';
import { createStyles } from 'antd-style';
import { rgba } from 'polished';
import { Suspense, memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import StopLoadingIcon from '@/components/StopLoading';
import LocalFiles from '@/features/ChatInput/Desktop/FilePreview';
import SaveTopic from '@/features/ChatInput/Topic';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { isMacOS } from '@/utils/platform';
import MessageFromUrl from './MessageFromUrl';
import SendMore from './SendMore';
import ShortcutHint from './ShortcutHint';
const useStyles = createStyles(({ css, prefixCls, token }) => {
return {
arrow: css`
&.${prefixCls}-btn.${prefixCls}-btn-icon-only {
width: 28px;
}
`,
loadingButton: css`
display: flex;
align-items: center;
`,
overrideAntdIcon: css`
.${prefixCls}-btn.${prefixCls}-btn-icon-only {
display: flex;
align-items: center;
justify-content: center;
}
.${prefixCls}-btn.${prefixCls}-dropdown-trigger {
&::before {
background-color: ${rgba(token.colorBgLayout, 0.1)} !important;
}
}
`,
};
});
interface FooterProps {
expand: boolean;
onExpandChange: (expand: boolean) => void;
}
const Footer = memo<FooterProps>(({ onExpandChange, expand }) => {
const { t } = useTranslation('chat');
const { styles } = useStyles();
const [isAIGenerating, stopGenerateMessage] = useChatStore((s) => [
chatSelectors.isAIGenerating(s),
s.stopGenerateMessage,
]);
const { send: sendMessage, canSend } = useSendMessage();
const [isMac, setIsMac] = useState<boolean>();
useEffect(() => {
setIsMac(isMacOS());
}, [setIsMac]);
return (
<>
<Suspense>
<MessageFromUrl />
</Suspense>
<Flexbox
align={'end'}
className={styles.overrideAntdIcon}
distribution={'space-between'}
flex={'none'}
gap={8}
horizontal
paddingInline={16}
>
<Flexbox align={'center'} gap={8} horizontal style={{ overflow: 'hidden' }}>
{expand && <LocalFiles />}
</Flexbox>
<Flexbox align={'center'} flex={'none'} gap={8} horizontal>
<ShortcutHint />
<SaveTopic />
<Flexbox style={{ minWidth: 92 }}>
{isAIGenerating ? (
<Button
className={styles.loadingButton}
icon={StopLoadingIcon}
onClick={stopGenerateMessage}
>
{t('input.stop')}
</Button>
) : (
<Space.Compact>
<Button
disabled={!canSend}
loading={!canSend}
onClick={() => {
sendMessage();
onExpandChange?.(false);
}}
type={'primary'}
>
{t('input.send')}
</Button>
<SendMore disabled={!canSend} isMac={isMac} />
</Space.Compact>
)}
</Flexbox>
</Flexbox>
</Flexbox>
</>
);
});
Footer.displayName = 'Footer';
export default Footer;
@@ -3,12 +3,13 @@
import { useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { useSend } from '../useSend';
const MessageFromUrl = () => {
const updateInputMessage = useChatStore((s) => s.updateInputMessage);
const { send: sendMessage } = useSendMessage();
const { send: sendMessage } = useSend();
const searchParams = useSearchParams();
useEffect(() => {
@@ -1,332 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useChatStore } from '@/store/chat';
import { useUserStore } from '@/store/user';
import InputArea from './TextArea';
let sendMessageMock: () => Promise<void>;
// Mock the useSendMessage hook to return our mock function
vi.mock('@/features/ChatInput/useSend', () => ({
useSendMessage: () => ({
send: sendMessageMock,
canSend: true,
}),
}));
// Mock the Chinese warning hook to always allow sending
vi.mock('@/hooks/useGeminiChineseWarning', () => ({
useGeminiChineseWarning: () => () => Promise.resolve(true),
}));
let onSendMock: () => void;
beforeEach(() => {
onSendMock = vi.fn();
sendMessageMock = vi.fn().mockResolvedValue(undefined);
vi.clearAllMocks();
});
describe('<InputArea />', () => {
it('renders the TextArea component correctly', () => {
render(<InputArea />);
const textArea = screen.getByRole('textbox');
expect(textArea).toBeInTheDocument();
});
it('auto-focuses the TextArea component on mount', () => {
render(<InputArea />);
const textArea = screen.getByRole('textbox');
// The document's active element should be the textarea if it was auto-focused
expect(document.activeElement).toBe(textArea);
});
it('renders with correct placeholder text', () => {
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByPlaceholderText('sendPlaceholder');
expect(textArea).toBeInTheDocument();
});
it('has the correct initial value', () => {
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
expect(textArea).toHaveValue('');
});
describe('input behavior', () => {
it('calls updateInputMessage on input change', async () => {
const updateInputMessageMock = vi.fn();
act(() => {
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
});
render(<InputArea />);
const textArea = screen.getByRole('textbox');
const newValue = 'New message';
fireEvent.change(textArea, { target: { value: newValue } });
expect(updateInputMessageMock).toHaveBeenCalledWith(newValue);
});
it('handles composition events for IME input correctly', () => {
const updateInputMessageMock = vi.fn();
act(() => {
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
});
render(<InputArea />);
const textArea = screen.getByRole('textbox');
// Start composition (IME input starts)
fireEvent.compositionStart(textArea);
fireEvent.change(textArea, { target: { value: '正在' } });
expect(updateInputMessageMock).toHaveBeenCalledWith('正在');
// End composition (IME input ends)
fireEvent.compositionEnd(textArea);
fireEvent.change(textArea, { target: { value: '正在输入' } });
expect(updateInputMessageMock).toHaveBeenCalledWith('正在输入');
});
it('does not send a message when Enter is pressed during IME composition', () => {
const updateInputMessageMock = vi.fn();
act(() => {
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
});
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
// Start composition (IME input starts)
fireEvent.compositionStart(textArea);
// Simulate pressing Enter during IME composition
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
// Since we are in the middle of IME composition, the message should not be sent
expect(onSendMock).not.toHaveBeenCalled();
expect(updateInputMessageMock).not.toHaveBeenCalled();
// End composition (IME input ends)
fireEvent.compositionEnd(textArea);
// Now simulate pressing Enter after IME composition
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
// Since IME composition has ended, now the message should be sent
expect(onSendMock).toHaveBeenCalled();
expect(updateInputMessageMock).toHaveBeenCalled();
});
it('updates the input message when TextArea loses focus', () => {
const updateInputMessageMock = vi.fn();
act(() => {
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
});
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
const newText = 'New input text';
fireEvent.change(textArea, { target: { value: newText } });
fireEvent.blur(textArea);
expect(updateInputMessageMock).toHaveBeenCalledWith(newText);
});
});
describe('leaving protect', () => {
it('triggers a warning when trying to leave the page with unsaved input', () => {
const beforeUnloadEvent = new Event('beforeunload', { cancelable: true });
act(() => {
useChatStore.setState({ inputMessage: 'Unsaved input' });
});
render(<InputArea />);
// trigger beforeunload
window.dispatchEvent(beforeUnloadEvent);
// 检查 returnValue 是否被设置为 true,这是触发警告的标识
expect(beforeUnloadEvent.returnValue).toBeTruthy();
});
it('does not trigger a warning when trying to leave the page with empty input', () => {
act(() => {
useChatStore.setState({ inputMessage: '' });
});
// 模拟 window.addEventListener 来捕获 beforeunload 事件处理程序
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const beforeUnloadHandler = vi.fn();
addEventListenerSpy.mockImplementation((event, handler) => {
// @ts-ignore
if (event === 'beforeunload') {
beforeUnloadHandler.mockImplementation(handler as any);
}
});
// 渲染组件
render(<InputArea />);
// 触发 beforeunload 事件
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
// 检查 beforeunload 事件的处理程序是否没有被调用
expect(beforeUnloadHandler).not.toHaveBeenCalled();
// 清理模拟
addEventListenerSpy.mockRestore();
});
describe('cleanup', () => {
it('removes beforeunload listener on unmount', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
// 渲染并立即卸载组件
const { unmount } = render(<InputArea />);
unmount();
// 检查是否为 beforeunload 事件添加了监听器
expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
// 检查是否移除了对应的监听器
expect(removeEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
// 清理 spy
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
});
});
describe('message sending behavior', () => {
it('does not send message when loading or shift key is pressed', () => {
act(() => {
useChatStore.setState({ chatLoadingIds: ['123'] });
});
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', shiftKey: true });
expect(sendMessageMock).not.toHaveBeenCalled();
});
it('sends message on Enter press when not loading and no shift key', async () => {
act(() => {
useChatStore.setState({
chatLoadingIds: [],
inputMessage: 'abc',
});
});
render(<InputArea />);
const textArea = screen.getByRole('textbox');
fireEvent.change(textArea, { target: { value: 'Test message' } });
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
await vi.waitFor(() => {
expect(sendMessageMock).toHaveBeenCalled();
});
});
describe('metaKey behavior for sending messages', () => {
it('windows: sends message on ctrl + enter when useCmdEnterToSend is true', async () => {
act(() => {
useChatStore.setState({
chatLoadingIds: [],
inputMessage: '123',
});
useUserStore.getState().updatePreference({ useCmdEnterToSend: true });
});
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
fireEvent.keyDown(textArea, { code: 'Enter', ctrlKey: true, key: 'Enter' });
await vi.waitFor(() => {
expect(sendMessageMock).toHaveBeenCalled();
});
});
it('windows: inserts a new line on ctrl + enter when useCmdEnterToSend is false', () => {
const updateInputMessageMock = vi.fn();
act(() => {
useChatStore.setState({
chatLoadingIds: [],
inputMessage: 'Test',
updateInputMessage: updateInputMessageMock,
});
useUserStore.getState().updatePreference({ useCmdEnterToSend: false });
});
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
fireEvent.keyDown(textArea, { code: 'Enter', ctrlKey: true, key: 'Enter' });
expect(updateInputMessageMock).toHaveBeenCalledWith('Test\n');
expect(sendMessageMock).not.toHaveBeenCalled(); // sendMessage should not be called
});
it('macOS: sends message on cmd + enter when useCmdEnterToSend is true', async () => {
vi.stubGlobal('navigator', {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
act(() => {
useChatStore.setState({
chatLoadingIds: [],
inputMessage: '123',
});
useUserStore.getState().updatePreference({ useCmdEnterToSend: true });
});
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', metaKey: true });
await vi.waitFor(() => {
expect(sendMessageMock).toHaveBeenCalled();
});
vi.restoreAllMocks();
});
it('macOS: inserts a new line on cmd + enter when useCmdEnterToSend is false', () => {
vi.stubGlobal('navigator', {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const updateInputMessageMock = vi.fn();
act(() => {
useChatStore.setState({
chatLoadingIds: [],
inputMessage: 'Test',
updateInputMessage: updateInputMessageMock,
});
useUserStore.getState().updatePreference({ useCmdEnterToSend: false });
});
render(<InputArea onSend={onSendMock} />);
const textArea = screen.getByRole('textbox');
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', metaKey: true });
expect(updateInputMessageMock).toHaveBeenCalledWith('Test\n');
expect(sendMessageMock).not.toHaveBeenCalled(); // sendMessage should not be called
vi.restoreAllMocks();
});
});
});
});
@@ -1,29 +0,0 @@
import { memo } from 'react';
import InputArea from '@/features/ChatInput/Desktop/InputArea';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/slices/message/selectors';
const TextArea = memo<{ onSend?: () => void }>(({ onSend }) => {
const [loading, value, updateInputMessage] = useChatStore((s) => [
chatSelectors.isAIGenerating(s),
s.inputMessage,
s.updateInputMessage,
]);
const { send: sendMessage } = useSendMessage();
return (
<InputArea
loading={loading}
onChange={updateInputMessage}
onSend={() => {
sendMessage();
onSend?.();
}}
value={value}
/>
);
});
export default TextArea;
@@ -1,51 +1,152 @@
'use client';
import { memo } from 'react';
import { Alert, Hotkey, Icon } from '@lobehub/ui';
import { BotMessageSquare, LucideCheck, MessageSquarePlus } from 'lucide-react';
import { Suspense, memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { ActionKeys } from '@/features/ChatInput/ActionBar/config';
import DesktopChatInput, { FooterRender } from '@/features/ChatInput/Desktop';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { type ActionKeys, ChatInputProvider, DesktopChatInput } from '@/features/ChatInput';
import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
import { useChatStore } from '@/store/chat';
import { aiChatSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors, settingsSelectors } from '@/store/user/selectors';
import { HotkeyEnum, KeyEnum } from '@/types/hotkey';
import Footer from './Footer';
import TextArea from './TextArea';
import { useSend } from '../useSend';
import MessageFromUrl from './MessageFromUrl';
const leftActions = [
const leftActions: ActionKeys[] = [
'model',
'search',
'typo',
'fileUpload',
'knowledgeBase',
'params',
'history',
'stt',
'tools',
'---',
['params', 'history', 'stt', 'clear'],
'mainToken',
] as ActionKeys[];
];
const rightActions = ['clear'] as ActionKeys[];
const renderTextArea = (onSend: () => void) => <TextArea onSend={onSend} />;
const renderFooter: FooterRender = ({ expand, onExpandChange }) => (
<Footer expand={expand} onExpandChange={onExpandChange} />
);
const rightActions: ActionKeys[] = ['saveTopic'];
const Desktop = memo(() => {
const [inputHeight, updatePreference] = useGlobalStore((s) => [
systemStatusSelectors.inputHeight(s),
s.updateSystemStatus,
const { t } = useTranslation('chat');
const { send, generating, disabled, stop } = useSend();
const [useCmdEnterToSend, updatePreference] = useUserStore((s) => [
preferenceSelectors.useCmdEnterToSend(s),
s.updatePreference,
]);
const [mainInputSendErrorMsg, clearSendMessageError] = useChatStore((s) => [
aiChatSelectors.isCurrentSendMessageError(s),
s.clearSendMessageError,
]);
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.AddUserMessage));
return (
<DesktopChatInput
inputHeight={inputHeight}
leftActions={leftActions}
onInputHeightChange={(height) => {
updatePreference({ inputHeight: height });
<ChatInputProvider
chatInputEditorRef={(instance) => {
if (!instance) return;
useChatStore.setState({ mainInputEditor: instance });
}}
leftActions={leftActions}
onMarkdownContentChange={(content) => {
useChatStore.setState({ inputMessage: content });
}}
onSend={() => {
send();
}}
renderFooter={renderFooter}
renderTextArea={renderTextArea}
rightActions={rightActions}
/>
sendButtonProps={{ disabled, generating, onStop: stop }}
sendMenu={{
items: [
{
icon: !useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
key: 'sendWithEnter',
label: (
<Flexbox align={'center'} gap={4} horizontal>
<Trans
components={{
key: <Hotkey keys={KeyEnum.Enter} variant={'borderless'} />,
}}
i18nKey={'input.sendWithEnter'}
ns={'chat'}
/>
</Flexbox>
),
onClick: () => {
updatePreference({ useCmdEnterToSend: false });
},
},
{
icon: useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
key: 'sendWithCmdEnter',
label: (
<Flexbox align={'center'} gap={4} horizontal>
<Trans
components={{
key: (
<Hotkey
keys={[KeyEnum.Mod, KeyEnum.Enter].join('+')}
variant={'borderless'}
/>
),
}}
i18nKey={'input.sendWithCmdEnter'}
ns={'chat'}
/>
</Flexbox>
),
onClick: () => {
updatePreference({ useCmdEnterToSend: true });
},
},
{ type: 'divider' },
{
// disabled,
icon: <Icon icon={BotMessageSquare} />,
key: 'addAi',
label: t('input.addAi'),
onClick: () => {
send({ onlyAddAIMessage: true });
},
},
{
// disabled,
icon: <Icon icon={MessageSquarePlus} />,
key: 'addUser',
label: (
<Flexbox align={'center'} gap={24} horizontal>
{t('input.addUser')}
<Hotkey keys={hotkey} />
</Flexbox>
),
onClick: () => {
send({ onlyAddUserMessage: true });
},
},
],
}}
>
<WideScreenContainer>
{mainInputSendErrorMsg && (
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
<Alert
closable
message={t('input.errorMsg', { errorMsg: mainInputSendErrorMsg })}
onClose={clearSendMessageError}
type={'warning'}
/>
</Flexbox>
)}
<DesktopChatInput />
</WideScreenContainer>
<Suspense>
<MessageFromUrl />
</Suspense>
</ChatInputProvider>
);
});
@@ -1,33 +0,0 @@
import { PreviewGroup } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { filesSelectors, useFileStore } from '@/store/file';
import FileItem from './FileItem';
const Files = memo(() => {
const list = useFileStore(filesSelectors.chatUploadFileList, isEqual);
if (!list || list?.length === 0) return null;
return (
<Flexbox paddingBlock={4} style={{ position: 'relative' }}>
<Flexbox
gap={4}
horizontal
padding={'4px 8px 8px'}
style={{ overflow: 'scroll', width: '100%' }}
>
<PreviewGroup>
{list.map((i) => (
<FileItem {...i} key={i.id} loading={i.status === 'pending'} />
))}
</PreviewGroup>
</Flexbox>
</Flexbox>
);
});
export default Files;
@@ -1,41 +0,0 @@
import { css, cx } from 'antd-style';
import { FC, ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const container = css`
height: inherit;
padding-block: 0;
padding-inline: 8px;
`;
interface InnerContainerProps {
bottomAddons?: ReactNode;
children: ReactNode;
expand?: boolean;
textAreaLeftAddons?: ReactNode;
textAreaRightAddons?: ReactNode;
topAddons?: ReactNode;
}
const InnerContainer: FC<InnerContainerProps> = memo(
({ children, expand, textAreaRightAddons, textAreaLeftAddons, bottomAddons, topAddons }) =>
expand ? (
<Flexbox className={cx(container)} gap={8}>
<Flexbox gap={8} horizontal justify={'flex-end'}>
{textAreaLeftAddons}
{textAreaRightAddons}
</Flexbox>
{children}
{topAddons}
{bottomAddons}
</Flexbox>
) : (
<Flexbox align={'flex-end'} className={cx(container)} gap={8} horizontal>
{textAreaLeftAddons}
{children}
{textAreaRightAddons}
</Flexbox>
),
);
export default InnerContainer;
@@ -1,156 +0,0 @@
import { ActionIcon, TextArea } from '@lobehub/ui';
import { SafeArea } from '@lobehub/ui/mobile';
import { useSize } from 'ahooks';
import { createStyles } from 'antd-style';
import { TextAreaRef } from 'antd/es/input/TextArea';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { rgba } from 'polished';
import { CSSProperties, ReactNode, forwardRef, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import InnerContainer from './Container';
const useStyles = createStyles(({ css, token }) => {
return {
container: css`
flex: none;
padding-block: 12px 12px;
border-block-start: 1px solid ${rgba(token.colorBorder, 0.25)};
background: ${token.colorFillQuaternary};
`,
expand: css`
position: absolute;
height: 100%;
`,
expandButton: css`
position: absolute;
inset-inline-start: 14px;
`,
textarea: css`
flex: 1;
transition: none !important;
`,
};
});
export interface MobileChatInputAreaProps {
bottomAddons?: ReactNode;
className?: string;
expand?: boolean;
loading?: boolean;
onInput?: (value: string) => void;
onSend?: () => void;
safeArea?: boolean;
setExpand?: (expand: boolean) => void;
style?: CSSProperties;
textAreaLeftAddons?: ReactNode;
textAreaRightAddons?: ReactNode;
topAddons?: ReactNode;
value: string;
}
const MobileChatInputArea = forwardRef<TextAreaRef, MobileChatInputAreaProps>(
(
{
className,
style,
topAddons,
textAreaLeftAddons,
textAreaRightAddons,
bottomAddons,
expand = false,
setExpand,
onSend,
onInput,
loading,
value,
safeArea,
},
ref,
) => {
const { t } = useTranslation('chat');
const isChineseInput = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const { cx, styles } = useStyles();
const size = useSize(containerRef);
const [showFullscreen, setShowFullscreen] = useState<boolean>(false);
const [isFocused, setIsFocused] = useState<boolean>(false);
useEffect(() => {
if (!size?.height) return;
setShowFullscreen(size.height > 72);
}, [size]);
const showAddons = !expand && !isFocused;
return (
<Flexbox
className={cx(styles.container, expand && styles.expand, className)}
gap={12}
style={style}
>
{topAddons && <Flexbox style={showAddons ? {} : { display: 'none' }}>{topAddons}</Flexbox>}
<Flexbox
className={cx(expand && styles.expand)}
ref={containerRef}
style={{ position: 'relative' }}
>
{showFullscreen && (
<ActionIcon
active
className={styles.expandButton}
icon={expand ? ChevronDown : ChevronUp}
onClick={() => setExpand?.(!expand)}
size={{ blockSize: 24, borderRadius: '50%', size: 14 }}
style={expand ? { top: 6 } : {}}
/>
)}
<InnerContainer
bottomAddons={bottomAddons}
expand={expand}
textAreaLeftAddons={textAreaLeftAddons}
textAreaRightAddons={textAreaRightAddons}
topAddons={topAddons}
>
<TextArea
autoSize={expand ? false : { maxRows: 6, minRows: 0 }}
className={styles.textarea}
onBlur={(e) => {
onInput?.(e.target.value);
setIsFocused(false);
}}
onChange={(e) => {
onInput?.(e.target.value);
}}
onCompositionEnd={() => {
isChineseInput.current = false;
}}
onCompositionStart={() => {
isChineseInput.current = true;
}}
onFocus={() => setIsFocused(true)}
onPressEnter={(e) => {
if (!loading && !isChineseInput.current && e.shiftKey) {
e.preventDefault();
onSend?.();
}
}}
placeholder={t('sendPlaceholder')}
ref={ref}
style={{ height: 36, paddingBlock: 6 }}
value={value}
variant={expand ? 'borderless' : 'filled'}
/>
</InnerContainer>
</Flexbox>
{bottomAddons && (
<Flexbox style={showAddons ? {} : { display: 'none' }}>{bottomAddons}</Flexbox>
)}
{safeArea && !isFocused && <SafeArea position={'bottom'} />}
</Flexbox>
);
},
);
export default MobileChatInputArea;
@@ -1,33 +0,0 @@
import { ActionIcon, type ActionIconSize, Button } from '@lobehub/ui';
import { Loader2, SendHorizontal } from 'lucide-react';
import { memo } from 'react';
export interface MobileChatSendButtonProps {
disabled?: boolean;
loading?: boolean;
onSend?: () => void;
onStop?: () => void;
}
const MobileChatSendButton = memo<MobileChatSendButtonProps>(
({ loading, onStop, onSend, disabled }) => {
const size: ActionIconSize = {
blockSize: 36,
size: 16,
};
return loading ? (
<ActionIcon active icon={Loader2} onClick={onStop} size={size} spin />
) : (
<Button
disabled={disabled}
icon={SendHorizontal}
onClick={onSend}
style={{ flex: 'none' }}
type={'primary'}
/>
);
},
);
export default MobileChatSendButton;
@@ -1,91 +1,69 @@
'use client';
import { Skeleton } from 'antd';
import { useTheme } from 'antd-style';
import { TextAreaRef } from 'antd/es/input/TextArea';
import { memo, useRef, useState } from 'react';
import { Alert } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import ActionBar from '@/features/ChatInput/ActionBar';
import STT from '@/features/ChatInput/ActionBar/STT';
import { ActionKeys } from '@/features/ChatInput/ActionBar/config';
import SaveTopic from '@/features/ChatInput/Topic';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useInitAgentConfig } from '@/hooks/useInitAgentConfig';
import {
type ActionKey,
type ActionKeys,
MobileChatInput as ChatInput,
ChatInputProvider,
} from '@/features/ChatInput';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { aiChatSelectors } from '@/store/chat/slices/aiChat/selectors';
import Files from './Files';
import InputArea from './InputArea';
import SendButton from './Send';
import { useSend } from '../useSend';
const defaultLeftActions: ActionKeys[] = [
const leftActions: ActionKeys[] = [
'model',
'search',
'fileUpload',
'knowledgeBase',
'history',
'tools',
'params',
'---',
['params', 'history', 'stt', 'clear'],
'mainToken',
];
const defaultRightActions: ActionKeys[] = ['clear'];
const rightActions: ActionKey[] = ['saveTopic'];
const MobileChatInput = memo(() => {
const theme = useTheme();
const ref = useRef<TextAreaRef>(null);
const [expand, setExpand] = useState<boolean>(false);
const { send: sendMessage, canSend } = useSendMessage();
const { isLoading } = useInitAgentConfig();
const { t } = useTranslation('chat');
const { send, disabled, generating, stop } = useSend();
const [loading, value, onInput, onStop] = useChatStore((s) => [
chatSelectors.isAIGenerating(s),
s.inputMessage,
s.updateInputMessage,
s.stopGenerateMessage,
const [mainInputSendErrorMsg, clearSendMessageError] = useChatStore((s) => [
aiChatSelectors.isCurrentSendMessageError(s),
s.clearSendMessageError,
]);
return (
<InputArea
expand={expand}
onInput={onInput}
onSend={() => {
setExpand(false);
sendMessage();
<ChatInputProvider
chatInputEditorRef={(instance) => {
if (!instance) return;
useChatStore.setState({ mainInputEditor: instance });
}}
ref={ref}
setExpand={setExpand}
style={{
background: theme.colorBgLayout,
top: expand ? 0 : undefined,
width: '100%',
zIndex: 101,
leftActions={leftActions}
mobile
onMarkdownContentChange={(content) => {
useChatStore.setState({ inputMessage: content });
}}
textAreaLeftAddons={<STT mobile />}
textAreaRightAddons={
<SendButton disabled={!canSend} loading={loading} onSend={sendMessage} onStop={onStop} />
}
topAddons={
isLoading ? (
<Flexbox paddingInline={8}>
<Skeleton.Button active block size={'small'} />
</Flexbox>
) : (
<>
<Files />
<ActionBar
leftActions={defaultLeftActions}
padding={'0 8px'}
rightActions={defaultRightActions}
rightAreaStartRender={<SaveTopic mobile />}
/>
</>
)
}
value={value}
/>
onSend={() => send()}
rightActions={rightActions}
sendButtonProps={{ disabled, generating, onStop: stop }}
>
{mainInputSendErrorMsg && (
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
<Alert
closable
message={t('input.errorMsg', { errorMsg: mainInputSendErrorMsg })}
onClose={clearSendMessageError}
type={'warning'}
/>
</Flexbox>
)}
<ChatInput />
</ChatInputProvider>
);
});
@@ -0,0 +1,141 @@
import { useAnalytics } from '@lobehub/analytics/react';
import { useMemo } from 'react';
import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
import { getAgentStoreState } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { getChatStoreState, useChatStore } from '@/store/chat';
import { aiChatSelectors, chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { fileChatSelectors, useFileStore } from '@/store/file';
import { getUserStoreState } from '@/store/user';
export interface UseSendMessageParams {
isWelcomeQuestion?: boolean;
onlyAddAIMessage?: boolean;
onlyAddUserMessage?: boolean;
}
export const useSend = () => {
const [
isContentEmpty,
sendMessage,
addAIMessage,
stopGenerateMessage,
cancelSendMessageInServer,
generating,
isSendButtonDisabledByMessage,
isSendingMessage,
] = useChatStore((s) => [
!s.inputMessage,
s.sendMessage,
s.addAIMessage,
s.stopGenerateMessage,
s.cancelSendMessageInServer,
chatSelectors.isAIGenerating(s),
chatSelectors.isSendButtonDisabledByMessage(s),
aiChatSelectors.isCurrentSendMessageLoading(s),
]);
const { analytics } = useAnalytics();
const checkGeminiChineseWarning = useGeminiChineseWarning();
const fileList = fileChatSelectors.chatUploadFileList(useFileStore.getState());
const [isUploadingFiles, clearChatUploadFileList] = useFileStore((s) => [
fileChatSelectors.isUploadingFiles(s),
s.clearChatUploadFileList,
]);
const isInputEmpty = isContentEmpty && fileList.length === 0;
const canNotSend =
isInputEmpty || isUploadingFiles || isSendButtonDisabledByMessage || isSendingMessage;
const handleSend = async (params: UseSendMessageParams = {}) => {
if (canNotSend) return;
const store = useChatStore.getState();
const mainInputEditor = store.mainInputEditor;
if (!mainInputEditor) {
console.warn('not found mainInputEditor instance');
return;
}
if (chatSelectors.isAIGenerating(store)) return;
const inputMessage = store.inputMessage;
// if there is no message and no image, then we should not send the message
if (!inputMessage && fileList.length === 0) return;
// Check for Chinese text warning with Gemini model
const agentStore = getAgentStoreState();
const currentModel = agentSelectors.currentAgentModel(agentStore);
const shouldContinue = await checkGeminiChineseWarning({
model: currentModel,
prompt: inputMessage,
scenario: 'chat',
});
if (!shouldContinue) return;
if (params.onlyAddAIMessage) {
addAIMessage();
} else {
sendMessage({ files: fileList, message: inputMessage, ...params });
}
clearChatUploadFileList();
mainInputEditor.setExpand(false);
mainInputEditor.clearContent();
mainInputEditor.focus();
// 获取分析数据
const userStore = getUserStoreState();
// 直接使用现有数据结构判断消息类型
const hasImages = fileList.some((file) => file.file?.type?.startsWith('image'));
const messageType = fileList.length === 0 ? 'text' : hasImages ? 'image' : 'file';
analytics?.track({
name: 'send_message',
properties: {
chat_id: store.activeId || 'unknown',
current_topic: topicSelectors.currentActiveTopic(store)?.title || null,
has_attachments: fileList.length > 0,
history_message_count: chatSelectors.activeBaseChats(store).length,
message: inputMessage,
message_length: inputMessage.length,
message_type: messageType,
selected_model: agentSelectors.currentAgentModel(agentStore),
session_id: store.activeId || 'inbox', // 当前活跃的会话ID
user_id: userStore.user?.id || 'anonymous',
},
});
};
const stop = () => {
const store = getChatStoreState();
const generating = chatSelectors.isAIGenerating(store);
if (generating) {
stopGenerateMessage();
return;
}
const isCreatingMessage = aiChatSelectors.isCurrentSendMessageLoading(store);
if (isCreatingMessage) {
cancelSendMessageInServer();
}
};
return useMemo(
() => ({
disabled: canNotSend,
generating: generating || isSendingMessage,
send: handleSend,
stop,
}),
[canNotSend, generating, isSendingMessage, stop, handleSend],
);
};
@@ -3,6 +3,7 @@
import React, { memo, useCallback } from 'react';
import { SkeletonList, VirtualizedList } from '@/features/Conversation';
import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
import { useFetchMessages } from '@/hooks/useFetchMessages';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
@@ -27,7 +28,12 @@ const Content = memo<ListProps>(({ mobile }) => {
if (!isCurrentChatLoaded) return <SkeletonList mobile={mobile} />;
if (data.length === 0) return <Welcome />;
if (data.length === 0)
return (
<WideScreenContainer flex={1} height={'100%'}>
<Welcome />
</WideScreenContainer>
);
return <VirtualizedList dataSource={data} itemContent={itemContent} mobile={mobile} />;
});
@@ -11,9 +11,10 @@ import { Flexbox } from 'react-layout-kit';
import { BRANDING_NAME } from '@/const/branding';
import { USAGE_DOCUMENTS } from '@/const/url';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { useSend } from '../../../ChatInput/useSend';
const useStyles = createStyles(({ css, token, responsive }) => ({
card: css`
padding-block: 12px;
@@ -60,7 +61,7 @@ const QuestionSuggest = memo<{ mobile?: boolean }>(({ mobile }) => {
const { t } = useTranslation('welcome');
const { styles } = useStyles();
const { send: sendMessage } = useSendMessage();
const { send: sendMessage } = useSend();
return (
<Flexbox gap={8} width={'100%'}>
@@ -6,9 +6,10 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useSendMessage } from '@/features/ChatInput/useSend';
import { useChatStore } from '@/store/chat';
import { useSend } from '../../ChatInput/useSend';
const useStyles = createStyles(({ css, token, responsive }) => ({
card: css`
padding-block: 8px;
@@ -42,7 +43,7 @@ const OpeningQuestions = memo<OpeningQuestionsProps>(({ mobile, questions }) =>
const [updateInputMessage] = useChatStore((s) => [s.updateInputMessage]);
const { styles } = useStyles();
const { send: sendMessage } = useSendMessage();
const { send: sendMessage } = useSend();
return (
<div className={styles.container}>
@@ -1,7 +1,12 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { PanelRightClose, PanelRightOpen } from 'lucide-react';
import {
PanelLeftRightDashedIcon,
PanelRightClose,
PanelRightOpen,
SquareChartGanttIcon,
} from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
@@ -20,15 +25,26 @@ import ShareButton from '../../../features/ShareButton';
const HeaderAction = memo<{ className?: string }>(({ className }) => {
const { t } = useTranslation('chat');
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.ToggleRightPanel));
const [showAgentSettings, toggleConfig] = useGlobalStore((s) => [
const [showAgentSettings, wideScreen, toggleConfig, toggleWideScreen] = useGlobalStore((s) => [
systemStatusSelectors.showChatSideBar(s),
systemStatusSelectors.wideScreen(s),
s.toggleChatSideBar,
s.toggleWideScreen,
]);
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
return (
<Flexbox className={className} gap={4} horizontal>
<ActionIcon
icon={wideScreen ? SquareChartGanttIcon : PanelLeftRightDashedIcon}
onClick={() => toggleWideScreen()}
size={DESKTOP_HEADER_ICON_SIZE}
title={t(wideScreen ? 'toggleWideScreen.off' : 'toggleWideScreen.on')}
tooltipProps={{
placement: 'bottom',
}}
/>
<ShareButton />
<ActionIcon
icon={showAgentSettings ? PanelRightClose : PanelRightOpen}
+41 -47
View File
@@ -1,4 +1,4 @@
import { ActionIcon, Alert, Button, Dropdown, Highlighter } from '@lobehub/ui';
import { Alert, Button, Highlighter } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Mic, MicOff } from 'lucide-react';
import { memo, useState } from 'react';
@@ -7,6 +7,8 @@ import { Flexbox } from 'react-layout-kit';
import { ChatMessageError } from '@/types/message';
import Action from '../components/Action';
const useStyles = createStyles(({ css, token }) => ({
recording: css`
width: 8px;
@@ -49,35 +51,36 @@ const CommonSTT = memo<{
};
return (
<Dropdown
menu={{
// @ts-expect-error 等待 antd 修复
activeKey: 'time',
items: [
{
key: 'title',
label: (
<Flexbox>
<div style={{ fontWeight: 'bolder' }}>{t('stt.action')}</div>
</Flexbox>
),
},
{
key: 'time',
label: (
<Flexbox align={'center'} gap={8} horizontal>
<div className={styles.recording} />
{time > 0 ? formattedTime : t(isRecording ? 'stt.loading' : 'stt.prettifying')}
</Flexbox>
),
},
],
}}
onOpenChange={handleDropdownVisibleChange}
open={dropdownOpen || !!error || isRecording || isLoading}
placement={mobile ? 'topRight' : 'top'}
popupRender={
error
<Action
active={isRecording}
dropdown={{
menu: {
// @ts-expect-error 等待 antd 修复
activeKey: 'time',
items: [
{
key: 'title',
label: (
<Flexbox>
<div style={{ fontWeight: 'bolder' }}>{t('stt.action')}</div>
</Flexbox>
),
},
{
key: 'time',
label: (
<Flexbox align={'center'} gap={8} horizontal>
<div className={styles.recording} />
{time > 0 ? formattedTime : t(isRecording ? 'stt.loading' : 'stt.prettifying')}
</Flexbox>
),
},
],
},
onOpenChange: handleDropdownVisibleChange,
open: dropdownOpen || !!error || isRecording || isLoading,
placement: mobile ? 'topRight' : 'top',
popupRender: error
? () => (
<Alert
action={
@@ -103,23 +106,14 @@ const CommonSTT = memo<{
type="error"
/>
)
: undefined
}
trigger={['click']}
>
<ActionIcon
active={isRecording}
icon={isLoading ? MicOff : Mic}
onClick={handleTriggerStartStop}
size={mobile ? { blockSize: 36, size: 16 } : 22}
style={{ flex: 'none' }}
title={dropdownOpen ? '' : desc}
tooltipProps={{
placement: 'bottom',
}}
variant={mobile ? 'outlined' : 'borderless'}
/>
</Dropdown>
: undefined,
trigger: ['click'],
}}
icon={isLoading ? MicOff : Mic}
onClick={handleTriggerStartStop}
title={dropdownOpen ? undefined : desc}
variant={mobile ? 'outlined' : 'borderless'}
/>
);
},
);
@@ -1,4 +1,4 @@
import { ActionIcon, Button, Hotkey, Tooltip } from '@lobehub/ui';
import { ActionIcon, Hotkey } from '@lobehub/ui';
import { Popconfirm } from 'antd';
import { LucideGalleryVerticalEnd, LucideMessageSquarePlus } from 'lucide-react';
import { memo, useState } from 'react';
@@ -54,11 +54,22 @@ const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
);
} else {
return (
<Tooltip hotkey={hotkey} title={desc}>
<Button aria-label={desc} icon={icon} loading={isValidating} onClick={() => mutate()} />
</Tooltip>
<ActionIcon
aria-label={desc}
icon={icon}
loading={isValidating}
onClick={() => mutate()}
size={{ blockSize: 32, size: 16, strokeWidth: 2.3 }}
title={desc}
tooltipProps={{
hotkey,
}}
variant={'outlined'}
/>
);
}
});
SaveTopic.displayName = 'SaveTopic';
export default SaveTopic;
@@ -0,0 +1,22 @@
import { TypeIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatInputStore } from '../../store';
import Action from '../components/Action';
const Typo = memo(() => {
const { t } = useTranslation('editor');
const [showTypoBar, setShowTypoBar] = useChatInputStore((s) => [s.showTypoBar, s.setShowTypoBar]);
return (
<Action
active={showTypoBar}
icon={TypeIcon}
onClick={() => setShowTypoBar(!showTypoBar)}
title={t(showTypoBar ? 'actions.typobar.off' : 'actions.typobar.on')}
/>
);
});
export default Typo;
@@ -51,6 +51,10 @@ const Action = memo<ActionProps>(
placement: 'bottom',
}}
{...rest}
size={{
blockSize: 36,
size: 20,
}}
/>
);
+7 -1
View File
@@ -4,9 +4,11 @@ import Knowledge from './Knowledge';
import Model from './Model';
import Params from './Params';
import STT from './STT';
import SaveTopic from './SaveTopic';
import Search from './Search';
import { MainToken, PortalToken } from './Token';
import Tools from './Tools';
import Typo from './Typo';
import Upload from './Upload';
export const actionMap = {
@@ -18,10 +20,14 @@ export const actionMap = {
model: Model,
params: Params,
portalToken: PortalToken,
saveTopic: SaveTopic,
search: Search,
stt: STT,
temperature: Params,
tools: Tools,
typo: Typo,
} as const;
export type ActionKeys = keyof typeof actionMap;
export type ActionKey = keyof typeof actionMap;
export type ActionKeys = ActionKey | ActionKey[] | '---';
+40 -51
View File
@@ -1,55 +1,44 @@
import { ChatInputActionBar } from '@lobehub/ui/chat';
import { ReactNode, memo } from 'react';
import { ChatInputActions, type ChatInputActionsProps } from '@lobehub/editor/react';
import { memo, useMemo } from 'react';
import { ActionKeys, actionMap } from './config';
import { ActionKeys, actionMap } from '../ActionBar/config';
import { useChatInputStore } from '../store';
const RenderActionList = ({ dataSource }: { dataSource: ActionKeys[] }) => (
<>
{dataSource.map((key) => {
const Render = actionMap[key];
return <Render key={key} />;
})}
</>
);
export interface ActionBarProps {
leftActions: ActionKeys[];
leftAreaEndRender?: ReactNode;
leftAreaStartRender?: ReactNode;
padding?: number | string;
rightActions: ActionKeys[];
rightAreaEndRender?: ReactNode;
rightAreaStartRender?: ReactNode;
}
const ActionBar = memo<ActionBarProps>(
({
padding = '0 8px',
rightAreaStartRender,
rightAreaEndRender,
leftAreaStartRender,
leftAreaEndRender,
leftActions,
rightActions,
}) => (
<ChatInputActionBar
leftAddons={
<>
{leftAreaStartRender}
<RenderActionList dataSource={leftActions} />
{leftAreaEndRender}
</>
const mapActionsToItems = (keys: ActionKeys[]): ChatInputActionsProps['items'] =>
keys.map((actionKey, index) => {
if (typeof actionKey === 'string') {
if (actionKey === '---') {
return {
key: `divider-${index}`,
type: 'divider',
};
}
padding={padding}
rightAddons={
<>
{rightAreaStartRender}
<RenderActionList dataSource={rightActions} />
{rightAreaEndRender}
</>
}
/>
),
);
const Render = actionMap[actionKey];
return {
alwaysDisplay: actionKey === 'mainToken',
children: <Render key={actionKey} />,
key: actionKey,
};
} else {
return {
children: actionKey.map((groupActionKey) => {
const Render = actionMap[groupActionKey];
return {
children: <Render key={groupActionKey} />,
key: groupActionKey,
};
}),
key: `group-${index}`,
type: 'collapse',
};
}
});
export default ActionBar;
const ActionToolbar = memo(() => {
const leftActions = useChatInputStore((s) => s.leftActions);
const mobile = useChatInputStore((s) => s.mobile);
const items = useMemo(() => mapActionsToItems(leftActions), [leftActions]);
return <ChatInputActions collapseOffset={mobile ? 48 : 80} items={items} />;
});
export default ActionToolbar;
@@ -0,0 +1,54 @@
import { useEditor } from '@lobehub/editor/react';
import { ReactNode, memo, useRef } from 'react';
import StoreUpdater, { StoreUpdaterProps } from './StoreUpdater';
import { Provider, createStore } from './store';
interface ChatInputProviderProps extends StoreUpdaterProps {
children: ReactNode;
}
export const ChatInputProvider = memo<ChatInputProviderProps>(
({
children,
leftActions,
rightActions,
mobile,
sendButtonProps,
onSend,
sendMenu,
chatInputEditorRef,
onMarkdownContentChange,
}) => {
const editor = useEditor();
const slashMenuRef = useRef<HTMLDivElement>(null);
return (
<Provider
createStore={() =>
createStore({
editor,
leftActions,
mobile,
rightActions,
sendButtonProps,
sendMenu,
slashMenuRef,
})
}
>
<StoreUpdater
chatInputEditorRef={chatInputEditorRef}
leftActions={leftActions}
mobile={mobile}
onMarkdownContentChange={onMarkdownContentChange}
onSend={onSend}
rightActions={rightActions}
sendButtonProps={sendButtonProps}
sendMenu={sendMenu}
/>
{children}
</Provider>
);
},
);
@@ -1,4 +1,4 @@
import { ActionIcon, Text } from '@lobehub/ui';
import { ActionIcon, Block, Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Trash2Icon } from 'lucide-react';
import { memo } from 'react';
@@ -26,17 +26,13 @@ const useStyles = createStyles(({ css, token }) => ({
${token.boxShadowTertiary};
`,
container: css`
user-select: none;
position: relative;
width: 180px;
height: 64px;
border-radius: 8px;
background: ${token.colorBgContainer};
:hover {
background: ${token.colorBgElevated};
}
`,
image: css`
margin-block: 0 !important;
@@ -58,15 +54,28 @@ const FileItem = memo<FileItemProps>((props) => {
const [removeChatUploadFile] = useFileStore((s) => [s.removeChatUploadFile]);
return (
<Flexbox align={'center'} className={styles.container} horizontal>
<Block align={'center'} className={styles.container} horizontal variant={'outlined'}>
<Center flex={1} height={64} padding={4} style={{ maxWidth: 64 }}>
<Content {...props} />
</Center>
<Flexbox flex={1} gap={4} style={{ paddingBottom: 4, paddingInline: 4 }}>
<Text ellipsis={{ tooltip: true }} style={{ fontSize: 12, maxWidth: 100 }}>
<Text
ellipsis={{
tooltip: {
styles: {
body: {
fontSize: 12,
whiteSpace: 'balance',
wordBreak: 'break-all',
},
},
title: file.name,
},
}}
style={{ fontSize: 12, maxWidth: 88 }}
>
{file.name}
</Text>
<UploadDetail size={file.size} status={status} tasks={tasks} uploadState={uploadState} />
</Flexbox>
<Flexbox className={styles.actions}>
@@ -80,7 +89,7 @@ const FileItem = memo<FileItemProps>((props) => {
title={t('delete', { ns: 'common' })}
/>
</Flexbox>
</Flexbox>
</Block>
);
});
@@ -1,42 +1,43 @@
import { ScrollShadow } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { lighten } from 'polished';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useChatInputStore } from '@/features/ChatInput/store';
import { fileChatSelectors, useFileStore } from '@/store/file';
import FileItem from './FileItem';
const useStyles = createStyles(({ css, token }) => ({
const useStyles = createStyles(({ css }) => ({
container: css`
overflow-x: scroll;
width: 100%;
border-start-start-radius: 8px;
border-start-end-radius: 8px;
background: ${lighten(0.01, token.colorBgLayout)};
`,
}));
const FileList = memo(() => {
const expand = useChatInputStore((s) => s.expand);
const inputFilesList = useFileStore(fileChatSelectors.chatUploadFileList);
const showFileList = useFileStore(fileChatSelectors.chatUploadFileListHasItem);
const { styles } = useStyles();
if (!inputFilesList.length) return null;
if (!inputFilesList.length || !showFileList) return null;
return (
<Flexbox
<ScrollShadow
className={styles.container}
gap={6}
hideScrollBar
horizontal
padding={showFileList ? '16px 16px 12px' : 0}
orientation={'horizontal'}
size={8}
>
{inputFilesList.map((item) => (
<FileItem key={item.id} {...item} />
))}
</Flexbox>
<Flexbox gap={6} horizontal paddingBlock={8} paddingInline={expand ? 0 : 12}>
{inputFilesList.map((item) => (
<FileItem key={item.id} {...item} />
))}
</Flexbox>
</ScrollShadow>
);
});
@@ -1,30 +0,0 @@
import { ActionIcon } from '@lobehub/ui';
import { Maximize2, Minimize2 } from 'lucide-react';
import { memo } from 'react';
import ActionBar from '@/features/ChatInput/ActionBar';
import { ActionKeys } from '@/features/ChatInput/types';
interface HeaderProps {
expand: boolean;
leftActions: ActionKeys[];
rightActions: ActionKeys[];
setExpand: (expand: boolean) => void;
}
const Header = memo<HeaderProps>(({ expand, setExpand, leftActions, rightActions }) => (
<ActionBar
leftActions={leftActions}
rightActions={rightActions}
rightAreaEndRender={
<ActionIcon
icon={expand ? Minimize2 : Maximize2}
onClick={() => {
setExpand(!expand);
}}
/>
}
/>
));
export default Header;
@@ -1,143 +0,0 @@
import { TextArea } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { TextAreaRef } from 'antd/es/input/TextArea';
import { RefObject, memo, useEffect, useRef } from 'react';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { HotkeyEnum } from '@/types/hotkey';
import { isCommandPressed } from '@/utils/keyboard';
import { useAutoFocus } from '../useAutoFocus';
const useStyles = createStyles(({ css }) => {
return {
textarea: css`
resize: none !important;
height: 100% !important;
padding-block: 0;
padding-inline: 16px;
line-height: 1.5;
box-shadow: none !important;
`,
textareaContainer: css`
position: relative;
flex: 1;
`,
};
});
interface InputAreaProps {
loading?: boolean;
onChange: (string: string) => void;
onSend: () => void;
value: string;
}
const InputArea = memo<InputAreaProps>(({ onSend, value, loading, onChange }) => {
const { enableScope, disableScope } = useHotkeysContext();
const { t } = useTranslation('chat');
const { styles } = useStyles();
const ref = useRef<TextAreaRef>(null);
const isChineseInput = useRef(false);
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
useAutoFocus(ref as RefObject<TextAreaRef>);
const hasValue = !!value;
useEffect(() => {
const fn = (e: BeforeUnloadEvent) => {
if (hasValue) {
// set returnValue to trigger alert modal
// Note: No matter what value is set, the browser will display the standard text
e.returnValue = '你有正在输入中的内容,确定要离开吗?';
}
};
window.addEventListener('beforeunload', fn);
return () => {
window.removeEventListener('beforeunload', fn);
};
}, [hasValue]);
return (
<div className={styles.textareaContainer}>
<TextArea
autoFocus
className={styles.textarea}
onBlur={(e) => {
onChange?.(e.target.value);
disableScope(HotkeyEnum.AddUserMessage);
}}
onChange={(e) => {
onChange?.(e.target.value);
}}
onCompositionEnd={() => {
isChineseInput.current = false;
}}
onCompositionStart={() => {
isChineseInput.current = true;
}}
onContextMenu={async (e) => {
if (isDesktop) {
e.preventDefault();
const textArea = ref.current?.resizableTextArea?.textArea;
const hasSelection = textArea && textArea.selectionStart !== textArea.selectionEnd;
const { electronSystemService } = await import('@/services/electron/system');
electronSystemService.showContextMenu('editor', {
hasSelection: !!hasSelection,
value: value,
});
}
}}
onFocus={() => {
enableScope(HotkeyEnum.AddUserMessage);
}}
onPressEnter={(e) => {
if (loading || e.altKey || e.shiftKey || isChineseInput.current) return;
// eslint-disable-next-line unicorn/consistent-function-scoping
const send = () => {
// avoid inserting newline when sending message.
// refs: https://github.com/lobehub/lobe-chat/pull/989
e.preventDefault();
onSend();
};
const commandKey = isCommandPressed(e);
// when user like cmd + enter to send message
if (useCmdEnterToSend) {
if (commandKey) send();
} else {
// cmd + enter to wrap
if (commandKey) {
onChange?.((e.target as any).value + '\n');
return;
}
send();
}
}}
placeholder={t('sendPlaceholder')}
ref={ref}
value={value}
variant={'borderless'}
/>
</div>
);
});
InputArea.displayName = 'DesktopInputArea';
export default InputArea;
@@ -1,45 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { useAutoFocus } from '../useAutoFocus';
vi.mock('zustand/traditional');
describe('useAutoFocus', () => {
it('should focus the input when chatKey changes', () => {
const focusMock = vi.fn();
const inputRef = { current: { focus: focusMock } };
act(() => {
useChatStore.setState({ activeId: '1', activeTopicId: '2' });
});
renderHook(() => useAutoFocus(inputRef as any));
expect(focusMock).toHaveBeenCalledTimes(1);
act(() => {
useChatStore.setState({ activeId: '1', activeTopicId: '3' });
});
renderHook(() => useAutoFocus(inputRef as any));
// I don't know why its 3, but is large than 2 is fine
expect(focusMock).toHaveBeenCalledTimes(3);
});
it('should not focus the input if inputRef is not available', () => {
const inputRef = { current: null };
act(() => {
useChatStore.setState({ activeId: '1', activeTopicId: '2' });
});
renderHook(() => useAutoFocus(inputRef as any));
expect(inputRef.current).toBeNull();
});
});
+81 -68
View File
@@ -1,82 +1,95 @@
'use client';
import { DraggablePanel } from '@lobehub/ui';
import { ReactNode, memo, useCallback, useState } from 'react';
import { ChatInput, ChatInputActionBar } from '@lobehub/editor/react';
import { createStyles } from 'antd-style';
import { memo, useEffect } from 'react';
import { Flexbox } from 'react-layout-kit';
import { CHAT_TEXTAREA_HEIGHT, CHAT_TEXTAREA_MAX_HEIGHT } from '@/const/layoutTokens';
import { useChatInputStore } from '@/features/ChatInput/store';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { ActionKeys } from '../ActionBar/config';
import LocalFiles from './FilePreview';
import Head from './Header';
import ActionBar from '../ActionBar';
import InputEditor from '../InputEditor';
import SendArea from '../SendArea';
import ShortcutHint from '../SendArea/ShortcutHint';
import TypoBar from '../TypoBar';
import FilePreview from './FilePreview';
export type FooterRender = (params: {
expand: boolean;
onExpandChange: (expand: boolean) => void;
}) => ReactNode;
const useStyles = createStyles(({ css, token }) => ({
container: css`
.show-on-hover {
opacity: 0;
}
interface DesktopChatInputProps {
inputHeight: number;
leftActions: ActionKeys[];
onInputHeightChange?: (height: number) => void;
renderFooter: FooterRender;
renderTextArea: (onSend: () => void) => ReactNode;
rightActions: ActionKeys[];
}
&:hover {
.show-on-hover {
opacity: 1;
}
}
`,
fullscreen: css`
position: absolute;
z-index: 100;
inset: 0;
const DesktopChatInput = memo<DesktopChatInputProps>(
({
leftActions,
rightActions,
renderTextArea,
inputHeight,
onInputHeightChange,
renderFooter,
}) => {
const [expand, setExpand] = useState<boolean>(false);
const onSend = useCallback(() => {
setExpand(false);
}, []);
width: 100%;
height: 100%;
padding: 12px;
return (
<>
{!expand && leftActions.includes('fileUpload') && <LocalFiles />}
<DraggablePanel
fullscreen={expand}
maxHeight={CHAT_TEXTAREA_MAX_HEIGHT}
minHeight={CHAT_TEXTAREA_HEIGHT}
onSizeChange={(_, size) => {
if (!size) return;
const height =
typeof size.height === 'string' ? Number.parseInt(size.height) : size.height;
if (!height) return;
background: ${token.colorBgContainerSecondary};
`,
}));
onInputHeightChange?.(height);
}}
placement="bottom"
size={{ height: inputHeight, width: '100%' }}
style={{ zIndex: 10 }}
>
<Flexbox
gap={8}
height={'100%'}
paddingBlock={'4px 16px'}
style={{ minHeight: CHAT_TEXTAREA_HEIGHT, position: 'relative' }}
>
<Head
expand={expand}
leftActions={leftActions}
rightActions={rightActions}
setExpand={setExpand}
const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) => {
const [slashMenuRef, expand, showTypoBar, editor, leftActions] = useChatInputStore((s) => [
s.slashMenuRef,
s.expand,
s.showTypoBar,
s.editor,
s.leftActions,
]);
const { styles, cx } = useStyles();
const chatKey = useChatStore(chatSelectors.currentChatKey);
useEffect(() => {
if (editor) editor.focus();
}, [chatKey, editor]);
const fileNode = leftActions.flat().includes('fileUpload') && <FilePreview />;
return (
<>
{!expand && fileNode}
<Flexbox
className={cx(styles.container, expand && styles.fullscreen)}
paddingBlock={showFootnote ? 0 : '0 12px'}
paddingInline={12}
>
<ChatInput
footer={
<ChatInputActionBar
left={<ActionBar />}
right={<SendArea />}
style={{
paddingRight: 8,
}}
/>
{renderTextArea(onSend)}
{renderFooter({ expand, onExpandChange: setExpand })}
</Flexbox>
</DraggablePanel>
</>
);
},
);
}
fullscreen={expand}
header={showTypoBar && <TypoBar />}
slashMenuRef={slashMenuRef}
>
{expand && fileNode}
<InputEditor />
</ChatInput>
{showFootnote && !expand && <ShortcutHint />}
</Flexbox>
</>
);
});
DesktopChatInput.displayName = 'DesktopChatInput';
@@ -1,13 +0,0 @@
import { TextAreaRef } from 'antd/es/input/TextArea';
import { RefObject, useEffect } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
export const useAutoFocus = (inputRef: RefObject<TextAreaRef>) => {
const chatKey = useChatStore(chatSelectors.currentChatKey);
useEffect(() => {
inputRef.current?.focus();
}, [chatKey]);
};
@@ -0,0 +1,134 @@
import { isDesktop } from '@lobechat/const';
import { HotkeyEnum } from '@lobechat/types';
import { isCommandPressed } from '@lobechat/utils';
import {
INSERT_TABLE_COMMAND,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactListPlugin,
ReactTablePlugin,
} from '@lobehub/editor';
import { Editor, SlashMenu, useEditorState } from '@lobehub/editor/react';
import { Table2Icon } from 'lucide-react';
import { memo, useEffect, useRef } from 'react';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { useChatInputStore, useStoreApi } from '../store';
const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
const [editor, slashMenuRef, send, updateMarkdownContent] = useChatInputStore((s) => [
s.editor,
s.slashMenuRef,
s.handleSendButton,
s.updateMarkdownContent,
]);
const storeApi = useStoreApi();
const state = useEditorState(editor);
const { enableScope, disableScope } = useHotkeysContext();
const { t } = useTranslation(['editor', 'chat']);
const isChineseInput = useRef(false);
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
useEffect(() => {
const fn = (e: BeforeUnloadEvent) => {
if (!state.isEmpty) {
// set returnValue to trigger alert modal
// Note: No matter what value is set, the browser will display the standard text
e.returnValue = 'You are typing something, are you sure you want to leave?';
}
};
window.addEventListener('beforeunload', fn);
return () => {
window.removeEventListener('beforeunload', fn);
};
}, [state.isEmpty]);
return (
<Editor
autoFocus
content={''}
editor={editor}
onBlur={() => {
disableScope(HotkeyEnum.AddUserMessage);
}}
onChange={() => {
updateMarkdownContent();
}}
onCompositionEnd={() => {
isChineseInput.current = false;
}}
onCompositionStart={() => {
isChineseInput.current = true;
}}
onContextMenu={async ({ event: e, editor }) => {
if (isDesktop) {
e.preventDefault();
const { electronSystemService } = await import('@/services/electron/system');
const selectionValue = editor.getSelectionDocument('markdown') as unknown as string;
const hasSelection = !!selectionValue;
await electronSystemService.showContextMenu('editor', {
hasSelection,
value: selectionValue,
});
}
}}
onFocus={() => {
enableScope(HotkeyEnum.AddUserMessage);
}}
onInit={(editor) => storeApi.setState({ editor })}
onPressEnter={({ event: e }) => {
if (e.altKey || e.shiftKey || isChineseInput.current) return;
const commandKey = isCommandPressed(e);
// when user like cmd + enter to send message
if (useCmdEnterToSend) {
if (commandKey) send();
} else {
if (!commandKey) send();
}
}}
placeholder={t('sendPlaceholder', { ns: 'chat' })}
plugins={[
ReactListPlugin,
ReactLinkPlugin,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactTablePlugin,
]}
slashOption={{
items: [
{
icon: Table2Icon,
key: 'table',
label: t('typobar.table'),
onSelect: (editor) => {
editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: '3', rows: '3' });
},
},
],
renderComp: (props) => {
return <SlashMenu {...props} getPopupContainer={() => (slashMenuRef as any)?.current} />;
},
}}
style={{
minHeight: defaultRows > 1 ? defaultRows * 23 : undefined,
}}
type={'text'}
variant={'chat'}
/>
);
});
InputEditor.displayName = 'InputEditor';
export default InputEditor;
@@ -5,10 +5,9 @@ import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import FileIcon from '@/components/FileIcon';
import UploadDetail from '@/features/ChatInput/components/UploadDetail';
import { UploadFileItem } from '@/types/files';
import UploadDetail from '../../../../../../../../../../../features/ChatInput/components/UploadDetail';
const useStyles = createStyles(({ css, token }) => ({
container: css`
cursor: pointer;
@@ -0,0 +1,44 @@
import { PreviewGroup, ScrollShadow } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useChatInputStore } from '@/features/ChatInput/store';
import { filesSelectors, useFileStore } from '@/store/file';
import FileItem from './FileItem';
const useStyles = createStyles(({ css }) => ({
container: css`
overflow-x: scroll;
width: 100%;
`,
}));
const FilePreview = memo(() => {
const expand = useChatInputStore((s) => s.expand);
const list = useFileStore(filesSelectors.chatUploadFileList, isEqual);
const { styles } = useStyles();
if (!list || list?.length === 0) return null;
return (
<ScrollShadow
className={styles.container}
hideScrollBar
horizontal
orientation={'horizontal'}
size={8}
>
<Flexbox gap={6} horizontal paddingBlock={8} paddingInline={expand ? 0 : 12}>
<PreviewGroup>
{list.map((i) => (
<FileItem {...i} key={i.id} loading={i.status === 'pending'} />
))}
</PreviewGroup>
</Flexbox>
</ScrollShadow>
);
});
export default FilePreview;
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { ChatInput, ChatInputActionBar } from '@lobehub/editor/react';
import { createStyles } from 'antd-style';
import dynamic from 'next/dynamic';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useChatInputStore } from '@/features/ChatInput/store';
import ActionBar from '../ActionBar';
import InputEditor from '../InputEditor';
import SendArea from '../SendArea';
const FilePreview = dynamic(() => import('./FilePreview'), { ssr: false });
const useStyles = createStyles(({ css, token }) => ({
container: css``,
fullscreen: css`
position: absolute;
z-index: 100;
inset: 0;
width: 100%;
height: 100%;
padding: 12px;
background: ${token.colorBgLayout};
`,
}));
const DesktopChatInput = memo(() => {
const [slashMenuRef, expand] = useChatInputStore((s) => [s.slashMenuRef, s.expand]);
const leftActions = useChatInputStore((s) => s.leftActions);
const { styles, cx } = useStyles();
const fileNode = leftActions.flat().includes('fileUpload') && <FilePreview />;
return (
<>
{!expand && fileNode}
<Flexbox
className={cx(styles.container, expand && styles.fullscreen)}
paddingBlock={'0 12px'}
paddingInline={12}
>
<ChatInput
footer={
<ChatInputActionBar
left={<div />}
right={<SendArea />}
style={{
paddingRight: 8,
}}
/>
}
fullscreen={expand}
header={<ChatInputActionBar left={<ActionBar />} />}
slashMenuRef={slashMenuRef}
>
{expand && fileNode}
<InputEditor defaultRows={1} />
</ChatInput>
</Flexbox>
</>
);
});
DesktopChatInput.displayName = 'DesktopChatInput';
export default DesktopChatInput;
@@ -0,0 +1,30 @@
import { ActionIcon } from '@lobehub/ui';
import { Maximize2Icon, Minimize2Icon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatInputStore } from '@/features/ChatInput/store';
const ExpandButton = memo(() => {
const { t } = useTranslation('editor');
const [expand, setExpand, editor] = useChatInputStore((s) => [s.expand, s.setExpand, s.editor]);
return (
<ActionIcon
className="show-on-hover"
icon={expand ? Minimize2Icon : Maximize2Icon}
onClick={() => {
setExpand?.(!expand);
editor?.focus();
}}
size={{ blockSize: 32, size: 16, strokeWidth: 2.3 }}
style={{
zIndex: 10,
}}
title={t(expand ? 'actions.expand.off' : 'actions.expand.on')}
/>
);
});
ExpandButton.displayName = 'ExpandButton';
export default ExpandButton;
@@ -0,0 +1,29 @@
import { SendButton as Send } from '@lobehub/editor/react';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { selectors, useChatInputStore } from '../store';
const SendButton = memo(() => {
const sendMenu = useChatInputStore((s) => s.sendMenu);
const shape = useChatInputStore((s) => s.sendButtonProps?.shape);
const { generating, disabled } = useChatInputStore(selectors.sendButtonProps, isEqual);
const [send, handleStop] = useChatInputStore((s) => [s.handleSendButton, s.handleStop]);
return (
<Send
disabled={disabled}
generating={generating}
menu={sendMenu as any}
onClick={() => send()}
onStop={() => handleStop()}
placement={'topRight'}
shape={shape}
trigger={['hover']}
/>
);
});
SendButton.displayName = 'SendButton';
export default SendButton;
@@ -0,0 +1,52 @@
import { Hotkey, Text, combineKeys } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { KeyEnum } from '@/types/hotkey';
const ShortcutHint = memo(() => {
const { t } = useTranslation('chat');
const theme = useTheme();
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
const sendShortcut = useCmdEnterToSend
? combineKeys([KeyEnum.Mod, KeyEnum.Enter])
: KeyEnum.Enter;
const wrapperShortcut = useCmdEnterToSend
? KeyEnum.Enter
: combineKeys([KeyEnum.Mod, KeyEnum.Enter]);
return (
<Text fontSize={12} style={{ color: theme.colorTextQuaternary, userSelect: 'none', zIndex: 1 }}>
<Flexbox align={'center'} gap={4} horizontal justify={'flex-end'} paddingBlock={4}>
<Hotkey
keys={sendShortcut}
style={{ color: 'inherit' }}
styles={{
kbdStyle: { color: 'inherit' },
}}
variant={'borderless'}
/>
<span>{t('input.send')}</span>
<span>/</span>
<Hotkey
keys={wrapperShortcut}
style={{ color: 'inherit' }}
styles={{
kbdStyle: { color: 'inherit' },
}}
variant={'borderless'}
/>
<span>{t('input.warp')}</span>
</Flexbox>
</Text>
);
});
export default ShortcutHint;
+36
View File
@@ -0,0 +1,36 @@
import isEqual from 'fast-deep-equal';
import { memo, useMemo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { ActionKey, actionMap } from '../ActionBar/config';
import { useChatInputStore } from '../store';
import ExpandButton from './ExpandButton';
import SendButton from './SendButton';
const mapActionsToItems = (keys: ActionKey[]) =>
keys.map((actionKey) => {
const Render = actionMap[actionKey];
return <Render key={actionKey} />;
});
const SendArea = memo(() => {
const allowExpand = useChatInputStore((s) => s.allowExpand);
const rightActions = useChatInputStore((s) => s.rightActions, isEqual);
const items = useMemo(
() => mapActionsToItems((rightActions as ActionKey[]) || []),
[rightActions],
);
return (
<Flexbox align={'center'} flex={'none'} gap={6} horizontal>
{allowExpand && <ExpandButton />}
{items}
<SendButton />
</Flexbox>
);
});
SendArea.displayName = 'SendArea';
export default SendArea;
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { ForwardedRef, memo, useImperativeHandle } from 'react';
import { createStoreUpdater } from 'zustand-utils';
import { ChatInputEditor, useChatInputEditor } from './hooks/useChatInputEditor';
import { PublicState, useStoreApi } from './store';
export interface StoreUpdaterProps extends Partial<PublicState> {
chatInputEditorRef?: ForwardedRef<ChatInputEditor | null>;
}
const StoreUpdater = memo<StoreUpdaterProps>(
({
chatInputEditorRef,
mobile,
sendButtonProps,
leftActions,
rightActions,
onSend,
onMarkdownContentChange,
}) => {
const storeApi = useStoreApi();
const useStoreUpdater = createStoreUpdater(storeApi);
const editor = useChatInputEditor();
useStoreUpdater('mobile', mobile);
useStoreUpdater('leftActions', leftActions);
useStoreUpdater('rightActions', rightActions);
useStoreUpdater('sendButtonProps', sendButtonProps);
useStoreUpdater('onSend', onSend);
useStoreUpdater('onMarkdownContentChange', onMarkdownContentChange);
useImperativeHandle(chatInputEditorRef, () => editor);
return null;
},
);
export default StoreUpdater;
+139
View File
@@ -0,0 +1,139 @@
import { useEditorState } from '@lobehub/editor/react';
import {
ChatInputActionBar,
ChatInputActions,
type ChatInputActionsProps,
CodeLanguageSelect,
} from '@lobehub/editor/react';
import { useTheme } from 'antd-style';
import {
BoldIcon,
CodeXmlIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
MessageSquareQuote,
SquareDashedBottomCodeIcon,
StrikethroughIcon,
} from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatInputStore } from '@/features/ChatInput/store';
const TypoBar = memo(() => {
const { t } = useTranslation('editor');
const editor = useChatInputStore((s) => s.editor);
const editorState = useEditorState(editor);
const theme = useTheme();
return (
<ChatInputActionBar
left={
<ChatInputActions
items={
[
{
active: editorState.isBold,
icon: BoldIcon,
key: 'bold',
label: t('typobar.bold'),
onClick: editorState.bold,
},
{
active: editorState.isItalic,
icon: ItalicIcon,
key: 'italic',
label: t('typobar.italic'),
onClick: editorState.italic,
},
// TODO: 目前 markdown 不支持 <u>
// {
// active: editorState.isUnderline,
// icon: UnderlineIcon,
// key: 'underline',
// label: t('typobar.underline'),
// onClick: editorState.underline,
// },
{
active: editorState.isStrikethrough,
icon: StrikethroughIcon,
key: 'strikethrough',
label: t('typobar.strikethrough'),
onClick: editorState.strikethrough,
},
{
type: 'divider',
},
{
icon: ListIcon,
key: 'bulletList',
label: t('typobar.bulletList'),
onClick: editorState.bulletList,
},
{
icon: ListOrderedIcon,
key: 'numberlist',
label: t('typobar.numberList'),
onClick: editorState.numberList,
},
{
active: editorState.isBlockquote,
icon: MessageSquareQuote,
key: 'blockquote',
label: t('typobar.blockquote'),
onClick: editorState.blockquote,
},
{
icon: LinkIcon,
key: 'link',
label: t('typobar.link'),
onClick: editorState.insertLink,
},
{
type: 'divider',
},
{
active: editorState.isCode,
icon: CodeXmlIcon,
key: 'code',
label: t('typobar.code'),
onClick: editorState.code,
},
{
icon: SquareDashedBottomCodeIcon,
key: 'codeblock',
label: t('typobar.codeblock'),
onClick: editorState.codeblock,
},
editorState.isCodeblock && {
children: (
<CodeLanguageSelect
onSelect={(value) => editorState.updateCodeblockLang(value)}
value={editorState.codeblockLang}
/>
),
disabled: !editorState.isCodeblock,
key: 'codeblockLang',
},
].filter(Boolean) as ChatInputActionsProps['items']
}
onClick={() => {
editor?.focus();
}}
/>
}
style={{
background: theme.colorFillQuaternary,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
}}
/>
);
});
TypoBar.displayName = 'TypoBar';
export default TypoBar;
@@ -0,0 +1,36 @@
import { IEditor } from '@lobehub/editor';
import { useMemo } from 'react';
import { useChatInputStore } from '@/features/ChatInput/store';
export interface ChatInputEditor {
clearContent: () => void;
focus: () => void;
getJSONState: () => any;
getMarkdownContent: () => string;
instance: IEditor;
setExpand: (expand: boolean) => void;
setJSONState: (content: any) => void;
}
export const useChatInputEditor = () => {
const [editor, getMarkdownContent, getJSONState, setExpand, setJSONState] = useChatInputStore(
(s) => [s.editor, s.getMarkdownContent, s.getJSONState, s.setExpand, s.setJSONState],
);
return useMemo<ChatInputEditor>(
() => ({
clearContent: () => {
editor?.cleanDocument();
},
focus: () => {
editor?.focus();
},
getJSONState,
getMarkdownContent,
instance: editor!,
setExpand,
setJSONState,
}),
[editor],
);
};
+7
View File
@@ -0,0 +1,7 @@
export type { ActionKey, ActionKeys } from './ActionBar/config';
export { ChatInputProvider } from './ChatInputProvider';
export { default as DesktopChatInput } from './Desktop';
export type { ChatInputEditor } from './hooks/useChatInputEditor';
export { useChatInputEditor } from './hooks/useChatInputEditor';
export { default as MobileChatInput } from './Mobile';
export type { SendButtonHandler } from './store/initialState';
+75
View File
@@ -0,0 +1,75 @@
import { StateCreator } from 'zustand/vanilla';
import { PublicState, State, initialState } from './initialState';
export interface Action {
getJSONState: () => any;
getMarkdownContent: () => string;
handleSendButton: () => void;
handleStop: () => void;
setExpand: (expend: boolean) => void;
setJSONState: (content: any) => void;
setShowTypoBar: (show: boolean) => void;
updateMarkdownContent: () => void;
}
export type Store = Action & State;
// const t = setNamespace('ChatInput');
type CreateStore = (
initState?: Partial<PublicState>,
) => StateCreator<Store, [['zustand/devtools', never]]>;
export const store: CreateStore = (publicState) => (set, get) => ({
...initialState,
...publicState,
getJSONState: () => {
return get().editor?.getDocument('json');
},
getMarkdownContent: () => {
return String(get().editor?.getDocument('markdown') || '').trimEnd();
},
handleSendButton: () => {
if (!get().editor) return;
const editor = get().editor;
get().onSend?.({
clearContent: () => editor?.cleanDocument(),
editor: editor!,
getMarkdownContent: get().getMarkdownContent,
});
},
handleStop: () => {
if (!get().editor) return;
get().sendButtonProps?.onStop?.({ editor: get().editor! });
},
setExpand: (expand) => {
set({ expand });
},
setJSONState: (content) => {
get().editor?.setDocument('json', content);
},
setShowTypoBar: (showTypoBar) => {
set({ showTypoBar });
},
updateMarkdownContent: () => {
if (!get().onMarkdownContentChange) return;
const content = get().getMarkdownContent();
if (content === get().markdownContent) return;
get().onMarkdownContentChange?.(content);
set({ markdownContent: content });
},
});
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { StoreApiWithSelector } from '@lobechat/types';
import { createContext } from 'zustand-utils';
import { subscribeWithSelector } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { Store, store } from './action';
import { State } from './initialState';
export type { PublicState, State } from './initialState';
export const createStore = (initState?: Partial<State>) =>
createWithEqualityFn(subscribeWithSelector(store(initState)), shallow);
export const {
useStore: useChatInputStore,
useStoreApi,
Provider,
} = createContext<StoreApiWithSelector<Store>>();
export { selectors } from './selectors';
@@ -0,0 +1,54 @@
import type { IEditor } from '@lobehub/editor';
import type { ChatInputProps } from '@lobehub/editor/react';
import type { MenuProps } from '@lobehub/ui/es/Menu';
import { ActionKeys } from '@/features/ChatInput';
export type SendButtonHandler = (params: {
clearContent: () => void;
editor: IEditor;
getMarkdownContent: () => string;
}) => Promise<void> | void;
export interface SendButtonProps {
disabled?: boolean;
generating: boolean;
onStop: (params: { editor: IEditor }) => void;
shape?: 'round' | 'default';
}
export const initialSendButtonState: SendButtonProps = {
disabled: false,
generating: false,
onStop: () => {},
};
export interface PublicState {
allowExpand?: boolean;
expand?: boolean;
leftActions: ActionKeys[];
mobile?: boolean;
onMarkdownContentChange?: (content: string) => void;
onSend?: SendButtonHandler;
rightActions: ActionKeys[];
sendButtonProps?: SendButtonProps;
sendMenu?: MenuProps;
showTypoBar?: boolean;
}
export interface State extends PublicState {
editor?: IEditor;
isContentEmpty: boolean;
markdownContent: string;
slashMenuRef: ChatInputProps['slashMenuRef'];
}
export const initialState: State = {
allowExpand: true,
expand: false,
isContentEmpty: false,
leftActions: [],
markdownContent: '',
rightActions: [],
slashMenuRef: { current: null },
};
@@ -0,0 +1,5 @@
import { SendButtonProps, State, initialSendButtonState } from './initialState';
export const selectors = {
sendButtonProps: (s: State): SendButtonProps => s.sendButtonProps || initialSendButtonState,
};
-102
View File
@@ -1,102 +0,0 @@
import { useAnalytics } from '@lobehub/analytics/react';
import { useCallback, useMemo } from 'react';
import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
import { getAgentStoreState } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { fileChatSelectors, useFileStore } from '@/store/file';
import { getUserStoreState } from '@/store/user';
import { SendMessageParams } from '@/types/message';
export type UseSendMessageParams = Pick<
SendMessageParams,
'onlyAddUserMessage' | 'isWelcomeQuestion'
>;
export const useSendMessage = () => {
const [sendMessage, updateInputMessage] = useChatStore((s) => [
s.sendMessage,
s.updateInputMessage,
]);
const { analytics } = useAnalytics();
const checkGeminiChineseWarning = useGeminiChineseWarning();
const clearChatUploadFileList = useFileStore((s) => s.clearChatUploadFileList);
const isUploadingFiles = useFileStore(fileChatSelectors.isUploadingFiles);
const isSendButtonDisabledByMessage = useChatStore(chatSelectors.isSendButtonDisabledByMessage);
const canSend = !isUploadingFiles && !isSendButtonDisabledByMessage;
const send = useCallback(async (params: UseSendMessageParams = {}) => {
const store = useChatStore.getState();
if (chatSelectors.isAIGenerating(store)) return;
// if uploading file or send button is disabled by message, then we should not send the message
const isUploadingFiles = fileChatSelectors.isUploadingFiles(useFileStore.getState());
const isSendButtonDisabledByMessage = chatSelectors.isSendButtonDisabledByMessage(
useChatStore.getState(),
);
const canSend = !isUploadingFiles && !isSendButtonDisabledByMessage;
if (!canSend) return;
const fileList = fileChatSelectors.chatUploadFileList(useFileStore.getState());
// if there is no message and no image, then we should not send the message
if (!store.inputMessage && fileList.length === 0) return;
// Check for Chinese text warning with Gemini model
const agentStore = getAgentStoreState();
const currentModel = agentSelectors.currentAgentModel(agentStore);
const shouldContinue = await checkGeminiChineseWarning({
model: currentModel,
prompt: store.inputMessage,
scenario: 'chat',
});
if (!shouldContinue) return;
sendMessage({
files: fileList,
message: store.inputMessage,
...params,
});
updateInputMessage('');
clearChatUploadFileList();
// 获取分析数据
const userStore = getUserStoreState();
// 直接使用现有数据结构判断消息类型
const hasImages = fileList.some((file) => file.file?.type?.startsWith('image'));
const messageType = fileList.length === 0 ? 'text' : hasImages ? 'image' : 'file';
analytics?.track({
name: 'send_message',
properties: {
chat_id: store.activeId || 'unknown',
current_topic: topicSelectors.currentActiveTopic(store)?.title || null,
has_attachments: fileList.length > 0,
history_message_count: chatSelectors.activeBaseChats(store).length,
message: store.inputMessage,
message_length: store.inputMessage.length,
message_type: messageType,
selected_model: agentSelectors.currentAgentModel(agentStore),
session_id: store.activeId || 'inbox', // 当前活跃的会话ID
user_id: userStore.user?.id || 'anonymous',
},
});
// const hasSystemRole = agentSelectors.hasSystemRole(useAgentStore.getState());
// const agentSetting = useAgentStore.getState().agentSettingInstance;
// // if there is a system role, then we need to use agent setting instance to autocomplete agent meta
// if (hasSystemRole && !!agentSetting) {
// agentSetting.autocompleteAllMeta();
// }
}, []);
return useMemo(() => ({ canSend, send }), [canSend]);
};
@@ -8,7 +8,7 @@ export const useStyles = createStyles(({ token, css, stylish, cx, responsive })
pointer-events: none;
position: absolute;
z-index: 1000;
z-index: 50;
inset-block-end: 16px;
inset-inline-end: 16px;
transform: translateY(16px);
@@ -3,7 +3,8 @@
import { Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
const useStyles = createStyles(({ css, prefixCls }) => ({
message: css`
@@ -30,7 +31,13 @@ const SkeletonList = memo<SkeletonListProps>(({ mobile }) => {
const { cx, styles } = useStyles();
return (
<Flexbox gap={24} padding={mobile ? 8 : 12} style={{ marginTop: 24 }}>
<WideScreenContainer
flex={1}
gap={24}
height={'100%'}
padding={mobile ? 8 : 12}
style={{ marginTop: 24 }}
>
<Skeleton
active
avatar={{ size: mobile ? 32 : 40 }}
@@ -45,7 +52,7 @@ const SkeletonList = memo<SkeletonListProps>(({ mobile }) => {
paragraph={{ width: mobile ? ['80%', '40%'] : ['50%', '30%'] }}
title={false}
/>
</Flexbox>
</WideScreenContainer>
);
});
export default SkeletonList;
@@ -1,13 +1,10 @@
'use client';
import { Icon } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { Loader2Icon } from 'lucide-react';
import React, { ReactNode, memo, useCallback, useEffect, useRef, useState } from 'react';
import { Center, Flexbox } from 'react-layout-kit';
import { ReactNode, forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { isServerMode } from '@/const/version';
import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
@@ -21,8 +18,17 @@ interface VirtualizedListProps {
mobile?: boolean;
}
const List = forwardRef(({ ...props }, ref) => {
return (
<Flexbox>
<WideScreenContainer id={'chatlist-list'} ref={ref} {...props} />
</Flexbox>
);
});
const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemContent }) => {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const prevDataLengthRef = useRef(dataSource.length);
const [atBottom, setAtBottom] = useState(true);
const [isScrolling, setIsScrolling] = useState(false);
@@ -32,71 +38,74 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
chatSelectors.isCurrentChatLoaded(s),
]);
useEffect(() => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
}
}, [id]);
const prevDataLengthRef = useRef(dataSource.length);
const getFollowOutput = useCallback(() => {
const newFollowOutput = dataSource.length > prevDataLengthRef.current ? 'auto' : false;
prevDataLengthRef.current = dataSource.length;
return newFollowOutput;
}, [dataSource.length]);
const theme = useTheme();
const scrollToBottom = useCallback(
(behavior: 'auto' | 'smooth' = 'smooth') => {
if (atBottom) return;
if (!virtuosoRef.current) return;
virtuosoRef.current.scrollToIndex({ align: 'end', behavior, index: 'LAST' });
},
[atBottom],
);
useEffect(() => {
scrollToBottom();
}, [id]);
// overscan should be 3 times the height of the window
const overscan = typeof window !== 'undefined' ? window.innerHeight * 3 : 0;
// first time loading or not loaded
if (isFirstLoading) return <SkeletonList mobile={mobile} />;
if (!isCurrentChatLoaded)
// use skeleton list when not loaded in server mode due to the loading duration is much longer than client mode
return isServerMode ? (
<SkeletonList mobile={mobile} />
) : (
// in client mode and switch page, using the center loading for smooth transition
<Center height={'100%'} width={'100%'}>
<Icon icon={Loader2Icon} size={32} spin style={{ color: theme.colorTextTertiary }} />
</Center>
);
if (isFirstLoading || !isCurrentChatLoaded) return <SkeletonList mobile={mobile} />;
return (
<VirtuosoContext value={virtuosoRef}>
<Flexbox height={'100%'}>
<Virtuoso
atBottomStateChange={setAtBottom}
atBottomThreshold={50 * (mobile ? 2 : 1)}
computeItemKey={(_, item) => item}
data={dataSource}
followOutput={getFollowOutput}
increaseViewportBy={overscan}
initialTopMostItemIndex={dataSource?.length - 1}
isScrolling={setIsScrolling}
itemContent={itemContent}
ref={virtuosoRef}
/>
<Virtuoso
atBottomStateChange={setAtBottom}
atBottomThreshold={50 * (mobile ? 2 : 1)}
components={{
List,
}}
computeItemKey={(_, item) => item}
data={dataSource}
followOutput={getFollowOutput}
increaseViewportBy={overscan}
initialTopMostItemIndex={dataSource?.length - 1}
isScrolling={setIsScrolling}
itemContent={itemContent}
ref={virtuosoRef}
/>
<WideScreenContainer
onChange={() => {
if (!atBottom) return;
setTimeout(scrollToBottom, 100);
}}
style={{
position: 'relative',
}}
>
<AutoScroll
atBottom={atBottom}
isScrolling={isScrolling}
onScrollToBottom={(type) => {
const virtuoso = virtuosoRef.current;
switch (type) {
case 'auto': {
virtuoso?.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
scrollToBottom();
break;
}
case 'click': {
virtuoso?.scrollToIndex({ align: 'end', behavior: 'smooth', index: 'LAST' });
scrollToBottom('smooth');
break;
}
}
}}
/>
</Flexbox>
</WideScreenContainer>
</VirtuosoContext>
);
});
@@ -0,0 +1,43 @@
'use client';
import { createStyles } from 'antd-style';
import { memo, useEffect } from 'react';
import { Flexbox, FlexboxProps } from 'react-layout-kit';
import { CONVERSATION_MIN_WIDTH } from '@/const/layoutTokens';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
const useStyles = createStyles(({ css, token }) => ({
container: css`
align-self: center;
transition: width 0.25s ${token.motionEaseInOut};
`,
}));
interface WideScreenContainerProps extends FlexboxProps {
onChange?: () => void;
}
const WideScreenContainer = memo<WideScreenContainerProps>(
({ children, className, onChange, ...rest }) => {
const { cx, styles } = useStyles();
const wideScreen = useGlobalStore(systemStatusSelectors.wideScreen);
useEffect(() => {
onChange?.();
}, [wideScreen]);
return (
<Flexbox
className={cx(styles.container, className)}
width={wideScreen ? '100%' : `min(${CONVERSATION_MIN_WIDTH}px, 100%)`}
{...rest}
>
{children}
</Flexbox>
);
},
);
export default WideScreenContainer;

Some files were not shown because too many files have changed in this diff Show More