mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ 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:
@@ -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": "ملخص التاريخ",
|
||||
|
||||
@@ -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": "تسطير"
|
||||
}
|
||||
}
|
||||
@@ -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": "Историческо резюме",
|
||||
|
||||
@@ -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": "Подчертаване"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": "خلاصه تاریخ",
|
||||
|
||||
@@ -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": "زیرخط"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "履歴の要約",
|
||||
|
||||
@@ -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": "下線"
|
||||
}
|
||||
}
|
||||
@@ -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": "역사 요약",
|
||||
|
||||
@@ -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": "밑줄"
|
||||
}
|
||||
}
|
||||
@@ -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는 동적 모델로, 최신 버전을 유지하기 위해 실시간으로 업데이트됩니다. 강력한 언어 이해 및 생성 능력을 결합하여 고객 서비스, 교육 및 기술 지원을 포함한 대규모 응용 프로그램에 적합합니다."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Историческое резюме",
|
||||
|
||||
@@ -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": "Подчёркнутый"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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ử",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "历史总结",
|
||||
|
||||
@@ -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": "下划线"
|
||||
}
|
||||
}
|
||||
@@ -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": "歷史總結",
|
||||
|
||||
@@ -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": "底線"
|
||||
}
|
||||
}
|
||||
@@ -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是一款動態模型,實時更新以保持當前最新版本。它結合了強大的語言理解與生成能力,適合於大規模應用場景,包括客戶服務、教育和技術支持。"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
-104
@@ -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;
|
||||
-40
@@ -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;
|
||||
-125
@@ -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
-2
@@ -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(() => {
|
||||
-332
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
-29
@@ -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;
|
||||
+130
-29
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
-33
@@ -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;
|
||||
-41
@@ -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;
|
||||
-156
@@ -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;
|
||||
-33
@@ -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;
|
||||
+44
-66
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+141
@@ -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],
|
||||
);
|
||||
};
|
||||
+7
-1
@@ -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} />;
|
||||
});
|
||||
|
||||
+3
-2
@@ -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%'}>
|
||||
|
||||
+3
-2
@@ -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}>
|
||||
|
||||
+18
-2
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+15
-4
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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[] | '---';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
+1
-2
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user