diff --git a/api/package.json b/api/package.json index bad538a5fc2a..fe65c599fa43 100644 --- a/api/package.json +++ b/api/package.json @@ -91,7 +91,7 @@ "multer": "^2.1.1", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", - "nodemailer": "^8.0.4", + "nodemailer": "^8.0.5", "ollama": "^0.5.0", "openai": "5.8.2", "openid-client": "^6.5.0", diff --git a/client/src/locales/ar/translation.json b/client/src/locales/ar/translation.json index bdcabc403737..241ffaa8d089 100644 --- a/client/src/locales/ar/translation.json +++ b/client/src/locales/ar/translation.json @@ -452,7 +452,6 @@ "com_ui_authentication": "المصادقة", "com_ui_avatar": "الصورة الرمزية", "com_ui_back": "عودة", - "com_ui_back_to_chat": "العودة إلى الدردشة", "com_ui_back_to_prompts": "العودة إلى الأوامر", "com_ui_bookmark_delete_confirm": "هل أنت متأكد أنك تريد حذف هذه الإشارة المرجعية؟", "com_ui_bookmarks": "الإشارات المرجعية", @@ -494,7 +493,6 @@ "com_ui_create_link": "إنشاء رابط", "com_ui_create_prompt": "إنشاء أمر", "com_ui_custom_prompt_mode": "وضع الأمر المخصص", - "com_ui_dashboard": "لوحة التحكم", "com_ui_date": "تاريخ", "com_ui_date_april": "أبريل", "com_ui_date_august": "أغسطس", @@ -631,7 +629,6 @@ "com_ui_rename": "إعادة تسمية", "com_ui_rename_conversation": "إعادة تسمية المحادثة", "com_ui_rename_failed": "فشل في إعادة تسمية المحادثة", - "com_ui_rename_prompt": "إعادة تسمية الأمر", "com_ui_requires_auth": "يتطلب مصادقة", "com_ui_reset_var": "إعادة تعيين {{0}}", "com_ui_reset_zoom": "إعادة تعيين التقريب", diff --git a/client/src/locales/ca/translation.json b/client/src/locales/ca/translation.json index b4c0e0f7e93c..b71740a9493a 100644 --- a/client/src/locales/ca/translation.json +++ b/client/src/locales/ca/translation.json @@ -506,7 +506,6 @@ "com_ui_authentication_type": "Tipus d'autenticació", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", - "com_ui_back_to_chat": "Torna al xat", "com_ui_back_to_prompts": "Torna als prompts", "com_ui_backup_codes": "Codis de recuperació", "com_ui_backup_codes_regenerate_error": "S'ha produït un error en regenerar els codis de recuperació", @@ -568,7 +567,6 @@ "com_ui_custom": "Personalitzat", "com_ui_custom_header_name": "Nom de capçalera personalitzat", "com_ui_custom_prompt_mode": "Mode de prompt personalitzat", - "com_ui_dashboard": "Tauler", "com_ui_date": "Data", "com_ui_date_april": "Abril", "com_ui_date_august": "Agost", @@ -661,7 +659,6 @@ "com_ui_fork_visible": "Només missatges visibles", "com_ui_generate_qrcode": "Genera codi QR", "com_ui_generating": "Generant...", - "com_ui_global_group": "Cal afegir alguna cosa aquí. Estava buit", "com_ui_go_back": "Torna enrere", "com_ui_go_to_conversation": "Ves a la conversa", "com_ui_good_afternoon": "Bona tarda", @@ -745,7 +742,6 @@ "com_ui_rename": "Reanomena", "com_ui_rename_conversation": "Reanomena la conversa", "com_ui_rename_failed": "No s'ha pogut reanomenar la conversa", - "com_ui_rename_prompt": "Reanomena el prompt", "com_ui_requires_auth": "Requereix autenticació", "com_ui_reset_var": "Reinicia {{0}}", "com_ui_result": "Resultat", diff --git a/client/src/locales/cs/translation.json b/client/src/locales/cs/translation.json index 050773a4c44e..f8658f8dab86 100644 --- a/client/src/locales/cs/translation.json +++ b/client/src/locales/cs/translation.json @@ -389,7 +389,6 @@ "com_ui_authentication_type": "Typ ověření", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", - "com_ui_back_to_chat": "Zpět do chatu", "com_ui_back_to_prompts": "Zpět na výzvy", "com_ui_backup_codes": "Záložní kódy", "com_ui_backup_codes_regenerate_error": "Při generování záložních kódů došlo k chybě", @@ -446,7 +445,6 @@ "com_ui_custom": "Vlastní", "com_ui_custom_header_name": "Vlastní název hlavičky", "com_ui_custom_prompt_mode": "Režim vlastní výzvy", - "com_ui_dashboard": "Dashboard", "com_ui_date": "Datum", "com_ui_date_april": "Duben", "com_ui_date_august": "Srpen", @@ -533,7 +531,6 @@ "com_ui_fork_visible": "Pouze viditelné zprávy", "com_ui_generate_qrcode": "Generovat QR kód", "com_ui_generating": "Generuji...", - "com_ui_global_group": "něco sem musí přijít. bylo prázdné", "com_ui_go_back": "Zpět", "com_ui_go_to_conversation": "Přejít na konverzaci", "com_ui_happy_birthday": "Mám 1. narozeniny!", @@ -605,7 +602,6 @@ "com_ui_regenerating": "Generuji znovu...", "com_ui_region": "Oblast", "com_ui_rename": "Přejmenovat", - "com_ui_rename_prompt": "Přejmenovat výzvu", "com_ui_requires_auth": "Vyžaduje ověření", "com_ui_reset_var": "Obnovit {{0}}", "com_ui_result": "Výsledek", diff --git a/client/src/locales/da/translation.json b/client/src/locales/da/translation.json index 5b165bd680cb..57c1943500e3 100644 --- a/client/src/locales/da/translation.json +++ b/client/src/locales/da/translation.json @@ -529,7 +529,6 @@ "com_ui_authentication_type": "Godkendelsestype", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", - "com_ui_back_to_chat": "Tilbage til chat", "com_ui_back_to_prompts": "Tilbage til Prompts", "com_ui_backup_codes": "Backupkoder", "com_ui_backup_codes_regenerate_error": "Der opstod en fejl ved genskabelse af backup-koder", @@ -589,7 +588,6 @@ "com_ui_custom": "Brugerdefineret", "com_ui_custom_header_name": "Brugerdefineret overskriftsnavn", "com_ui_custom_prompt_mode": "Brugerdefineret prompttilstand", - "com_ui_dashboard": "Dashboard", "com_ui_date": "Dato", "com_ui_date_april": "April", "com_ui_date_august": "August", @@ -684,7 +682,6 @@ "com_ui_generate_qrcode": "Generer QR-kode", "com_ui_generating": "Genererer...", "com_ui_getting_started": "Kom godt i gang", - "com_ui_global_group": "Der skal ske noget her. Det var tomt.", "com_ui_go_back": "Gå tilbage", "com_ui_go_to_conversation": "Gå til samtale", "com_ui_good_afternoon": "God eftermiddag", @@ -767,7 +764,6 @@ "com_ui_rename": "Omdøb", "com_ui_rename_conversation": "Omdøb samtalen", "com_ui_rename_failed": "Kunne ikke omdøbe samtalen", - "com_ui_rename_prompt": "Omdøb prompt", "com_ui_requires_auth": "Kræver godkendelse", "com_ui_reset_var": "Nulstil {{0}}", "com_ui_result": "Resultat", diff --git a/client/src/locales/de/translation.json b/client/src/locales/de/translation.json index bff178b28ac3..d3da2fc72002 100644 --- a/client/src/locales/de/translation.json +++ b/client/src/locales/de/translation.json @@ -588,7 +588,6 @@ "com_nav_theme_dark": "Dunkel", "com_nav_theme_light": "Hell", "com_nav_theme_system": "System", - "com_nav_toggle_sidebar": "Seitenleiste umschalten", "com_nav_tool_dialog": "Assistenten-Werkzeuge", "com_nav_tool_dialog_agents": "Agent-Tools", "com_nav_tool_dialog_description": "Agent muss gespeichert werden, um Werkzeugauswahlen zu speichern.", @@ -761,7 +760,6 @@ "com_ui_azure_ad": "Entra ID", "com_ui_back": "Zurück", "com_ui_back_to_builder": "Zurück zum Ersteller", - "com_ui_back_to_chat": "Zurück zum Chat", "com_ui_back_to_prompts": "Zurück zu den Prompts", "com_ui_backup_code_number": "Code #{{number}}", "com_ui_backup_codes": "Backup-Codes", @@ -874,7 +872,6 @@ "com_ui_custom_header_name": "Benutzerdefinierter Headername", "com_ui_custom_prompt_mode": "Benutzerdefinierter Promptmodus für Artefakte", "com_ui_dark_theme_enabled": "Dunkles Design aktiviert", - "com_ui_dashboard": "Dashboard", "com_ui_date": "Datum", "com_ui_date_april": "April", "com_ui_date_august": "August", @@ -913,7 +910,6 @@ "com_ui_delete_not_allowed": "Löschvorgang ist nicht erlaubt", "com_ui_delete_preset": "Voreinstellung löschen?", "com_ui_delete_prompt": "Prompt löschen?", - "com_ui_delete_prompt_name": "Prompt löschen - {{name}}", "com_ui_delete_shared_link": "Geteilten Link löschen?", "com_ui_delete_shared_link_heading": "Geteilten Link löschen", "com_ui_delete_success": "Erfolgreich gelöscht", @@ -1034,7 +1030,6 @@ "com_ui_generating": "Generiere …", "com_ui_generation_settings": "Einstellungen für die Generierung", "com_ui_getting_started": "Erste Schritte\n", - "com_ui_global_group": "Leer – etwas fehlt noch", "com_ui_go_back": "Zurück", "com_ui_go_to_conversation": "Zur Konversation gehen", "com_ui_good_afternoon": "Guten Nachmittag", @@ -1290,8 +1285,6 @@ "com_ui_rename": "Umbenennen", "com_ui_rename_conversation": "Chat umbenennen", "com_ui_rename_failed": "Chat konnte nicht umbenannt werden.", - "com_ui_rename_prompt": "Prompt umbenennen", - "com_ui_rename_prompt_name": "Prompt umbenennen - {{name}}", "com_ui_requires_auth": "Authentifizierung erforderlich", "com_ui_reset": "Zurücksetzen", "com_ui_reset_adjustments": "Anpassungen zurücksetzen", diff --git a/client/src/locales/es/translation.json b/client/src/locales/es/translation.json index 3d0043b971fb..1f7dd816a5a8 100644 --- a/client/src/locales/es/translation.json +++ b/client/src/locales/es/translation.json @@ -536,7 +536,6 @@ "com_ui_auth_type": "Tipo de autorización", "com_ui_authentication": "Autenticación", "com_ui_avatar": "Avatar", - "com_ui_back_to_chat": "Volver al Chat", "com_ui_back_to_prompts": "Volver a las Indicaciones", "com_ui_bookmark_delete_confirm": "¿Está seguro de que desea eliminar este marcador?", "com_ui_bookmarks": "Marcadores", @@ -579,7 +578,6 @@ "com_ui_create_link": "Crear enlace", "com_ui_create_prompt": "Crear Indicación", "com_ui_custom_prompt_mode": "Modo de Indicación Personalizado", - "com_ui_dashboard": "Panel de control", "com_ui_date": "Fecha", "com_ui_date_april": "Abril", "com_ui_date_august": "Agosto", @@ -727,7 +725,6 @@ "com_ui_region": "Región", "com_ui_rename": "Renombrar", "com_ui_rename_conversation": "Renombrar conversación", - "com_ui_rename_prompt": "Renombrar indicación", "com_ui_reset_var": "Restablecer {{0}}", "com_ui_result": "Resultado", "com_ui_revoke": "Revocar", diff --git a/client/src/locales/et/translation.json b/client/src/locales/et/translation.json index 0e7af3f61396..0fe33299b4b0 100644 --- a/client/src/locales/et/translation.json +++ b/client/src/locales/et/translation.json @@ -530,7 +530,6 @@ "com_ui_authentication_type": "Autentimise tüüp", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", - "com_ui_back_to_chat": "Tagasi vestlusesse", "com_ui_back_to_prompts": "Tagasi sisendite juurde", "com_ui_backup_codes": "Varukoodid", "com_ui_backup_codes_regenerate_error": "Varukoodide loomisel tekkis viga", @@ -591,7 +590,6 @@ "com_ui_custom": "Kohandatud", "com_ui_custom_header_name": "Kohandatud päise nimi", "com_ui_custom_prompt_mode": "Kohandatud viibarežiim", - "com_ui_dashboard": "Armatuurlaud", "com_ui_date": "Kuupäev", "com_ui_date_april": "Aprill", "com_ui_date_august": "August", @@ -702,7 +700,6 @@ "com_ui_generate_qrcode": "Loo QR-kood", "com_ui_generating": "Loomine...", "com_ui_getting_started": "Genereerimise seaded", - "com_ui_global_group": "Ülene grupp", "com_ui_go_back": "Mine tagasi", "com_ui_go_to_conversation": "Mine vestlusesse", "com_ui_good_afternoon": "Tere pärastlõunast", @@ -788,7 +785,6 @@ "com_ui_rename": "Nimeta ümber", "com_ui_rename_conversation": "Nimeta vestlus ümber", "com_ui_rename_failed": "Ei õnnestunud vestlust ümber nimetada", - "com_ui_rename_prompt": "Nimeta sisend ümber", "com_ui_requires_auth": "Vajab autentimist", "com_ui_reset_var": "Lähtesta {{0}}", "com_ui_result": "Tulemus", diff --git a/client/src/locales/fa/translation.json b/client/src/locales/fa/translation.json index d8178cf251ac..9f77d18a2947 100644 --- a/client/src/locales/fa/translation.json +++ b/client/src/locales/fa/translation.json @@ -584,7 +584,6 @@ "com_nav_theme_dark": "تاریک", "com_nav_theme_light": "نور", "com_nav_theme_system": "سیستم", - "com_nav_toggle_sidebar": "تغییر وضعیت نوار کناری", "com_nav_tool_dialog": "ابزارهای دستیار", "com_nav_tool_dialog_agents": "ابزار کارگزار", "com_nav_tool_dialog_description": "برای تداوم انتخاب ابزار، دستیار باید ذخیره شود.", @@ -754,7 +753,6 @@ "com_ui_azure_ad": "شناسه Entra", "com_ui_back": "بازگشت", "com_ui_back_to_builder": "بازگشت به سازنده", - "com_ui_back_to_chat": "بازگشت به چت", "com_ui_back_to_prompts": "بازگشت به Prompts", "com_ui_backup_code_number": "کد #{{number}}", "com_ui_backup_codes": "کدهای پشتیبان", @@ -864,7 +862,6 @@ "com_ui_custom_header_name": "نام هدر سفارشی", "com_ui_custom_prompt_mode": "حالت درخواست سفارشی", "com_ui_dark_theme_enabled": "تم تاریک فعال شد", - "com_ui_dashboard": "داشبورد", "com_ui_date": "تاریخ", "com_ui_date_april": "آوریل", "com_ui_date_august": "مرداد", @@ -901,7 +898,6 @@ "com_ui_delete_not_allowed": "عملیات حذف مجاز نیست", "com_ui_delete_preset": "حذف پیش‌تنظیم؟", "com_ui_delete_prompt": "درخواست حذف شود؟", - "com_ui_delete_prompt_name": "حذف پرامپت - {{name}}", "com_ui_delete_shared_link": "پیوند مشترک حذف شود؟", "com_ui_delete_shared_link_heading": "حذف لینک اشتراک‌گذاری", "com_ui_delete_success": "با موفقیت حذف شد", @@ -1021,7 +1017,6 @@ "com_ui_generating": "در حال تولید...", "com_ui_generation_settings": "تنظیمات تولید", "com_ui_getting_started": "شروع کار", - "com_ui_global_group": "چیزی باید به اینجا برود خالی بود", "com_ui_go_back": "به عقب برگرد", "com_ui_go_to_conversation": "به گفتگو بروید", "com_ui_good_afternoon": "ظهر بخیر", @@ -1277,8 +1272,6 @@ "com_ui_rename": "تغییر نام دهید", "com_ui_rename_conversation": "تغییر نام مکالمه", "com_ui_rename_failed": "تغییر نام مکالمه ناموفق بود", - "com_ui_rename_prompt": "تغییر نام درخواست", - "com_ui_rename_prompt_name": "تغییر نام پرامپت - {{name}}", "com_ui_requires_auth": "نیاز به احراز هویت", "com_ui_reset": "بازنشانی", "com_ui_reset_adjustments": "بازنشانی تنظیمات", diff --git a/client/src/locales/fi/translation.json b/client/src/locales/fi/translation.json index bc100e69f7dc..7feeedaaa55e 100644 --- a/client/src/locales/fi/translation.json +++ b/client/src/locales/fi/translation.json @@ -390,7 +390,6 @@ "com_ui_attachment": "Liitetiedosto", "com_ui_authentication": "Autentikointi", "com_ui_avatar": "Profiilikuva", - "com_ui_back_to_chat": "Palaa keskusteluun", "com_ui_back_to_prompts": "Palaa syötteisiin", "com_ui_bookmark_delete_confirm": "Oletko varma, että haluat poistaa tämän kirjanmerkin?", "com_ui_bookmarks": "Kirjanmerkit", @@ -422,7 +421,6 @@ "com_ui_create": "Luo", "com_ui_create_link": "Luo linkki", "com_ui_create_prompt": "Luo syöte", - "com_ui_dashboard": "Työpöytä", "com_ui_date": "Päivämäärä", "com_ui_date_april": "Huhtikuu", "com_ui_date_august": "Elokuu", diff --git a/client/src/locales/fr/translation.json b/client/src/locales/fr/translation.json index ebee73222c8f..2be405701798 100644 --- a/client/src/locales/fr/translation.json +++ b/client/src/locales/fr/translation.json @@ -692,7 +692,6 @@ "com_ui_azure_ad": "Entra ID", "com_ui_back": "Retour", "com_ui_back_to_builder": "Retour au constructeur", - "com_ui_back_to_chat": "Retour à la discussion", "com_ui_back_to_prompts": "Retour aux Prompts", "com_ui_backup_code_number": "Code #{{number}}", "com_ui_backup_codes": "Codes de sauvegarde", @@ -773,7 +772,6 @@ "com_ui_custom": "Personnalisé", "com_ui_custom_header_name": "Nom d'en-tête personnalisé", "com_ui_custom_prompt_mode": "Mode de prompt personnalisé", - "com_ui_dashboard": "Tableau de bord", "com_ui_date": "Date", "com_ui_date_april": "Avril", "com_ui_date_august": "Août", @@ -907,7 +905,6 @@ "com_ui_generating": "Génération en cours...", "com_ui_generation_settings": "Réglages de génération", "com_ui_getting_started": "Commencer", - "com_ui_global_group": "Groupe global", "com_ui_go_back": "Revenir en arrière", "com_ui_go_to_conversation": "Aller à la conversation", "com_ui_good_afternoon": "Bon après-midi", @@ -1071,7 +1068,6 @@ "com_ui_rename": "Renommer", "com_ui_rename_conversation": "Renommer la conversation", "com_ui_rename_failed": "Le changement de nom de la conversation a échoué", - "com_ui_rename_prompt": "Renommer le message", "com_ui_requires_auth": "Nécessite une authentification", "com_ui_reset": "Réinitialiser", "com_ui_reset_adjustments": "Réinitialiser les ajustements", diff --git a/client/src/locales/he/translation.json b/client/src/locales/he/translation.json index 3bfc3dbcbabe..165fd806e22d 100644 --- a/client/src/locales/he/translation.json +++ b/client/src/locales/he/translation.json @@ -689,7 +689,6 @@ "com_ui_azure": "אז'ור (Azure)", "com_ui_azure_ad": "שירות ניהול זהויות וגישה של מיקרוסופט (Entra ID)", "com_ui_back": "חזור", - "com_ui_back_to_chat": "חזור לצ'אט", "com_ui_back_to_prompts": "חזור להנחיות (פרומפטים)", "com_ui_backup_code_number": "קוד {{number}}#", "com_ui_backup_codes": "קודי גיבוי", @@ -764,7 +763,6 @@ "com_ui_custom": "מותאם אישית", "com_ui_custom_header_name": "שם כותרת מותאם אישית", "com_ui_custom_prompt_mode": "מצב הנחיה (פרומפט) מותאם אישית", - "com_ui_dashboard": "לוח מחוונים", "com_ui_date": "תאריך", "com_ui_date_april": "אפריל", "com_ui_date_august": "אוגוסט", @@ -897,7 +895,6 @@ "com_ui_generating": "יוצר...", "com_ui_generation_settings": "הגדרות יצירה", "com_ui_getting_started": "תחילת העבודה", - "com_ui_global_group": "שדה זה לא יכול להישאר ריק", "com_ui_go_back": "חזור", "com_ui_go_to_conversation": "חזור לצ'אט", "com_ui_good_afternoon": "צהריים טובים", @@ -1061,7 +1058,6 @@ "com_ui_rename": "שנה שם", "com_ui_rename_conversation": "החלפת שם הצ'אט", "com_ui_rename_failed": "החלפת שם הצ'אט נכשלה", - "com_ui_rename_prompt": "שנה שם הנחיה (פרומפט)", "com_ui_requires_auth": "נדרש אימות", "com_ui_reset": "אתחול", "com_ui_reset_adjustments": "איפוס התאמות", diff --git a/client/src/locales/hu/translation.json b/client/src/locales/hu/translation.json index 92ac8179e33e..3ebd072536c7 100644 --- a/client/src/locales/hu/translation.json +++ b/client/src/locales/hu/translation.json @@ -477,7 +477,6 @@ "com_ui_authentication_type": "Hitelesítési típus", "com_ui_avatar": "Avatár", "com_ui_azure": "Azure", - "com_ui_back_to_chat": "Vissza a csevegéshez", "com_ui_back_to_prompts": "Vissza a promptokhoz", "com_ui_backup_codes": "Biztonsági mentési kódok", "com_ui_backup_codes_regenerate_error": "Hiba történt a biztonsági mentési kódok újragenerálása során", @@ -534,7 +533,6 @@ "com_ui_custom": "Egyéni", "com_ui_custom_header_name": "Egyéni fejléc neve", "com_ui_custom_prompt_mode": "Egyéni prompt mód", - "com_ui_dashboard": "Irányítópult", "com_ui_date": "Dátum", "com_ui_date_april": "Április", "com_ui_date_august": "Augusztus", @@ -622,7 +620,6 @@ "com_ui_fork_visible": "Csak látható üzenetek", "com_ui_generate_qrcode": "QR-kód generálása", "com_ui_generating": "Generálás...", - "com_ui_global_group": "valaminek itt kell lennie. üres volt", "com_ui_go_back": "Vissza", "com_ui_go_to_conversation": "Ugrás a beszélgetéshez", "com_ui_good_afternoon": "Jó délutánt", @@ -699,7 +696,6 @@ "com_ui_regenerating": "Újragenerálás...", "com_ui_region": "Régió", "com_ui_rename": "Átnevezés", - "com_ui_rename_prompt": "Prompt átnevezése", "com_ui_requires_auth": "Hitelesítést igényel", "com_ui_reset_var": "{{0}} visszaállítása", "com_ui_result": "Eredmény", diff --git a/client/src/locales/it/translation.json b/client/src/locales/it/translation.json index 8b419f52f3b5..c609de2cc722 100644 --- a/client/src/locales/it/translation.json +++ b/client/src/locales/it/translation.json @@ -580,7 +580,6 @@ "com_nav_theme_dark": "Scuro", "com_nav_theme_light": "Chiaro", "com_nav_theme_system": "Sistema", - "com_nav_toggle_sidebar": "Alterna la barra laterale", "com_nav_tool_dialog": "Strumenti Assistente", "com_nav_tool_dialog_agents": "Strumenti Agente", "com_nav_tool_dialog_description": "L'Assistente deve essere salvato per conservare le selezioni degli strumenti.", @@ -750,7 +749,6 @@ "com_ui_azure_ad": "ID Entra", "com_ui_back": "Indietro", "com_ui_back_to_builder": "Torna al costruttore", - "com_ui_back_to_chat": "Torna alla Chat", "com_ui_back_to_prompts": "Torna ai prompt", "com_ui_backup_code_number": "Codice #{{number}}", "com_ui_backup_codes": "Codici di backup", @@ -863,7 +861,6 @@ "com_ui_custom_header_name": "Nome intestazione personalizzato", "com_ui_custom_prompt_mode": "Modalità Prompt Personalizzato", "com_ui_dark_theme_enabled": "Tema scuro abilitato", - "com_ui_dashboard": "Pannello di controllo", "com_ui_date": "Data", "com_ui_date_april": "Aprile", "com_ui_date_august": "Agosto", @@ -902,7 +899,6 @@ "com_ui_delete_not_allowed": "L'operazione di cancellazione non è consentita", "com_ui_delete_preset": "Cancellare la preimpostazione?", "com_ui_delete_prompt": "Eliminare il prompt?", - "com_ui_delete_prompt_name": "Cancellare il Prompt - {{name}}", "com_ui_delete_shared_link": "Eliminare il link condiviso?", "com_ui_delete_shared_link_heading": "Eliminare il collegamento condiviso", "com_ui_delete_success": "Eliminato con successo", @@ -1025,7 +1021,6 @@ "com_ui_generating": "Generazione in corso...", "com_ui_generation_settings": "Impostazioni di generazione", "com_ui_getting_started": "Come iniziare", - "com_ui_global_group": "Qui ci vuole qualcosa. era vuoto", "com_ui_go_back": "Torna indietro", "com_ui_go_to_conversation": "Vai alla conversazione", "com_ui_good_afternoon": "Buon pomeriggio", @@ -1281,8 +1276,6 @@ "com_ui_rename": "Rinomina", "com_ui_rename_conversation": "Rinominare la conversazione", "com_ui_rename_failed": "Impossibile rinominare la conversazione", - "com_ui_rename_prompt": "Rinomina Prompt", - "com_ui_rename_prompt_name": "Rinominare il prompt - {{name}}", "com_ui_requires_auth": "Richiede autenticazione", "com_ui_reset": "Reset", "com_ui_reset_adjustments": "Regolazioni di reset", diff --git a/client/src/locales/ja/translation.json b/client/src/locales/ja/translation.json index c5636ead80d3..2d13061f6367 100644 --- a/client/src/locales/ja/translation.json +++ b/client/src/locales/ja/translation.json @@ -586,7 +586,6 @@ "com_nav_theme_dark": "ダーク", "com_nav_theme_light": "ライト", "com_nav_theme_system": "システム", - "com_nav_toggle_sidebar": "サイドバーの切り替え", "com_nav_tool_dialog": "アシスタントツール", "com_nav_tool_dialog_agents": "エージェントツール", "com_nav_tool_dialog_description": "ツールの選択を維持するには、アシスタントを保存する必要があります。", @@ -758,7 +757,6 @@ "com_ui_azure_ad": "Entra ID", "com_ui_back": "戻る", "com_ui_back_to_builder": "ビルダーに戻る", - "com_ui_back_to_chat": "チャットに戻る", "com_ui_back_to_prompts": "プロンプトに戻る", "com_ui_backup_code_number": "コード番号{{number}}", "com_ui_backup_codes": "バックアップコード", @@ -870,7 +868,6 @@ "com_ui_custom_header_name": "カスタムヘッダー名", "com_ui_custom_prompt_mode": "カスタムプロンプトモード", "com_ui_dark_theme_enabled": "ダークテーマを有効にする", - "com_ui_dashboard": "ダッシュボード", "com_ui_date": "日付", "com_ui_date_april": "4月", "com_ui_date_august": "8月", @@ -908,7 +905,6 @@ "com_ui_delete_not_allowed": "削除操作は許可されていません", "com_ui_delete_preset": "プリセットを削除しますか?", "com_ui_delete_prompt": "プロンプトを消しますか?", - "com_ui_delete_prompt_name": "プロンプトの削除 - {{name}}", "com_ui_delete_shared_link": "共有リンクを削除しますか?", "com_ui_delete_shared_link_heading": "共有リンクを削除", "com_ui_delete_success": "削除に成功", @@ -1029,7 +1025,6 @@ "com_ui_generating": "生成中...", "com_ui_generation_settings": "生成設定", "com_ui_getting_started": "はじめに", - "com_ui_global_group": "ここに何かを入れる必要があります。空でした", "com_ui_go_back": "戻る", "com_ui_go_to_conversation": "会話に移動する", "com_ui_good_afternoon": "こんにちは", @@ -1281,8 +1276,6 @@ "com_ui_rename": "タイトル変更", "com_ui_rename_conversation": "会話の名前を変更する", "com_ui_rename_failed": "会話の名前を変更できませんでした", - "com_ui_rename_prompt": "プロンプトの名前を変更します", - "com_ui_rename_prompt_name": "プロンプトの名前変更 - {{name}}", "com_ui_requires_auth": "認証が必要です", "com_ui_reset": "リセット", "com_ui_reset_adjustments": "調整をリセットする", diff --git a/client/src/locales/ko/translation.json b/client/src/locales/ko/translation.json index b4f34756d2ef..38401bafd0a6 100644 --- a/client/src/locales/ko/translation.json +++ b/client/src/locales/ko/translation.json @@ -572,7 +572,6 @@ "com_ui_avatar": "프로필 사진", "com_ui_azure": "Azure", "com_ui_back": "뒤로", - "com_ui_back_to_chat": "채팅으로 돌아가기", "com_ui_back_to_prompts": "프롬프트로 돌아가기", "com_ui_backup_codes": "백업 코드", "com_ui_backup_codes_regenerate_error": "백업 코드 재생성 중 오류 발생", @@ -643,7 +642,6 @@ "com_ui_custom": "사용자 지정", "com_ui_custom_header_name": "사용자 지정 헤더 이름", "com_ui_custom_prompt_mode": "사용자 지정 프롬프트 모드", - "com_ui_dashboard": "대시보드", "com_ui_date": "날짜", "com_ui_date_april": "4월", "com_ui_date_august": "8월", @@ -771,7 +769,6 @@ "com_ui_generating": "생성 중...", "com_ui_generation_settings": "출력 설정", "com_ui_getting_started": "시작하기", - "com_ui_global_group": "내용이 비어 있었습니다.", "com_ui_go_back": "뒤로가기", "com_ui_go_to_conversation": "대화로 이동", "com_ui_good_afternoon": "좋은 오후입니다", @@ -908,7 +905,6 @@ "com_ui_rename": "이름 바꾸기", "com_ui_rename_conversation": "대화 이름 변경", "com_ui_rename_failed": "대화 이름 변경 실패", - "com_ui_rename_prompt": "프롬프트 이름 변경", "com_ui_requires_auth": "인증이 필요합니다", "com_ui_reset_var": "{{0}} 초기화", "com_ui_reset_zoom": "초기화", diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 79e37b3eba2c..d9496cb00505 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -595,7 +595,6 @@ "com_nav_theme_dark": "Tumšs", "com_nav_theme_light": "Gaišs", "com_nav_theme_system": "Sistēmas uzstādījums", - "com_nav_toggle_sidebar": "Pārslēgt sānu joslu", "com_nav_tool_dialog": "Asistenta rīki", "com_nav_tool_dialog_agents": "Aģenta rīki", "com_nav_tool_dialog_description": "Lai saglabātu rīku atlasi, ir jāsaglabā asistents.", @@ -770,7 +769,6 @@ "com_ui_azure_ad": "Azure Entra ID", "com_ui_back": "Atpakaļ", "com_ui_back_to_builder": "Atpakaļ uz veidotāju", - "com_ui_back_to_chat": "Atpakaļ uz sarunu", "com_ui_back_to_prompts": "Atpakaļ pie uzvednēm", "com_ui_backup_code_number": "Kods #{{number}}", "com_ui_backup_codes": "Rezerves kodi", @@ -889,7 +887,6 @@ "com_ui_custom_header_name": "Pielāgota galvenes nosaukums", "com_ui_custom_prompt_mode": "Pielāgots uzvednes režīms", "com_ui_dark_theme_enabled": "Ieslēgta tumšā tēma", - "com_ui_dashboard": "Informācijas panelis", "com_ui_date": "Datums", "com_ui_date_april": "Aprīlis", "com_ui_date_august": "Augusts", @@ -928,7 +925,6 @@ "com_ui_delete_not_allowed": "Dzēšanas darbība nav atļauta", "com_ui_delete_preset": "Vai dzēst iestatījumu?", "com_ui_delete_prompt": "Vai dzēst uzvedni?", - "com_ui_delete_prompt_name": "Dzēst uzvedni - {{name}}", "com_ui_delete_shared_link": "Vai dzēst kopīgoto saiti?", "com_ui_delete_shared_link_heading": "Dzēst koplietoto saiti", "com_ui_delete_success": "Veiksmīgi dzēsts", @@ -1056,7 +1052,6 @@ "com_ui_generating_image": "Attēla ģenerēšana...", "com_ui_generation_settings": "Ģenerēšanas iestatījumi", "com_ui_getting_started": "Darba sākšana", - "com_ui_global_group": "Globālā uzvedne", "com_ui_go_back": "Atgriezties", "com_ui_go_to_conversation": "Doties uz sarunu", "com_ui_good_afternoon": "Labdien", @@ -1290,7 +1285,6 @@ "com_ui_prompt_name": "Uzvednes nosaukums", "com_ui_prompt_name_required": "Uzvednes nosaukums ir obligāts", "com_ui_prompt_preview_not_shared": "Autors nav atļāvis sadarbību šajā uzvednē.", - "com_ui_prompt_renamed": "Uzvedne veiksmīgi pārdēvēta", "com_ui_prompt_text": "Teksts", "com_ui_prompt_text_required": "Teksts ir obligāts", "com_ui_prompt_update_error": "Atjauninot uzvedni, radās kļūda.", @@ -1334,8 +1328,6 @@ "com_ui_rename": "Pārdēvēt", "com_ui_rename_conversation": "Pārdēvēt sarunu", "com_ui_rename_failed": "Neizdevās pārdēvēt sarunu", - "com_ui_rename_prompt": "Pārdēvēt uzvedni", - "com_ui_rename_prompt_name": "Pārdēvēt uzvedni - {{name}}", "com_ui_requires_auth": "Nepieciešama autentifikācija", "com_ui_reset": "Attiestatīt", "com_ui_reset_adjustments": "Atiestatīt korekcijas", diff --git a/client/src/locales/nb/translation.json b/client/src/locales/nb/translation.json index a865e6c57a08..92b672d0f2e8 100644 --- a/client/src/locales/nb/translation.json +++ b/client/src/locales/nb/translation.json @@ -584,7 +584,6 @@ "com_nav_theme_dark": "Mørkt", "com_nav_theme_light": "Lyst", "com_nav_theme_system": "System", - "com_nav_toggle_sidebar": "Skru sidebar av/på", "com_nav_tool_dialog": "Assistentverktøy", "com_nav_tool_dialog_agents": "Agentverktøy", "com_nav_tool_dialog_description": "Assistenten må lagres for at verktøyvalg skal vedvare.", @@ -754,7 +753,6 @@ "com_ui_azure_ad": "Entra ID", "com_ui_back": "Tilbake", "com_ui_back_to_builder": "Tilbake til bygger", - "com_ui_back_to_chat": "Tilbake til samtale", "com_ui_back_to_prompts": "Tilbake til prompter", "com_ui_backup_code_number": "Kode #{{number}}", "com_ui_backup_codes": "Reservekoder", @@ -866,7 +864,6 @@ "com_ui_custom_header_name": "Egendefinert overskriftsnavn", "com_ui_custom_prompt_mode": "Egendefinert prompt-modus", "com_ui_dark_theme_enabled": "Mørkt tema aktivert", - "com_ui_dashboard": "Oversikt", "com_ui_date": "Dato", "com_ui_date_april": "April", "com_ui_date_august": "August", @@ -905,7 +902,6 @@ "com_ui_delete_not_allowed": "Sletteoperasjon er ikke tillatt.", "com_ui_delete_preset": "Ønsker du å slette forhåndsinnstillingen", "com_ui_delete_prompt": "Slette prompten?", - "com_ui_delete_prompt_name": "Slett prompt - {{name}}", "com_ui_delete_shared_link": "Slette delt lenke?", "com_ui_delete_shared_link_heading": "Slett delt lenke", "com_ui_delete_success": "Vellykket slettet", @@ -1026,7 +1022,6 @@ "com_ui_generating": "Genererer ...", "com_ui_generation_settings": "Genereringsinnstillinger", "com_ui_getting_started": "Kom i gang", - "com_ui_global_group": "[Plassholder: Global gruppe]", "com_ui_go_back": "Gå tilbake", "com_ui_go_to_conversation": "Gå til samtale", "com_ui_good_afternoon": "God ettermiddag", @@ -1201,7 +1196,6 @@ "com_ui_rename": "Gi nytt navn", "com_ui_rename_conversation": "Gi samtalen nytt navn", "com_ui_rename_failed": "Endring av navn på samtalen mislyktes.", - "com_ui_rename_prompt": "Gi prompten nytt navn", "com_ui_requires_auth": "Krever autentisering", "com_ui_reset_var": "Tilbakestill {{0}}", "com_ui_reset_zoom": "Tilbakestill zoom", diff --git a/client/src/locales/nl/translation.json b/client/src/locales/nl/translation.json index bf2b37cd007c..11861adf5f95 100644 --- a/client/src/locales/nl/translation.json +++ b/client/src/locales/nl/translation.json @@ -396,7 +396,6 @@ "com_nav_theme_dark": "Donker", "com_nav_theme_light": "Licht", "com_nav_theme_system": "Systeem", - "com_nav_toggle_sidebar": "Zijbalk tonen/verbergen", "com_nav_tool_dialog_mcp_server_tools": "MCP-server Hulpmiddelen", "com_nav_user": "GEBRUIKER", "com_sidepanel_attach_files": "Bestanden bijvoegen", diff --git a/client/src/locales/pl/translation.json b/client/src/locales/pl/translation.json index 1256429e7422..7dcae7f5de92 100644 --- a/client/src/locales/pl/translation.json +++ b/client/src/locales/pl/translation.json @@ -588,7 +588,6 @@ "com_nav_theme_dark": "Ciemny", "com_nav_theme_light": "Jasny", "com_nav_theme_system": "Zgodny z systemem", - "com_nav_toggle_sidebar": "Przełącz pasek boczny", "com_nav_tool_dialog": "Narzędzia asystenta", "com_nav_tool_dialog_agents": "Narzędzia agenta", "com_nav_tool_dialog_description": "Asystent musi zostać zapisany, aby zachować wybrane narzędzia.", @@ -758,7 +757,6 @@ "com_ui_azure_ad": "Entra ID", "com_ui_back": "Wstecz", "com_ui_back_to_builder": "Powrót do kreatora", - "com_ui_back_to_chat": "Powrót do czatu", "com_ui_back_to_prompts": "Powrót do promptów", "com_ui_backup_code_number": "Kod #{{number}}", "com_ui_backup_codes": "Kody zapasowe", @@ -871,7 +869,6 @@ "com_ui_custom_header_name": "Niestandardowa nazwa nagłówka", "com_ui_custom_prompt_mode": "Tryb niestandardowego promptu", "com_ui_dark_theme_enabled": "Włączono ciemny motyw", - "com_ui_dashboard": "Panel", "com_ui_date": "Data", "com_ui_date_april": "Kwiecień", "com_ui_date_august": "Sierpień", @@ -910,7 +907,6 @@ "com_ui_delete_not_allowed": "Operacja usuwania jest niedozwolona", "com_ui_delete_preset": "Usunąć preset?", "com_ui_delete_prompt": "Usunąć prompt?", - "com_ui_delete_prompt_name": "Usuń prompt - {{name}}", "com_ui_delete_shared_link": "Usunąć udostępniony link?", "com_ui_delete_shared_link_heading": "Usuń udostępniony link", "com_ui_delete_success": "Pomyślnie usunięto", @@ -1031,7 +1027,6 @@ "com_ui_generating": "Generowanie...", "com_ui_generation_settings": "Ustawienia generowania", "com_ui_getting_started": "Pierwsze kroki", - "com_ui_global_group": "Grupa globalna", "com_ui_go_back": "Wróć", "com_ui_go_to_conversation": "Przejdź do rozmowy", "com_ui_good_afternoon": "Dzień dobry", @@ -1287,8 +1282,6 @@ "com_ui_rename": "Zmień nazwę", "com_ui_rename_conversation": "Zmień nazwę rozmowy", "com_ui_rename_failed": "Nie udało się zmienić nazwy rozmowy", - "com_ui_rename_prompt": "Zmień nazwę promptu", - "com_ui_rename_prompt_name": "Zmień nazwę promptu - {{name}}", "com_ui_requires_auth": "Wymaga Uwierzytelnienia", "com_ui_reset": "Resetuj", "com_ui_reset_adjustments": "Resetuj dostosowania", diff --git a/client/src/locales/pt-BR/translation.json b/client/src/locales/pt-BR/translation.json index 398b07204653..082512ccc5b4 100644 --- a/client/src/locales/pt-BR/translation.json +++ b/client/src/locales/pt-BR/translation.json @@ -580,7 +580,6 @@ "com_ui_authentication_type": "Tipo de Autenticação", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", - "com_ui_back_to_chat": "Voltar ao Chat", "com_ui_back_to_prompts": "Voltar aos Prompts", "com_ui_backup_codes": "Códigos de Backup", "com_ui_backup_codes_regenerate_error": "Ocorreu um erro ao regerar os códigos de backup", @@ -645,7 +644,6 @@ "com_ui_custom": "Personalizado", "com_ui_custom_header_name": "Nome do cabeçalho personalizado", "com_ui_custom_prompt_mode": "Modo de Prompt Personalizado", - "com_ui_dashboard": "Painel", "com_ui_date": "Data", "com_ui_date_april": "Abril", "com_ui_date_august": "Agosto", @@ -755,7 +753,6 @@ "com_ui_generate_qrcode": "Gerar QR Code", "com_ui_generating": "Gerando...", "com_ui_generation_settings": "Configurações de Geração", - "com_ui_global_group": "algo precisa ir aqui. estava vazio", "com_ui_go_back": "Volte", "com_ui_go_to_conversation": "Ir para a conversa", "com_ui_good_afternoon": "Boa tarde", @@ -858,7 +855,6 @@ "com_ui_regenerating": "Regerando...", "com_ui_region": "Região", "com_ui_rename": "Renomear", - "com_ui_rename_prompt": "Renomear prompt", "com_ui_requires_auth": "Requer autenticação", "com_ui_reset_var": "Redefinir {{0}}", "com_ui_result": "Resultado", diff --git a/client/src/locales/pt-PT/translation.json b/client/src/locales/pt-PT/translation.json index a806fe7a9ff8..f0a27f3318cd 100644 --- a/client/src/locales/pt-PT/translation.json +++ b/client/src/locales/pt-PT/translation.json @@ -672,7 +672,6 @@ "com_ui_azure": "Azure", "com_ui_azure_ad": "Entra ID", "com_ui_back": "Voltar", - "com_ui_back_to_chat": "Voltar ao Chat", "com_ui_back_to_prompts": "Voltar aos Prompts", "com_ui_backup_code_number": "Código #{{number}}", "com_ui_backup_codes": "Copia de segurança dos Códigos", @@ -746,7 +745,6 @@ "com_ui_custom": "Costumizar", "com_ui_custom_header_name": "Nome de Cabeçalho Customizado", "com_ui_custom_prompt_mode": "Modo de Prompt Personalizado", - "com_ui_dashboard": "Painel", "com_ui_date": "Data", "com_ui_date_april": "Abril", "com_ui_date_august": "Agosto", @@ -880,7 +878,6 @@ "com_ui_generating": "A gerar...", "com_ui_generation_settings": "Definições de Geração", "com_ui_getting_started": "Primeiros Passos", - "com_ui_global_group": "algo precisa de ir aqui. estava vazio", "com_ui_go_back": "Para trás", "com_ui_go_to_conversation": "Ir para a conversa", "com_ui_good_afternoon": "Boa tarde", @@ -1040,7 +1037,6 @@ "com_ui_rename": "Renomear", "com_ui_rename_conversation": "Renomear Conversa", "com_ui_rename_failed": "Falhou ao renomear conversa", - "com_ui_rename_prompt": "Renomear comando", "com_ui_requires_auth": "Requer autenticação", "com_ui_reset_var": "Reiniciar {{0}}", "com_ui_reset_zoom": "Repor Zoom", diff --git a/client/src/locales/ru/translation.json b/client/src/locales/ru/translation.json index b71abadd1576..0d2e717cdffc 100644 --- a/client/src/locales/ru/translation.json +++ b/client/src/locales/ru/translation.json @@ -710,7 +710,6 @@ "com_ui_azure_ad": "Entra ID", "com_ui_back": "Назад", "com_ui_back_to_builder": "Вернуться в конструктор", - "com_ui_back_to_chat": "Вернуться к чату", "com_ui_back_to_prompts": "Вернуться к промтам", "com_ui_backup_code_number": "Код #{{number}}", "com_ui_backup_codes": "Резервные коды", @@ -807,7 +806,6 @@ "com_ui_custom_header_name": "Настраиваемое имя заголовка", "com_ui_custom_prompt_mode": "Режим пользовательского промта", "com_ui_dark_theme_enabled": "Включена темная тема", - "com_ui_dashboard": "Главная панель", "com_ui_date": "Дата", "com_ui_date_april": "Апрель", "com_ui_date_august": "Август", @@ -842,7 +840,6 @@ "com_ui_delete_not_allowed": "Операция удаления не допускается", "com_ui_delete_preset": "Удалить пресет?", "com_ui_delete_prompt": "Удалить промт?", - "com_ui_delete_prompt_name": "Удалить промпт - {{name}}", "com_ui_delete_shared_link": "Удалить общую ссылку?", "com_ui_delete_success": "Успешное удаление", "com_ui_delete_tool": "Удалить инструмент", @@ -957,7 +954,6 @@ "com_ui_generating": "Генерация...", "com_ui_generation_settings": "Генерация настроек", "com_ui_getting_started": "Начало работы", - "com_ui_global_group": "здесь должно быть что-то. было пусто", "com_ui_go_back": "Назад", "com_ui_go_to_conversation": "Перейти к беседе", "com_ui_good_afternoon": "Добрый день", @@ -1136,7 +1132,6 @@ "com_ui_rename": "Переименовать", "com_ui_rename_conversation": "Переименовать чат", "com_ui_rename_failed": "Не удалось переименовать чат", - "com_ui_rename_prompt": "Переименовать промт", "com_ui_requires_auth": "Требуется аутентификация", "com_ui_reset": "Сбросить", "com_ui_reset_adjustments": "Сбросить настройки", diff --git a/client/src/locales/th/translation.json b/client/src/locales/th/translation.json index be2fbc7e24bc..7de09f73fda6 100644 --- a/client/src/locales/th/translation.json +++ b/client/src/locales/th/translation.json @@ -487,7 +487,6 @@ "com_ui_authentication_type": "ประเภทการยืนยันตัวตน", "com_ui_avatar": "อวตาร", "com_ui_azure": "Azure", - "com_ui_back_to_chat": "กลับไปแชท", "com_ui_back_to_prompts": "กลับไปพรอมต์", "com_ui_backup_codes": "รหัสสำรอง", "com_ui_backup_codes_regenerate_error": "เกิดข้อผิดพลาดในการสร้างรหัสสำรองใหม่", @@ -558,7 +557,6 @@ "com_ui_custom": "กำหนดเอง", "com_ui_custom_header_name": "ชื่อส่วนหัวกำหนดเอง", "com_ui_custom_prompt_mode": "โหมดพรอมต์กำหนดเอง", - "com_ui_dashboard": "แดชบอร์ด", "com_ui_date": "วันที่", "com_ui_date_april": "เมษายน", "com_ui_date_august": "สิงหาคม", @@ -720,7 +718,6 @@ "com_ui_region": "ภูมิภาค", "com_ui_rename": "เปลี่ยนชื่อ", "com_ui_rename_conversation": "เปลี่ยนชื่อการสนทนา", - "com_ui_rename_prompt": "เปลี่ยนชื่อพรอมต์", "com_ui_requires_auth": "ต้องการการยืนยันตัวตน", "com_ui_reset_var": "รีเซ็ต {{0}}", "com_ui_result": "ผลลัพธ์", diff --git a/client/src/locales/tr/translation.json b/client/src/locales/tr/translation.json index 2566ced5e00f..d0c86fec6957 100644 --- a/client/src/locales/tr/translation.json +++ b/client/src/locales/tr/translation.json @@ -430,7 +430,6 @@ "com_ui_attachment": "Ek", "com_ui_authentication": "Kimlik Doğrulama", "com_ui_avatar": "Avatar", - "com_ui_back_to_chat": "Sohbete Dön", "com_ui_back_to_prompts": "İstemlere Dön", "com_ui_bookmark_delete_confirm": "Bu yer imini silmek istediğinizden emin misiniz?", "com_ui_bookmarks": "Yer İmleri", @@ -476,7 +475,6 @@ "com_ui_create_prompt": "İstem Oluştur", "com_ui_currently_production": "Şu anda üretimde", "com_ui_custom_prompt_mode": "Özel İstem Modu", - "com_ui_dashboard": "Gösterge Paneli", "com_ui_date": "Tarih", "com_ui_date_april": "Nisan", "com_ui_date_august": "Ağustos", @@ -615,7 +613,6 @@ "com_ui_regenerate": "Yeniden Oluştur", "com_ui_region": "Bölge", "com_ui_rename": "Yeniden adlandır", - "com_ui_rename_prompt": "İstemi Yeniden Adlandır", "com_ui_reset_var": "{{0}} sıfırla", "com_ui_result": "Sonuç", "com_ui_revoke": "Geri Al", diff --git a/client/src/locales/uk/translation.json b/client/src/locales/uk/translation.json index 35c67e163a3e..bb3fd9c9639b 100644 --- a/client/src/locales/uk/translation.json +++ b/client/src/locales/uk/translation.json @@ -684,7 +684,6 @@ "com_ui_azure": "Azure", "com_ui_azure_ad": "Entra ID", "com_ui_back": "Назад", - "com_ui_back_to_chat": "Повернутися до чату", "com_ui_back_to_prompts": "Повернутися до підказок", "com_ui_backup_code_number": "Код #{{number}}", "com_ui_backup_codes": "Резервні коди", @@ -758,7 +757,6 @@ "com_ui_custom": "Налаштовуваний", "com_ui_custom_header_name": "Налаштовувана назва заголовка", "com_ui_custom_prompt_mode": "Режим користувацької підказки", - "com_ui_dashboard": "Панель керування", "com_ui_date": "Дата", "com_ui_date_april": "Квітень", "com_ui_date_august": "Серпень", @@ -888,7 +886,6 @@ "com_ui_generating": "Генерація...", "com_ui_generation_settings": "Налаштування генерації", "com_ui_getting_started": "Початок роботи", - "com_ui_global_group": "Глобальна група", "com_ui_go_back": "Назад", "com_ui_go_to_conversation": "Перейти до розмови", "com_ui_good_afternoon": "Добрий день", @@ -1047,7 +1044,6 @@ "com_ui_rename": "Перейменувати", "com_ui_rename_conversation": "Перейменувати чат", "com_ui_rename_failed": "Не вдалося перейменувати чат", - "com_ui_rename_prompt": "Перейменувати підказку", "com_ui_requires_auth": "Потрібна автентифікація", "com_ui_reset_var": "Скинути {{0}}", "com_ui_reset_zoom": "Скинути масштаб", diff --git a/client/src/locales/vi/translation.json b/client/src/locales/vi/translation.json index 95451b3df44e..863c5d185a92 100644 --- a/client/src/locales/vi/translation.json +++ b/client/src/locales/vi/translation.json @@ -319,7 +319,6 @@ "com_ui_copy_link": "Sao chép liên kết", "com_ui_copy_to_clipboard": "Sao chép vào clipboard", "com_ui_create_link": "Tạo liên kết", - "com_ui_dashboard": "Bảng điều khiển", "com_ui_date": "Ngày", "com_ui_date_today": "Hôm nay", "com_ui_date_yesterday": "Hôm qua", diff --git a/client/src/locales/zh-Hans/translation.json b/client/src/locales/zh-Hans/translation.json index 2f861e2623f4..9ebc350d61a3 100644 --- a/client/src/locales/zh-Hans/translation.json +++ b/client/src/locales/zh-Hans/translation.json @@ -731,7 +731,6 @@ "com_ui_azure_ad": "Entra ID", "com_ui_back": "后退", "com_ui_back_to_builder": "返回构建器", - "com_ui_back_to_chat": "返回对话", "com_ui_back_to_prompts": "返回提示词", "com_ui_backup_code_number": "代码 #{{number}}", "com_ui_backup_codes": "备份代码", @@ -838,7 +837,6 @@ "com_ui_custom_header_name": "自定义 Header 名称", "com_ui_custom_prompt_mode": "自定义提示词模式", "com_ui_dark_theme_enabled": "an", - "com_ui_dashboard": "仪表板", "com_ui_date": "日期", "com_ui_date_april": "四月", "com_ui_date_august": "八月", @@ -983,7 +981,6 @@ "com_ui_generating": "生成中...", "com_ui_generation_settings": "生成设置", "com_ui_getting_started": "已经开始", - "com_ui_global_group": "这里需要放点东西,当前是空的", "com_ui_go_back": "返回", "com_ui_go_to_conversation": "转到对话", "com_ui_good_afternoon": "下午好", @@ -1168,7 +1165,6 @@ "com_ui_rename": "重命名", "com_ui_rename_conversation": "重命名对话", "com_ui_rename_failed": "重命名对话失败", - "com_ui_rename_prompt": "重命名 Prompt", "com_ui_requires_auth": "需要认证", "com_ui_reset": "重置", "com_ui_reset_adjustments": "重置调整", diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json index e0cc4ee79544..fe4bc6f27771 100644 --- a/client/src/locales/zh-Hant/translation.json +++ b/client/src/locales/zh-Hant/translation.json @@ -577,7 +577,6 @@ "com_ui_authentication": "驗證", "com_ui_auto": "自動", "com_ui_avatar": "大頭照", - "com_ui_back_to_chat": "返回對話", "com_ui_back_to_prompts": "返回提示", "com_ui_bookmark_delete_confirm": "你確定要刪除這個書籤嗎?", "com_ui_bookmarks": "書籤", @@ -620,7 +619,6 @@ "com_ui_create_memory": "建立記憶", "com_ui_create_prompt": "建立提示", "com_ui_custom_prompt_mode": "自訂提示模式", - "com_ui_dashboard": "儀表板", "com_ui_date": "日期", "com_ui_date_april": "四月", "com_ui_date_august": "八月", diff --git a/package-lock.json b/package-lock.json index 4f0a1afd0f5e..975f4cb31260 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,7 +106,7 @@ "multer": "^2.1.1", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", - "nodemailer": "^8.0.4", + "nodemailer": "^8.0.5", "ollama": "^0.5.0", "openai": "5.8.2", "openid-client": "^6.5.0", @@ -34700,9 +34700,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", - "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 5993c911ff12..de6232e85b00 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -770,14 +770,27 @@ class GenerationJobManagerClass { if (!runtime.hasSubscriber) { runtime.hasSubscriber = true; + /** + * Pass earlyReplayCount to syncReorderBuffer so it can prune duplicate pub/sub + * entries (seqs 0..count-1) without touching live in-flight chunks. + * + * Only set when the buffer was actually replayed — those specific seqs were + * delivered via onChunk and their pub/sub copies are duplicates. + * When skipBufferReplay is true, the resume sync payload delivers aggregated + * content up to the Redis counter, so syncReorderBuffer should trust currentSeq + * as the frontier (earlyReplayCount = 0). + */ + let earlyReplayCount = 0; + if (runtime.earlyEventBuffer.length > 0) { if (options?.skipBufferReplay) { logger.debug( `[GenerationJobManager] Skipping ${runtime.earlyEventBuffer.length} buffered events for ${streamId} (skipBufferReplay)`, ); } else { + earlyReplayCount = runtime.earlyEventBuffer.length; logger.debug( - `[GenerationJobManager] Replaying ${runtime.earlyEventBuffer.length} buffered events for ${streamId}`, + `[GenerationJobManager] Replaying ${earlyReplayCount} buffered events for ${streamId}`, ); for (const bufferedEvent of runtime.earlyEventBuffer) { onChunk(bufferedEvent); @@ -807,7 +820,14 @@ class GenerationJobManagerClass { onChunk(createdEvent); } - this.eventTransport.syncReorderBuffer?.(streamId); + try { + await this.eventTransport.syncReorderBuffer?.(streamId, earlyReplayCount); + } catch (err) { + logger.warn( + `[GenerationJobManager] Failed to sync reorder buffer for ${streamId}; proceeding with current nextSeq:`, + err, + ); + } } if (isFirst) { diff --git a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts index 3e85ace56d20..0c119ab8d2ab 100644 --- a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts @@ -14,6 +14,9 @@ import { keyvRedisClientReady, } from '~/cache/redisClients'; +/** Suppress winston Console transport output (survives jest.resetModules) */ +jest.spyOn(console, 'log').mockImplementation(); + /** * Integration tests for GenerationJobManager. * @@ -800,9 +803,8 @@ describe('GenerationJobManager Integration Tests', () => { GenerationJobManager.initialize(); const received: unknown[] = []; - const subscription = await GenerationJobManager.subscribe( - streamId, - (event) => received.push(event), + const subscription = await GenerationJobManager.subscribe(streamId, (event) => + received.push(event), ); expect(subscription).not.toBeNull(); @@ -839,9 +841,8 @@ describe('GenerationJobManager Integration Tests', () => { GenerationJobManager.initialize(); const received: unknown[] = []; - const subscription = await GenerationJobManager.subscribe( - streamId, - (event) => received.push(event), + const subscription = await GenerationJobManager.subscribe(streamId, (event) => + received.push(event), ); expect(subscription).not.toBeNull(); diff --git a/packages/api/src/stream/__tests__/RedisEventTransport.stream_integration.spec.ts b/packages/api/src/stream/__tests__/RedisEventTransport.stream_integration.spec.ts index b5e53dfbff57..e254cf66ba53 100644 --- a/packages/api/src/stream/__tests__/RedisEventTransport.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/RedisEventTransport.stream_integration.spec.ts @@ -1,4 +1,8 @@ import type { Redis, Cluster } from 'ioredis'; +import { logger } from '@librechat/data-schemas'; +import { createMockPublisher } from './helpers/publisher'; + +logger.silent = true; /** * Integration tests for RedisEventTransport. @@ -318,9 +322,7 @@ describe('RedisEventTransport Integration Tests', () => { test('should reorder out-of-sequence messages', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -359,9 +361,7 @@ describe('RedisEventTransport Integration Tests', () => { test('should buffer early messages and deliver when gaps are filled', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -407,9 +407,7 @@ describe('RedisEventTransport Integration Tests', () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -450,9 +448,7 @@ describe('RedisEventTransport Integration Tests', () => { test('should handle messages without sequence numbers (backward compatibility)', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -492,9 +488,7 @@ describe('RedisEventTransport Integration Tests', () => { test('should deliver done event after all pending chunks (terminal event ordering)', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { subscribe: jest.fn().mockResolvedValue(undefined), unsubscribe: jest.fn().mockResolvedValue(undefined), @@ -547,9 +541,7 @@ describe('RedisEventTransport Integration Tests', () => { test('should deliver error event after all pending chunks (terminal event ordering)', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { subscribe: jest.fn().mockResolvedValue(undefined), unsubscribe: jest.fn().mockResolvedValue(undefined), @@ -864,6 +856,62 @@ describe('RedisEventTransport Integration Tests', () => { }); }); + /** + * Cross-Replica Sequence Synchronization (#12575) + * + * The core cross-replica sync logic (Redis INCR counter, async GET in + * syncReorderBuffer, pruneStaleEntries flag) is verified by: + * - Unit tests with mock publishers (deterministic, no cluster timing) + * - GenerationJobManager integration tests (end-to-end with earlyEventBuffer) + * - The race-condition unit test (paused GET with injected message) + * + * Transport-level integration tests with two real Redis transports are + * inherently flaky in Redis Cluster: cluster pub/sub fan-out is async + * across nodes, so a PUBLISH acknowledged on node A can arrive at the + * subscriber on node B after a subsequent SUBSCRIBE takes effect, + * causing non-deterministic message counts. + */ + describe('Cross-Replica Sequence Synchronization (#12575)', () => { + test('shared counter is cleaned up on stream cleanup', async () => { + if (!ioredisClient) { + console.warn('Redis not available, skipping test'); + return; + } + + const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); + + const subscriber = (ioredisClient as Redis).duplicate(); + const transport = new RedisEventTransport(ioredisClient, subscriber); + + const streamId = `cross-replica-cleanup-${Date.now()}`; + + // Publish chunks to create the Redis counter key + for (let i = 0; i < 5; i++) { + await transport.emitChunk(streamId, { index: i }); + } + + // Verify the key exists + const key = `stream:{${streamId}}:seq`; + const valBefore = await ioredisClient.get(key); + expect(valBefore).toBe('5'); + + // Cleanup the stream + transport.cleanup(streamId); + + // Poll for the fire-and-forget DEL to complete (robust under CI load) + const start = Date.now(); + let valAfter: string | null = 'pending'; + while (valAfter !== null && Date.now() - start < 2000) { + await new Promise((resolve) => setTimeout(resolve, 10)); + valAfter = await ioredisClient.get(key); + } + expect(valAfter).toBeNull(); + + transport.destroy(); + subscriber.disconnect(); + }); + }); + describe('Cleanup', () => { test('should clean up stream resources', async () => { if (!ioredisClient) { @@ -898,9 +946,8 @@ describe('RedisEventTransport Integration Tests', () => { test('should swallow emitChunk publish errors (callers fire-and-forget)', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockRejectedValue(new Error('Redis connection lost')), - }; + const mockPublisher = createMockPublisher(); + mockPublisher.publish.mockRejectedValue(new Error('Redis connection lost')); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -921,12 +968,35 @@ describe('RedisEventTransport Integration Tests', () => { transport.destroy(); }); - test('should throw when emitDone publish fails', async () => { + test('should swallow emitChunk incr errors (sequence allocation failure)', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockRejectedValue(new Error('Redis connection lost')), + const mockPublisher = createMockPublisher(); + mockPublisher.incr.mockRejectedValue(new Error('INCR failed')); + const mockSubscriber = { + on: jest.fn(), + subscribe: jest.fn().mockResolvedValue(undefined), + unsubscribe: jest.fn().mockResolvedValue(undefined), }; + + const transport = new RedisEventTransport( + mockPublisher as unknown as Redis, + mockSubscriber as unknown as Redis, + ); + + const streamId = `error-prop-incr-${Date.now()}`; + + await expect(transport.emitChunk(streamId, { data: 'test' })).resolves.toBeUndefined(); + expect(mockPublisher.publish).not.toHaveBeenCalled(); + + transport.destroy(); + }); + + test('should throw when emitDone publish fails', async () => { + const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); + + const mockPublisher = createMockPublisher(); + mockPublisher.publish.mockRejectedValue(new Error('Redis connection lost')); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -950,9 +1020,8 @@ describe('RedisEventTransport Integration Tests', () => { test('should throw when emitError publish fails', async () => { const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); - const mockPublisher = { - publish: jest.fn().mockRejectedValue(new Error('Redis connection lost')), - }; + const mockPublisher = createMockPublisher(); + mockPublisher.publish.mockRejectedValue(new Error('Redis connection lost')); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -973,6 +1042,52 @@ describe('RedisEventTransport Integration Tests', () => { transport.destroy(); }); + test('should propagate when emitDone incr fails', async () => { + const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); + + const mockPublisher = createMockPublisher(); + mockPublisher.incr.mockRejectedValue(new Error('INCR failed')); + const mockSubscriber = { + on: jest.fn(), + subscribe: jest.fn().mockResolvedValue(undefined), + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + const transport = new RedisEventTransport( + mockPublisher as unknown as Redis, + mockSubscriber as unknown as Redis, + ); + + const streamId = `error-prop-done-incr-${Date.now()}`; + + await expect(transport.emitDone(streamId, { finished: true })).rejects.toThrow('INCR failed'); + + transport.destroy(); + }); + + test('should propagate when emitError incr fails', async () => { + const { RedisEventTransport } = await import('../implementations/RedisEventTransport'); + + const mockPublisher = createMockPublisher(); + mockPublisher.incr.mockRejectedValue(new Error('INCR failed')); + const mockSubscriber = { + on: jest.fn(), + subscribe: jest.fn().mockResolvedValue(undefined), + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + const transport = new RedisEventTransport( + mockPublisher as unknown as Redis, + mockSubscriber as unknown as Redis, + ); + + const streamId = `error-prop-error-incr-${Date.now()}`; + + await expect(transport.emitError(streamId, 'some error')).rejects.toThrow('INCR failed'); + + transport.destroy(); + }); + test('should still deliver events successfully when publish succeeds', async () => { if (!ioredisClient) { console.warn('Redis not available, skipping test'); diff --git a/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts b/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts index a64ba11f26a1..d45f186dc017 100644 --- a/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts @@ -3,6 +3,9 @@ import type { Agents } from 'librechat-data-provider'; import type { Redis, Cluster } from 'ioredis'; import { StandardGraph } from '@librechat/agents'; +/** Suppress winston Console transport output (survives jest.resetModules) */ +jest.spyOn(console, 'log').mockImplementation(); + /** * Integration tests for RedisJobStore. * diff --git a/packages/api/src/stream/__tests__/collectedUsage.spec.ts b/packages/api/src/stream/__tests__/collectedUsage.spec.ts index d9a9ab95fe6f..635eba3aa439 100644 --- a/packages/api/src/stream/__tests__/collectedUsage.spec.ts +++ b/packages/api/src/stream/__tests__/collectedUsage.spec.ts @@ -8,6 +8,9 @@ import type { UsageMetadata } from '../interfaces/IJobStore'; +/** Suppress winston Console transport output (survives jest.resetModules) */ +jest.spyOn(console, 'log').mockImplementation(); + describe('CollectedUsage - InMemoryJobStore', () => { beforeEach(() => { jest.resetModules(); diff --git a/packages/api/src/stream/__tests__/helpers/publisher.ts b/packages/api/src/stream/__tests__/helpers/publisher.ts new file mode 100644 index 000000000000..3e1799d3738e --- /dev/null +++ b/packages/api/src/stream/__tests__/helpers/publisher.ts @@ -0,0 +1,31 @@ +export interface MockPublisher { + publish: jest.Mock; + incr: jest.Mock; + expire: jest.Mock; + get: jest.Mock; + del: jest.Mock; +} + +/** Mock publisher with Redis command simulation for atomic sequence counters */ +export function createMockPublisher(): MockPublisher { + const counters = new Map(); + return { + publish: jest.fn().mockResolvedValue(1), + incr: jest.fn().mockImplementation((key: string) => { + const current = (counters.get(key) ?? 0) + 1; + counters.set(key, current); + return Promise.resolve(current); + }), + expire: jest.fn().mockResolvedValue(1), + get: jest.fn().mockImplementation((key: string) => { + const val = counters.get(key); + return Promise.resolve(val != null ? String(val) : null); + }), + del: jest.fn().mockImplementation((...keys: string[]) => { + for (const key of keys) { + counters.delete(key); + } + return Promise.resolve(keys.length); + }), + }; +} diff --git a/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts b/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts index effb7c5c7d0b..6f7ad2f6eb0e 100644 --- a/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts @@ -1,13 +1,17 @@ +import { logger } from '@librechat/data-schemas'; import type { Redis, Cluster } from 'ioredis'; import { RedisEventTransport } from '~/stream/implementations/RedisEventTransport'; import { GenerationJobManagerClass } from '~/stream/GenerationJobManager'; import { createStreamServices } from '~/stream/createStreamServices'; +import { createMockPublisher } from './helpers/publisher'; import { ioredisClient as staticRedisClient, keyvRedisClient as staticKeyvClient, keyvRedisClientReady, } from '~/cache/redisClients'; +logger.silent = true; + /** * Regression tests for the reconnect reorder buffer desync bug. * @@ -26,9 +30,7 @@ import { describe('Reconnect Reorder Buffer Desync (Regression)', () => { describe('Callback preservation across reconnect cycles (Unit)', () => { test('allSubscribersLeft callback fires on every disconnect, not just the first', () => { - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -70,9 +72,7 @@ describe('Reconnect Reorder Buffer Desync (Regression)', () => { }); test('abort callback survives across reconnect cycles', () => { - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -125,9 +125,7 @@ describe('Reconnect Reorder Buffer Desync (Regression)', () => { * immediately regardless of how many reconnect cycles have occurred. */ test('syncReorderBuffer works correctly on third+ reconnect', async () => { - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -159,7 +157,7 @@ describe('Reconnect Reorder Buffer Desync (Regression)', () => { }); // Sync reorder buffer (as GenerationJobManager.subscribe does) - transport.syncReorderBuffer(streamId); + await transport.syncReorderBuffer(streamId); const baseSeq = cycle * 10; @@ -189,9 +187,7 @@ describe('Reconnect Reorder Buffer Desync (Regression)', () => { }); test('reorder buffer works correctly when syncReorderBuffer IS called', async () => { - const mockPublisher = { - publish: jest.fn().mockResolvedValue(1), - }; + const mockPublisher = createMockPublisher(); const mockSubscriber = { on: jest.fn(), subscribe: jest.fn().mockResolvedValue(undefined), @@ -216,8 +212,8 @@ describe('Reconnect Reorder Buffer Desync (Regression)', () => { onChunk: (event) => chunks.push(event), }); - // This is the critical call - sync nextSeq to match publisher - transport.syncReorderBuffer(streamId); + // This is the critical call - sync nextSeq to match publisher (reads from Redis) + await transport.syncReorderBuffer(streamId); // Deliver messages starting at seq 20 const messageHandler = mockSubscriber.on.mock.calls.find( @@ -240,6 +236,187 @@ describe('Reconnect Reorder Buffer Desync (Regression)', () => { }); }); + describe('syncReorderBuffer race: message arrives during async GET window (Unit)', () => { + test('should not drop a chunk that lands in pending while GET is in-flight', async () => { + const mockPublisher = createMockPublisher(); + const mockSubscriber = { + on: jest.fn(), + subscribe: jest.fn().mockResolvedValue(undefined), + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + const transport = new RedisEventTransport( + mockPublisher as unknown as Redis, + mockSubscriber as unknown as Redis, + ); + + const streamId = 'race-get-window-test'; + const chunks: unknown[] = []; + + // Emit seq 0 so the Redis counter is 1 + await transport.emitChunk(streamId, { index: 0 }); + + // Subscribe (nextSeq starts at 0) + transport.subscribe(streamId, { + onChunk: (event) => chunks.push(event), + }); + + const messageHandler = mockSubscriber.on.mock.calls.find( + (call) => call[0] === 'message', + )?.[1] as (channel: string, message: string) => void; + const channel = `stream:{${streamId}}:events`; + + // Pause the GET: intercept with a deferred promise + let resolveGet!: (val: string | null) => void; + mockPublisher.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGet = resolve; + }), + ); + + const syncPromise = transport.syncReorderBuffer(streamId); + + // While GET is in-flight, the publisher emits seq 1 (INCR → counter=2) + // and the subscriber receives it via pub/sub → pending[1] + await transport.emitChunk(streamId, { index: 1 }); + messageHandler(channel, JSON.stringify({ type: 'chunk', seq: 1, data: { index: 1 } })); + + // Resolve GET with counter=2 (reflects the INCR for seq 1) + resolveGet('2'); + await syncPromise; + + // seq 1 MUST be delivered — it arrived during the GET window and must not be pruned + expect(chunks.map((c) => (c as { index: number }).index)).toContain(1); + + // Subsequent chunks must also deliver immediately + messageHandler(channel, JSON.stringify({ type: 'chunk', seq: 2, data: { index: 2 } })); + expect(chunks.map((c) => (c as { index: number }).index)).toContain(2); + + transport.destroy(); + }); + + test('same-replica: should not drop a live chunk when INCR advances past earlyReplayCount during GET', async () => { + const mockPublisher = createMockPublisher(); + const mockSubscriber = { + on: jest.fn(), + subscribe: jest.fn().mockResolvedValue(undefined), + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + const transport = new RedisEventTransport( + mockPublisher as unknown as Redis, + mockSubscriber as unknown as Redis, + ); + + const streamId = 'race-same-replica-test'; + const chunks: unknown[] = []; + + // Emit seqs 0–4 (earlyEventBuffer would have held these; counter = 5) + for (let i = 0; i < 5; i++) { + await transport.emitChunk(streamId, { index: i }); + } + + // Subscribe (nextSeq starts at 0) + transport.subscribe(streamId, { + onChunk: (event) => chunks.push(event), + }); + + const messageHandler = mockSubscriber.on.mock.calls.find( + (call) => call[0] === 'message', + )?.[1] as (channel: string, message: string) => void; + const channel = `stream:{${streamId}}:events`; + + // Pause the GET + let resolveGet!: (val: string | null) => void; + mockPublisher.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGet = resolve; + }), + ); + + // Call syncReorderBuffer with earlyReplayCount=5 (seqs 0–4 were replayed) + const syncPromise = transport.syncReorderBuffer(streamId, 5); + + // During GET window: LLM emits seq 5 (INCR → counter=6), subscriber receives it + await transport.emitChunk(streamId, { index: 5 }); + messageHandler(channel, JSON.stringify({ type: 'chunk', seq: 5, data: { index: 5 } })); + + // GET returns 6 (counter advanced past seq 5) + resolveGet('6'); + await syncPromise; + + // seq 5 MUST be delivered — it's live (seq 5 >= earlyReplayCount 5), not a duplicate. + // With the old boolean pruneStaleEntries, 5 < currentSeq(6) would have pruned it. + expect(chunks.map((c) => (c as { index: number }).index)).toContain(5); + + // Subsequent chunks deliver normally + messageHandler(channel, JSON.stringify({ type: 'chunk', seq: 6, data: { index: 6 } })); + expect(chunks.map((c) => (c as { index: number }).index)).toContain(6); + + transport.destroy(); + }); + + test('same-replica: should not drop chunk whose pub/sub arrives AFTER GET resolves', async () => { + const mockPublisher = createMockPublisher(); + const mockSubscriber = { + on: jest.fn(), + subscribe: jest.fn().mockResolvedValue(undefined), + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + const transport = new RedisEventTransport( + mockPublisher as unknown as Redis, + mockSubscriber as unknown as Redis, + ); + + const streamId = 'race-post-get-test'; + const chunks: unknown[] = []; + + // Emit seqs 0–4 (earlyEventBuffer would have held these; counter = 5) + for (let i = 0; i < 5; i++) { + await transport.emitChunk(streamId, { index: i }); + } + + transport.subscribe(streamId, { + onChunk: (event) => chunks.push(event), + }); + + const messageHandler = mockSubscriber.on.mock.calls.find( + (call) => call[0] === 'message', + )?.[1] as (channel: string, message: string) => void; + const channel = `stream:{${streamId}}:events`; + + let resolveGet!: (val: string | null) => void; + mockPublisher.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGet = resolve; + }), + ); + + const syncPromise = transport.syncReorderBuffer(streamId, 5); + + // LLM emits seq 5 during the GET window (INCR → counter=6) + // but do NOT deliver via messageHandler yet — simulates pub/sub arriving late + await transport.emitChunk(streamId, { index: 5 }); + + // GET resolves with counter=6 while pending is EMPTY (pub/sub hasn't arrived) + resolveGet('6'); + await syncPromise; + + // Now pub/sub for seq 5 arrives AFTER sync completed + messageHandler(channel, JSON.stringify({ type: 'chunk', seq: 5, data: { index: 5 } })); + + // seq 5 must be delivered — nextSeq should have been capped at earlyReplayCount (5), + // not advanced to currentSeq (6) which would have dropped it. + expect(chunks.map((c) => (c as { index: number }).index)).toContain(5); + + transport.destroy(); + }); + }); + describe('End-to-end reconnect with GenerationJobManager (Integration)', () => { let originalEnv: NodeJS.ProcessEnv; let ioredisClient: Redis | Cluster | null = null; diff --git a/packages/api/src/stream/implementations/RedisEventTransport.ts b/packages/api/src/stream/implementations/RedisEventTransport.ts index 3682a9a74979..73c82aa01194 100644 --- a/packages/api/src/stream/implementations/RedisEventTransport.ts +++ b/packages/api/src/stream/implementations/RedisEventTransport.ts @@ -10,6 +10,14 @@ const CHANNELS = { events: (streamId: string) => `stream:{${streamId}}:events`, }; +/** + * Redis keys for shared state (hash-tagged for cluster slot compatibility) + */ +const KEYS = { + /** Atomic sequence counter: shared across all replicas for a given stream */ + sequence: (streamId: string) => `stream:{${streamId}}:seq`, +}; + /** * Event types for pub/sub messages */ @@ -96,8 +104,6 @@ export class RedisEventTransport implements IEventTransport { private channelSubscriptions = new Map>(); /** Counter for generating unique subscriber IDs */ private subscriberIdCounter = 0; - /** Sequence counters per stream for publishing (ensures ordered delivery in cluster mode) */ - private sequenceCounters = new Map(); /** * Create a new Redis event transport. @@ -115,16 +121,29 @@ export class RedisEventTransport implements IEventTransport { }); } - /** Get next sequence number for a stream (0-indexed) */ - private getNextSequence(streamId: string): number { - const current = this.sequenceCounters.get(streamId) ?? 0; - this.sequenceCounters.set(streamId, current + 1); - return current; + /** Safety-net TTL (seconds) set once on first INCR. Not refreshed — prevents mid-stream resets. */ + private static readonly SEQUENCE_TTL_SECONDS = 86400; + + /** + * Get next sequence number for a stream (0-indexed, backed by Redis INCR). + * A 24-hour TTL is set on the first INCR only (val === 1) as a safety net + * for orphaned keys from crashed processes. It is never refreshed, so an + * active stream cannot have its counter reset mid-generation. + * Keys are also deleted explicitly by cleanup() on normal stream teardown. + */ + private async getNextSequence(streamId: string): Promise { + const key = KEYS.sequence(streamId); + const val = await this.publisher.incr(key); + if (val === 1) { + this.publisher.expire(key, RedisEventTransport.SEQUENCE_TTL_SECONDS).catch((err) => { + logger.warn(`[RedisEventTransport] Failed to set TTL on sequence key ${key}:`, err); + }); + } + return val - 1; } - /** Reset publish sequence counter and subscriber reorder state for a stream (full cleanup only) */ - resetSequence(streamId: string): void { - this.sequenceCounters.delete(streamId); + /** Reset subscriber reorder buffer state to initial values */ + private resetReorderBuffer(streamId: string): void { const state = this.streams.get(streamId); if (state) { if (state.reorderBuffer.flushTimeout) { @@ -136,17 +155,63 @@ export class RedisEventTransport implements IEventTransport { } } - /** Advance subscriber reorder buffer to current publisher sequence without resetting publisher (cross-replica safe) */ - syncReorderBuffer(streamId: string): void { - const currentSeq = this.sequenceCounters.get(streamId) ?? 0; + /** + * Advance subscriber reorder buffer to the authoritative Redis sequence counter (cross-replica safe). + * + * @param earlyReplayCount - Number of events replayed from earlyEventBuffer (same-replica). + * Pending entries with seq < earlyReplayCount are duplicates and are pruned; entries at or + * above are live chunks from ongoing generation that arrived during the async GET window. + * Using the replay count (not the Redis counter) as the prune cutoff is critical: INCR can + * advance the counter past a live chunk's seq during the GET window, so currentSeq is not + * a safe proxy for "already delivered via earlyEventBuffer." + * When 0/undefined (cross-replica), all pending entries are treated as live and preserved. + */ + async syncReorderBuffer(streamId: string, earlyReplayCount = 0): Promise { + const key = KEYS.sequence(streamId); + const rawStr = await this.publisher.get(key); + const parsed = rawStr != null ? parseInt(rawStr, 10) : 0; + const currentSeq = Number.isNaN(parsed) ? 0 : parsed; const state = this.streams.get(streamId); if (state) { if (state.reorderBuffer.flushTimeout) { clearTimeout(state.reorderBuffer.flushTimeout); state.reorderBuffer.flushTimeout = null; } - state.reorderBuffer.nextSeq = currentSeq; - state.reorderBuffer.pending.clear(); + // Prune true duplicates: entries with seq < earlyReplayCount were already delivered + // via earlyEventBuffer. Entries at or above are live (possibly from ongoing generation). + if (earlyReplayCount > 0) { + for (const seq of state.reorderBuffer.pending.keys()) { + if (seq < earlyReplayCount) { + state.reorderBuffer.pending.delete(seq); + } + } + } + // Set nextSeq from remaining state. Never regress — handleOrderedChunk may have + // already advanced it during the async GET window. + if (state.reorderBuffer.pending.size === 0) { + // Same-replica: INCR precedes PUBLISH, so currentSeq may reflect allocated-but- + // not-yet-delivered events. Cap at earlyReplayCount to avoid skipping in-flight chunks. + // Cross-replica (earlyReplayCount=0): trust the Redis counter. + const ceiling = earlyReplayCount > 0 ? earlyReplayCount : currentSeq; + state.reorderBuffer.nextSeq = Math.max(state.reorderBuffer.nextSeq, ceiling); + } else { + let minPending = Infinity; + for (const seq of state.reorderBuffer.pending.keys()) { + if (seq < minPending) { + minPending = seq; + } + } + state.reorderBuffer.nextSeq = Math.max( + state.reorderBuffer.nextSeq, + Math.min(currentSeq, minPending), + ); + this.flushPendingMessages(streamId, state); + } + // Re-arm flush timeout if gaps remain after sync — without this, + // buffered messages could sit indefinitely if no new messages arrive. + if (state.reorderBuffer.pending.size > 0) { + this.scheduleFlushTimeout(streamId, state); + } } } @@ -442,13 +507,15 @@ export class RedisEventTransport implements IEventTransport { /** * Publish a chunk event to all subscribers across all instances. * Includes sequence number for ordered delivery in Redis Cluster mode. + * + * Performance: each emit requires two sequential Redis round-trips (INCR + PUBLISH). + * This is the unavoidable cost of cross-replica sequence coordination. */ async emitChunk(streamId: string, event: unknown): Promise { - const channel = CHANNELS.events(streamId); - const seq = this.getNextSequence(streamId); - const message: PubSubMessage = { type: EventTypes.CHUNK, seq, data: event }; - try { + const channel = CHANNELS.events(streamId); + const seq = await this.getNextSequence(streamId); + const message: PubSubMessage = { type: EventTypes.CHUNK, seq, data: event }; await this.publisher.publish(channel, JSON.stringify(message)); } catch (err) { logger.error(`[RedisEventTransport] Failed to publish chunk:`, err); @@ -461,7 +528,7 @@ export class RedisEventTransport implements IEventTransport { */ async emitDone(streamId: string, event: unknown): Promise { const channel = CHANNELS.events(streamId); - const seq = this.getNextSequence(streamId); + const seq = await this.getNextSequence(streamId); const message: PubSubMessage = { type: EventTypes.DONE, seq, data: event }; try { @@ -478,7 +545,7 @@ export class RedisEventTransport implements IEventTransport { */ async emitError(streamId: string, error: string): Promise { const channel = CHANNELS.events(streamId); - const seq = this.getNextSequence(streamId); + const seq = await this.getNextSequence(streamId); const message: PubSubMessage = { type: EventTypes.ERROR, seq, error }; try { @@ -598,20 +665,19 @@ export class RedisEventTransport implements IEventTransport { const state = this.streams.get(streamId); if (state) { - // Clear flush timeout - if (state.reorderBuffer.flushTimeout) { - clearTimeout(state.reorderBuffer.flushTimeout); - state.reorderBuffer.flushTimeout = null; - } - // Clear all handlers and callbacks state.handlers.clear(); state.allSubscribersLeftCallbacks = []; state.abortCallbacks = []; - state.reorderBuffer.pending.clear(); } - // Reset sequence counter for this stream - this.resetSequence(streamId); + this.resetReorderBuffer(streamId); + + // Delete the shared sequence key — safe because cleanup() is only called + // when the stream's job is complete (no more publishes will happen). + const seqKey = KEYS.sequence(streamId); + this.publisher.del(seqKey).catch((err) => { + logger.error(`[RedisEventTransport] Failed to delete sequence key ${seqKey}:`, err); + }); if (this.channelSubscriptions.has(channel)) { this.subscriber.unsubscribe(channel).catch((err) => { @@ -627,7 +693,11 @@ export class RedisEventTransport implements IEventTransport { * Destroy all resources. */ destroy(): void { - // Clear all flush timeouts and buffered messages + // Clear all flush timeouts and buffered messages. + // Sequence keys are NOT deleted here — they are shared across replicas. + // A shutting-down replica must not nuke the counter for active publishers. + // cleanup() deletes keys on normal teardown; a 24h safety-net TTL (set once + // at first INCR, never refreshed) caps orphan lifetime on abnormal shutdown. for (const [, state] of this.streams) { if (state.reorderBuffer.flushTimeout) { clearTimeout(state.reorderBuffer.flushTimeout); @@ -642,7 +712,6 @@ export class RedisEventTransport implements IEventTransport { this.channelSubscriptions.clear(); this.streams.clear(); - this.sequenceCounters.clear(); try { this.subscriber.disconnect(); diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index b59eed66f896..0d07b1953816 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -337,11 +337,14 @@ export interface IEventTransport { /** Listen for all subscribers leaving */ onAllSubscribersLeft(streamId: string, callback: () => void): void; - /** Reset publish sequence counter for a stream (used during full stream cleanup) */ - resetSequence?(streamId: string): void; - - /** Advance subscriber reorder buffer to match publisher sequence (cross-replica safe: doesn't reset publisher counter) */ - syncReorderBuffer?(streamId: string): void; + /** + * Advance subscriber reorder buffer to match publisher sequence (cross-replica safe). + * @param earlyReplayCount - Number of events replayed from earlyEventBuffer (same-replica). + * Pending entries with seq < earlyReplayCount are duplicates and are pruned; entries at or + * above are live chunks that arrived during the async GET window and are preserved. + * When 0/undefined (cross-replica), all pending entries are treated as live. + */ + syncReorderBuffer?(streamId: string, earlyReplayCount?: number): void | Promise; /** Cleanup transport resources for a specific stream */ cleanup(streamId: string): void; diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts index e241b7f9a49a..b751e0bbcf04 100644 --- a/packages/data-schemas/src/app/resolution.spec.ts +++ b/packages/data-schemas/src/app/resolution.spec.ts @@ -50,12 +50,187 @@ describe('mergeConfigOverrides', () => { expect(reg.custom).toBe('yes'); }); - it('replaces arrays instead of concatenating', () => { + it('replaces plain arrays (no merge key) instead of concatenating', () => { const configs = [fakeConfig({ endpoints: ['anthropic', 'google'] }, 10)]; const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; expect(result.endpoints).toEqual(['anthropic', 'google']); }); + it('merges endpoints.custom arrays by name instead of replacing', () => { + const base = { + endpoints: { + custom: [ + { name: 'yaml-only', baseURL: 'https://yaml-only.com', apiKey: 'key1' }, + { + name: 'shared', + baseURL: 'https://original.com', + apiKey: 'key2', + models: { default: ['m1'] }, + }, + ], + }, + } as unknown as AppConfig; + + const configs = [ + fakeConfig( + { + endpoints: { + custom: [ + { name: 'shared', baseURL: 'https://overridden.com' }, + { name: 'db-only', baseURL: 'https://db-only.com', apiKey: 'key3' }, + ], + }, + }, + 10, + ), + ]; + + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const endpoints = result.endpoints as Record; + const custom = endpoints.custom as Array>; + + expect(custom).toHaveLength(3); + // YAML-only item preserved + expect(custom[0]).toEqual({ + name: 'yaml-only', + baseURL: 'https://yaml-only.com', + apiKey: 'key1', + }); + // Shared item deep-merged: baseURL overridden, apiKey + models preserved from base + expect(custom[1]).toEqual({ + name: 'shared', + baseURL: 'https://overridden.com', + apiKey: 'key2', + models: { default: ['m1'] }, + }); + // DB-only item appended + expect(custom[2]).toEqual({ name: 'db-only', baseURL: 'https://db-only.com', apiKey: 'key3' }); + }); + + it('preserves all YAML custom endpoints when DB override is empty', () => { + const base = { + endpoints: { + custom: [ + { name: 'ep1', baseURL: 'https://ep1.com' }, + { name: 'ep2', baseURL: 'https://ep2.com' }, + ], + }, + } as unknown as AppConfig; + + const configs = [fakeConfig({ endpoints: { custom: [] } }, 10)]; + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const endpoints = result.endpoints as Record; + const custom = endpoints.custom as Array>; + + expect(custom).toHaveLength(2); + expect(custom[0].name).toBe('ep1'); + expect(custom[1].name).toBe('ep2'); + }); + + it('deduplicates when source contains repeated endpoint names', () => { + const base = { + endpoints: { custom: [] }, + } as unknown as AppConfig; + + const configs = [ + fakeConfig( + { + endpoints: { + custom: [ + { name: 'dup', baseURL: 'https://first.com' }, + { name: 'dup', baseURL: 'https://second.com' }, + ], + }, + }, + 10, + ), + ]; + + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const custom = (result.endpoints as Record).custom as Array< + Record + >; + + expect(custom).toHaveLength(1); + expect(custom[0].name).toBe('dup'); + // last-write-wins: Map.set overwrites on duplicate keys + expect(custom[0].baseURL).toBe('https://second.com'); + }); + + it('silently drops source items without a name field', () => { + const base = { + endpoints: { custom: [{ name: 'ep1', baseURL: 'https://ep1.com' }] }, + } as unknown as AppConfig; + + const configs = [ + fakeConfig({ endpoints: { custom: [{ baseURL: 'https://nameless.com' }] } }, 10), + ]; + + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const custom = (result.endpoints as Record).custom as Array< + Record + >; + + expect(custom).toHaveLength(1); + expect(custom[0].name).toBe('ep1'); + }); + + it('preserves base items without a name field', () => { + const base = { + endpoints: { custom: [{ baseURL: 'https://ep1.com' }] }, + } as unknown as AppConfig; + + const configs = [ + fakeConfig({ endpoints: { custom: [{ name: 'db-only', baseURL: 'https://db.com' }] } }, 10), + ]; + + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const custom = (result.endpoints as Record).custom as Array< + Record + >; + + expect(custom).toHaveLength(2); + expect(custom[0].baseURL).toBe('https://ep1.com'); + expect(custom[1].name).toBe('db-only'); + }); + + it('does not mutate base custom endpoint items', () => { + const base = { + endpoints: { custom: [{ name: 'ep1', baseURL: 'https://ep1.com' }] }, + } as unknown as AppConfig; + + const configs = [fakeConfig({ endpoints: { custom: [] } }, 10)]; + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const custom = (result.endpoints as Record).custom as Array< + Record + >; + + custom[0].baseURL = 'https://mutated.com'; + const original = (base as unknown as Record).endpoints as Record< + string, + unknown + >; + expect((original.custom as Array>)[0].baseURL).toBe('https://ep1.com'); + }); + + it('respects priority for custom endpoint merges — higher priority wins', () => { + const base = { + endpoints: { custom: [{ name: 'shared', baseURL: 'https://yaml.com' }] }, + } as unknown as AppConfig; + + const configs = [ + fakeConfig({ endpoints: { custom: [{ name: 'shared', baseURL: 'https://low.com' }] } }, 10), + fakeConfig({ endpoints: { custom: [{ name: 'shared', baseURL: 'https://high.com' }] } }, 100), + ]; + + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const custom = (result.endpoints as Record).custom as Array< + Record + >; + + expect(custom[0].baseURL).toBe('https://high.com'); + }); + it('does not mutate the base config', () => { const original = JSON.parse(JSON.stringify(baseConfig)); const configs = [fakeConfig({ interface: { modelSelect: false } }, 10)]; diff --git a/packages/data-schemas/src/app/resolution.ts b/packages/data-schemas/src/app/resolution.ts index 08b4d1b12d9f..cc1e11cb8210 100644 --- a/packages/data-schemas/src/app/resolution.ts +++ b/packages/data-schemas/src/app/resolution.ts @@ -7,6 +7,19 @@ type AnyObject = { [key: string]: unknown }; const MAX_MERGE_DEPTH = 10; const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +/** + * Paths within the config tree where arrays of objects should be merged by + * a key field rather than replaced wholesale. `deepMerge` matches items by + * the given key, deep-merges matching pairs, preserves unmatched base items, + * and appends new override-only items. + * + * Paths use AppConfig key names (post-OVERRIDE_KEY_MAP remapping), + * not YAML-level key names. E.g. use `interfaceConfig.x`, not `interface.x`. + */ +const ARRAY_MERGE_KEYS: Record = { + 'endpoints.custom': 'name', +}; + /** * Maps YAML-level override keys (TCustomConfig) to their AppConfig equivalents. * Overrides are stored with YAML keys but merged into the already-processed AppConfig @@ -23,12 +36,62 @@ const OVERRIDE_KEY_MAP: Partial> = turnstile: 'turnstileConfig', }; -function deepMerge(target: T, source: AnyObject, depth = 0): T { +function mergeArrayByKey( + target: AnyObject[], + source: AnyObject[], + keyField: string, + depth: number, + path: string, +): AnyObject[] { + const sourceByKey = new Map(); + for (const item of source) { + if (item != null && typeof item === 'object') { + const key = item[keyField]; + // Source items without a key value are skipped: no stable identity + // for matching or appending. (Keyless target items are preserved as-is below.) + if (key != null) { + sourceByKey.set(key, item); + } + } + } + + const result: AnyObject[] = []; + const seen = new Set(); + + // Pass the array container path (not a per-element path) so item + // properties build paths like 'endpoints.custom.baseURL' for any + // nested ARRAY_MERGE_KEYS lookups. + for (const item of target) { + if (item != null && typeof item === 'object') { + const key = item[keyField]; + const override = key != null ? sourceByKey.get(key) : undefined; + if (override) { + result.push(deepMerge(item, override, depth + 1, path)); + seen.add(key); + } else { + result.push({ ...item }); + } + } else { + result.push(item); + } + } + + for (const key of sourceByKey.keys()) { + if (!seen.has(key)) { + result.push(deepMerge({} as AnyObject, sourceByKey.get(key)!, depth + 1, path)); + } + } + + return result; +} + +function deepMerge(target: T, source: AnyObject, depth = 0, path = ''): T { const result = { ...target } as AnyObject; for (const key of Object.keys(source)) { if (UNSAFE_KEYS.has(key)) { continue; } + const currentPath = path ? `${path}.${key}` : key; const sourceVal = source[key]; const targetVal = result[key]; if ( @@ -40,7 +103,25 @@ function deepMerge(target: T, source: AnyObject, depth = 0) typeof targetVal === 'object' && !Array.isArray(targetVal) ) { - result[key] = deepMerge(targetVal as AnyObject, sourceVal as AnyObject, depth + 1); + result[key] = deepMerge( + targetVal as AnyObject, + sourceVal as AnyObject, + depth + 1, + currentPath, + ); + } else if ( + depth < MAX_MERGE_DEPTH && + Array.isArray(sourceVal) && + Array.isArray(targetVal) && + ARRAY_MERGE_KEYS[currentPath] + ) { + result[key] = mergeArrayByKey( + targetVal as AnyObject[], + sourceVal as AnyObject[], + ARRAY_MERGE_KEYS[currentPath], + depth, + currentPath, + ); } else { result[key] = sourceVal; }