diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e86290732f2..68dc5b16eb010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ # Changelog +### [Version 1.11.5](https://github.com/lobehub/lobe-chat/compare/v1.11.4...v1.11.5) + +Released on **2024-08-18** + +#### ♻ Code Refactoring + +- **misc**: Refactor the fetch method to fix `response.undefined`. + +
+ +
+Improvements and Fixes + +#### Code refactoring + +- **misc**: Refactor the fetch method to fix `response.undefined`, closes [#3493](https://github.com/lobehub/lobe-chat/issues/3493) ([30d0609](https://github.com/lobehub/lobe-chat/commit/30d0609)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ ### [Version 1.11.4](https://github.com/lobehub/lobe-chat/compare/v1.11.3...v1.11.4) Released on **2024-08-18** diff --git a/locales/ar/error.json b/locales/ar/error.json index f599c7e584016..794bf0bbea935 100644 --- a/locales/ar/error.json +++ b/locales/ar/error.json @@ -79,7 +79,9 @@ "PluginServerError": "خطأ في استجابة الخادم لطلب الإضافة، يرجى التحقق من ملف وصف الإضافة وتكوين الإضافة وتنفيذ الخادم وفقًا لمعلومات الخطأ أدناه", "PluginSettingsInvalid": "تحتاج هذه الإضافة إلى تكوين صحيح قبل الاستخدام، يرجى التحقق من صحة تكوينك", "ProviderBizError": "طلب خدمة {{provider}} خاطئ، يرجى التحقق من المعلومات التالية أو إعادة المحاولة", - "SubscriptionPlanLimit": "لقد استنفذت حصتك من الاشتراك، لا يمكنك استخدام هذه الوظيفة، يرجى الترقية إلى خطة أعلى أو شراء حزمة موارد للمتابعة" + "StreamChunkError": "خطأ في تحليل كتلة الرسالة لطلب التدفق، يرجى التحقق مما إذا كانت واجهة برمجة التطبيقات الحالية تتوافق مع المعايير، أو الاتصال بمزود واجهة برمجة التطبيقات الخاصة بك للاستفسار.", + "SubscriptionPlanLimit": "لقد استنفذت حصتك من الاشتراك، لا يمكنك استخدام هذه الوظيفة، يرجى الترقية إلى خطة أعلى أو شراء حزمة موارد للمتابعة", + "UnknownChatFetchError": "عذرًا، حدث خطأ غير معروف في الطلب، يرجى التحقق من المعلومات التالية أو المحاولة مرة أخرى" }, "stt": { "responseError": "فشل طلب الخدمة، يرجى التحقق من الإعدادات أو إعادة المحاولة" diff --git a/locales/bg-BG/error.json b/locales/bg-BG/error.json index ebedfc9368cb4..1c53bf6f595c7 100644 --- a/locales/bg-BG/error.json +++ b/locales/bg-BG/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Заявката към сървъра на плъгина върна грешка. Моля, проверете файла на манифеста на плъгина, конфигурацията на плъгина или изпълнението на сървъра въз основа на информацията за грешката по-долу", "PluginSettingsInvalid": "Този плъгин трябва да бъде конфигуриран правилно, преди да може да се използва. Моля, проверете дали конфигурацията ви е правилна", "ProviderBizError": "Грешка в услугата на {{provider}}, моля проверете следната информация или опитайте отново", - "SubscriptionPlanLimit": "Изчерпали сте вашия абонаментен лимит и не можете да използвате тази функционалност. Моля, надстройте до по-висок план или закупете допълнителни ресурси, за да продължите да я използвате." + "StreamChunkError": "Грешка при парсирането на съобщение от потокова заявка. Моля, проверете дали текущият API интерфейс отговаря на стандартите или се свържете с вашия доставчик на API за консултация.", + "SubscriptionPlanLimit": "Изчерпали сте вашия абонаментен лимит и не можете да използвате тази функционалност. Моля, надстройте до по-висок план или закупете допълнителни ресурси, за да продължите да я използвате.", + "UnknownChatFetchError": "Съжаляваме, възникна неизвестна грешка при заявката. Моля, проверете информацията по-долу или опитайте отново." }, "stt": { "responseError": "Заявката за услуга е неуспешна, моля, проверете конфигурацията или опитайте отново" diff --git a/locales/de-DE/error.json b/locales/de-DE/error.json index a262f071dff1e..fc688c249f8c1 100644 --- a/locales/de-DE/error.json +++ b/locales/de-DE/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Fehler bei der Serveranfrage des Plugins. Bitte überprüfen Sie die Fehlerinformationen unten in Ihrer Plugin-Beschreibungsdatei, Plugin-Konfiguration oder Serverimplementierung", "PluginSettingsInvalid": "Das Plugin muss korrekt konfiguriert werden, um verwendet werden zu können. Bitte überprüfen Sie Ihre Konfiguration auf Richtigkeit", "ProviderBizError": "Fehler bei der Anforderung des {{provider}}-Dienstes. Bitte überprüfen Sie die folgenden Informationen oder versuchen Sie es erneut.", - "SubscriptionPlanLimit": "Ihr Abonnementkontingent wurde aufgebraucht und Sie können diese Funktion nicht nutzen. Bitte aktualisieren Sie auf ein höheres Abonnement oder kaufen Sie ein Ressourcenpaket, um fortzufahren." + "StreamChunkError": "Fehler beim Parsen des Nachrichtenchunks der Streaming-Anfrage. Bitte überprüfen Sie, ob die aktuelle API-Schnittstelle den Standards entspricht, oder wenden Sie sich an Ihren API-Anbieter.", + "SubscriptionPlanLimit": "Ihr Abonnementkontingent wurde aufgebraucht und Sie können diese Funktion nicht nutzen. Bitte aktualisieren Sie auf ein höheres Abonnement oder kaufen Sie ein Ressourcenpaket, um fortzufahren.", + "UnknownChatFetchError": "Es tut uns leid, es ist ein unbekannter Anforderungsfehler aufgetreten. Bitte überprüfen Sie die folgenden Informationen oder versuchen Sie es erneut." }, "stt": { "responseError": "Serviceanfrage fehlgeschlagen. Bitte überprüfen Sie die Konfiguration oder versuchen Sie es erneut" diff --git a/locales/en-US/error.json b/locales/en-US/error.json index 8c8532d008591..0c756a836a5d0 100644 --- a/locales/en-US/error.json +++ b/locales/en-US/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Plugin server request returned an error. Please check your plugin manifest file, plugin configuration, or server implementation based on the error information below", "PluginSettingsInvalid": "This plugin needs to be correctly configured before it can be used. Please check if your configuration is correct", "ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information", - "SubscriptionPlanLimit": "Your subscription limit has been reached, and you cannot use this feature. Please upgrade to a higher plan or purchase a resource pack to continue using it." + "StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.", + "SubscriptionPlanLimit": "Your subscription limit has been reached, and you cannot use this feature. Please upgrade to a higher plan or purchase a resource pack to continue using it.", + "UnknownChatFetchError": "Sorry, an unknown request error occurred. Please check the information below or try again." }, "stt": { "responseError": "Service request failed, please check the configuration or try again" diff --git a/locales/es-ES/error.json b/locales/es-ES/error.json index 11c7c0cdf3ba3..c41b859b6823e 100644 --- a/locales/es-ES/error.json +++ b/locales/es-ES/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Error al recibir la respuesta del servidor del complemento. Verifique el archivo de descripción del complemento, la configuración del complemento o la implementación del servidor según la información de error a continuación", "PluginSettingsInvalid": "Este complemento necesita una configuración correcta antes de poder usarse. Verifique si su configuración es correcta", "ProviderBizError": "Se produjo un error al solicitar el servicio de {{provider}}, por favor, revise la siguiente información o inténtelo de nuevo", - "SubscriptionPlanLimit": "Has alcanzado el límite de tu suscripción y no puedes utilizar esta función. Por favor, actualiza a un plan superior o compra un paquete de recursos para seguir utilizando." + "StreamChunkError": "Error de análisis del bloque de mensajes de la solicitud en streaming. Por favor, verifica si la API actual cumple con las normas estándar o contacta a tu proveedor de API para más información.", + "SubscriptionPlanLimit": "Has alcanzado el límite de tu suscripción y no puedes utilizar esta función. Por favor, actualiza a un plan superior o compra un paquete de recursos para seguir utilizando.", + "UnknownChatFetchError": "Lo sentimos, se ha producido un error desconocido en la solicitud. Por favor, verifica la información a continuación o intenta de nuevo." }, "stt": { "responseError": "Error en la solicitud de servicio. Verifique la configuración o reintente" diff --git a/locales/fr-FR/error.json b/locales/fr-FR/error.json index 6e535b01bb639..fda6562815513 100644 --- a/locales/fr-FR/error.json +++ b/locales/fr-FR/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Erreur de réponse du serveur du plugin. Veuillez vérifier le fichier de description du plugin, la configuration du plugin ou la mise en œuvre côté serveur en fonction des informations d'erreur ci-dessous", "PluginSettingsInvalid": "Ce plugin doit être correctement configuré avant de pouvoir être utilisé. Veuillez vérifier votre configuration", "ProviderBizError": "Erreur de service {{provider}}. Veuillez vérifier les informations suivantes ou réessayer.", - "SubscriptionPlanLimit": "Vous avez atteint votre limite d'abonnement et ne pouvez pas utiliser cette fonction. Veuillez passer à un plan supérieur ou acheter un pack de ressources pour continuer à l'utiliser." + "StreamChunkError": "Erreur de parsing du bloc de message de la requête en streaming. Veuillez vérifier si l'API actuelle respecte les normes ou contacter votre fournisseur d'API pour des conseils.", + "SubscriptionPlanLimit": "Vous avez atteint votre limite d'abonnement et ne pouvez pas utiliser cette fonction. Veuillez passer à un plan supérieur ou acheter un pack de ressources pour continuer à l'utiliser.", + "UnknownChatFetchError": "Désolé, une erreur de requête inconnue s'est produite. Veuillez vérifier les informations ci-dessous ou réessayer." }, "stt": { "responseError": "Échec de la requête de service. Veuillez vérifier la configuration ou réessayer" diff --git a/locales/it-IT/error.json b/locales/it-IT/error.json index 1417f8049c366..a10c1e0ff19e5 100644 --- a/locales/it-IT/error.json +++ b/locales/it-IT/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Errore nella risposta del server del plugin. Verifica il file descrittivo del plugin, la configurazione del plugin o l'implementazione del server", "PluginSettingsInvalid": "Il plugin deve essere configurato correttamente prima di poter essere utilizzato. Verifica che la tua configurazione sia corretta", "ProviderBizError": "Errore di business del fornitore {{provider}}. Si prega di controllare le informazioni seguenti o riprovare.", - "SubscriptionPlanLimit": "Il tuo piano di abbonamento ha raggiunto il limite e non puoi utilizzare questa funzione. Per favore, passa a un piano superiore o acquista un pacchetto di risorse per continuare." + "StreamChunkError": "Erro di analisi del blocco di messaggi della richiesta in streaming. Controlla se l'interfaccia API attuale è conforme agli standard o contatta il tuo fornitore di API per ulteriori informazioni.", + "SubscriptionPlanLimit": "Il tuo piano di abbonamento ha raggiunto il limite e non puoi utilizzare questa funzione. Per favore, passa a un piano superiore o acquista un pacchetto di risorse per continuare.", + "UnknownChatFetchError": "Ci scusiamo, si è verificato un errore di richiesta sconosciuto. Si prega di controllare le informazioni seguenti o riprovare." }, "stt": { "responseError": "Errore nella richiesta del servizio. Verifica la configurazione o riprova" diff --git a/locales/ja-JP/error.json b/locales/ja-JP/error.json index b95d066379234..e03c14e0cac8f 100644 --- a/locales/ja-JP/error.json +++ b/locales/ja-JP/error.json @@ -79,7 +79,9 @@ "PluginServerError": "プラグインサーバーのリクエストエラーが発生しました。以下のエラーメッセージを参考に、プラグインのマニフェストファイル、設定、サーバー実装を確認してください", "PluginSettingsInvalid": "このプラグインを使用するには、正しい設定が必要です。設定が正しいかどうか確認してください", "ProviderBizError": "リクエスト {{provider}} サービスでエラーが発生しました。以下の情報を確認して再試行してください。", - "SubscriptionPlanLimit": "ご契約のクォータが使い切られましたので、この機能を使用することはできません。より高いプランにアップグレードするか、リソースパッケージを購入して継続してください。" + "StreamChunkError": "ストリーミングリクエストのメッセージブロック解析エラーです。現在のAPIインターフェースが標準仕様に準拠しているか確認するか、APIプロバイダーにお問い合わせください。", + "SubscriptionPlanLimit": "ご契約のクォータが使い切られましたので、この機能を使用することはできません。より高いプランにアップグレードするか、リソースパッケージを購入して継続してください。", + "UnknownChatFetchError": "申し訳ありませんが、未知のリクエストエラーが発生しました。以下の情報をもとに確認するか、再試行してください。" }, "stt": { "responseError": "サービスリクエストが失敗しました。設定を確認するか、もう一度お試しください" diff --git a/locales/ko-KR/error.json b/locales/ko-KR/error.json index c97f615f833f4..5098d69dbb99a 100644 --- a/locales/ko-KR/error.json +++ b/locales/ko-KR/error.json @@ -79,7 +79,9 @@ "PluginServerError": "플러그인 서버 요청이 오류로 반환되었습니다. 플러그인 설명 파일, 플러그인 구성 또는 서버 구현을 확인해주세요.", "PluginSettingsInvalid": "플러그인을 사용하려면 올바른 구성이 필요합니다. 구성이 올바른지 확인해주세요.", "ProviderBizError": "요청한 {{provider}} 서비스에서 오류가 발생했습니다. 아래 정보를 확인하고 다시 시도해주세요.", - "SubscriptionPlanLimit": "구독 한도를 모두 사용했으므로이 기능을 사용할 수 없습니다. 더 높은 요금제로 업그레이드하거나 리소스 패키지를 구매하여 계속 사용하십시오." + "StreamChunkError": "스트리밍 요청의 메시지 블록 구문 분석 오류입니다. 현재 API 인터페이스가 표준 규격에 부합하는지 확인하거나 API 공급자에게 문의하십시오.", + "SubscriptionPlanLimit": "구독 한도를 모두 사용했으므로이 기능을 사용할 수 없습니다. 더 높은 요금제로 업그레이드하거나 리소스 패키지를 구매하여 계속 사용하십시오.", + "UnknownChatFetchError": "죄송합니다. 알 수 없는 요청 오류가 발생했습니다. 아래 정보를 참고하여 문제를 해결하거나 다시 시도해 주세요." }, "stt": { "responseError": "서비스 요청이 실패했습니다. 구성을 확인하거나 다시 시도해주세요." diff --git a/locales/nl-NL/error.json b/locales/nl-NL/error.json index a953ea8617077..8d353b7f4c908 100644 --- a/locales/nl-NL/error.json +++ b/locales/nl-NL/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Fout bij serverrespons voor plug-in. Controleer de foutinformatie hieronder voor uw plug-inbeschrijvingsbestand, plug-inconfiguratie of serverimplementatie", "PluginSettingsInvalid": "Deze plug-in moet correct geconfigureerd zijn voordat deze kan worden gebruikt. Controleer of uw configuratie juist is", "ProviderBizError": "Er is een fout opgetreden bij het aanvragen van de {{provider}}-service. Controleer de volgende informatie of probeer het opnieuw.", - "SubscriptionPlanLimit": "Uw abonnementslimiet is bereikt en u kunt deze functie niet gebruiken. Upgrade naar een hoger plan of koop een resourcepakket om door te gaan met gebruiken." + "StreamChunkError": "Fout bij het parseren van het berichtblok van de streamingaanroep. Controleer of de huidige API-interface voldoet aan de standaardnormen, of neem contact op met uw API-leverancier voor advies.", + "SubscriptionPlanLimit": "Uw abonnementslimiet is bereikt en u kunt deze functie niet gebruiken. Upgrade naar een hoger plan of koop een resourcepakket om door te gaan met gebruiken.", + "UnknownChatFetchError": "Het spijt me, er is een onbekende verzoekfout opgetreden. Controleer de onderstaande informatie of probeer het opnieuw." }, "stt": { "responseError": "Serviceverzoek mislukt. Controleer de configuratie of probeer opnieuw" diff --git a/locales/pl-PL/error.json b/locales/pl-PL/error.json index e4cf34f4c424f..6f467d1affc6f 100644 --- a/locales/pl-PL/error.json +++ b/locales/pl-PL/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Błąd zwrócony przez serwer wtyczki. Proszę sprawdź plik opisowy wtyczki, konfigurację wtyczki lub implementację serwera zgodnie z poniższymi informacjami o błędzie", "PluginSettingsInvalid": "Ta wtyczka wymaga poprawnej konfiguracji przed użyciem. Proszę sprawdź, czy Twoja konfiguracja jest poprawna", "ProviderBizError": "Wystąpił błąd usługi {{provider}}, proszę sprawdzić poniższe informacje lub spróbować ponownie", - "SubscriptionPlanLimit": "Wykorzystałeś limit swojego abonamentu i nie możesz korzystać z tej funkcji. Proszę uaktualnić do wyższego planu lub zakupić dodatkowy pakiet zasobów, aby kontynuować korzystanie." + "StreamChunkError": "Błąd analizy bloku wiadomości w żądaniu strumieniowym. Proszę sprawdzić, czy aktualny interfejs API jest zgodny z normami, lub skontaktować się z dostawcą API w celu uzyskania informacji.", + "SubscriptionPlanLimit": "Wykorzystałeś limit swojego abonamentu i nie możesz korzystać z tej funkcji. Proszę uaktualnić do wyższego planu lub zakupić dodatkowy pakiet zasobów, aby kontynuować korzystanie.", + "UnknownChatFetchError": "Przykro nam, wystąpił nieznany błąd żądania. Proszę sprawdzić poniższe informacje lub spróbować ponownie." }, "stt": { "responseError": "Błąd żądania usługi. Proszę sprawdź konfigurację i spróbuj ponownie" diff --git a/locales/pt-BR/error.json b/locales/pt-BR/error.json index 1be1c1b6adb4c..ec7ee73da149d 100644 --- a/locales/pt-BR/error.json +++ b/locales/pt-BR/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Erro na resposta do servidor do plugin. Verifique o arquivo de descrição do plugin, a configuração do plugin ou a implementação do servidor de acordo com as informações de erro abaixo", "PluginSettingsInvalid": "Este plugin precisa ser configurado corretamente antes de ser usado. Verifique se sua configuração está correta", "ProviderBizError": "Erro no serviço {{provider}} solicitado. Por favor, verifique as informações abaixo ou tente novamente.", - "SubscriptionPlanLimit": "Você atingiu o limite de sua assinatura e não pode usar essa função. Por favor, faça upgrade para um plano superior ou compre um pacote de recursos para continuar usando." + "StreamChunkError": "Erro de análise do bloco de mensagem da solicitação em fluxo. Verifique se a interface da API atual está em conformidade com os padrões ou entre em contato com seu fornecedor de API para mais informações.", + "SubscriptionPlanLimit": "Você atingiu o limite de sua assinatura e não pode usar essa função. Por favor, faça upgrade para um plano superior ou compre um pacote de recursos para continuar usando.", + "UnknownChatFetchError": "Desculpe, ocorreu um erro desconhecido na solicitação. Por favor, verifique as informações abaixo ou tente novamente." }, "stt": { "responseError": "Falha na solicitação de serviço. Verifique a configuração ou tente novamente" diff --git a/locales/ru-RU/error.json b/locales/ru-RU/error.json index e42eb34d0616b..a79a5297a6fb8 100644 --- a/locales/ru-RU/error.json +++ b/locales/ru-RU/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Запрос сервера плагина возвратил ошибку. Проверьте файл манифеста плагина, конфигурацию плагина или реализацию сервера на основе информации об ошибке ниже", "PluginSettingsInvalid": "Этот плагин необходимо правильно настроить, прежде чем его можно будет использовать. Пожалуйста, проверьте правильность вашей конфигурации", "ProviderBizError": "Ошибка обслуживания {{provider}}. Пожалуйста, проверьте следующую информацию или повторите попытку", - "SubscriptionPlanLimit": "Вы исчерпали свой лимит подписки и не можете использовать эту функцию. Пожалуйста, перейдите на более высокий план или приобретите дополнительные ресурсы для продолжения использования." + "StreamChunkError": "Ошибка разбора блока сообщения потокового запроса. Пожалуйста, проверьте, соответствует ли текущий API стандартам, или свяжитесь с вашим поставщиком API для получения консультации.", + "SubscriptionPlanLimit": "Вы исчерпали свой лимит подписки и не можете использовать эту функцию. Пожалуйста, перейдите на более высокий план или приобретите дополнительные ресурсы для продолжения использования.", + "UnknownChatFetchError": "Извините, произошла неизвестная ошибка запроса. Пожалуйста, проверьте информацию ниже или попробуйте снова." }, "stt": { "responseError": "Ошибка запроса сервиса. Пожалуйста, проверьте конфигурацию или повторите попытку" diff --git a/locales/tr-TR/error.json b/locales/tr-TR/error.json index 50a5b4cde2b08..d6ddc2d7b55b5 100644 --- a/locales/tr-TR/error.json +++ b/locales/tr-TR/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Eklenti sunucusu isteği bir hata ile döndü. Lütfen aşağıdaki hata bilgilerine dayanarak eklenti bildirim dosyanızı, eklenti yapılandırmanızı veya sunucu uygulamanızı kontrol edin", "PluginSettingsInvalid": "Bu eklenti, kullanılmadan önce doğru şekilde yapılandırılmalıdır. Lütfen yapılandırmanızın doğru olup olmadığını kontrol edin", "ProviderBizError": "Talep {{provider}} hizmetinde bir hata oluştu, lütfen aşağıdaki bilgilere göre sorunu giderin veya tekrar deneyin", - "SubscriptionPlanLimit": "Abonelik kotası tükenmiş, bu özelliği kullanamazsınız. Lütfen daha yüksek bir plana yükseltin veya kaynak paketi satın alarak devam edin." + "StreamChunkError": "Akış isteği mesaj parçası çözümleme hatası, lütfen mevcut API arayüzünün standartlara uygun olup olmadığını kontrol edin veya API sağlayıcınızla iletişime geçin.", + "SubscriptionPlanLimit": "Abonelik kotası tükenmiş, bu özelliği kullanamazsınız. Lütfen daha yüksek bir plana yükseltin veya kaynak paketi satın alarak devam edin.", + "UnknownChatFetchError": "Üzgünüm, bilinmeyen bir istek hatasıyla karşılaştık. Lütfen aşağıdaki bilgileri kontrol edin veya tekrar deneyin." }, "stt": { "responseError": "Hizmet isteği başarısız oldu, lütfen yapılandırmayı kontrol edin veya tekrar deneyin" diff --git a/locales/vi-VN/error.json b/locales/vi-VN/error.json index 403d9439b8672..3e1a66a3aa5d5 100644 --- a/locales/vi-VN/error.json +++ b/locales/vi-VN/error.json @@ -79,7 +79,9 @@ "PluginServerError": "Lỗi trả về từ máy chủ plugin, vui lòng kiểm tra tệp mô tả plugin, cấu hình plugin hoặc triển khai máy chủ theo thông tin lỗi dưới đây", "PluginSettingsInvalid": "Plugin cần phải được cấu hình đúng trước khi sử dụng, vui lòng kiểm tra cấu hình của bạn có đúng không", "ProviderBizError": "Yêu cầu dịch vụ {{provider}} gặp sự cố, vui lòng kiểm tra thông tin dưới đây hoặc thử lại", - "SubscriptionPlanLimit": "Số lượng đăng ký của bạn đã hết, không thể sử dụng tính năng này. Vui lòng nâng cấp lên gói cao hơn hoặc mua gói tài nguyên để tiếp tục sử dụng." + "StreamChunkError": "Lỗi phân tích khối tin nhắn yêu cầu luồng, vui lòng kiểm tra xem API hiện tại có tuân thủ tiêu chuẩn hay không, hoặc liên hệ với nhà cung cấp API của bạn để được tư vấn.", + "SubscriptionPlanLimit": "Số lượng đăng ký của bạn đã hết, không thể sử dụng tính năng này. Vui lòng nâng cấp lên gói cao hơn hoặc mua gói tài nguyên để tiếp tục sử dụng.", + "UnknownChatFetchError": "Xin lỗi, đã xảy ra lỗi yêu cầu không xác định. Vui lòng kiểm tra hoặc thử lại theo thông tin dưới đây." }, "stt": { "responseError": "Yêu cầu dịch vụ thất bại, vui lòng kiểm tra cấu hình hoặc thử lại" diff --git a/locales/zh-CN/error.json b/locales/zh-CN/error.json index fa1b6af45f4a6..35ab5eec4dbe8 100644 --- a/locales/zh-CN/error.json +++ b/locales/zh-CN/error.json @@ -74,6 +74,8 @@ "NoOpenAIAPIKey": "OpenAI API Key 不正确或为空,请添加自定义 OpenAI API Key", "OpenAIBizError": "请求 OpenAI 服务出错,请根据以下信息排查或重试", "InvalidBedrockCredentials": "Bedrock 鉴权未通过,请检查 AccessKeyId/SecretAccessKey 后重试", + "StreamChunkError": "流式请求的消息块解析错误,请检查当前 API 接口是否符合标准规范,或联系你的 API 供应商咨询", + "UnknownChatFetchError": "很抱歉,遇到未知请求错误,请根据以下信息排查或重试", "InvalidOllamaArgs": "Ollama 配置不正确,请检查 Ollama 配置后重试", "OllamaBizError": "请求 Ollama 服务出错,请根据以下信息排查或重试", "OllamaServiceUnavailable": "Ollama 服务连接失败,请检查 Ollama 是否运行正常,或是否正确设置 Ollama 的跨域配置", diff --git a/locales/zh-TW/error.json b/locales/zh-TW/error.json index 44ae9ebfbe2d6..e617182437ee0 100644 --- a/locales/zh-TW/error.json +++ b/locales/zh-TW/error.json @@ -79,7 +79,9 @@ "PluginServerError": "外掛伺服器請求回傳錯誤。請根據下面的錯誤資訊檢查您的外掛描述檔案、外掛設定或伺服器實作", "PluginSettingsInvalid": "該外掛需要正確設定後才可以使用。請檢查您的設定是否正確", "ProviderBizError": "請求 {{provider}} 服務出錯,請根據以下資訊排查或重試", - "SubscriptionPlanLimit": "您的訂閱額度已用盡,無法使用該功能,請升級到更高的計劃,或購買資源包後繼續使用" + "StreamChunkError": "流式請求的消息塊解析錯誤,請檢查當前 API 介面是否符合標準規範,或聯繫你的 API 供應商諮詢", + "SubscriptionPlanLimit": "您的訂閱額度已用盡,無法使用該功能,請升級到更高的計劃,或購買資源包後繼續使用", + "UnknownChatFetchError": "很抱歉,遇到未知請求錯誤,請根據以下資訊排查或重試" }, "stt": { "responseError": "服務請求失敗,請檢查配置或重試" diff --git a/package.json b/package.json index 68f060db948c3..081f94029189a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lobehub/chat", - "version": "1.11.4", + "version": "1.11.5", "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.", "keywords": [ "framework", @@ -39,7 +39,6 @@ "db:studio": "drizzle-kit studio", "db:z-pull": "drizzle-kit introspect", "dev": "next dev -p 3010", - "dev:clerk-proxy": "ngrok http http://localhost:3011", "docs:i18n": "lobe-i18n md && npm run lint:mdx", "docs:seo": "lobe-seo && npm run lint:mdx", "i18n": "npm run workflow:i18n && lobe-i18n", @@ -64,6 +63,7 @@ "test-server:coverage": "vitest run --config vitest.server.config.ts --coverage", "test:update": "vitest -u", "type-check": "tsc --noEmit", + "webhook:ngrok": "ngrok http http://localhost:3011", "workflow:docs": "tsx scripts/docsWorkflow/index.ts", "workflow:i18n": "tsx scripts/i18nWorkflow/index.ts", "workflow:mdx": "tsx ./scripts/mdxWorkflow/index.ts", @@ -120,7 +120,6 @@ "@lobehub/icons": "^1.28.0", "@lobehub/tts": "^1.24.3", "@lobehub/ui": "^1.149.2", - "@microsoft/fetch-event-source": "^2.0.1", "@neondatabase/serverless": "^0.9.4", "@next/third-parties": "^14.2.4", "@sentry/nextjs": "^7.118.0", diff --git a/src/app/(main)/chat/(workspace)/@portal/features/ArtifactUI/Footer.tsx b/src/app/(main)/chat/(workspace)/@portal/Artifacts/Footer.tsx similarity index 100% rename from src/app/(main)/chat/(workspace)/@portal/features/ArtifactUI/Footer.tsx rename to src/app/(main)/chat/(workspace)/@portal/Artifacts/Footer.tsx diff --git a/src/app/(main)/chat/(workspace)/@portal/features/ArtifactUI/ToolRender.tsx b/src/app/(main)/chat/(workspace)/@portal/Artifacts/ToolRender.tsx similarity index 100% rename from src/app/(main)/chat/(workspace)/@portal/features/ArtifactUI/ToolRender.tsx rename to src/app/(main)/chat/(workspace)/@portal/Artifacts/ToolRender.tsx diff --git a/src/app/(main)/chat/(workspace)/@portal/features/ArtifactUI/index.tsx b/src/app/(main)/chat/(workspace)/@portal/Artifacts/index.tsx similarity index 100% rename from src/app/(main)/chat/(workspace)/@portal/features/ArtifactUI/index.tsx rename to src/app/(main)/chat/(workspace)/@portal/Artifacts/index.tsx diff --git a/src/app/(main)/chat/(workspace)/@portal/features/Artifacts/ArtifactList/Item/index.tsx b/src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/ArtifactList/Item/index.tsx similarity index 100% rename from src/app/(main)/chat/(workspace)/@portal/features/Artifacts/ArtifactList/Item/index.tsx rename to src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/ArtifactList/Item/index.tsx diff --git a/src/app/(main)/chat/(workspace)/@portal/features/Artifacts/ArtifactList/Item/style.ts b/src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/ArtifactList/Item/style.ts similarity index 100% rename from src/app/(main)/chat/(workspace)/@portal/features/Artifacts/ArtifactList/Item/style.ts rename to src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/ArtifactList/Item/style.ts diff --git a/src/app/(main)/chat/(workspace)/@portal/features/Artifacts/ArtifactList/index.tsx b/src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/ArtifactList/index.tsx similarity index 95% rename from src/app/(main)/chat/(workspace)/@portal/features/Artifacts/ArtifactList/index.tsx rename to src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/ArtifactList/index.tsx index e0abea04090f2..b7a22c34a8d1e 100644 --- a/src/app/(main)/chat/(workspace)/@portal/features/Artifacts/ArtifactList/index.tsx +++ b/src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/ArtifactList/index.tsx @@ -41,7 +41,7 @@ const ArtifactList = () => { size={48} /> - {t('emptyArtifactList')} + {t('emptyArtifactList')} ) : ( diff --git a/src/app/(main)/chat/(workspace)/@portal/features/Artifacts/index.tsx b/src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/index.tsx similarity index 86% rename from src/app/(main)/chat/(workspace)/@portal/features/Artifacts/index.tsx rename to src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/index.tsx index 2fdf43c474694..9407b5eff0787 100644 --- a/src/app/(main)/chat/(workspace)/@portal/features/Artifacts/index.tsx +++ b/src/app/(main)/chat/(workspace)/@portal/Home/Artifacts/index.tsx @@ -3,7 +3,7 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Flexbox } from 'react-layout-kit'; -import ToolList from './ArtifactList'; +import ArtifactList from './ArtifactList'; export const Artifacts = memo(() => { const { t } = useTranslation('portal'); @@ -13,7 +13,7 @@ export const Artifacts = memo(() => { {t('Artifacts')} - + ); }); diff --git a/src/app/(main)/chat/(workspace)/@portal/Home/index.tsx b/src/app/(main)/chat/(workspace)/@portal/Home/index.tsx new file mode 100644 index 0000000000000..1b8a4f2929dce --- /dev/null +++ b/src/app/(main)/chat/(workspace)/@portal/Home/index.tsx @@ -0,0 +1,13 @@ +import { Flexbox } from 'react-layout-kit'; + +import Artifacts from './Artifacts'; + +const Home = () => { + return ( + + + + ); +}; + +export default Home; diff --git a/src/app/(main)/chat/(workspace)/@portal/_layout/Desktop.tsx b/src/app/(main)/chat/(workspace)/@portal/_layout/Desktop.tsx index 522f386215848..d929430e10ffa 100644 --- a/src/app/(main)/chat/(workspace)/@portal/_layout/Desktop.tsx +++ b/src/app/(main)/chat/(workspace)/@portal/_layout/Desktop.tsx @@ -7,7 +7,7 @@ const Layout = ({ children }: PropsWithChildren) => { return ( <>
- + {children} diff --git a/src/app/(main)/chat/(workspace)/@portal/components/SkeletonLoading.tsx b/src/app/(main)/chat/(workspace)/@portal/components/SkeletonLoading.tsx new file mode 100644 index 0000000000000..7a14c392e6a76 --- /dev/null +++ b/src/app/(main)/chat/(workspace)/@portal/components/SkeletonLoading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from 'antd'; +import { memo } from 'react'; + +interface SkeletonLoadingProps { + count?: number; +} + +const SkeletonLoading = memo(({ count = 3 }) => { + return Array.from({ length: count }).map((key, index) => ( + + )); +}); + +export default SkeletonLoading; diff --git a/src/app/(main)/chat/(workspace)/@portal/default.tsx b/src/app/(main)/chat/(workspace)/@portal/default.tsx index 3928ec7e477e2..30ac232a2e904 100644 --- a/src/app/(main)/chat/(workspace)/@portal/default.tsx +++ b/src/app/(main)/chat/(workspace)/@portal/default.tsx @@ -6,7 +6,7 @@ import { isMobileDevice } from '@/utils/responsive'; import Desktop from './_layout/Desktop'; import Mobile from './_layout/Mobile'; -const InspectorContent = lazy(() => import('./index')); +const PortalView = lazy(() => import('./router')); const Inspector = () => { const mobile = isMobileDevice(); @@ -14,11 +14,11 @@ const Inspector = () => { const Layout = mobile ? Mobile : Desktop; return ( - - }> - - - + }> + + + + ); }; diff --git a/src/app/(main)/chat/(workspace)/@portal/error.tsx b/src/app/(main)/chat/(workspace)/@portal/error.tsx new file mode 100644 index 0000000000000..071491038c704 --- /dev/null +++ b/src/app/(main)/chat/(workspace)/@portal/error.tsx @@ -0,0 +1,5 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +export default dynamic(() => import('@/components/Error')); diff --git a/src/app/(main)/chat/(workspace)/@portal/features/Header.tsx b/src/app/(main)/chat/(workspace)/@portal/features/Header.tsx index 9b7375de6090a..177ea4019096f 100644 --- a/src/app/(main)/chat/(workspace)/@portal/features/Header.tsx +++ b/src/app/(main)/chat/(workspace)/@portal/features/Header.tsx @@ -30,6 +30,7 @@ const Header = memo(() => { return ( toggleInspector(false)} />} + style={{ paddingBlock: 8 }} title={ showToolUI ? ( diff --git a/src/app/(main)/chat/(workspace)/@portal/index.tsx b/src/app/(main)/chat/(workspace)/@portal/index.tsx deleted file mode 100644 index e8dd9619c1b33..0000000000000 --- a/src/app/(main)/chat/(workspace)/@portal/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { memo } from 'react'; -import { Flexbox } from 'react-layout-kit'; - -import { useChatStore } from '@/store/chat'; -import { chatPortalSelectors } from '@/store/chat/selectors'; - -import ToolUI from './features/ArtifactUI'; -import Artifacts from './features/Artifacts'; - -const PortalView = memo(() => { - const showToolUI = useChatStore(chatPortalSelectors.showArtifactUI); - - if (showToolUI) return ; - - return ( - - - - ); -}); - -export default PortalView; diff --git a/src/app/(main)/chat/(workspace)/@portal/loading.tsx b/src/app/(main)/chat/(workspace)/@portal/loading.tsx new file mode 100644 index 0000000000000..22efdc4fff716 --- /dev/null +++ b/src/app/(main)/chat/(workspace)/@portal/loading.tsx @@ -0,0 +1,3 @@ +import Loading from '@/components/CircleLoading'; + +export default () => ; diff --git a/src/app/(main)/chat/(workspace)/@portal/router.tsx b/src/app/(main)/chat/(workspace)/@portal/router.tsx new file mode 100644 index 0000000000000..fc3a0d8214315 --- /dev/null +++ b/src/app/(main)/chat/(workspace)/@portal/router.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { memo } from 'react'; + +import { useChatStore } from '@/store/chat'; +import { chatPortalSelectors } from '@/store/chat/selectors'; + +import Artifacts from './Artifacts'; +import Home from './Home'; + +const PortalView = memo(() => { + const showArtifactUI = useChatStore(chatPortalSelectors.showArtifactUI); + + if (showArtifactUI) return ; + + return ; +}); + +export default PortalView; diff --git a/src/const/message.ts b/src/const/message.ts index be0178b467eda..f3ef4fa26a075 100644 --- a/src/const/message.ts +++ b/src/const/message.ts @@ -1 +1,3 @@ export const LOADING_FLAT = '...'; + +export const MESSAGE_CANCEL_FLAT = 'canceled'; diff --git a/src/features/Conversation/Messages/Assistant/index.tsx b/src/features/Conversation/Messages/Assistant/index.tsx index b2bf0fde207ad..61833cf5226f1 100644 --- a/src/features/Conversation/Messages/Assistant/index.tsx +++ b/src/features/Conversation/Messages/Assistant/index.tsx @@ -23,9 +23,9 @@ export const AssistantMessage = memo< {(content || editing) && ( diff --git a/src/features/Conversation/Messages/Default.tsx b/src/features/Conversation/Messages/Default.tsx index e06850ae854ec..075628fc76a34 100644 --- a/src/features/Conversation/Messages/Default.tsx +++ b/src/features/Conversation/Messages/Default.tsx @@ -2,17 +2,22 @@ import { ReactNode, memo } from 'react'; import BubblesLoading from '@/components/BubblesLoading'; import { LOADING_FLAT } from '@/const/message'; +import { useChatStore } from '@/store/chat'; +import { chatSelectors } from '@/store/chat/selectors'; import { ChatMessage } from '@/types/message'; export const DefaultMessage = memo< ChatMessage & { + addIdOnDOM?: boolean; editableContent: ReactNode; isToolCallGenerating?: boolean; } ->(({ id, editableContent, content, isToolCallGenerating }) => { +>(({ id, editableContent, content, isToolCallGenerating, addIdOnDOM = true }) => { + const editing = useChatStore(chatSelectors.isMessageEditing(id)); + if (isToolCallGenerating) return; - if (content === LOADING_FLAT) return ; + if (content === LOADING_FLAT && !editing) return ; - return
{editableContent}
; + return
{editableContent}
; }); diff --git a/src/libs/agent-runtime/error.ts b/src/libs/agent-runtime/error.ts index 6bd5a37ad8729..e364bba1cba5e 100644 --- a/src/libs/agent-runtime/error.ts +++ b/src/libs/agent-runtime/error.ts @@ -11,6 +11,7 @@ export const AgentRuntimeErrorType = { OllamaBizError: 'OllamaBizError', InvalidBedrockCredentials: 'InvalidBedrockCredentials', + StreamChunkError: 'StreamChunkError', /** * @deprecated diff --git a/src/libs/agent-runtime/utils/streams/openai.test.ts b/src/libs/agent-runtime/utils/streams/openai.test.ts index 203775d86d598..48b032e7bce84 100644 --- a/src/libs/agent-runtime/utils/streams/openai.test.ts +++ b/src/libs/agent-runtime/utils/streams/openai.test.ts @@ -260,4 +260,46 @@ describe('OpenAIStream', () => { `data: [{"function":{"name":"tool1","arguments":"{}"},"id":"call_1","index":0,"type":"function"},{"function":{"name":"tool2","arguments":"{}"},"id":"call_2","index":1,"type":"function"}]\n\n`, ]); }); + + it('should handle error when there is not correct error', async () => { + const mockOpenAIStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + choices: [ + { + delta: { content: 'Hello' }, + index: 0, + }, + ], + id: '1', + }); + controller.enqueue({ + id: '1', + }); + + controller.close(); + }, + }); + + const protocolStream = OpenAIStream(mockOpenAIStream); + + const decoder = new TextDecoder(); + const chunks = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + expect(chunks).toEqual( + [ + 'id: 1', + 'event: text', + `data: "Hello"\n`, + 'id: 1', + 'event: error', + `data: {"body":{"message":"chat response streaming chunk parse error, please contact your API Provider to fix it.","context":{"error":{"message":"Cannot read properties of undefined (reading '0')","name":"TypeError"},"chunk":{"id":"1"}}},"type":"StreamChunkError"}\n`, + ].map((i) => `${i}\n`), + ); + }); }); diff --git a/src/libs/agent-runtime/utils/streams/openai.ts b/src/libs/agent-runtime/utils/streams/openai.ts index fd0ab74179c26..52f81a776f085 100644 --- a/src/libs/agent-runtime/utils/streams/openai.ts +++ b/src/libs/agent-runtime/utils/streams/openai.ts @@ -2,6 +2,8 @@ import { readableFromAsyncIterable } from 'ai'; import OpenAI from 'openai'; import type { Stream } from 'openai/streaming'; +import { ChatMessageError } from '@/types/message'; + import { ChatStreamCallbacks } from '../../types'; import { StreamProtocolChunk, @@ -15,53 +17,74 @@ import { export const transformOpenAIStream = (chunk: OpenAI.ChatCompletionChunk): StreamProtocolChunk => { // maybe need another structure to add support for multiple choices - const item = chunk.choices[0]; - if (!item) { - return { data: chunk, id: chunk.id, type: 'data' }; - } + try { + const item = chunk.choices[0]; + if (!item) { + return { data: chunk, id: chunk.id, type: 'data' }; + } - if (typeof item.delta?.content === 'string') { - return { data: item.delta.content, id: chunk.id, type: 'text' }; - } + if (typeof item.delta?.content === 'string') { + return { data: item.delta.content, id: chunk.id, type: 'text' }; + } + + if (item.delta?.tool_calls) { + return { + data: item.delta.tool_calls.map( + (value, index): StreamToolCallChunkData => ({ + function: value.function, + id: value.id || generateToolCallId(index, value.function?.name), + + // mistral's tool calling don't have index and function field, it's data like: + // [{"id":"xbhnmTtY7","function":{"name":"lobe-image-designer____text2image____builtin","arguments":"{\"prompts\": [\"A photo of a small, fluffy dog with a playful expression and wagging tail.\", \"A watercolor painting of a small, energetic dog with a glossy coat and bright eyes.\", \"A vector illustration of a small, adorable dog with a short snout and perky ears.\", \"A drawing of a small, scruffy dog with a mischievous grin and a wagging tail.\"], \"quality\": \"standard\", \"seeds\": [123456, 654321, 111222, 333444], \"size\": \"1024x1024\", \"style\": \"vivid\"}"}}] + + // minimax's tool calling don't have index field, it's data like: + // [{"id":"call_function_4752059746","type":"function","function":{"name":"lobe-image-designer____text2image____builtin","arguments":"{\"prompts\": [\"一个流浪的地球,背景是浩瀚"}}] + + // so we need to add these default values + index: typeof value.index !== 'undefined' ? value.index : index, + type: value.type || 'function', + }), + ), + id: chunk.id, + type: 'tool_calls', + } as StreamProtocolToolCallChunk; + } - if (item.delta?.tool_calls) { + // 给定结束原因 + if (item.finish_reason) { + return { data: item.finish_reason, id: chunk.id, type: 'stop' }; + } + + if (item.delta?.content === null) { + return { data: item.delta, id: chunk.id, type: 'data' }; + } + + // 其余情况下,返回 delta 和 index return { - data: item.delta.tool_calls.map( - (value, index): StreamToolCallChunkData => ({ - function: value.function, - id: value.id || generateToolCallId(index, value.function?.name), - - // mistral's tool calling don't have index and function field, it's data like: - // [{"id":"xbhnmTtY7","function":{"name":"lobe-image-designer____text2image____builtin","arguments":"{\"prompts\": [\"A photo of a small, fluffy dog with a playful expression and wagging tail.\", \"A watercolor painting of a small, energetic dog with a glossy coat and bright eyes.\", \"A vector illustration of a small, adorable dog with a short snout and perky ears.\", \"A drawing of a small, scruffy dog with a mischievous grin and a wagging tail.\"], \"quality\": \"standard\", \"seeds\": [123456, 654321, 111222, 333444], \"size\": \"1024x1024\", \"style\": \"vivid\"}"}}] - - // minimax's tool calling don't have index field, it's data like: - // [{"id":"call_function_4752059746","type":"function","function":{"name":"lobe-image-designer____text2image____builtin","arguments":"{\"prompts\": [\"一个流浪的地球,背景是浩瀚"}}] - - // so we need to add these default values - index: typeof value.index !== 'undefined' ? value.index : index, - type: value.type || 'function', - }), - ), + data: { delta: item.delta, id: chunk.id, index: item.index }, id: chunk.id, - type: 'tool_calls', - } as StreamProtocolToolCallChunk; - } + type: 'data', + }; + } catch (e) { + const errorName = 'StreamChunkError'; + console.error(`[${errorName}]`, e); + console.error(`[${errorName}] raw chunk:`, chunk); - // 给定结束原因 - if (item.finish_reason) { - return { data: item.finish_reason, id: chunk.id, type: 'stop' }; - } + const err = e as Error; - if (item.delta?.content === null) { - return { data: item.delta, id: chunk.id, type: 'data' }; - } + /* eslint-disable sort-keys-fix/sort-keys-fix */ + const errorData = { + body: { + message: + 'chat response streaming chunk parse error, please contact your API Provider to fix it.', + context: { error: { message: err.message, name: err.name }, chunk }, + }, + type: 'StreamChunkError', + } as ChatMessageError; + /* eslint-enable */ - // 其余情况下,返回 delta 和 index - return { - data: { delta: item.delta, id: chunk.id, index: item.index }, - id: chunk.id, - type: 'data', - }; + return { data: errorData, id: chunk.id, type: 'error' }; + } }; const chatStreamable = async function* (stream: AsyncIterable) { diff --git a/src/libs/agent-runtime/utils/streams/protocol.ts b/src/libs/agent-runtime/utils/streams/protocol.ts index 2c57a8c46fe25..d9cee1a08b9cd 100644 --- a/src/libs/agent-runtime/utils/streams/protocol.ts +++ b/src/libs/agent-runtime/utils/streams/protocol.ts @@ -13,7 +13,7 @@ export interface StreamStack { export interface StreamProtocolChunk { data: any; id?: string; - type: 'text' | 'tool_calls' | 'data' | 'stop'; + type: 'text' | 'tool_calls' | 'data' | 'stop' | 'error'; } export interface StreamToolCallChunkData { diff --git a/src/libs/trpc/client/edge.ts b/src/libs/trpc/client/edge.ts index cfb640c6443b4..070710cc4c340 100644 --- a/src/libs/trpc/client/edge.ts +++ b/src/libs/trpc/client/edge.ts @@ -13,6 +13,7 @@ export const edgeClient = createTRPCClient({ return createHeaderWithAuth(); }, + maxURLLength: 2083, transformer: superjson, url: withBasePath('/trpc/edge'), }), diff --git a/src/libs/trpc/middleware/userAuth.ts b/src/libs/trpc/middleware/userAuth.ts index 12a36fae7244d..3a68459fc1584 100644 --- a/src/libs/trpc/middleware/userAuth.ts +++ b/src/libs/trpc/middleware/userAuth.ts @@ -6,6 +6,7 @@ export const userAuth = trpc.middleware(async (opts) => { const { ctx } = opts; // `ctx.user` is nullable if (!ctx.userId) { + console.log('clerk auth:', ctx.clerkAuth); throw new TRPCError({ code: 'UNAUTHORIZED' }); } diff --git a/src/locales/default/error.ts b/src/locales/default/error.ts index 4d8d97361c905..e012ec493b76a 100644 --- a/src/locales/default/error.ts +++ b/src/locales/default/error.ts @@ -85,10 +85,15 @@ export default { * @deprecated */ NoOpenAIAPIKey: 'OpenAI API Key 不正确或为空,请添加自定义 OpenAI API Key', + /** + * @deprecated + */ OpenAIBizError: '请求 OpenAI 服务出错,请根据以下信息排查或重试', InvalidBedrockCredentials: 'Bedrock 鉴权未通过,请检查 AccessKeyId/SecretAccessKey 后重试', - + StreamChunkError: + '流式请求的消息块解析错误,请检查当前 API 接口是否符合标准规范,或联系你的 API 供应商咨询', + UnknownChatFetchError: '很抱歉,遇到未知请求错误,请根据以下信息排查或重试', InvalidOllamaArgs: 'Ollama 配置不正确,请检查 Ollama 配置后重试', OllamaBizError: '请求 Ollama 服务出错,请根据以下信息排查或重试', OllamaServiceUnavailable: diff --git a/src/services/__tests__/chat.test.ts b/src/services/__tests__/chat.test.ts index ae21ba48a6f15..cb41b66ab09f3 100644 --- a/src/services/__tests__/chat.test.ts +++ b/src/services/__tests__/chat.test.ts @@ -577,7 +577,6 @@ Get data from users`, body: JSON.stringify(expectedPayload), headers: expect.any(Object), method: 'POST', - signal: expect.any(AbortSignal), }); }); diff --git a/src/store/agent/slices/chat/action.ts b/src/store/agent/slices/chat/action.ts index ff550c6699e06..153ede1a4a62d 100644 --- a/src/store/agent/slices/chat/action.ts +++ b/src/store/agent/slices/chat/action.ts @@ -4,6 +4,7 @@ import { SWRResponse, mutate } from 'swr'; import { DeepPartial } from 'utility-types'; import { StateCreator } from 'zustand/vanilla'; +import { MESSAGE_CANCEL_FLAT } from '@/const/message'; import { INBOX_SESSION_ID } from '@/const/session'; import { DEFAULT_AGENT_CONFIG } from '@/const/settings'; import { useClientDataSWR, useOnlyFetchOnceSWR } from '@/libs/swr'; @@ -168,7 +169,7 @@ export const createChatSlice: StateCreator< internal_createAbortController: (key) => { const abortController = get()[key] as AbortController; - if (abortController) abortController.abort('canceled'); + if (abortController) abortController.abort(MESSAGE_CANCEL_FLAT); const controller = new AbortController(); set({ [key]: controller }, false, 'internal_createAbortController'); diff --git a/src/store/chat/slices/message/action.test.ts b/src/store/chat/slices/message/action.test.ts index e4501e25cc8b3..c6ce258ce7d6f 100644 --- a/src/store/chat/slices/message/action.test.ts +++ b/src/store/chat/slices/message/action.test.ts @@ -958,10 +958,10 @@ describe('chatMessage actions', () => { it('should not do anything if there is no abortController', async () => { const { result } = renderHook(() => useChatStore()); - // 确保没有设置 abortController - useChatStore.setState({ abortController: undefined }); - await act(async () => { + // 确保没有设置 abortController + useChatStore.setState({ abortController: undefined }); + result.current.stopGenerateMessage(); }); @@ -1089,7 +1089,7 @@ describe('chatMessage actions', () => { // Mock fetch to reject with an error const errorMessage = 'Error fetching AI response'; - vi.mocked(fetch).mockRejectedValue(new Error(errorMessage)); + vi.mocked(fetch).mockRejectedValueOnce(new Error(errorMessage)); await act(async () => { expect( diff --git a/src/store/chat/slices/message/action.ts b/src/store/chat/slices/message/action.ts index c1937f3777b43..78006a59b4845 100644 --- a/src/store/chat/slices/message/action.ts +++ b/src/store/chat/slices/message/action.ts @@ -7,7 +7,7 @@ import { template } from 'lodash-es'; import { SWRResponse, mutate } from 'swr'; import { StateCreator } from 'zustand/vanilla'; -import { LOADING_FLAT } from '@/const/message'; +import { LOADING_FLAT, MESSAGE_CANCEL_FLAT } from '@/const/message'; import { TraceEventType, TraceNameMap } from '@/const/trace'; import { useClientDataSWR } from '@/libs/swr'; import { chatService } from '@/services/chat'; @@ -399,7 +399,7 @@ export const chatMessage: StateCreator< const { abortController, internal_toggleChatLoading } = get(); if (!abortController) return; - abortController.abort(); + abortController.abort(MESSAGE_CANCEL_FLAT); internal_toggleChatLoading(false, undefined, n('stopGenerateMessage') as string); }, diff --git a/src/store/session/slices/session/action.ts b/src/store/session/slices/session/action.ts index 6de698b631545..958429f630a51 100644 --- a/src/store/session/slices/session/action.ts +++ b/src/store/session/slices/session/action.ts @@ -5,6 +5,7 @@ import { DeepPartial } from 'utility-types'; import { StateCreator } from 'zustand/vanilla'; import { message } from '@/components/AntdStaticMethods'; +import { MESSAGE_CANCEL_FLAT } from '@/const/message'; import { DEFAULT_AGENT_LOBE_SESSION, INBOX_SESSION_ID } from '@/const/session'; import { useClientDataSWR } from '@/libs/swr'; import { sessionService } from '@/services/session'; @@ -188,7 +189,7 @@ export const createSessionSlice: StateCreator< const { activeId, refreshSessions } = get(); const abortController = get().signalSessionMeta as AbortController; - if (abortController) abortController.abort('canceled'); + if (abortController) abortController.abort(MESSAGE_CANCEL_FLAT); const controller = new AbortController(); set({ signalSessionMeta: controller }, false, 'updateSessionMetaSignal'); diff --git a/src/store/tool/slices/plugin/action.ts b/src/store/tool/slices/plugin/action.ts index 9c0c3d6ef27ff..c55fe9822a649 100644 --- a/src/store/tool/slices/plugin/action.ts +++ b/src/store/tool/slices/plugin/action.ts @@ -2,6 +2,7 @@ import { Schema, ValidationResult } from '@cfworker/json-schema'; import useSWR, { SWRResponse } from 'swr'; import { StateCreator } from 'zustand/vanilla'; +import { MESSAGE_CANCEL_FLAT } from '@/const/message'; import { pluginService } from '@/services/plugin'; import { merge } from '@/utils/merge'; @@ -46,7 +47,7 @@ export const createPluginSlice: StateCreator< }, updatePluginSettings: async (id, settings) => { const signal = get().updatePluginSettingsSignal; - if (signal) signal.abort('canceled'); + if (signal) signal.abort(MESSAGE_CANCEL_FLAT); const newSignal = new AbortController(); diff --git a/src/store/user/slices/settings/action.ts b/src/store/user/slices/settings/action.ts index 64b3ca90bc947..acc892ebd6290 100644 --- a/src/store/user/slices/settings/action.ts +++ b/src/store/user/slices/settings/action.ts @@ -3,6 +3,7 @@ import isEqual from 'fast-deep-equal'; import { DeepPartial } from 'utility-types'; import type { StateCreator } from 'zustand/vanilla'; +import { MESSAGE_CANCEL_FLAT } from '@/const/message'; import { shareService } from '@/services/share'; import { userService } from '@/services/user'; import type { UserStore } from '@/store/user'; @@ -63,7 +64,8 @@ export const createSettingsSlice: StateCreator< internal_createSignal: () => { const abortController = get().updateSettingsSignal; - if (abortController && !abortController.signal.aborted) abortController.abort('canceled'); + if (abortController && !abortController.signal.aborted) + abortController.abort(MESSAGE_CANCEL_FLAT); const newSignal = new AbortController(); diff --git a/src/types/fetch.ts b/src/types/fetch.ts index a0495365043f0..be3ae5e7f542c 100644 --- a/src/types/fetch.ts +++ b/src/types/fetch.ts @@ -12,6 +12,7 @@ export const ChatErrorType = { NoOpenAIAPIKey: 'NoOpenAIAPIKey', OllamaServiceUnavailable: 'OllamaServiceUnavailable', // 未启动/检测到 Ollama 服务 PluginFailToTransformArguments: 'PluginFailToTransformArguments', + UnknownChatFetchError: 'UnknownChatFetchError', // ******* 客户端错误 ******* // BadRequest: 400, diff --git a/src/utils/downloadFile.ts b/src/utils/downloadFile.ts new file mode 100644 index 0000000000000..617ca9e7cff44 --- /dev/null +++ b/src/utils/downloadFile.ts @@ -0,0 +1,18 @@ +export const downloadFile = async (url: string, fileName: string) => { + try { + const res = await fetch(url); + const blob = await res.blob(); + + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = fileName; + link.style.display = 'none'; + document.body.append(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(blobUrl); + } catch (error) { + console.error('Download failed:', error); + } +}; diff --git a/src/utils/fetch.test.ts b/src/utils/fetch/__tests__/fetchSSE.test.ts similarity index 57% rename from src/utils/fetch.test.ts rename to src/utils/fetch/__tests__/fetchSSE.test.ts index bcf9d99f94f9a..a12b2b38ffc84 100644 --- a/src/utils/fetch.test.ts +++ b/src/utils/fetch/__tests__/fetchSSE.test.ts @@ -1,48 +1,18 @@ -import { fetchEventSource } from '@microsoft/fetch-event-source'; -import { FetchEventSourceInit } from '@microsoft/fetch-event-source'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ZodError } from 'zod'; -import { ErrorResponse } from '@/types/fetch'; +import { MESSAGE_CANCEL_FLAT } from '@/const/message'; +import { ChatMessageError } from '@/types/message'; -import { fetchSSE, getMessageError, parseToolCalls } from './fetch'; +import { FetchEventSourceInit } from '../fetchEventSource'; +import { fetchEventSource } from '../fetchEventSource'; +import { fetchSSE } from '../fetchSSE'; // 模拟 i18next vi.mock('i18next', () => ({ t: vi.fn((key) => `translated_${key}`), })); -// 模拟 Response -const createMockResponse = (body: any, ok: boolean, status: number = 200) => ({ - ok, - status, - json: vi.fn(async () => body), - clone: vi.fn(function () { - // @ts-ignore - return this; - }), - text: vi.fn(async () => JSON.stringify(body)), - body: { - getReader: () => { - let done = false; - return { - read: () => { - if (!done) { - done = true; - return Promise.resolve({ - value: new TextEncoder().encode(JSON.stringify(body)), - done: false, - }); - } else { - return Promise.resolve({ done: true }); - } - }, - }; - }, - }, -}); - -vi.mock('@microsoft/fetch-event-source', () => ({ +vi.mock('../fetchEventSource', () => ({ fetchEventSource: vi.fn(), })); @@ -51,169 +21,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('getMessageError', () => { - it('should handle business error correctly', async () => { - const mockErrorResponse: ErrorResponse = { - body: 'Error occurred', - errorType: 'InvalidAccessCode', - }; - const mockResponse = createMockResponse(mockErrorResponse, false, 400); - - const error = await getMessageError(mockResponse as any); - - expect(error).toEqual({ - body: mockErrorResponse.body, - message: 'translated_response.InvalidAccessCode', - type: mockErrorResponse.errorType, - }); - expect(mockResponse.json).toHaveBeenCalled(); - }); - - it('should handle regular error correctly', async () => { - const mockResponse = createMockResponse({}, false, 500); - mockResponse.json.mockImplementationOnce(() => { - throw new Error('Failed to parse'); - }); - - const error = await getMessageError(mockResponse as any); - - expect(error).toEqual({ - message: 'translated_response.500', - type: 500, - }); - expect(mockResponse.json).toHaveBeenCalled(); - }); - - it('should handle timeout error correctly', async () => { - const mockResponse = createMockResponse(undefined, false, 504); - const error = await getMessageError(mockResponse as any); - - expect(error).toEqual({ - message: 'translated_response.504', - type: 504, - }); - }); -}); - -describe('parseToolCalls', () => { - it('should create add new item', () => { - const chunk = [ - { index: 0, id: '1', type: 'function', function: { name: 'func', arguments: '' } }, - ]; - - const result = parseToolCalls([], chunk); - expect(result).toEqual([ - { id: '1', type: 'function', function: { name: 'func', arguments: '' } }, - ]); - }); - - it('should update arguments if there is a toolCall', () => { - const origin = [{ id: '1', type: 'function', function: { name: 'func', arguments: '' } }]; - - const chunk1 = [{ index: 0, function: { arguments: '{"lo' } }]; - - const result1 = parseToolCalls(origin, chunk1); - expect(result1).toEqual([ - { id: '1', type: 'function', function: { name: 'func', arguments: '{"lo' } }, - ]); - - const chunk2 = [{ index: 0, function: { arguments: 'cation\\": \\"Hangzhou\\"}' } }]; - const result2 = parseToolCalls(result1, chunk2); - - expect(result2).toEqual([ - { - id: '1', - type: 'function', - function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, - }, - ]); - }); - - it('should add a new tool call if the index is different', () => { - const origin = [ - { - id: '1', - type: 'function', - function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, - }, - ]; - - const chunk = [ - { - index: 1, - id: '2', - type: 'function', - function: { name: 'func', arguments: '' }, - }, - ]; - - const result1 = parseToolCalls(origin, chunk); - expect(result1).toEqual([ - { - id: '1', - type: 'function', - function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, - }, - { id: '2', type: 'function', function: { name: 'func', arguments: '' } }, - ]); - }); - - it('should update correct arguments if there are multi tool calls', () => { - const origin = [ - { - id: '1', - type: 'function', - function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, - }, - { id: '2', type: 'function', function: { name: 'func', arguments: '' } }, - ]; - - const chunk = [{ index: 1, function: { arguments: '{"location\\": \\"Beijing\\"}' } }]; - - const result1 = parseToolCalls(origin, chunk); - expect(result1).toEqual([ - { - id: '1', - type: 'function', - function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, - }, - { - id: '2', - type: 'function', - function: { name: 'func', arguments: '{"location\\": \\"Beijing\\"}' }, - }, - ]); - }); - - it('should throw error if incomplete tool calls data', () => { - const origin = [ - { - id: '1', - type: 'function', - function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, - }, - ]; - - const chunk = [{ index: 1, id: '2', type: 'function' }]; - - try { - parseToolCalls(origin, chunk as any); - } catch (e) { - expect(e).toEqual( - new ZodError([ - { - code: 'invalid_type', - expected: 'object', - received: 'undefined', - path: ['function'], - message: 'Required', - }, - ]), - ); - } - }); -}); - describe('fetchSSE', () => { it('should handle text event correctly', async () => { const mockOnMessageHandle = vi.fn(); @@ -293,65 +100,6 @@ describe('fetchSSE', () => { }); }); - it('should call onAbort when AbortError is thrown', async () => { - const mockOnAbort = vi.fn(); - - (fetchEventSource as any).mockImplementationOnce( - (url: string, options: FetchEventSourceInit) => { - options.onmessage!({ event: 'text', data: JSON.stringify('Hello') } as any); - options.onerror!({ name: 'AbortError' }); - }, - ); - - await fetchSSE('/', { onAbort: mockOnAbort, smoothing: false }); - - expect(mockOnAbort).toHaveBeenCalledWith('Hello'); - }); - - it('should call onErrorHandle when other error is thrown', async () => { - const mockOnErrorHandle = vi.fn(); - const mockError = new Error('Unknown error'); - - (fetchEventSource as any).mockImplementationOnce( - (url: string, options: FetchEventSourceInit) => { - options.onerror!(mockError); - }, - ); - - try { - await fetchSSE('/', { onErrorHandle: mockOnErrorHandle }); - } catch (e) {} - - expect(mockOnErrorHandle).toHaveBeenCalled(); - }); - - it('should call onErrorHandle when response is not ok', async () => { - const mockOnErrorHandle = vi.fn(); - - (fetchEventSource as any).mockImplementationOnce( - async (url: string, options: FetchEventSourceInit) => { - const res = new Response(JSON.stringify({ errorType: 'SomeError' }), { - status: 400, - statusText: 'Error', - }); - - try { - await options.onopen!(res as any); - } catch (e) {} - }, - ); - - try { - await fetchSSE('/', { onErrorHandle: mockOnErrorHandle }); - } catch (e) { - expect(mockOnErrorHandle).toHaveBeenCalledWith({ - body: undefined, - message: 'translated_response.SomeError', - type: 'SomeError', - }); - } - }); - it('should call onMessageHandle with full text if no message event', async () => { const mockOnMessageHandle = vi.fn(); const mockOnFinish = vi.fn(); @@ -531,4 +279,166 @@ describe('fetchSSE', () => { type: 'error', }); }); + + describe('onAbort', () => { + it('should call onAbort when AbortError is thrown', async () => { + const mockOnAbort = vi.fn(); + + (fetchEventSource as any).mockImplementationOnce( + (url: string, options: FetchEventSourceInit) => { + options.onmessage!({ event: 'text', data: JSON.stringify('Hello') } as any); + options.onerror!({ name: 'AbortError' }); + }, + ); + + await fetchSSE('/', { onAbort: mockOnAbort, smoothing: false }); + + expect(mockOnAbort).toHaveBeenCalledWith('Hello'); + }); + + it('should call onAbort when MESSAGE_CANCEL_FLAT is thrown', async () => { + const mockOnAbort = vi.fn(); + + (fetchEventSource as any).mockImplementationOnce( + (url: string, options: FetchEventSourceInit) => { + options.onmessage!({ event: 'text', data: JSON.stringify('Hello') } as any); + options.onerror!(MESSAGE_CANCEL_FLAT); + }, + ); + + await fetchSSE('/', { onAbort: mockOnAbort, smoothing: false }); + + expect(mockOnAbort).toHaveBeenCalledWith('Hello'); + }); + }); + + describe('onErrorHandle', () => { + it('should call onErrorHandle when Chat Message error is thrown', async () => { + const mockOnErrorHandle = vi.fn(); + const mockError: ChatMessageError = { + body: {}, + message: 'StreamChunkError', + type: 'StreamChunkError', + }; + + (fetchEventSource as any).mockImplementationOnce( + (url: string, options: FetchEventSourceInit) => { + options.onerror!(mockError); + }, + ); + + try { + await fetchSSE('/', { onErrorHandle: mockOnErrorHandle }); + } catch (e) {} + + expect(mockOnErrorHandle).toHaveBeenCalledWith(mockError); + }); + + it('should call onErrorHandle when Unknown error is thrown', async () => { + const mockOnErrorHandle = vi.fn(); + const mockError = new Error('Unknown error'); + + (fetchEventSource as any).mockImplementationOnce( + (url: string, options: FetchEventSourceInit) => { + options.onerror!(mockError); + }, + ); + + try { + await fetchSSE('/', { onErrorHandle: mockOnErrorHandle }); + } catch (e) {} + + expect(mockOnErrorHandle).toHaveBeenCalledWith({ + type: 'UnknownChatFetchError', + message: 'Unknown error', + body: { + message: 'Unknown error', + name: 'Error', + stack: expect.any(String), + }, + }); + }); + + it('should call onErrorHandle when response is not ok', async () => { + const mockOnErrorHandle = vi.fn(); + + (fetchEventSource as any).mockImplementationOnce( + async (url: string, options: FetchEventSourceInit) => { + const res = new Response(JSON.stringify({ errorType: 'SomeError' }), { + status: 400, + statusText: 'Error', + }); + + try { + await options.onopen!(res as any); + } catch (e) {} + }, + ); + + try { + await fetchSSE('/', { onErrorHandle: mockOnErrorHandle }); + } catch (e) { + expect(mockOnErrorHandle).toHaveBeenCalledWith({ + body: undefined, + message: 'translated_response.SomeError', + type: 'SomeError', + }); + } + }); + + it('should call onErrorHandle when stream chunk has error type', async () => { + const mockOnErrorHandle = vi.fn(); + const mockError = { + type: 'StreamChunkError', + message: 'abc', + body: { message: 'abc', context: {} }, + }; + + (fetchEventSource as any).mockImplementationOnce( + (url: string, options: FetchEventSourceInit) => { + options.onmessage!({ + event: 'error', + data: JSON.stringify(mockError), + } as any); + }, + ); + + try { + await fetchSSE('/', { onErrorHandle: mockOnErrorHandle }); + } catch (e) {} + + expect(mockOnErrorHandle).toHaveBeenCalledWith(mockError); + }); + + it('should call onErrorHandle when stream chunk is not valid json', async () => { + const mockOnErrorHandle = vi.fn(); + const mockError = 'abc'; + + (fetchEventSource as any).mockImplementationOnce( + (url: string, options: FetchEventSourceInit) => { + options.onmessage!({ event: 'text', data: mockError } as any); + }, + ); + + try { + await fetchSSE('/', { onErrorHandle: mockOnErrorHandle }); + } catch (e) {} + + expect(mockOnErrorHandle).toHaveBeenCalledWith({ + body: { + context: { + chunk: 'abc', + error: { + message: 'Unexpected token a in JSON at position 0', + name: 'SyntaxError', + }, + }, + message: + 'chat response streaming chunk parse error, please contact your API Provider to fix it.', + }, + message: 'parse error', + type: 'StreamChunkError', + }); + }); + }); }); diff --git a/src/utils/fetch/__tests__/parseError.test.ts b/src/utils/fetch/__tests__/parseError.test.ts new file mode 100644 index 0000000000000..2a5a6ae93deba --- /dev/null +++ b/src/utils/fetch/__tests__/parseError.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ErrorResponse } from '@/types/fetch'; + +import { getMessageError } from '../parseError'; + +// 模拟 i18next +vi.mock('i18next', () => ({ + t: vi.fn((key) => `translated_${key}`), +})); + +// 模拟 Response +const createMockResponse = (body: any, ok: boolean, status: number = 200) => ({ + ok, + status, + json: vi.fn(async () => body), + clone: vi.fn(function () { + // @ts-ignore + return this; + }), + text: vi.fn(async () => JSON.stringify(body)), + body: { + getReader: () => { + let done = false; + return { + read: () => { + if (!done) { + done = true; + return Promise.resolve({ + value: new TextEncoder().encode(JSON.stringify(body)), + done: false, + }); + } else { + return Promise.resolve({ done: true }); + } + }, + }; + }, + }, +}); + +// 在每次测试后清理所有模拟 +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getMessageError', () => { + it('should handle business error correctly', async () => { + const mockErrorResponse: ErrorResponse = { + body: 'Error occurred', + errorType: 'InvalidAccessCode', + }; + const mockResponse = createMockResponse(mockErrorResponse, false, 400); + + const error = await getMessageError(mockResponse as any); + + expect(error).toEqual({ + body: mockErrorResponse.body, + message: 'translated_response.InvalidAccessCode', + type: mockErrorResponse.errorType, + }); + expect(mockResponse.json).toHaveBeenCalled(); + }); + + it('should handle regular error correctly', async () => { + const mockResponse = createMockResponse({}, false, 500); + mockResponse.json.mockImplementationOnce(() => { + throw new Error('Failed to parse'); + }); + + const error = await getMessageError(mockResponse as any); + + expect(error).toEqual({ + message: 'translated_response.500', + type: 500, + }); + expect(mockResponse.json).toHaveBeenCalled(); + }); + + it('should handle timeout error correctly', async () => { + const mockResponse = createMockResponse(undefined, false, 504); + const error = await getMessageError(mockResponse as any); + + expect(error).toEqual({ + message: 'translated_response.504', + type: 504, + }); + }); +}); diff --git a/src/utils/fetch/__tests__/parseToolCalls.test.ts b/src/utils/fetch/__tests__/parseToolCalls.test.ts new file mode 100644 index 0000000000000..8a46f596bde62 --- /dev/null +++ b/src/utils/fetch/__tests__/parseToolCalls.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { ZodError } from 'zod'; + +import { parseToolCalls } from '../parseToolCalls'; + +describe('parseToolCalls', () => { + it('should create add new item', () => { + const chunk = [ + { index: 0, id: '1', type: 'function', function: { name: 'func', arguments: '' } }, + ]; + + const result = parseToolCalls([], chunk); + expect(result).toEqual([ + { id: '1', type: 'function', function: { name: 'func', arguments: '' } }, + ]); + }); + + it('should update arguments if there is a toolCall', () => { + const origin = [{ id: '1', type: 'function', function: { name: 'func', arguments: '' } }]; + + const chunk1 = [{ index: 0, function: { arguments: '{"lo' } }]; + + const result1 = parseToolCalls(origin, chunk1); + expect(result1).toEqual([ + { id: '1', type: 'function', function: { name: 'func', arguments: '{"lo' } }, + ]); + + const chunk2 = [{ index: 0, function: { arguments: 'cation\\": \\"Hangzhou\\"}' } }]; + const result2 = parseToolCalls(result1, chunk2); + + expect(result2).toEqual([ + { + id: '1', + type: 'function', + function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, + }, + ]); + }); + + it('should add a new tool call if the index is different', () => { + const origin = [ + { + id: '1', + type: 'function', + function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, + }, + ]; + + const chunk = [ + { + index: 1, + id: '2', + type: 'function', + function: { name: 'func', arguments: '' }, + }, + ]; + + const result1 = parseToolCalls(origin, chunk); + expect(result1).toEqual([ + { + id: '1', + type: 'function', + function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, + }, + { id: '2', type: 'function', function: { name: 'func', arguments: '' } }, + ]); + }); + + it('should update correct arguments if there are multi tool calls', () => { + const origin = [ + { + id: '1', + type: 'function', + function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, + }, + { id: '2', type: 'function', function: { name: 'func', arguments: '' } }, + ]; + + const chunk = [{ index: 1, function: { arguments: '{"location\\": \\"Beijing\\"}' } }]; + + const result1 = parseToolCalls(origin, chunk); + expect(result1).toEqual([ + { + id: '1', + type: 'function', + function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, + }, + { + id: '2', + type: 'function', + function: { name: 'func', arguments: '{"location\\": \\"Beijing\\"}' }, + }, + ]); + }); + + it('should throw error if incomplete tool calls data', () => { + const origin = [ + { + id: '1', + type: 'function', + function: { name: 'func', arguments: '{"location\\": \\"Hangzhou\\"}' }, + }, + ]; + + const chunk = [{ index: 1, id: '2', type: 'function' }]; + + try { + parseToolCalls(origin, chunk as any); + } catch (e) { + expect(e).toEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'object', + received: 'undefined', + path: ['function'], + message: 'Required', + }, + ]), + ); + } + }); +}); diff --git a/src/utils/fetch/fetchEventSource/index.ts b/src/utils/fetch/fetchEventSource/index.ts new file mode 100644 index 0000000000000..daf30573f057c --- /dev/null +++ b/src/utils/fetch/fetchEventSource/index.ts @@ -0,0 +1,110 @@ +/** + * file copy from https://github.com/Azure/fetch-event-source/blob/45ac3cfffd30b05b79fbf95c21e67d4ef59aa56a/src/fetch.ts + * and remove some code + */ +import { EventSourceMessage, getBytes, getLines, getMessages } from './parse'; + +export const EventStreamContentType = 'text/event-stream'; + +const LastEventId = 'last-event-id'; + +// eslint-disable-next-line no-undef +export interface FetchEventSourceInit extends RequestInit { + /** The Fetch function to use. Defaults to window.fetch */ + fetch?: typeof fetch; + + /** + * The request headers. FetchEventSource only supports the Record format. + */ + headers?: Record; + + /** + * Called when a response finishes. If you don't expect the server to kill + * the connection, you can throw an exception here and retry using onerror. + */ + onclose?: () => void; + + /** + * Called when there is any error making the request / processing messages / + * handling callbacks etc. Use this to control the retry strategy: if the + * error is fatal, rethrow the error inside the callback to stop the entire + * operation. Otherwise, you can return an interval (in milliseconds) after + * which the request will automatically retry (with the last-event-id). + * If this callback is not specified, or it returns undefined, fetchEventSource + * will treat every error as retriable and will try again after 1 second. + */ + onerror?: (err: any) => number | null | undefined | void; + + /** + * Called when a message is received. NOTE: Unlike the default browser + * EventSource.onmessage, this callback is called for _all_ events, + * even ones with a custom `event` field. + */ + onmessage?: (ev: EventSourceMessage) => void; + + /** + * Called when a response is received. Use this to validate that the response + * actually matches what you expect (and throw if it doesn't.) If not provided, + * will default to a basic validation to ensure the content-type is text/event-stream. + */ + onopen: (response: Response) => Promise; +} + +export function fetchEventSource( + // eslint-disable-next-line no-undef + input: RequestInfo, + { + signal: inputSignal, + headers: inputHeaders, + onopen: inputOnOpen, + onmessage, + onclose, + onerror, + fetch: inputFetch, + ...rest + }: FetchEventSourceInit, +) { + return new Promise((resolve) => { + // make a copy of the input headers since we may modify it below: + const headers = { ...inputHeaders }; + if (!headers.accept) { + headers.accept = EventStreamContentType; + } + + const fetch = inputFetch ?? window.fetch; + async function create() { + try { + const response = await fetch(input, { + ...rest, + headers, + signal: inputSignal, + }); + + await inputOnOpen(response); + + await getBytes( + response.body!, + getLines( + getMessages((id) => { + if (id) { + // store the id and send it back on the next retry: + headers[LastEventId] = id; + } else { + // don't send the last-event-id header anymore: + delete headers[LastEventId]; + } + }, onmessage), + ), + ); + + onclose?.(); + resolve(); + } catch (err) { + onerror?.(err); + resolve(); + } + } + + create(); + }); +} diff --git a/src/utils/fetch/fetchEventSource/parse.ts b/src/utils/fetch/fetchEventSource/parse.ts new file mode 100644 index 0000000000000..0bde40a5de87e --- /dev/null +++ b/src/utils/fetch/fetchEventSource/parse.ts @@ -0,0 +1,182 @@ +//@ts-nocheck +/* eslint-disable unicorn/no-abusive-eslint-disable */ +/* eslint-disable */ +/** + * Represents a message sent in an event stream + * https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format + */ +export interface EventSourceMessage { + /** The event ID to set the EventSource object's last event ID value. */ + id: string; + /** A string identifying the type of event described. */ + event: string; + /** The event data */ + data: string; + /** The reconnection interval (in milliseconds) to wait before retrying the connection */ + retry?: number; +} + +/** + * Converts a ReadableStream into a callback pattern. + * @param stream The input ReadableStream. + * @param onChunk A function that will be called on each new byte chunk in the stream. + * @returns {Promise} A promise that will be resolved when the stream closes. + */ +export async function getBytes( + stream: ReadableStream, + onChunk: (arr: Uint8Array) => void, +) { + const reader = stream.getReader(); + let result: ReadableStreamDefaultReadResult; + while (!(result = await reader.read()).done) { + onChunk(result.value); + } +} + +const enum ControlChars { + NewLine = 10, + CarriageReturn = 13, + Space = 32, + Colon = 58, +} + +/** + * Parses arbitary byte chunks into EventSource line buffers. + * Each line should be of the format "field: value" and ends with \r, \n, or \r\n. + * @param onLine A function that will be called on each new EventSource line. + * @returns A function that should be called for each incoming byte chunk. + */ +export function getLines(onLine: (line: Uint8Array, fieldLength: number) => void) { + let buffer: Uint8Array | undefined; + let position: number; // current read position + let fieldLength: number; // length of the `field` portion of the line + let discardTrailingNewline = false; + + // return a function that can process each incoming byte chunk: + return function onChunk(arr: Uint8Array) { + if (buffer === undefined) { + buffer = arr; + position = 0; + fieldLength = -1; + } else { + // we're still parsing the old line. Append the new bytes into buffer: + buffer = concat(buffer, arr); + } + + const bufLength = buffer.length; + let lineStart = 0; // index where the current line starts + while (position < bufLength) { + if (discardTrailingNewline) { + if (buffer[position] === ControlChars.NewLine) { + lineStart = ++position; // skip to next char + } + + discardTrailingNewline = false; + } + + // start looking forward till the end of line: + let lineEnd = -1; // index of the \r or \n char + for (; position < bufLength && lineEnd === -1; ++position) { + switch (buffer[position]) { + case ControlChars.Colon: + if (fieldLength === -1) { + // first colon in line + fieldLength = position - lineStart; + } + break; + // @ts-ignore:7029 \r case below should fallthrough to \n: + case ControlChars.CarriageReturn: + discardTrailingNewline = true; + case ControlChars.NewLine: + lineEnd = position; + break; + } + } + + if (lineEnd === -1) { + // We reached the end of the buffer but the line hasn't ended. + // Wait for the next arr and then continue parsing: + break; + } + + // we've reached the line end, send it out: + onLine(buffer.subarray(lineStart, lineEnd), fieldLength); + lineStart = position; // we're now on the next line + fieldLength = -1; + } + + if (lineStart === bufLength) { + buffer = undefined; // we've finished reading it + } else if (lineStart !== 0) { + // Create a new view into buffer beginning at lineStart so we don't + // need to copy over the previous lines when we get the new arr: + buffer = buffer.subarray(lineStart); + position -= lineStart; + } + }; +} + +/** + * Parses line buffers into EventSourceMessages. + * @param onId A function that will be called on each `id` field. + * @param onRetry A function that will be called on each `retry` field. + * @param onMessage A function that will be called on each message. + * @returns A function that should be called for each incoming line buffer. + */ +export function getMessages( + onId: (id: string) => void, + onMessage?: (msg: EventSourceMessage) => void, +) { + let message = newMessage(); + const decoder = new TextDecoder(); + + // return a function that can process each incoming line buffer: + return function onLine(line: Uint8Array, fieldLength: number) { + if (line.length === 0) { + // empty line denotes end of message. Trigger the callback and start a new message: + onMessage?.(message); + message = newMessage(); + } else if (fieldLength > 0) { + // exclude comments and lines with no values + // line is of format ":" or ": " + // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + const field = decoder.decode(line.subarray(0, fieldLength)); + const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1); + const value = decoder.decode(line.subarray(valueOffset)); + + switch (field) { + case 'data': + // if this message already has data, append the new value to the old. + // otherwise, just set to the new value: + message.data = message.data ? message.data + '\n' + value : value; // otherwise, + break; + case 'event': + message.event = value; + break; + case 'id': + onId((message.id = value)); + break; + } + } + }; +} + +function concat(a: Uint8Array, b: Uint8Array) { + const res = new Uint8Array(a.length + b.length); + res.set(a); + res.set(b, a.length); + return res; +} + +function newMessage(): EventSourceMessage { + // data, event, and id must be initialized to empty strings: + // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + // retry should be initialized to undefined so we return a consistent shape + // to the js engine all the time: https://mathiasbynens.be/notes/shapes-ics#takeaways + return { + data: '', + event: '', + id: '', + retry: undefined, + }; +} diff --git a/src/utils/fetch.ts b/src/utils/fetch/fetchSSE.ts similarity index 66% rename from src/utils/fetch.ts rename to src/utils/fetch/fetchSSE.ts index d31f02aa893f8..7e6b480de15f5 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch/fetchSSE.ts @@ -1,9 +1,6 @@ -import { fetchEventSource } from '@microsoft/fetch-event-source'; -import { t } from 'i18next'; -import { produce } from 'immer'; - +import { MESSAGE_CANCEL_FLAT } from '@/const/message'; import { LOBE_CHAT_OBSERVATION_ID, LOBE_CHAT_TRACE_ID } from '@/const/trace'; -import { ErrorResponse, ErrorType } from '@/types/fetch'; +import { ChatErrorType } from '@/types/fetch'; import { ChatMessageError, MessageToolCall, @@ -11,27 +8,9 @@ import { MessageToolCallSchema, } from '@/types/message'; -export const getMessageError = async (response: Response) => { - let chatMessageError: ChatMessageError; - - // 尝试取一波业务错误语义 - try { - const data = (await response.json()) as ErrorResponse; - chatMessageError = { - body: data.body, - message: t(`response.${data.errorType}` as any, { ns: 'error' }), - type: data.errorType, - }; - } catch { - // 如果无法正常返回,说明是常规报错 - chatMessageError = { - message: t(`response.${response.status}` as any, { ns: 'error' }), - type: response.status as ErrorType, - }; - } - - return chatMessageError; -}; +import { fetchEventSource } from './fetchEventSource'; +import { getMessageError } from './parseError'; +import { parseToolCalls } from './parseToolCalls'; type SSEFinishType = 'done' | 'error' | 'abort'; @@ -65,28 +44,6 @@ export interface FetchSSEOptions { smoothing?: boolean; } -export const parseToolCalls = (origin: MessageToolCall[], value: MessageToolCallChunk[]) => - produce(origin, (draft) => { - // if there is no origin, we should parse all the value and set it to draft - if (draft.length === 0) { - draft.push(...value.map((item) => MessageToolCallSchema.parse(item))); - return; - } - - // if there is origin, we should merge the value to the origin - value.forEach(({ index, ...item }) => { - if (!draft?.[index]) { - // if not, we should insert it to the draft - draft?.splice(index, 0, MessageToolCallSchema.parse(item)); - } else { - // if it is already in the draft, we should merge the arguments to the draft - if (item.function?.arguments) { - draft[index].function.arguments += item.function.arguments; - } - } - }); - }); - const createSmoothMessage = (params: { onTextUpdate: (delta: string, text: string) => void }) => { let buffer = ''; // why use queue: https://shareg.pt/GLBrjpK @@ -285,86 +242,111 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio }, }); - try { - await fetchEventSource(url, { - body: options.body, - fetch: options?.fetcher, - headers: options.headers as Record, - method: options.method, - onerror: (error) => { - if ((error as TypeError).name === 'AbortError') { - finishedType = 'abort'; - options?.onAbort?.(output); - textController.stopAnimation(); - } else { + await fetchEventSource(url, { + body: options.body, + fetch: options?.fetcher, + headers: options.headers as Record, + method: options.method, + onerror: (error) => { + if (error === MESSAGE_CANCEL_FLAT || (error as TypeError).name === 'AbortError') { + finishedType = 'abort'; + options?.onAbort?.(output); + textController.stopAnimation(); + } else { + finishedType = 'error'; + + options.onErrorHandle?.( + error.type + ? error + : { + body: { + message: error.message, + name: error.name, + stack: error.stack, + }, + message: error.message, + type: ChatErrorType.UnknownChatFetchError, + }, + ); + return; + } + }, + onmessage: (ev) => { + triggerOnMessageHandler = true; + let data; + try { + data = JSON.parse(ev.data); + } catch (e) { + console.warn('parse error:', e); + options.onErrorHandle?.({ + body: { + context: { + chunk: ev.data, + error: { message: (e as Error).message, name: (e as Error).name }, + }, + message: + 'chat response streaming chunk parse error, please contact your API Provider to fix it.', + }, + message: 'parse error', + type: 'StreamChunkError', + }); + + return; + } + + switch (ev.event) { + case 'error': { finishedType = 'error'; - options.onErrorHandle?.(error); + options.onErrorHandle?.(data); + break; } - throw new Error(error); - }, - onmessage: (ev) => { - triggerOnMessageHandler = true; - let data; - try { - data = JSON.parse(ev.data); - } catch (e) { - console.warn('parse error, fallback to stream', e); - options.onMessageHandle?.({ text: data, type: 'text' }); - return; - } + case 'text': { + if (smoothing) { + textController.pushToQueue(data); - switch (ev.event) { - case 'text': { - if (smoothing) { - textController.pushToQueue(data); + if (!textController.isAnimationActive) textController.startAnimation(); + } else { + output += data; + options.onMessageHandle?.({ text: data, type: 'text' }); + } - if (!textController.isAnimationActive) textController.startAnimation(); - } else { - output += data; - options.onMessageHandle?.({ text: data, type: 'text' }); - } + break; + } - break; - } + case 'tool_calls': { + // get finial + // if there is no tool calls, we should initialize the tool calls + if (!toolCalls) toolCalls = []; + toolCalls = parseToolCalls(toolCalls, data); - case 'tool_calls': { - // get finial - // if there is no tool calls, we should initialize the tool calls - if (!toolCalls) toolCalls = []; - toolCalls = parseToolCalls(toolCalls, data); - - if (smoothing) { - // make the tool calls smooth - - // push the tool calls to the smooth queue - toolCallsController.pushToQueue(data); - // if there is no animation active, we should start the animation - if (toolCallsController.isAnimationActives.some((value) => !value)) { - toolCallsController.startAnimations(); - } - } else { - options.onMessageHandle?.({ - tool_calls: toolCalls, - type: 'tool_calls', - }); + if (smoothing) { + // make the tool calls smooth + + // push the tool calls to the smooth queue + toolCallsController.pushToQueue(data); + // if there is no animation active, we should start the animation + if (toolCallsController.isAnimationActives.some((value) => !value)) { + toolCallsController.startAnimations(); } + } else { + options.onMessageHandle?.({ + tool_calls: toolCalls, + type: 'tool_calls', + }); } } - }, - onopen: async (res) => { - response = res.clone(); - // 如果不 ok 说明有请求错误 - if (!response.ok) { - throw await getMessageError(res); - } - }, - // we should keep open when page hidden, or it will case lots of token cost - // refs: https://github.com/lobehub/lobe-chat/issues/2501 - openWhenHidden: true, - signal: options.signal, - }); - } catch {} + } + }, + onopen: async (res) => { + response = res.clone(); + // 如果不 ok 说明有请求错误 + if (!response.ok) { + throw await getMessageError(res); + } + }, + signal: options.signal, + }); // only call onFinish when response is available // so like abort, we don't need to call onFinish diff --git a/src/utils/fetch/index.ts b/src/utils/fetch/index.ts new file mode 100644 index 0000000000000..bf2fc7b625ed2 --- /dev/null +++ b/src/utils/fetch/index.ts @@ -0,0 +1,2 @@ +export * from './fetchSSE'; +export * from './parseError'; diff --git a/src/utils/fetch/parseError.ts b/src/utils/fetch/parseError.ts new file mode 100644 index 0000000000000..f0db947f89c3e --- /dev/null +++ b/src/utils/fetch/parseError.ts @@ -0,0 +1,26 @@ +import { t } from 'i18next'; + +import { ErrorResponse, ErrorType } from '@/types/fetch'; +import { ChatMessageError } from '@/types/message'; + +export const getMessageError = async (response: Response) => { + let chatMessageError: ChatMessageError; + + // try to get the biz error + try { + const data = (await response.json()) as ErrorResponse; + chatMessageError = { + body: data.body, + message: t(`response.${data.errorType}` as any, { ns: 'error' }), + type: data.errorType, + }; + } catch { + // if not return, then it's a common error + chatMessageError = { + message: t(`response.${response.status}` as any, { ns: 'error' }), + type: response.status as ErrorType, + }; + } + + return chatMessageError; +}; diff --git a/src/utils/fetch/parseToolCalls.ts b/src/utils/fetch/parseToolCalls.ts new file mode 100644 index 0000000000000..09f4a2b2098e6 --- /dev/null +++ b/src/utils/fetch/parseToolCalls.ts @@ -0,0 +1,25 @@ +import { produce } from 'immer'; + +import { MessageToolCall, MessageToolCallChunk, MessageToolCallSchema } from '@/types/message'; + +export const parseToolCalls = (origin: MessageToolCall[], value: MessageToolCallChunk[]) => + produce(origin, (draft) => { + // if there is no origin, we should parse all the value and set it to draft + if (draft.length === 0) { + draft.push(...value.map((item) => MessageToolCallSchema.parse(item))); + return; + } + + // if there is origin, we should merge the value to the origin + value.forEach(({ index, ...item }) => { + if (!draft?.[index]) { + // if not, we should insert it to the draft + draft?.splice(index, 0, MessageToolCallSchema.parse(item)); + } else { + // if it is already in the draft, we should merge the arguments to the draft + if (item.function?.arguments) { + draft[index].function.arguments += item.function.arguments; + } + } + }); + }); diff --git a/vitest.config.ts b/vitest.config.ts index f115e79c68baf..15406e3850d23 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ // we will use pglite in the future // so the coverage of this file is not important 'src/database/client/core/db.ts', + 'src/utils/fetch/fetchEventSource/*.ts', ], provider: 'v8', reporter: ['text', 'json', 'lcov', 'text-summary'],