diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 8b0d204bc..444f48348 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -167,6 +167,49 @@ "Type": "Typ", "Mode": "Modus", "Custom": "Benutzerdefiniert", + "Extended thinking": "Erweitertes Denken", + "A Gemini Web API mode with this model and thinking setting already exists.": "Ein Gemini-Web-API-Modus mit diesem Modell und dieser Denkeinstellung ist bereits vorhanden.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Ungültiges Antwortformat. Das Website-Protokoll hat sich möglicherweise geändert.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Ungültige Antwort-Frame-Länge.", + "Gemini Web: Truncated response frame.": "Gemini Web: Abgeschnittener Antwort-Frame.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Ungültige Antwort-Hüllstruktur.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Ungültige Konversationskennung.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Die Konversationskennungen haben sich während der Generierung geändert.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: Die Website hat die Anfrage abgelehnt. Überprüfen Sie Gemini in Ihrem Browser.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: Die Website hat die Anfrage abgelehnt ({{code}}). Überprüfen Sie Gemini in Ihrem Browser.", + "Gemini Web: Invalid response payload.": "Gemini Web: Ungültige Antwortdaten.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Ungültige Antwortkandidaten-Kennung.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: Der Antwortkandidat hat sich während der Generierung geändert.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Ungültiger Textkandidat.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: Es wurde keine Textantwort empfangen. Überprüfen Sie Gemini in Ihrem Browser.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: Es konnte nicht bestätigt werden, dass die Antwort fertig generiert wurde.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: Die Antwort wurde vor Abschluss unterbrochen. Überprüfen Sie Gemini vor einem erneuten Versuch.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Fehlende Konversationskennungen.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Ungültige Google-Konto-Route. Starten Sie eine neue Konversation.", + "Gemini Web: Invalid website session data.": "Gemini Web: Ungültige Website-Sitzungsdaten.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Überprüfen Sie Anmeldung und Limits in Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Melden Sie sich in diesem Browser-Profil bei Gemini an, bevor Sie es erneut versuchen.", + "Gemini Web: Empty website response.": "Gemini Web: Leere Website-Antwort.", + "Gemini Web: Website response is too large.": "Gemini Web: Die Website-Antwort ist zu groß.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: Die Google-Konto-Route hat sich geändert. Starten Sie eine neue Konversation.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: Das ausgewählte Modell ist für dieses Google-Konto nicht verfügbar.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Ungültige Gemini-Web-Modellauswahl.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: Gemini konnte die verfügbaren Modelle für dieses Konto nicht laden.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Ungültige Gemini-Modellerkennungsantwort.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Gemini hat keine verfügbaren Modellinformationen zurückgegeben.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: Dieses Google-Konto kann keine Modelle auswählen ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Ungültiger Konversationsstatus. Starten Sie eine neue Konversation.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: Diese Konversation hat keine gespeicherte Google-Konto-Route. Starten Sie eine neue Konversation.", + "Gemini Web: Empty question.": "Gemini Web: Leere Frage.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: Zeitüberschreitung der Anfrage. Überprüfen Sie Gemini vor einem erneuten Versuch.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Bei Gemini ist ein vorübergehender Generierungsfehler aufgetreten. Bitte versuchen Sie es erneut.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: Das Nutzungslimit für das ausgewählte Modell wurde erreicht. Überprüfen Sie Gemini, bevor Sie es erneut versuchen.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: Das ausgewählte Modell stimmt nicht mit dieser Konversation überein. Starten Sie eine neue Konversation.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: Das ausgewählte Modell ist nicht verfügbar oder das Gemini-Webprotokoll hat sich geändert.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini hat Anfragen aus diesem Netzwerk vorübergehend blockiert. Versuchen Sie es später erneut.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: Diese Konversation kann nicht mehr fortgesetzt werden. Starten Sie eine neue Konversation.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: Für diese Konversation ist kein Gemini-Modell gespeichert. Starten Sie eine neue Konversation.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: Die vorherige Gemini-Anfrage könnte die entfernte Konversation geändert haben. Starten Sie eine neue ChatGPTBox-Konversation.", "This provider is still used by other API modes or saved conversations": "Dieser Anbieter wird noch von anderen API-Modi oder gespeicherten Unterhaltungen verwendet", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Dieser API-Schlüssel ist für den ausgewählten benutzerdefinierten Modus festgelegt. Wenn Sie ihn hier bearbeiten, wird ein dedizierter Provider für diesen Modus erstellt.", "Use shared key": "Gemeinsamen Schlüssel verwenden", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 8756caa19..b3a9003fa 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -178,6 +178,49 @@ "Type": "Type", "Mode": "Mode", "Custom": "Custom", + "Extended thinking": "Extended thinking", + "A Gemini Web API mode with this model and thinking setting already exists.": "A Gemini Web API mode with this model and thinking setting already exists.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Invalid response format. The website protocol may have changed.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Invalid response frame length.", + "Gemini Web: Truncated response frame.": "Gemini Web: Truncated response frame.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Invalid response envelope.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Invalid conversation identifier.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Conversation identifiers changed during generation.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: The website rejected the request. Check Gemini in your browser.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.", + "Gemini Web: Invalid response payload.": "Gemini Web: Invalid response payload.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Invalid candidate identifier.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: Candidate changed during generation.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Invalid text candidate.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: No text answer was received. Check Gemini in your browser.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: Could not confirm that the answer finished generating.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Missing conversation identifiers.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Invalid Google account route. Start a new conversation.", + "Gemini Web: Invalid website session data.": "Gemini Web: Invalid website session data.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Sign in to Gemini in this browser profile before trying again.", + "Gemini Web: Empty website response.": "Gemini Web: Empty website response.", + "Gemini Web: Website response is too large.": "Gemini Web: Website response is too large.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: The Google account route changed. Start a new conversation.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: The selected model is not available for this Google account.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Invalid Gemini Web model selection.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: Gemini could not load the available models for this account.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Invalid Gemini model discovery response.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Gemini returned no available model information.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: This Google account cannot select models ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Invalid conversation state. Start a new conversation.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: This conversation has no stored Google account route. Start a new conversation.", + "Gemini Web: Empty question.": "Gemini Web: Empty question.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: Request timed out. Check Gemini before retrying.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Gemini encountered a temporary generation error. Please retry.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: The selected model does not match this conversation. Start a new conversation.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: This conversation can no longer be continued. Start a new conversation.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.", "Crop Text to ensure the input tokens do not exceed the model's limit": "Crop Text to ensure the input tokens do not exceed the model's limit", "OpenAI (API)": "OpenAI (API)", "Anthropic (API)": "Anthropic (API)", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index c9f5b5a11..c19ec5d9f 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -165,6 +165,49 @@ "Type": "Tipo", "Mode": "Modo", "Custom": "Personalizado", + "Extended thinking": "Pensamiento extendido", + "A Gemini Web API mode with this model and thinking setting already exists.": "Ya existe un modo de API de Gemini Web con este modelo y esta configuración de razonamiento.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Formato de respuesta no válido. Es posible que el protocolo del sitio web haya cambiado.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Longitud de marco de respuesta no válida.", + "Gemini Web: Truncated response frame.": "Gemini Web: Marco de respuesta truncado.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Contenedor de respuesta no válido.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Identificador de conversación no válido.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Los identificadores de conversación cambiaron durante la generación.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: El sitio web rechazó la solicitud. Revisa Gemini en tu navegador.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: El sitio web rechazó la solicitud ({{code}}). Revisa Gemini en tu navegador.", + "Gemini Web: Invalid response payload.": "Gemini Web: Datos de respuesta no válidos.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Identificador de candidato no válido.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: El candidato cambió durante la generación.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Candidato de texto no válido.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: No se recibió una respuesta de texto. Revisa Gemini en tu navegador.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: No se pudo confirmar que la respuesta terminó de generarse.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: La respuesta se interrumpió antes de completarse. Revisa Gemini antes de reintentarlo.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Faltan los identificadores de conversación.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Ruta de cuenta de Google no válida. Inicia una nueva conversación.", + "Gemini Web: Invalid website session data.": "Gemini Web: Datos de sesión del sitio web no válidos.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Revisa tu inicio de sesión y los límites en Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Inicia sesión en Gemini con este perfil de navegador antes de intentarlo de nuevo.", + "Gemini Web: Empty website response.": "Gemini Web: Respuesta del sitio web vacía.", + "Gemini Web: Website response is too large.": "Gemini Web: La respuesta del sitio web es demasiado grande.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: La ruta de la cuenta de Google cambió. Inicia una nueva conversación.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: El modelo seleccionado no está disponible para esta cuenta de Google.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Selección de modelo de Gemini Web no válida.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: Gemini no pudo cargar los modelos disponibles para esta cuenta.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Respuesta de detección de modelos de Gemini no válida.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Gemini no devolvió información de modelos disponibles.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: Esta cuenta de Google no puede seleccionar modelos ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Estado de conversación no válido. Inicia una nueva conversación.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: Esta conversación no tiene una ruta de cuenta de Google guardada. Inicia una nueva conversación.", + "Gemini Web: Empty question.": "Gemini Web: Pregunta vacía.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: La solicitud caducó. Revisa Gemini antes de reintentarlo.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Gemini encontró un error temporal de generación. Inténtalo de nuevo.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: Se alcanzó el límite de uso del modelo seleccionado. Comprueba Gemini antes de volver a intentarlo.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: El modelo seleccionado no coincide con esta conversación. Inicia una conversación nueva.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: El modelo seleccionado no está disponible o el protocolo web de Gemini ha cambiado.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini bloqueó temporalmente las solicitudes desde esta red. Inténtalo de nuevo más tarde.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: Esta conversación ya no puede continuar. Inicia una conversación nueva.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: Esta conversación no tiene guardado el modelo de Gemini. Inicia una conversación nueva.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: La solicitud anterior de Gemini puede haber cambiado la conversación remota. Inicia una nueva conversación de ChatGPTBox.", "This provider is still used by other API modes or saved conversations": "Este proveedor todavía está siendo utilizado por otros modos de API o conversaciones guardadas", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Esta clave de API está configurada en el modo personalizado seleccionado. Si la editas aquí, se creará un proveedor dedicado para ese modo.", "Use shared key": "Usar clave compartida", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index 1fa86e8a3..2b6ecf4ea 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -165,6 +165,49 @@ "Type": "Type", "Mode": "Mode", "Custom": "Personnalisé", + "Extended thinking": "Réflexion étendue", + "A Gemini Web API mode with this model and thinking setting already exists.": "Un mode API Gemini Web avec ce modèle et ce réglage de raisonnement existe déjà.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web : Format de réponse non valide. Le protocole du site web a peut-être changé.", + "Gemini Web: Invalid response frame length.": "Gemini Web : Longueur de trame de réponse non valide.", + "Gemini Web: Truncated response frame.": "Gemini Web : Trame de réponse tronquée.", + "Gemini Web: Invalid response envelope.": "Gemini Web : Enveloppe de réponse non valide.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web : Identifiant de conversation non valide.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web : Les identifiants de conversation ont changé pendant la génération.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web : Le site web a rejeté la demande. Vérifiez Gemini dans votre navigateur.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web : Le site web a rejeté la demande ({{code}}). Vérifiez Gemini dans votre navigateur.", + "Gemini Web: Invalid response payload.": "Gemini Web : Données de réponse non valides.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web : Identifiant de candidat non valide.", + "Gemini Web: Candidate changed during generation.": "Gemini Web : Le candidat a changé pendant la génération.", + "Gemini Web: Invalid text candidate.": "Gemini Web : Candidat textuel non valide.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web : Aucune réponse textuelle reçue. Vérifiez Gemini dans votre navigateur.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web : Impossible de confirmer que la réponse a fini de générer.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web : La réponse a été interrompue avant la fin. Vérifiez Gemini avant de réessayer.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web : Identifiants de conversation manquants.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web : Route de compte Google non valide. Démarrez une nouvelle conversation.", + "Gemini Web: Invalid website session data.": "Gemini Web : Données de session du site web non valides.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web : HTTP {{status}}. Vérifiez votre connexion et vos limites sur Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web : Connectez-vous à Gemini dans ce profil de navigateur avant de réessayer.", + "Gemini Web: Empty website response.": "Gemini Web : Réponse du site web vide.", + "Gemini Web: Website response is too large.": "Gemini Web : Réponse du site web trop volumineuse.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web : La route du compte Google a changé. Démarrez une nouvelle conversation.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web : Le modèle sélectionné n’est pas disponible pour ce compte Google.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web : Sélection de modèle Gemini Web non valide.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web : Gemini n’a pas pu charger les modèles disponibles pour ce compte.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web : Réponse de détection des modèles Gemini non valide.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web : Gemini n’a renvoyé aucune information de modèle disponible.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web : Ce compte Google ne peut pas sélectionner de modèles ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web : État de conversation non valide. Démarrez une nouvelle conversation.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web : Cette conversation n’a aucune route de compte Google enregistrée. Démarrez une nouvelle conversation.", + "Gemini Web: Empty question.": "Gemini Web : Question vide.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web : Délai de la demande dépassé. Vérifiez Gemini avant de réessayer.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web : Gemini a rencontré une erreur temporaire de génération. Veuillez réessayer.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web : La limite d’utilisation du modèle sélectionné a été atteinte. Vérifiez Gemini avant de réessayer.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web : Le modèle sélectionné ne correspond pas à cette conversation. Démarrez une nouvelle conversation.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web : Le modèle sélectionné est indisponible ou le protocole web de Gemini a changé.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web : Gemini a temporairement bloqué les requêtes provenant de ce réseau. Réessayez plus tard.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web : Cette conversation ne peut plus être poursuivie. Démarrez une nouvelle conversation.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web : Aucun modèle Gemini n’est enregistré pour cette conversation. Démarrez une nouvelle conversation.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web : La requête Gemini précédente a peut-être modifié la conversation distante. Démarrez une nouvelle conversation ChatGPTBox.", "This provider is still used by other API modes or saved conversations": "Ce fournisseur est encore utilisé par d'autres modes d'API ou des conversations enregistrées", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Cette clé API est définie sur le mode personnalisé sélectionné. La modifier ici créera un fournisseur dédié pour ce mode.", "Use shared key": "Utiliser la clé partagée", diff --git a/src/_locales/id/main.json b/src/_locales/id/main.json index 5267098d2..8e235815d 100644 --- a/src/_locales/id/main.json +++ b/src/_locales/id/main.json @@ -165,6 +165,49 @@ "Type": "Jenis", "Mode": "Mode", "Custom": "Kustom", + "Extended thinking": "Pemikiran diperluas", + "A Gemini Web API mode with this model and thinking setting already exists.": "Mode API Gemini Web dengan model dan pengaturan penalaran ini sudah ada.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Format respons tidak valid. Protokol situs web mungkin telah berubah.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Panjang bingkai respons tidak valid.", + "Gemini Web: Truncated response frame.": "Gemini Web: Bingkai respons terpotong.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Amplop respons tidak valid.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Pengenal percakapan tidak valid.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Pengenal percakapan berubah selama pembuatan.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: Situs web menolak permintaan. Periksa Gemini di browser Anda.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: Situs web menolak permintaan ({{code}}). Periksa Gemini di browser Anda.", + "Gemini Web: Invalid response payload.": "Gemini Web: Muatan respons tidak valid.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Pengenal kandidat tidak valid.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: Kandidat berubah selama pembuatan.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Kandidat teks tidak valid.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: Tidak ada jawaban teks yang diterima. Periksa Gemini di browser Anda.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: Tidak dapat memastikan bahwa jawaban selesai dibuat.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: Jawaban terputus sebelum selesai. Periksa Gemini sebelum mencoba lagi.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Pengenal percakapan tidak ada.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Rute akun Google tidak valid. Mulai percakapan baru.", + "Gemini Web: Invalid website session data.": "Gemini Web: Data sesi situs web tidak valid.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Periksa login dan batas Anda di Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Masuk ke Gemini di profil browser ini sebelum mencoba lagi.", + "Gemini Web: Empty website response.": "Gemini Web: Respons situs web kosong.", + "Gemini Web: Website response is too large.": "Gemini Web: Respons situs web terlalu besar.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: Rute akun Google berubah. Mulai percakapan baru.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: Model yang dipilih tidak tersedia untuk akun Google ini.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Pemilihan model Gemini Web tidak valid.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: Gemini tidak dapat memuat model yang tersedia untuk akun ini.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Respons deteksi model Gemini tidak valid.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Gemini tidak mengembalikan informasi model yang tersedia.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: Akun Google ini tidak dapat memilih model ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Status percakapan tidak valid. Mulai percakapan baru.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: Percakapan ini tidak memiliki rute akun Google yang tersimpan. Mulai percakapan baru.", + "Gemini Web: Empty question.": "Gemini Web: Pertanyaan kosong.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: Permintaan kedaluwarsa. Periksa Gemini sebelum mencoba lagi.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Gemini mengalami kesalahan pembuatan sementara. Silakan coba lagi.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: Batas penggunaan untuk model yang dipilih telah tercapai. Periksa Gemini sebelum mencoba lagi.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: Model yang dipilih tidak cocok dengan percakapan ini. Mulai percakapan baru.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: Model yang dipilih tidak tersedia atau protokol web Gemini telah berubah.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini untuk sementara memblokir permintaan dari jaringan ini. Coba lagi nanti.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: Percakapan ini tidak dapat dilanjutkan lagi. Mulai percakapan baru.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: Percakapan ini tidak memiliki model Gemini yang tersimpan. Mulai percakapan baru.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: Permintaan Gemini sebelumnya mungkin telah mengubah percakapan jarak jauh. Mulai percakapan ChatGPTBox baru.", "This provider is still used by other API modes or saved conversations": "Penyedia ini masih digunakan oleh mode API lain atau percakapan yang tersimpan", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Kunci API ini ditetapkan pada mode kustom yang dipilih. Mengeditnya di sini akan membuat penyedia khusus untuk mode tersebut.", "Use shared key": "Gunakan kunci bersama", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 4a592ff01..4b9d05e97 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -165,6 +165,49 @@ "Type": "Tipo", "Mode": "Modalità", "Custom": "Personalizzato", + "Extended thinking": "Ragionamento esteso", + "A Gemini Web API mode with this model and thinking setting already exists.": "Esiste già una modalità API Gemini Web con questo modello e questa impostazione di ragionamento.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Formato di risposta non valido. Il protocollo del sito web potrebbe essere cambiato.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Lunghezza del frame di risposta non valida.", + "Gemini Web: Truncated response frame.": "Gemini Web: Frame di risposta troncato.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Busta di risposta non valida.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Identificatore di conversazione non valido.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Gli identificatori di conversazione sono cambiati durante la generazione.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: Il sito web ha rifiutato la richiesta. Controlla Gemini nel tuo browser.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: Il sito web ha rifiutato la richiesta ({{code}}). Controlla Gemini nel tuo browser.", + "Gemini Web: Invalid response payload.": "Gemini Web: Dati di risposta non validi.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Identificatore di candidato non valido.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: Il candidato è cambiato durante la generazione.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Candidato testuale non valido.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: Nessuna risposta testuale ricevuta. Controlla Gemini nel tuo browser.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: Impossibile confermare che la risposta abbia terminato la generazione.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: La risposta è stata interrotta prima del completamento. Controlla Gemini prima di riprovare.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Identificatori di conversazione mancanti.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Percorso dell’account Google non valido. Avvia una nuova conversazione.", + "Gemini Web: Invalid website session data.": "Gemini Web: Dati di sessione del sito web non validi.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Controlla accesso e limiti su Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Accedi a Gemini in questo profilo del browser prima di riprovare.", + "Gemini Web: Empty website response.": "Gemini Web: Risposta del sito web vuota.", + "Gemini Web: Website response is too large.": "Gemini Web: Risposta del sito web troppo grande.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: Il percorso dell’account Google è cambiato. Avvia una nuova conversazione.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: Il modello selezionato non è disponibile per questo account Google.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Selezione del modello Gemini Web non valida.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: Gemini non ha potuto caricare i modelli disponibili per questo account.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Risposta di rilevamento modelli Gemini non valida.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Gemini non ha restituito informazioni sui modelli disponibili.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: Questo account Google non può selezionare modelli ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Stato della conversazione non valido. Avvia una nuova conversazione.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: Questa conversazione non ha un percorso dell’account Google salvato. Avvia una nuova conversazione.", + "Gemini Web: Empty question.": "Gemini Web: Domanda vuota.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: Richiesta scaduta. Controlla Gemini prima di riprovare.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Gemini ha riscontrato un errore temporaneo di generazione. Riprova.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: È stato raggiunto il limite di utilizzo per il modello selezionato. Controlla Gemini prima di riprovare.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: Il modello selezionato non corrisponde a questa conversazione. Avvia una nuova conversazione.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: Il modello selezionato non è disponibile oppure il protocollo web di Gemini è cambiato.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini ha bloccato temporaneamente le richieste da questa rete. Riprova più tardi.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: Questa conversazione non può più essere continuata. Avvia una nuova conversazione.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: Per questa conversazione non è memorizzato alcun modello Gemini. Avvia una nuova conversazione.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: La richiesta Gemini precedente potrebbe aver modificato la conversazione remota. Avvia una nuova conversazione ChatGPTBox.", "This provider is still used by other API modes or saved conversations": "Questo provider è ancora utilizzato da altre modalità API o conversazioni salvate", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Questa chiave API è impostata nella modalità personalizzata selezionata. Modificandola qui verrà creato un provider dedicato per quella modalità.", "Use shared key": "Usa la chiave condivisa", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 91c960949..c024956c9 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -165,6 +165,49 @@ "Type": "タイプ", "Mode": "モード", "Custom": "カスタム", + "Extended thinking": "拡張思考", + "A Gemini Web API mode with this model and thinking setting already exists.": "このモデルと思考設定の Gemini Web API モードはすでに存在します。", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: 応答形式が無効です。Webサイトのプロトコルが変更された可能性があります。", + "Gemini Web: Invalid response frame length.": "Gemini Web: 応答フレームの長さが無効です。", + "Gemini Web: Truncated response frame.": "Gemini Web: 応答フレームが切り詰められています。", + "Gemini Web: Invalid response envelope.": "Gemini Web: 応答エンベロープが無効です。", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: 会話IDが無効です。", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: 生成中に会話IDが変更されました。", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: Webサイトがリクエストを拒否しました。ブラウザでGeminiを確認してください。", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: Webサイトがリクエストを拒否しました({{code}})。ブラウザでGeminiを確認してください。", + "Gemini Web: Invalid response payload.": "Gemini Web: 応答ペイロードが無効です。", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: 候補IDが無効です。", + "Gemini Web: Candidate changed during generation.": "Gemini Web: 生成中に候補が変更されました。", + "Gemini Web: Invalid text candidate.": "Gemini Web: テキスト候補が無効です。", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: テキストの回答を受信できませんでした。ブラウザでGeminiを確認してください。", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: 回答の生成が完了したことを確認できませんでした。", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: 回答が完了前に中断されました。再試行する前にGeminiを確認してください。", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: 会話IDがありません。", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Googleアカウントのルートが無効です。新しい会話を開始してください。", + "Gemini Web: Invalid website session data.": "Gemini Web: Webサイトのセッションデータが無効です。", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}。Geminiでのログインと制限を確認してください。", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: 再試行する前に、このブラウザプロファイルでGeminiにログインしてください。", + "Gemini Web: Empty website response.": "Gemini Web: Webサイトの応答が空です。", + "Gemini Web: Website response is too large.": "Gemini Web: Webサイトの応答が大きすぎます。", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: Googleアカウントのルートが変更されました。新しい会話を開始してください。", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: 選択したモデルはこのGoogleアカウントでは利用できません。", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Gemini Webのモデル選択が無効です。", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: このアカウントで利用可能なモデルを読み込めませんでした。", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Geminiのモデル検出応答が無効です。", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Geminiから利用可能なモデル情報が返されませんでした。", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: このGoogleアカウントではモデルを選択できません({{status}})。", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: 会話の状態が無効です。新しい会話を開始してください。", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: この会話には保存されたGoogleアカウントのルートがありません。新しい会話を開始してください。", + "Gemini Web: Empty question.": "Gemini Web: 質問が空です。", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: リクエストがタイムアウトしました。再試行する前にGeminiを確認してください。", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Gemini で一時的な生成エラーが発生しました。もう一度お試しください。", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: 選択したモデルの使用上限に達しました。Gemini の状態を確認してから再試行してください。", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: 選択したモデルはこの会話と一致しません。新しい会話を開始してください。", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: 選択したモデルを利用できないか、Gemini の Web プロトコルが変更されています。", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: このネットワークからのリクエストは Gemini によって一時的にブロックされています。後でもう一度お試しください。", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: この会話はこれ以上続行できません。新しい会話を開始してください。", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: この会話には保存された Gemini モデル情報がありません。新しい会話を開始してください。", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: 前回の Gemini リクエストによってリモート会話が変更された可能性があります。新しい ChatGPTBox の会話を開始してください。", "This provider is still used by other API modes or saved conversations": "このプロバイダーは他の API モードまたは保存済みの会話でまだ使用されています", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "この API キーは選択中のカスタムモードに設定されています。ここで編集すると、そのモード専用のプロバイダーが作成されます。", "Use shared key": "共有キーを使用", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 252ed15f8..cf27c8d34 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -169,6 +169,49 @@ "Type": "유형", "Mode": "모드", "Custom": "사용자 정의", + "Extended thinking": "확장 사고", + "A Gemini Web API mode with this model and thinking setting already exists.": "이 모델과 사고 설정을 사용하는 Gemini Web API 모드가 이미 있습니다.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini 웹: 응답 형식이 올바르지 않습니다. 웹사이트 프로토콜이 변경되었을 수 있습니다.", + "Gemini Web: Invalid response frame length.": "Gemini 웹: 응답 프레임 길이가 올바르지 않습니다.", + "Gemini Web: Truncated response frame.": "Gemini 웹: 응답 프레임이 잘렸습니다.", + "Gemini Web: Invalid response envelope.": "Gemini 웹: 응답 엔벨로프가 올바르지 않습니다.", + "Gemini Web: Invalid conversation identifier.": "Gemini 웹: 대화 식별자가 올바르지 않습니다.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini 웹: 생성 중에 대화 식별자가 변경되었습니다.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini 웹: 웹사이트에서 요청을 거부했습니다. 브라우저에서 Gemini를 확인하세요.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini 웹: 웹사이트에서 요청을 거부했습니다({{code}}). 브라우저에서 Gemini를 확인하세요.", + "Gemini Web: Invalid response payload.": "Gemini 웹: 응답 페이로드가 올바르지 않습니다.", + "Gemini Web: Invalid candidate identifier.": "Gemini 웹: 후보 식별자가 올바르지 않습니다.", + "Gemini Web: Candidate changed during generation.": "Gemini 웹: 생성 중에 후보가 변경되었습니다.", + "Gemini Web: Invalid text candidate.": "Gemini 웹: 텍스트 후보가 올바르지 않습니다.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini 웹: 텍스트 답변을 받지 못했습니다. 브라우저에서 Gemini를 확인하세요.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini 웹: 답변이 완전히 생성되었는지 확인할 수 없습니다.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini 웹: 답변이 완료되기 전에 중단되었습니다. 다시 시도하기 전에 Gemini를 확인하세요.", + "Gemini Web: Missing conversation identifiers.": "Gemini 웹: 대화 식별자가 없습니다.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini 웹: Google 계정 경로가 올바르지 않습니다. 새 대화를 시작하세요.", + "Gemini Web: Invalid website session data.": "Gemini 웹: 웹사이트 세션 데이터가 올바르지 않습니다.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini 웹: HTTP {{status}}. Gemini의 로그인과 한도를 확인하세요.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini 웹: 다시 시도하기 전에 이 브라우저 프로필에서 Gemini에 로그인하세요.", + "Gemini Web: Empty website response.": "Gemini 웹: 웹사이트 응답이 비어 있습니다.", + "Gemini Web: Website response is too large.": "Gemini 웹: 웹사이트 응답이 너무 큽니다.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini 웹: Google 계정 경로가 변경되었습니다. 새 대화를 시작하세요.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini 웹: 선택한 모델은 이 Google 계정에서 사용할 수 없습니다.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini 웹: Gemini 웹 모델 선택이 올바르지 않습니다.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini 웹: 이 계정에서 사용 가능한 모델을 불러오지 못했습니다.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini 웹: Gemini 모델 검색 응답이 올바르지 않습니다.", + "Gemini Web: Gemini returned no available model information.": "Gemini 웹: Gemini에서 사용 가능한 모델 정보를 반환하지 않았습니다.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini 웹: 이 Google 계정에서는 모델을 선택할 수 없습니다({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini 웹: 대화 상태가 올바르지 않습니다. 새 대화를 시작하세요.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini 웹: 이 대화에는 저장된 Google 계정 경로가 없습니다. 새 대화를 시작하세요.", + "Gemini Web: Empty question.": "Gemini 웹: 질문이 비어 있습니다.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini 웹: 요청 시간이 초과되었습니다. 다시 시도하기 전에 Gemini를 확인하세요.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Gemini에서 일시적인 생성 오류가 발생했습니다. 다시 시도하세요.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: 선택한 모델의 사용 한도에 도달했습니다. 다시 시도하기 전에 Gemini를 확인하세요.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: 선택한 모델이 이 대화와 일치하지 않습니다. 새 대화를 시작하세요.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: 선택한 모델을 사용할 수 없거나 Gemini 웹 프로토콜이 변경되었습니다.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini가 이 네트워크의 요청을 일시적으로 차단했습니다. 나중에 다시 시도하세요.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: 이 대화를 더 이상 계속할 수 없습니다. 새 대화를 시작하세요.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: 이 대화에 저장된 Gemini 모델 정보가 없습니다. 새 대화를 시작하세요.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: 이전 Gemini 요청으로 원격 대화가 변경되었을 수 있습니다. 새 ChatGPTBox 대화를 시작하세요.", "This provider is still used by other API modes or saved conversations": "이 공급자는 아직 다른 API 모드 또는 저장된 대화에서 사용 중입니다", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "이 API 키는 선택한 사용자 지정 모드에 설정되어 있습니다. 여기서 편집하면 해당 모드 전용 provider가 생성됩니다.", "Use shared key": "공유 키 사용", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index b3379c159..e4b31e315 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -165,6 +165,49 @@ "Type": "Tipo", "Mode": "Modo", "Custom": "Personalizado", + "Extended thinking": "Pensamento estendido", + "A Gemini Web API mode with this model and thinking setting already exists.": "Já existe um modo de API do Gemini Web com este modelo e esta configuração de raciocínio.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Formato de resposta inválido. O protocolo do site pode ter mudado.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Comprimento do quadro de resposta inválido.", + "Gemini Web: Truncated response frame.": "Gemini Web: Quadro de resposta truncado.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Envelope de resposta inválido.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Identificador de conversa inválido.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Os identificadores de conversa mudaram durante a geração.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: O site rejeitou a solicitação. Verifique o Gemini no seu navegador.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: O site rejeitou a solicitação ({{code}}). Verifique o Gemini no seu navegador.", + "Gemini Web: Invalid response payload.": "Gemini Web: Dados de resposta inválidos.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Identificador de candidato inválido.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: O candidato mudou durante a geração.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Candidato de texto inválido.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: Nenhuma resposta de texto recebida. Verifique o Gemini no seu navegador.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: Não foi possível confirmar que a resposta terminou de ser gerada.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: A resposta foi interrompida antes da conclusão. Verifique o Gemini antes de tentar de novo.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Identificadores de conversa ausentes.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Rota da conta Google inválida. Inicie uma nova conversa.", + "Gemini Web: Invalid website session data.": "Gemini Web: Dados de sessão do site inválidos.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Verifique seu login e os limites no Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Faça login no Gemini neste perfil do navegador antes de tentar de novo.", + "Gemini Web: Empty website response.": "Gemini Web: Resposta do site vazia.", + "Gemini Web: Website response is too large.": "Gemini Web: Resposta do site muito grande.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: A rota da conta Google mudou. Inicie uma nova conversa.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: O modelo selecionado não está disponível para esta conta Google.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Seleção de modelo do Gemini Web inválida.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: O Gemini não conseguiu carregar os modelos disponíveis para esta conta.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Resposta de detecção de modelos do Gemini inválida.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: O Gemini não retornou informações de modelos disponíveis.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: Esta conta Google não pode selecionar modelos ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Estado de conversa inválido. Inicie uma nova conversa.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: Esta conversa não tem uma rota de conta Google guardada. Inicie uma nova conversa.", + "Gemini Web: Empty question.": "Gemini Web: Pergunta vazia.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: A solicitação expirou. Verifique o Gemini antes de tentar de novo.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: O Gemini encontrou um erro temporário de geração. Tente novamente.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: O limite de uso do modelo selecionado foi atingido. Verifique o Gemini antes de tentar novamente.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: O modelo selecionado não corresponde a esta conversa. Inicie uma nova conversa.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: O modelo selecionado não está disponível ou o protocolo web do Gemini foi alterado.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: O Gemini bloqueou temporariamente solicitações desta rede. Tente novamente mais tarde.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: Esta conversa não pode mais ser continuada. Inicie uma nova conversa.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: Esta conversa não tem um modelo Gemini salvo. Inicie uma nova conversa.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: A solicitação anterior ao Gemini pode ter alterado a conversa remota. Inicie uma nova conversa do ChatGPTBox.", "This provider is still used by other API modes or saved conversations": "Este provedor ainda está sendo usado por outros modos de API ou conversas salvas", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Esta chave de API está definida no modo personalizado selecionado. Editá-la aqui vai criar um fornecedor dedicado para esse modo.", "Use shared key": "Usar chave partilhada", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index d857f3f62..00eab69d7 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -177,6 +177,49 @@ "Type": "Тип", "Mode": "Режим", "Custom": "Пользовательский", + "Extended thinking": "Расширенное мышление", + "A Gemini Web API mode with this model and thinking setting already exists.": "Режим API Gemini Web с этой моделью и настройкой рассуждений уже существует.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Неверный формат ответа. Возможно, протокол сайта изменился.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Неверная длина фрейма ответа.", + "Gemini Web: Truncated response frame.": "Gemini Web: Усечённый фрейм ответа.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Неверная оболочка ответа.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Неверный идентификатор диалога.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Идентификаторы диалога изменились во время генерации.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: Сайт отклонил запрос. Проверьте Gemini в вашем браузере.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: Сайт отклонил запрос ({{code}}). Проверьте Gemini в вашем браузере.", + "Gemini Web: Invalid response payload.": "Gemini Web: Неверные данные ответа.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Неверный идентификатор кандидата.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: Кандидат изменился во время генерации.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Неверный текстовый кандидат.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: Текстовый ответ не получен. Проверьте Gemini в вашем браузере.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: Не удалось подтвердить, что ответ полностью сформирован.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: Ответ был прерван до завершения. Проверьте Gemini перед повторной попыткой.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Отсутствуют идентификаторы диалога.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Неверный маршрут аккаунта Google. Начните новый диалог.", + "Gemini Web: Invalid website session data.": "Gemini Web: Неверные данные сессии сайта.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Проверьте вход и лимиты в Gemini.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Войдите в Gemini в этом профиле браузера перед повторной попыткой.", + "Gemini Web: Empty website response.": "Gemini Web: Пустой ответ сайта.", + "Gemini Web: Website response is too large.": "Gemini Web: Ответ сайта слишком большой.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: Маршрут аккаунта Google изменился. Начните новый диалог.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: Выбранная модель недоступна для этого аккаунта Google.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Неверный выбор модели Gemini Web.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: Gemini не смог загрузить доступные модели для этого аккаунта.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Неверный ответ обнаружения моделей Gemini.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Gemini не вернул информацию о доступных моделях.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: Этот аккаунт Google не может выбирать модели ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Неверное состояние диалога. Начните новый диалог.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: В этом диалоге нет сохранённого маршрута аккаунта Google. Начните новый диалог.", + "Gemini Web: Empty question.": "Gemini Web: Пустой вопрос.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: Время запроса истекло. Проверьте Gemini перед повторной попыткой.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: В Gemini произошла временная ошибка генерации. Повторите попытку.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: Достигнут лимит использования выбранной модели. Проверьте Gemini перед повторной попыткой.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: Выбранная модель не соответствует этой беседе. Начните новую беседу.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: Выбранная модель недоступна или веб-протокол Gemini изменился.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini временно заблокировал запросы из этой сети. Повторите попытку позже.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: Эту беседу больше нельзя продолжить. Начните новую беседу.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: Для этой беседы не сохранена модель Gemini. Начните новую беседу.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: Предыдущий запрос Gemini мог изменить удалённую беседу. Начните новую беседу ChatGPTBox.", "This provider is still used by other API modes or saved conversations": "Этот провайдер всё ещё используется другими режимами API или сохранёнными диалогами", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Этот API-ключ задан в выбранном пользовательском режиме. Если изменить его здесь, будет создан отдельный провайдер для этого режима.", "Use shared key": "Использовать общий ключ", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 1b2391cf4..34663e125 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -165,6 +165,49 @@ "Type": "Tür", "Mode": "Mod", "Custom": "Özel", + "Extended thinking": "Genişletilmiş düşünme", + "A Gemini Web API mode with this model and thinking setting already exists.": "Bu model ve düşünme ayarına sahip bir Gemini Web API modu zaten var.", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Gemini Web: Geçersiz yanıt biçimi. Web sitesi protokolü değişmiş olabilir.", + "Gemini Web: Invalid response frame length.": "Gemini Web: Geçersiz yanıt çerçeve uzunluğu.", + "Gemini Web: Truncated response frame.": "Gemini Web: Kesilmiş yanıt çerçevesi.", + "Gemini Web: Invalid response envelope.": "Gemini Web: Geçersiz yanıt zarfı.", + "Gemini Web: Invalid conversation identifier.": "Gemini Web: Geçersiz konuşma tanımlayıcısı.", + "Gemini Web: Conversation identifiers changed during generation.": "Gemini Web: Oluşturma sırasında konuşma tanımlayıcıları değişti.", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Gemini Web: Web sitesi isteği reddetti. Tarayıcınızda Gemini'yi kontrol edin.", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Gemini Web: Web sitesi isteği reddetti ({{code}}). Tarayıcınızda Gemini'yi kontrol edin.", + "Gemini Web: Invalid response payload.": "Gemini Web: Geçersiz yanıt verisi.", + "Gemini Web: Invalid candidate identifier.": "Gemini Web: Geçersiz aday tanımlayıcısı.", + "Gemini Web: Candidate changed during generation.": "Gemini Web: Oluşturma sırasında aday değişti.", + "Gemini Web: Invalid text candidate.": "Gemini Web: Geçersiz metin adayı.", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Gemini Web: Metin yanıtı alınamadı. Tarayıcınızda Gemini'yi kontrol edin.", + "Gemini Web: Could not confirm that the answer finished generating.": "Gemini Web: Yanıtın oluşturulmasının tamamlandığı doğrulanamadı.", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Gemini Web: Yanıt tamamlanmadan kesildi. Yeniden denemeden önce Gemini'yi kontrol edin.", + "Gemini Web: Missing conversation identifiers.": "Gemini Web: Konuşma tanımlayıcıları eksik.", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Gemini Web: Geçersiz Google hesap yolu. Yeni bir konuşma başlatın.", + "Gemini Web: Invalid website session data.": "Gemini Web: Geçersiz web sitesi oturum verisi.", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Gemini Web: HTTP {{status}}. Gemini'de girişinizi ve limitleri kontrol edin.", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Gemini Web: Yeniden denemeden önce bu tarayıcı profilinde Gemini'de oturum açın.", + "Gemini Web: Empty website response.": "Gemini Web: Boş web sitesi yanıtı.", + "Gemini Web: Website response is too large.": "Gemini Web: Web sitesi yanıtı çok büyük.", + "Gemini Web: The Google account route changed. Start a new conversation.": "Gemini Web: Google hesap yolu değişti. Yeni bir konuşma başlatın.", + "Gemini Web: The selected model is not available for this Google account.": "Gemini Web: Seçilen model bu Google hesabı için kullanılamıyor.", + "Gemini Web: Invalid Gemini Web model selection.": "Gemini Web: Geçersiz Gemini Web model seçimi.", + "Gemini Web: Gemini could not load the available models for this account.": "Gemini Web: Gemini bu hesap için kullanılabilir modelleri yükleyemedi.", + "Gemini Web: Invalid Gemini model discovery response.": "Gemini Web: Geçersiz Gemini model bulma yanıtı.", + "Gemini Web: Gemini returned no available model information.": "Gemini Web: Gemini kullanılabilir model bilgisi döndürmedi.", + "Gemini Web: This Google account cannot select models ({{status}}).": "Gemini Web: Bu Google hesabı model seçemiyor ({{status}}).", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Gemini Web: Geçersiz konuşma durumu. Yeni bir konuşma başlatın.", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Gemini Web: Bu konuşmanın kayıtlı Google hesap yolu yok. Yeni bir konuşma başlatın.", + "Gemini Web: Empty question.": "Gemini Web: Boş soru.", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Gemini Web: İstek zaman aşımına uğradı. Yeniden denemeden önce Gemini'yi kontrol edin.", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Gemini Web: Gemini geçici bir oluşturma hatasıyla karşılaştı. Lütfen yeniden deneyin.", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Gemini Web: Seçilen modelin kullanım sınırına ulaşıldı. Yeniden denemeden önce Gemini'yi kontrol edin.", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Gemini Web: Seçilen model bu konuşmayla eşleşmiyor. Yeni bir konuşma başlatın.", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Gemini Web: Seçilen model kullanılamıyor veya Gemini web protokolü değişti.", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Gemini Web: Gemini bu ağdan gelen istekleri geçici olarak engelledi. Daha sonra yeniden deneyin.", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Gemini Web: Bu konuşma artık sürdürülemiyor. Yeni bir konuşma başlatın.", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Gemini Web: Bu konuşma için kaydedilmiş bir Gemini modeli yok. Yeni bir konuşma başlatın.", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Gemini Web: Önceki Gemini isteği uzak konuşmayı değiştirmiş olabilir. Yeni bir ChatGPTBox konuşması başlatın.", "This provider is still used by other API modes or saved conversations": "Bu sağlayıcı hâlâ diğer API modları veya kaydedilmiş konuşmalar tarafından kullanılıyor", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Bu API anahtarı seçili özel modda ayarlanmış. Burada düzenlemek bu mod için özel bir sağlayıcı oluşturur.", "Use shared key": "Paylaşılan anahtarı kullan", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index cfad6d2d1..efbe4a76e 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -172,6 +172,49 @@ "Type": "类型", "Mode": "模式", "Custom": "自定义", + "Extended thinking": "延伸思考", + "A Gemini Web API mode with this model and thinking setting already exists.": "已存在使用此模型和思考设置的 Gemini 网页版 API 模式。", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Google Gemini 网页版:响应格式无效,网站通信协议可能已变更。", + "Gemini Web: Invalid response frame length.": "Google Gemini 网页版:响应框架长度无效。", + "Gemini Web: Truncated response frame.": "Google Gemini 网页版:响应框架不完整。", + "Gemini Web: Invalid response envelope.": "Google Gemini 网页版:响应封装格式无效。", + "Gemini Web: Invalid conversation identifier.": "Google Gemini 网页版:对话标识符无效。", + "Gemini Web: Conversation identifiers changed during generation.": "Google Gemini 网页版:生成响应期间,对话标识符发生变更。", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Google Gemini 网页版:网站拒绝此请求。请在浏览器中检查 Gemini。", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Google Gemini 网页版:网站拒绝此请求({{code}})。请在浏览器中检查 Gemini。", + "Gemini Web: Invalid response payload.": "Google Gemini 网页版:响应内容格式无效。", + "Gemini Web: Invalid candidate identifier.": "Google Gemini 网页版:候选响应标识符无效。", + "Gemini Web: Candidate changed during generation.": "Google Gemini 网页版:生成响应期间,候选响应发生变更。", + "Gemini Web: Invalid text candidate.": "Google Gemini 网页版:文本候选响应格式无效。", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Google Gemini 网页版:未收到文本回答。请在浏览器中检查 Gemini。", + "Gemini Web: Could not confirm that the answer finished generating.": "Google Gemini 网页版:无法确认回答是否已完成生成。", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Google Gemini 网页版:回答在完成前中断。重试前请先检查 Gemini。", + "Gemini Web: Missing conversation identifiers.": "Google Gemini 网页版:缺少对话标识符。", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Google Gemini 网页版:Google 账户路径无效,请开始新对话。", + "Gemini Web: Invalid website session data.": "Google Gemini 网页版:网站会话数据无效。", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Google Gemini 网页版:HTTP {{status}}。请确认 Gemini 的登录状态与使用限制。", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Google Gemini 网页版:请先在此浏览器配置文件登录 Gemini,再重试。", + "Gemini Web: Empty website response.": "Google Gemini 网页版:网站响应为空。", + "Gemini Web: Website response is too large.": "Google Gemini 网页版:网站响应过大。", + "Gemini Web: The Google account route changed. Start a new conversation.": "Google Gemini 网页版:Google 账户路径已变更,请开始新对话。", + "Gemini Web: The selected model is not available for this Google account.": "Google Gemini 网页版:此 Google 账户无法使用所选模型。", + "Gemini Web: Invalid Gemini Web model selection.": "Google Gemini 网页版:Gemini 网页版模型选择无效。", + "Gemini Web: Gemini could not load the available models for this account.": "Google Gemini 网页版:无法加载此账户可用的模型。", + "Gemini Web: Invalid Gemini model discovery response.": "Google Gemini 网页版:Gemini 模型信息响应无效。", + "Gemini Web: Gemini returned no available model information.": "Google Gemini 网页版:Gemini 未返回可用模型信息。", + "Gemini Web: This Google account cannot select models ({{status}}).": "Google Gemini 网页版:此 Google 账户无法选择模型({{status}})。", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Google Gemini 网页版:对话状态无效,请开始新对话。", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Google Gemini 网页版:此对话没有保存 Google 账户路径,请开始新对话。", + "Gemini Web: Empty question.": "Google Gemini 网页版:问题内容不可为空。", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Google Gemini 网页版:请求超时。重试前请先检查 Gemini。", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Google Gemini 网页版:Gemini 遇到临时生成错误,请重试。", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Google Gemini 网页版:所选模型已达到使用限制。请先在 Gemini 中检查使用限制,再重试。", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Google Gemini 网页版:所选模型与当前对话不匹配。请开始新对话。", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Google Gemini 网页版:所选模型当前不可用,或 Gemini 网页协议已发生变化。", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Google Gemini 网页版:Gemini 暂时阻止了来自此网络的请求,请稍后再试。", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Google Gemini 网页版:此对话已无法继续。请开始新对话。", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Google Gemini 网页版:此对话没有已保存的 Gemini 模型信息。请开始新对话。", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Google Gemini 网页版:上一次 Gemini 请求可能已改变远端对话状态。请开始新的 ChatGPTBox 对话。", "Kimi.Moonshot (Web, 100k)": "Kimi.Moonshot (网页版, 100k)", "ChatGLM (GLM-4-Air, 128k)": "ChatGLM (GLM4Air, 性价比, 128k)", "ChatGLM (GLM-4-0520, 128k)": "ChatGLM (GLM4-0520, 最智能, 128k)", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index 3dd20a229..0f398f07d 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -172,6 +172,49 @@ "Type": "類型", "Mode": "模式", "Custom": "自訂", + "Extended thinking": "延伸思考", + "A Gemini Web API mode with this model and thinking setting already exists.": "已存在使用此模型與思考設定的 Google Gemini (網頁版) API 模式。", + "Gemini Web: Invalid response format. The website protocol may have changed.": "Google Gemini 網頁版:回應格式無效,網站通訊協定可能已變更。", + "Gemini Web: Invalid response frame length.": "Google Gemini 網頁版:回應框架長度無效。", + "Gemini Web: Truncated response frame.": "Google Gemini 網頁版:回應框架不完整。", + "Gemini Web: Invalid response envelope.": "Google Gemini 網頁版:回應封裝格式無效。", + "Gemini Web: Invalid conversation identifier.": "Google Gemini 網頁版:對話識別碼無效。", + "Gemini Web: Conversation identifiers changed during generation.": "Google Gemini 網頁版:產生回應期間,對話識別碼發生變更。", + "Gemini Web: The website rejected the request. Check Gemini in your browser.": "Google Gemini 網頁版:網站拒絕此要求。請在瀏覽器中檢查 Gemini。", + "Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.": "Google Gemini 網頁版:網站拒絕此要求({{code}})。請在瀏覽器中檢查 Gemini。", + "Gemini Web: Invalid response payload.": "Google Gemini 網頁版:回應內容格式無效。", + "Gemini Web: Invalid candidate identifier.": "Google Gemini 網頁版:候選回應識別碼無效。", + "Gemini Web: Candidate changed during generation.": "Google Gemini 網頁版:產生回應期間,候選回應發生變更。", + "Gemini Web: Invalid text candidate.": "Google Gemini 網頁版:文字候選回應格式無效。", + "Gemini Web: No text answer was received. Check Gemini in your browser.": "Google Gemini 網頁版:未收到文字回答。請在瀏覽器中檢查 Gemini。", + "Gemini Web: Could not confirm that the answer finished generating.": "Google Gemini 網頁版:無法確認回答是否已完成產生。", + "Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.": "Google Gemini 網頁版:回答在完成前中斷。重試前請先檢查 Gemini。", + "Gemini Web: Missing conversation identifiers.": "Google Gemini 網頁版:缺少對話識別碼。", + "Gemini Web: Invalid Google account route. Start a new conversation.": "Google Gemini 網頁版:Google 帳戶路徑無效,請開始新對話。", + "Gemini Web: Invalid website session data.": "Google Gemini 網頁版:網站工作階段資料無效。", + "Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.": "Google Gemini 網頁版:HTTP {{status}}。請確認 Gemini 的登入狀態與使用限制。", + "Gemini Web: Sign in to Gemini in this browser profile before trying again.": "Google Gemini 網頁版:請先在此瀏覽器設定檔登入 Gemini,再重試。", + "Gemini Web: Empty website response.": "Google Gemini 網頁版:網站回應為空。", + "Gemini Web: Website response is too large.": "Google Gemini 網頁版:網站回應過大。", + "Gemini Web: The Google account route changed. Start a new conversation.": "Google Gemini 網頁版:Google 帳戶路徑已變更,請開始新對話。", + "Gemini Web: The selected model is not available for this Google account.": "Google Gemini 網頁版:此 Google 帳戶無法使用所選模型。", + "Gemini Web: Invalid Gemini Web model selection.": "Google Gemini 網頁版:Gemini 網頁版模型選擇無效。", + "Gemini Web: Gemini could not load the available models for this account.": "Google Gemini 網頁版:無法載入此帳戶可用的模型。", + "Gemini Web: Invalid Gemini model discovery response.": "Google Gemini 網頁版:Gemini 模型資訊回應無效。", + "Gemini Web: Gemini returned no available model information.": "Google Gemini 網頁版:Gemini 未回傳可用模型資訊。", + "Gemini Web: This Google account cannot select models ({{status}}).": "Google Gemini 網頁版:此 Google 帳戶無法選擇模型({{status}})。", + "Gemini Web: Invalid conversation state. Start a new conversation.": "Google Gemini 網頁版:對話狀態無效,請開始新對話。", + "Gemini Web: This conversation has no stored Google account route. Start a new conversation.": "Google Gemini 網頁版:此對話沒有儲存 Google 帳戶路徑,請開始新對話。", + "Gemini Web: Empty question.": "Google Gemini 網頁版:問題內容不可為空。", + "Gemini Web: Request timed out. Check Gemini before retrying.": "Google Gemini 網頁版:要求逾時。重試前請先檢查 Gemini。", + "Gemini Web: Gemini encountered a temporary generation error. Please retry.": "Google Gemini 網頁版:Gemini 發生暫時性的回答產生錯誤,請重試。", + "Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.": "Google Gemini 網頁版:所選模型已達使用限制。請先在 Gemini 確認使用限制,再重試。", + "Gemini Web: The selected model does not match this conversation. Start a new conversation.": "Google Gemini 網頁版:所選模型與目前對話不相符。請開始新對話。", + "Gemini Web: The selected model is unavailable or the Gemini website protocol changed.": "Google Gemini 網頁版:所選模型目前無法使用,或 Gemini 網頁版通訊協定已變更。", + "Gemini Web: Gemini temporarily blocked requests from this network. Try again later.": "Google Gemini 網頁版:Gemini 暫時封鎖來自此網路的要求,請稍後再試。", + "Gemini Web: This conversation can no longer be continued. Start a new conversation.": "Google Gemini 網頁版:此對話已無法繼續。請開始新對話。", + "Gemini Web: This conversation has no stored Gemini model. Start a new conversation.": "Google Gemini 網頁版:此對話缺少已儲存的 Gemini 模型資訊。請開始新對話。", + "Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.": "Google Gemini 網頁版:上一次 Gemini 要求可能已變更遠端對話狀態。請開始新的 ChatGPTBox 對話。", "Crop Text to ensure the input tokens do not exceed the model's limit": "裁剪文本以確保輸入token不超過模型限制", "Thinking Content": "思考內容", "This provider is still used by other API modes or saved conversations": "此供應商仍被其他 API 模式或已儲存的對話使用中", diff --git a/src/background/index.mjs b/src/background/index.mjs index 79b4f0c0d..eebb0d249 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -38,7 +38,6 @@ import { import '../_locales/i18n' import { openUrl } from '../utils/open-url' import { - getBardCookies, getBingAccessToken, getChatGptAccessToken, getClaudeSessionKey, @@ -46,7 +45,7 @@ import { } from '../services/wrappers.mjs' import { refreshMenu } from './menus.mjs' import { registerCommands } from './commands.mjs' -import { generateAnswersWithBardWebApi } from '../services/apis/bard-web.mjs' +import { generateAnswersWithGeminiWebApi } from '../services/apis/gemini-web.mjs' import { generateAnswersWithClaudeWebApi } from '../services/apis/claude-web.mjs' import { generateAnswersWithMoonshotWebApi } from '../services/apis/moonshot-web.mjs' import { isUsingModelName } from '../utils/model-name-convert.mjs' @@ -568,14 +567,14 @@ async function executeApi( } } else if (isUsingGeminiWebModel(session)) { console.debug('[background] Using Gemini Web Model') - const cookies = await getBardCookies() if (!isLatestSessionRequest()) return - await generateAnswersWithBardWebApi( + await generateAnswersWithGeminiWebApi( port, session.question, session, - cookies, + config, isLatestSessionRequest, + connectionPort, ) } else if (isUsingOpenAICompatibleApiSession(session)) { console.debug('[background] Using OpenAI-compatible API provider') diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 7458a0d89..b04825942 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -81,8 +81,12 @@ function ConversationCard(props) { const retryRecordRef = useRef(null) const retryGenerationIdRef = useRef(0) const requestGenerationIdRef = useRef(0) + const runtimeGeminiContinuationInFlightRef = useRef(false) const disposedRef = useRef(false) const portRef = useRef(port) + const sessionRef = useRef(session) + const onUpdateRef = useRef(props.onUpdate) + const conversationItemDataRef = useRef([]) const foregroundMessageListeners = useRef([]) const foregroundPortsRef = useRef(new Set()) const completeDraggable = useMemo(() => !isSafari() && !isFirefox() && !isMobile(), []) @@ -93,6 +97,9 @@ function ConversationCard(props) { * @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]} */ const [conversationItemData, setConversationItemData] = useState([]) + sessionRef.current = session + onUpdateRef.current = props.onUpdate + conversationItemDataRef.current = conversationItemData const config = useConfig() const customOpenAIProviders = Array.isArray(config.customOpenAIProviders) ? config.customOpenAIProviders @@ -112,8 +119,26 @@ function ConversationCard(props) { ? '-1' : UNMATCHED_API_MODE_VALUE + const markRuntimeGeminiContinuationUnsafe = ({ updateState = true } = {}) => { + if (!runtimeGeminiContinuationInFlightRef.current) return + runtimeGeminiContinuationInFlightRef.current = false + const currentSession = sessionRef.current + const retryRecord = retryRecordRef.current + const restoredSession = retryRecord + ? finalizeInterruptedSession(currentSession, '', retryRecord) + : currentSession + const unsafeSession = { + ...restoredSession, + geminiWeb_conversationUnsafe: true, + } + sessionRef.current = unsafeSession + if (updateState) setSession(unsafeSession) + else onUpdateRef.current?.(portRef.current, unsafeSession, conversationItemDataRef.current) + } + const disposeOwnedTransports = () => { if (disposedRef.current) return + markRuntimeGeminiContinuationUnsafe({ updateState: false }) disposedRef.current = true requestGenerationIdRef.current += 1 retryGenerationIdRef.current += 1 @@ -222,6 +247,15 @@ function ConversationCard(props) { if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return if (isSupersededGenerationMessage(msg, retryGenerationIdRef.current)) return + if (msg.geminiWebContinuationSubmitted === true) { + runtimeGeminiContinuationInFlightRef.current = true + } + if (msg.geminiWebConversationUnsafe === true) { + setSession((currentSession) => ({ + ...currentSession, + geminiWeb_conversationUnsafe: true, + })) + } if (msg.answer) { partialAnswerRef.current = msg.answer updateAnswer(msg.answer, false, 'answer') @@ -230,6 +264,11 @@ function ConversationCard(props) { setSession(msg.done ? { ...msg.session, isRetry: false } : msg.session) } if (msg.done) { + if (runtimeGeminiContinuationInFlightRef.current && !msg.session) { + markRuntimeGeminiContinuationUnsafe() + } else { + runtimeGeminiContinuationInFlightRef.current = false + } const partialAnswer = partialAnswerRef.current const retryRecord = retryRecordRef.current const completionState = getInterruptedCompletionState(msg, partialAnswer, retryRecord) @@ -245,6 +284,7 @@ function ConversationCard(props) { setIsReady(true) } if (msg.error) { + runtimeGeminiContinuationInFlightRef.current = false const retryRecord = retryRecordRef.current setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord)) switch (msg.error) { @@ -409,14 +449,19 @@ function ConversationCard(props) { } } } else { - port.postMessage( - createConversationPortMessage({ - session, - stop, - stopGenerationId, - requestGenerationId, - }), - ) + try { + port.postMessage( + createConversationPortMessage({ + session, + stop, + stopGenerationId, + requestGenerationId, + }), + ) + } catch (error) { + if (session) runtimeGeminiContinuationInFlightRef.current = false + throw error + } } } @@ -424,9 +469,11 @@ function ConversationCard(props) { const portListener = () => { if (replacedPortRef.current === port) { replacedPortRef.current = null + runtimeGeminiContinuationInFlightRef.current = false return } if (disposedRef.current) return + markRuntimeGeminiContinuationUnsafe() const nextPort = Browser.runtime.connect() portRef.current = nextPort setPort(nextPort) @@ -605,6 +652,9 @@ function ConversationCard(props) { aiName: apiMode ? getApiModeDisplayLabel(apiMode, t, customOpenAIProviders) : modelNameToDesc(modelName, t, config.customModelName), + ...(runtimeGeminiContinuationInFlightRef.current + ? { geminiWeb_conversationUnsafe: true } + : {}), } if (config.autoRegenAfterSwitchModel && conversationItemData.length > 0) getRetryFn(newSession)() diff --git a/src/components/ConversationCard/session.mjs b/src/components/ConversationCard/session.mjs index 40c60f166..d731dc805 100644 --- a/src/components/ConversationCard/session.mjs +++ b/src/components/ConversationCard/session.mjs @@ -1,19 +1,76 @@ import { pushRecord } from '../../services/apis/shared.mjs' +function clearGeminiWebRetryConversation(session) { + if (!Object.hasOwn(session, 'geminiWeb_retryConversation')) return session + const updatedSession = { ...session } + delete updatedSession.geminiWeb_retryConversation + return updatedSession +} + +function isGeminiWebSession(session) { + const modelName = typeof session?.modelName === 'string' ? session.modelName : '' + return ( + session?.apiMode?.groupName === 'bardWebModelKeys' || + modelName === 'bardWebFree' || + modelName.startsWith('bardWebFree-') + ) +} + +function getGeminiWebRequestConversation(session) { + if (Object.hasOwn(session ?? {}, 'geminiWeb_retryConversation')) { + return session.geminiWeb_retryConversation + } + return session?.geminiWeb_conversation ?? session?.bard_conversationObj ?? {} +} + +export function hasGeminiWebRemoteContinuation(session) { + if (!isGeminiWebSession(session)) return false + const conversation = getGeminiWebRequestConversation(session) + if (!conversation || typeof conversation !== 'object' || Array.isArray(conversation)) return false + const nonempty = (value) => value != null && value !== '' + return ( + ['c', 'r', 'rc'].some((key) => nonempty(conversation[key])) || + (Array.isArray(conversation.metadata) && conversation.metadata.some(nonempty)) + ) +} + +function getGeminiWebRetryConversation(session, conversationRecords) { + const removedRecordIndex = conversationRecords.length + const lastGeminiRecordIndex = Number.isInteger(session.geminiWeb_lastRecordIndex) + ? session.geminiWeb_lastRecordIndex + : null + const hasPreviousConversation = Object.hasOwn(session, 'geminiWeb_previousConversation') + + if (lastGeminiRecordIndex === removedRecordIndex) { + return hasPreviousConversation ? session.geminiWeb_previousConversation : null + } + if (lastGeminiRecordIndex !== null && lastGeminiRecordIndex < removedRecordIndex) { + if (session.geminiWeb_conversationUnsafe === true) return null + return session.geminiWeb_conversation ?? session.bard_conversationObj ?? {} + } + if ( + lastGeminiRecordIndex === null && + (hasPreviousConversation || session.geminiWeb_conversation || session.bard_conversationObj) + ) { + return null + } + return {} +} + export function finalizeInterruptedSession(session, answer, retryRecord = null) { if (!answer) { - if (!session.isRetry && !retryRecord) return session + if (!session.isRetry && !retryRecord) return clearGeminiWebRetryConversation(session) const lastRecord = session.conversationRecords.at(-1) const shouldRestoreRetryRecord = retryRecord && (lastRecord?.question !== retryRecord.question || lastRecord?.answer !== retryRecord.answer) - return { + return clearGeminiWebRetryConversation({ ...session, conversationRecords: shouldRestoreRetryRecord ? [...session.conversationRecords, { ...retryRecord }] : session.conversationRecords, isRetry: false, - } + }) } const updatedSession = { ...session, @@ -21,7 +78,7 @@ export function finalizeInterruptedSession(session, answer, retryRecord = null) } pushRecord(updatedSession, session.question, answer) updatedSession.isRetry = false - return updatedSession + return clearGeminiWebRetryConversation(updatedSession) } export function isSupersededGenerationMessage(message, latestSupersededGenerationId) { @@ -53,11 +110,19 @@ export function createConversationPortMessage({ } export function createRetrySession(session, conversationRecords, retryRecord) { - return { + const retrySession = { ...session, conversationRecords, isRetry: retryRecord === null, } + delete retrySession.geminiWeb_retryConversation + if (retryRecord !== null && isGeminiWebSession(session)) { + retrySession.geminiWeb_retryConversation = getGeminiWebRetryConversation( + session, + conversationRecords, + ) + } + return retrySession } export function getCompletedAnswerUpdate(restoredRetryAnswer) { diff --git a/src/config/index.mjs b/src/config/index.mjs index 0a043c38c..01ab2eed8 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -864,6 +864,8 @@ export const defaultConfig = { customChatGptWebApiPath: '/backend-api/conversation', customOpenAiApiUrl: 'https://api.openai.com', customAnthropicApiUrl: 'https://api.anthropic.com', + geminiWebModel: 'flash', + geminiWebExtendedThinking: false, disableWebModeHistory: true, hideContextMenu: false, cropText: true, diff --git a/src/pages/IndependentPanel/App.jsx b/src/pages/IndependentPanel/App.jsx index 5552b6106..529e7dd8a 100644 --- a/src/pages/IndependentPanel/App.jsx +++ b/src/pages/IndependentPanel/App.jsx @@ -172,9 +172,10 @@ function App() { pageMode={true} onUpdate={(port, session, cData) => { currentPort.current = port - if (cData.length > 0 && cData[cData.length - 1].done) { + const completed = cData.length > 0 && cData[cData.length - 1].done + if (completed || session.geminiWeb_conversationUnsafe === true) { updateSession(session).then(setSessions) - setCurrentSession(session) + if (completed) setCurrentSession(session) } }} /> diff --git a/src/popup/api-mode-config-utils.mjs b/src/popup/api-mode-config-utils.mjs index 2bfeae273..e700238a5 100644 --- a/src/popup/api-mode-config-utils.mjs +++ b/src/popup/api-mode-config-utils.mjs @@ -2,6 +2,8 @@ import { defaultApiModeIds } from '../config/index.mjs' import { canonicalizeModelKeyArray } from '../config/model-key-migrations.mjs' import { isApiModeSelected } from '../utils/model-name-convert.mjs' +const GEMINI_WEB_DEFAULT_MODEL = 'bardWebFree' + export const API_MODE_LIST_CONFIG_KEYS = [ 'activeApiModes', 'customApiModes', @@ -9,9 +11,13 @@ export const API_MODE_LIST_CONFIG_KEYS = [ ] export function buildApiModeListConfigUpdate(config, nextApiModes, { selectionPatch = {} } = {}) { + const implicitDefaultIds = Object.hasOwn(config ?? {}, 'geminiWebModel') + ? [GEMINI_WEB_DEFAULT_MODEL] + : [] const knownApiModeDefaultIds = canonicalizeModelKeyArray([ ...(Array.isArray(config?.knownApiModeDefaultIds) ? config.knownApiModeDefaultIds : []), ...defaultApiModeIds, + ...implicitDefaultIds, ]) return { diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx index 5f39e0fa0..996deb0da 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -5,6 +5,12 @@ import { getApiModesFromConfig, isApiModeSelected, modelNameToDesc } from '../.. import { PencilIcon, TrashIcon } from '@primer/octicons-react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { AlwaysCustomGroups, ModelGroups } from '../../config/index.mjs' +import { + GEMINI_WEB_MODELS, + getGeminiWebModelLabel, + hasDuplicateGeminiWebPreset, + resolveGeminiWebPreset, +} from '../../utils/gemini-web-preset.mjs' import { buildApiModeListConfigUpdate, getSelectionPatchWhenApiModeDisabled, @@ -50,6 +56,8 @@ ApiModes.propTypes = { } const LEGACY_CUSTOM_PROVIDER_ID = 'legacy-custom-default' +const GEMINI_WEB_GROUP = 'bardWebModelKeys' +const GEMINI_WEB_ITEM = 'bardWebFree' const defaultApiMode = { groupName: 'chatgptWebModelKeys', @@ -72,6 +80,34 @@ const defaultProviderDraftValidation = { apiUrl: false, } +function withGeminiWebPreset( + apiMode, + model, + extendedThinking, + extendedThinkingLabel = 'Extended thinking', +) { + const normalizedModel = GEMINI_WEB_MODELS.includes(model) ? model : 'flash' + const useExtendedThinking = + !['auto', 'thinking'].includes(normalizedModel) && extendedThinking === true + const modelLabel = getGeminiWebModelLabel(normalizedModel) + return { + ...apiMode, + groupName: GEMINI_WEB_GROUP, + itemName: GEMINI_WEB_ITEM, + isCustom: true, + customName: useExtendedThinking ? `${modelLabel} + ${extendedThinkingLabel}` : modelLabel, + geminiWebModel: normalizedModel, + geminiWebExtendedThinking: useExtendedThinking, + } +} + +function clearGeminiWebPreset(apiMode) { + const nextApiMode = { ...apiMode } + delete nextApiMode.geminiWebModel + delete nextApiMode.geminiWebExtendedThinking + return nextApiMode +} + export function ApiModes({ config, updateConfig }) { const { t } = useTranslation() const [editing, setEditing] = useState(false) @@ -93,6 +129,7 @@ export function ApiModes({ config, updateConfig }) { defaultProviderDraftValidation, ) const [providerSelectionValidation, setProviderSelectionValidation] = useState(false) + const [geminiPresetValidation, setGeminiPresetValidation] = useState('') const providerNameInputRef = useRef(null) const providerBaseUrlInputRef = useRef(null) const providerSelectorRef = useRef(null) @@ -141,6 +178,8 @@ export function ApiModes({ config, updateConfig }) { }, []) const shouldEditProvider = editingApiMode.groupName === 'customApiModelKeys' + const isEditingGeminiWeb = editingApiMode.groupName === GEMINI_WEB_GROUP + const geminiWebPreset = resolveGeminiWebPreset(editingApiMode, config) const effectiveProviders = useMemo( () => applyPendingProviderChanges( @@ -390,6 +429,29 @@ export function ApiModes({ config, updateConfig }) { const previousProviderId = editingIndex === -1 ? '' : apiModes[editingIndex]?.providerId || LEGACY_CUSTOM_PROVIDER_ID + if (nextApiMode.groupName === GEMINI_WEB_GROUP) { + const preset = resolveGeminiWebPreset(nextApiMode, config) + if (preset.source === 'invalid') { + setGeminiPresetValidation('invalid') + return + } + nextApiMode = withGeminiWebPreset( + nextApiMode, + preset.model, + preset.extendedThinking, + t('Extended thinking'), + ) + // Existing duplicate rows must be resolved too; saving them would keep selection ambiguous. + if (hasDuplicateGeminiWebPreset(apiModes, nextApiMode, editingIndex, config)) { + setGeminiPresetValidation('duplicate') + return + } + setGeminiPresetValidation('') + } else { + nextApiMode = clearGeminiWebPreset(nextApiMode) + setGeminiPresetValidation('') + } + if (shouldEditProvider) { const selectedProviderId = providerSelector === LEGACY_CUSTOM_PROVIDER_ID @@ -427,6 +489,7 @@ export function ApiModes({ config, updateConfig }) { onClick={(e) => { e.preventDefault() setEditing(false) + setGeminiPresetValidation('') clearPendingProviderChanges() closeProviderEditor() }} @@ -441,17 +504,31 @@ export function ApiModes({ config, updateConfig }) { value={editingApiMode.groupName} onChange={(e) => { const groupName = e.target.value + setGeminiPresetValidation('') let itemName = ModelGroups[groupName].value[0] - const isCustom = + let isCustom = editingApiMode.itemName === 'custom' && !AlwaysCustomGroups.includes(groupName) - if (isCustom) itemName = 'custom' + let nextApiMode = { ...editingApiMode, groupName } + if (groupName === GEMINI_WEB_GROUP) { + nextApiMode = withGeminiWebPreset(nextApiMode, 'flash', false, t('Extended thinking')) + itemName = GEMINI_WEB_ITEM + isCustom = true + } else { + if (isCustom) itemName = 'custom' + nextApiMode = clearGeminiWebPreset({ + ...nextApiMode, + itemName, + isCustom, + customName: isCustom ? nextApiMode.customName : '', + }) + } const providerId = resolveEditingProviderIdForGroupChange( groupName, editingApiMode.providerId, LEGACY_CUSTOM_PROVIDER_ID, ) setProviderSelectionValidation(false) - setEditingApiMode({ ...editingApiMode, groupName, itemName, isCustom, providerId }) + setEditingApiMode({ ...nextApiMode, providerId }) if (groupName === 'customApiModelKeys') { setProviderSelector(providerId) } else { @@ -462,39 +539,103 @@ export function ApiModes({ config, updateConfig }) { > {Object.entries(ModelGroups).map(([groupName, { desc }]) => ( ))} -
- {t('Mode')} - { + const itemName = e.target.value + const isCustom = itemName === 'custom' + setEditingApiMode({ ...editingApiMode, itemName, isCustom }) + }} + > + {ModelGroups[editingApiMode.groupName].value.map((itemName) => ( + + ))} + {!AlwaysCustomGroups.includes(editingApiMode.groupName) && ( + + )} + + {(editingApiMode.isCustom || AlwaysCustomGroups.includes(editingApiMode.groupName)) && ( + setEditingApiMode({ ...editingApiMode, customName: e.target.value })} + /> )} - - {(editingApiMode.isCustom || AlwaysCustomGroups.includes(editingApiMode.groupName)) && ( - setEditingApiMode({ ...editingApiMode, customName: e.target.value })} - /> - )} -
+ + )} + {isEditingGeminiWeb && ( + <> + + {geminiWebPreset.source !== 'invalid' && + !['auto', 'thinking'].includes(geminiWebPreset.model) && ( + + )} + {geminiPresetValidation && ( +
+ {t( + geminiPresetValidation === 'duplicate' + ? 'A Gemini Web API mode with this model and thinking setting already exists.' + : 'Gemini Web: Invalid Gemini Web model selection.', + )} +
+ )} + + )} {shouldEditProvider && (
{t('Provider')} @@ -643,11 +784,28 @@ export function ApiModes({ config, updateConfig }) { LEGACY_CUSTOM_PROVIDER_ID, ) : '' - setEditingApiMode({ + const editingMode = { ...defaultApiMode, ...apiMode, providerId, - }) + } + const resolvedGeminiPreset = + apiMode.groupName === GEMINI_WEB_GROUP + ? resolveGeminiWebPreset(apiMode, config) + : null + const nextEditingMode = + resolvedGeminiPreset && resolvedGeminiPreset.source !== 'invalid' + ? withGeminiWebPreset( + editingMode, + resolvedGeminiPreset.model, + resolvedGeminiPreset.extendedThinking, + t('Extended thinking'), + ) + : editingMode + setGeminiPresetValidation( + resolvedGeminiPreset?.source === 'invalid' ? 'invalid' : '', + ) + setEditingApiMode(nextEditingMode) setProviderSelector(isCustomApiMode ? providerId : LEGACY_CUSTOM_PROVIDER_ID) setProviderSelectionValidation(isCustomApiMode && !providerId) setProviderDraft(defaultProviderDraft) @@ -693,6 +851,7 @@ export function ApiModes({ config, updateConfig }) { onClick={(e) => { e.preventDefault() setEditing(true) + setGeminiPresetValidation('') setEditingApiMode(defaultApiMode) setProviderSelector(LEGACY_CUSTOM_PROVIDER_ID) setProviderSelectionValidation(false) diff --git a/src/popup/sections/api-modes-provider-utils.mjs b/src/popup/sections/api-modes-provider-utils.mjs index a4eaf32c0..31aa50a77 100644 --- a/src/popup/sections/api-modes-provider-utils.mjs +++ b/src/popup/sections/api-modes-provider-utils.mjs @@ -1,4 +1,5 @@ import { AlwaysCustomGroups } from '../../config/index.mjs' +import { getGeminiWebModelLabel, resolveGeminiWebPreset } from '../../utils/gemini-web-preset.mjs' import { apiModeToModelName, getUniquelySelectedApiModeIndex, @@ -621,7 +622,24 @@ export function getReferencedCustomProviderIdsFromSessions( return Array.from(referencedProviderIds) } +function getGeminiWebDisplayLabel(apiMode, t) { + if (normalizeText(apiMode?.groupName) !== 'bardWebModelKeys') return '' + const productLabel = `Google ${t ? t('Gemini (Web)') : 'Gemini (Web)'}` + if (!apiMode?.isCustom) return productLabel + + const { model, extendedThinking, source } = resolveGeminiWebPreset(apiMode) + if (source === 'default' || source === 'invalid') return productLabel + + const modelLabel = getGeminiWebModelLabel(model, t ? t('Auto') : 'Auto') + const thinkingLabel = t ? t('Extended thinking') : 'Extended thinking' + const modeLabel = extendedThinking ? `${modelLabel} + ${thinkingLabel}` : modelLabel + return `${productLabel} (${modeLabel})` +} + export function getApiModeDisplayLabel(apiMode, t, providers = []) { + const geminiLabel = getGeminiWebDisplayLabel(apiMode, t) + if (geminiLabel) return geminiLabel + const modelName = apiModeToModelName(apiMode) const fallbackLabel = modelNameToDesc(modelName, t) diff --git a/src/services/apis/bard-web.mjs b/src/services/apis/bard-web.mjs deleted file mode 100644 index 8e2dd7069..000000000 --- a/src/services/apis/bard-web.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { pushRecord } from './shared.mjs' -import Bard from '../clients/bard/index.mjs' - -/** - * @param {Runtime.Port} port - * @param {string} question - * @param {Session} session - * @param {string} cookies - * @param {() => boolean} isLatestSessionRequest - */ -export async function generateAnswersWithBardWebApi( - port, - question, - session, - cookies, - isLatestSessionRequest = () => true, -) { - // const { controller, messageListener, disconnectListener } = setAbortController(port) - const bot = new Bard(cookies) - - // eslint-disable-next-line - try { - const { answer, conversationObj } = await bot.ask(question, session.bard_conversationObj || {}) - if (!isLatestSessionRequest()) return - session.bard_conversationObj = conversationObj - pushRecord(session, question, answer) - console.debug('conversation history', { content: session.conversationRecords }) - // port.onMessage.removeListener(messageListener) - // port.onDisconnect.removeListener(disconnectListener) - port.postMessage({ answer: answer, done: true, session: session }) - } catch (err) { - // port.onMessage.removeListener(messageListener) - // port.onDisconnect.removeListener(disconnectListener) - throw err - } -} diff --git a/src/services/apis/gemini-web-transport.mjs b/src/services/apis/gemini-web-transport.mjs new file mode 100644 index 000000000..a68fc1bca --- /dev/null +++ b/src/services/apis/gemini-web-transport.mjs @@ -0,0 +1,489 @@ +import { buildRequest, decodeEnvelopes, protocolError } from '../clients/gemini-web/protocol.mjs' + +const ORIGIN = 'https://gemini.google.com' +const GENERATE_PATH = '/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate' +const BATCH_EXEC_PATH = '/_/BardChatUi/data/batchexecute' +const MODEL_STATUS_RPC = 'otAQ7b' +const MODEL_HEADER_KEY = 'x-goog-ext-525001261-jspb' +const RESPONSE_LIMIT = 16 * 1024 * 1024 +export const GEMINI_WEB_MODEL_NUMBERS = Object.freeze({ + 'flash-lite': 6, + flash: 1, + thinking: 5, + pro: 3, +}) +const THINKING_MODE_NUMBER = GEMINI_WEB_MODEL_NUMBERS.thinking +const THINKING_MODEL_IDS = new Map([ + [1, '5bf011840784117a'], + [2, 'e051ce1aa80aa576'], + [4, 'e051ce1aa80aa576'], +]) + +export function getAbortReason(signal) { + return signal.reason === undefined + ? new DOMException('The operation was aborted.', 'AbortError') + : signal.reason +} + +export function throwIfAborted(signal) { + if (signal?.aborted) throw getAbortReason(signal) +} + +export function normalizeRequestedGeminiWebModel(model) { + return typeof model === 'string' && model ? model : 'flash' +} + +function markConversationUncertain(error) { + if (error && typeof error === 'object') { + try { + error.geminiWebConversationUncertain = true + } catch { + // Preserve the original transport error if the browser error object is non-extensible. + } + } + return error +} + +function throwIfSuperseded(isRequestCurrent) { + if (typeof isRequestCurrent !== 'function' || isRequestCurrent()) return + throw new DOMException('The operation was superseded.', 'AbortError') +} + +function accountUrl(accountPath = '', path) { + if (typeof accountPath !== 'string' || (accountPath !== '' && !/^\/u\/\d+$/.test(accountPath))) { + throw protocolError('Invalid Google account route. Start a new conversation.') + } + return `${ORIGIN}${accountPath}${path}` +} + +function pageField(html, name) { + const value = html.match(new RegExp(`"${name}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`))?.[1] + if (!value) return undefined + try { + return JSON.parse(value) + } catch { + throw protocolError('Invalid website session data.') + } +} + +async function discardBody(response) { + try { + await response.body?.cancel() + } catch { + // Preserve the original HTTP/protocol error if stream cleanup also fails. + } +} + +async function readResponse(response, signal) { + if (!response.ok) { + await discardBody(response) + throw protocolError(`HTTP ${response.status}. Check your login and limits on Gemini.`) + } + if (response.url && new URL(response.url).origin !== ORIGIN) { + await discardBody(response) + throw protocolError('Sign in to Gemini in this browser profile before trying again.') + } + if (!response.body) throw protocolError('Empty website response.') + const reader = response.body.getReader() + const decoder = new TextDecoder() + const pieces = [] + let size = 0 + let finished = false + try { + for (;;) { + throwIfAborted(signal) + const { done, value } = await reader.read() + if (done) { + finished = true + break + } + size += value.byteLength + if (size > RESPONSE_LIMIT) throw protocolError('Website response is too large.') + pieces.push(decoder.decode(value, { stream: true })) + } + pieces.push(decoder.decode()) + throwIfAborted(signal) + return pieces.join('') + } finally { + if (!finished) { + try { + await reader.cancel() + } catch { + // Preserve the original error. + } + } + reader.releaseLock() + } +} + +export async function getGeminiRequestParams(signal, expectedAccountPath) { + const initUrl = accountUrl(expectedAccountPath, '/app') + throwIfAborted(signal) + const response = await fetch(initUrl, { + credentials: 'include', + cache: 'no-store', + signal, + }) + const html = await readResponse(response, signal) + const accountPath = + new URL(response.url || initUrl).pathname.match(/^\/u\/\d+(?=\/|$)/)?.[0] || '' + if (expectedAccountPath !== undefined && accountPath !== expectedAccountPath) { + throw protocolError('The Google account route changed. Start a new conversation.') + } + const at = pageField(html, 'SNlM0e') + if (!at) { + throw protocolError('Sign in to Gemini in this browser profile before trying again.') + } + return { + at, + accountPath, + bl: pageField(html, 'cfb2h'), + sessionId: pageField(html, 'FdrFJe'), + language: pageField(html, 'TuX5cc') || 'en', + } +} + +export function createGeminiRequestId() { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID().toUpperCase() + const bytes = crypto.getRandomValues(new Uint8Array(16)) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)] + .join('-') + .toUpperCase() +} + +function parseJsonString(value, message) { + if (typeof value !== 'string') throw protocolError(message) + try { + return JSON.parse(value) + } catch { + throw protocolError(message) + } +} + +function integer(value) { + return Number.isInteger(value) ? value : undefined +} + +function computeModelCapacity(tierFlags, capabilityFlags) { + const tiers = Array.isArray(tierFlags) ? tierFlags : [] + const capabilities = Array.isArray(capabilityFlags) ? capabilityFlags : [] + if (tiers.includes(21)) return { capacity: 1, capacityField: 13 } + if (tiers.includes(22)) return { capacity: 2, capacityField: 13 } + if (capabilities.includes(115)) return { capacity: 4, capacityField: 12 } + if (tiers.includes(16) || capabilities.includes(106)) { + return { capacity: 3, capacityField: 12 } + } + if (tiers.includes(8) || capabilities.includes(19)) { + return { capacity: 2, capacityField: 12 } + } + return { capacity: 1, capacityField: 12 } +} + +function normalizeModelEntry(entry, capacity) { + if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !entry[0]) return null + const modelNumber = integer(entry[17]) ?? integer(entry[9]) + const labels = [entry[1], entry[10], entry[11], entry[19]] + .filter((value) => typeof value === 'string') + .join(' ') + .toLowerCase() + return { id: entry[0], modelNumber, labels, ...capacity } +} + +function modelLabelsMatch(candidate, requestedModel) { + if (requestedModel === 'flash-lite') { + return candidate.labels.includes('flash') && candidate.labels.includes('lite') + } + if (requestedModel === 'flash') { + return candidate.labels.includes('flash') && !candidate.labels.includes('lite') + } + return /(^|\s)pro(\s|$)/.test(candidate.labels) +} + +function selectModel(models, requestedModel) { + const expectedNumber = GEMINI_WEB_MODEL_NUMBERS[requestedModel] + let model = models.find((candidate) => candidate.modelNumber === expectedNumber) + if (!model) { + model = models.find( + (candidate) => candidate.modelNumber == null && modelLabelsMatch(candidate, requestedModel), + ) + } + if (!model) { + throw protocolError('The selected model is not available for this Google account.') + } + if (model.modelNumber == null) { + return { ...model, modelNumber: expectedNumber } + } + return model +} + +function selectThinkingMode(status, capacity, models) { + const discoveredModel = models.find((candidate) => candidate.modelNumber === THINKING_MODE_NUMBER) + if (discoveredModel) return discoveredModel + + const modePicker = status?.[24]?.[1] + const thinkingMode = Array.isArray(modePicker) + ? modePicker.find((modeData) => { + const modeInfo = Array.isArray(modeData) ? modeData[0] : null + return Array.isArray(modeInfo) && integer(modeInfo[1]) === THINKING_MODE_NUMBER + }) + : undefined + const modeInfo = Array.isArray(thinkingMode) ? thinkingMode[0] : null + const isAvailable = modeInfo?.[7] === true || modeInfo?.[7] === 1 + if (!Array.isArray(modeInfo) || !isAvailable) { + throw protocolError('The selected model is not available for this Google account.') + } + + const id = THINKING_MODEL_IDS.get(capacity.capacity) + if (!id) { + throw protocolError('The selected model is not available for this Google account.') + } + return { id, modelNumber: THINKING_MODE_NUMBER, ...capacity } +} + +function buildModelHeader(model, generationId, thinkingLevel) { + const offset = model.capacityField === 13 ? 1 : 0 + const header = Array(15 + offset).fill(null) + header[0] = 1 + header[4] = model.id + header[7] = 0 + header[8] = [4, 5, 6, 8] + header[model.capacityField - 1] = model.capacity + header[14 + offset] = model.modelNumber + header.push(thinkingLevel, generationId) + return JSON.stringify(header) +} + +function normalizePinnedRouting(routing) { + if (!routing || typeof routing !== 'object' || Array.isArray(routing)) return null + if (typeof routing.id !== 'string' || !routing.id) return null + if (!Number.isInteger(routing.modelNumber) || routing.modelNumber <= 0) return null + if (!Number.isInteger(routing.capacity) || routing.capacity <= 0) return null + if (![12, 13].includes(routing.capacityField)) return null + if (typeof routing.standaloneThinking !== 'boolean') return null + return { + id: routing.id, + modelNumber: routing.modelNumber, + capacity: routing.capacity, + capacityField: routing.capacityField, + standaloneThinking: routing.standaloneThinking, + } +} + +function buildModelSelection(selected, standaloneThinking, extendedThinking, generationId) { + const routing = { + id: selected.id, + modelNumber: selected.modelNumber, + capacity: selected.capacity, + capacityField: selected.capacityField, + standaloneThinking, + } + return { + modelNumber: selected.modelNumber, + standaloneThinking, + routing, + headers: { + [MODEL_HEADER_KEY]: buildModelHeader( + selected, + generationId, + standaloneThinking ? null : extendedThinking ? 2 : 1, + ), + 'x-goog-ext-73010989-jspb': '[0]', + 'x-goog-ext-73010990-jspb': '[0,0,0]', + }, + } +} + +export async function getGeminiModelSelection( + requestedModel, + params, + extendedThinking, + signal, + isRequestCurrent = () => true, + pinnedRouting, +) { + requestedModel = normalizeRequestedGeminiWebModel(requestedModel) + if (requestedModel === 'auto') { + return { modelNumber: undefined, headers: {}, standaloneThinking: false, routing: null } + } + if (!['flash-lite', 'flash', 'thinking', 'pro'].includes(requestedModel)) { + throw protocolError('Invalid Gemini Web model selection.') + } + + const { at, bl, sessionId, language, accountPath } = params + const generationId = createGeminiRequestId() + if (pinnedRouting !== undefined) { + const selected = normalizePinnedRouting(pinnedRouting) + const standaloneThinking = requestedModel === 'thinking' + const expectedModelNumber = GEMINI_WEB_MODEL_NUMBERS[requestedModel] + if ( + !selected || + selected.standaloneThinking !== standaloneThinking || + selected.modelNumber !== expectedModelNumber + ) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } + return buildModelSelection(selected, standaloneThinking, extendedThinking, generationId) + } + + const endpoint = accountUrl(accountPath, BATCH_EXEC_PATH) + const sourcePath = `${accountPath || ''}/app` + const query = new URLSearchParams({ + rpcids: MODEL_STATUS_RPC, + hl: language, + _reqid: String(Math.floor(Math.random() * 90000) + 10000), + rt: 'c', + 'source-path': sourcePath, + }) + if (bl) query.set('bl', bl) + if (sessionId) query.set('f.sid', sessionId) + + const discoveryHeader = Array(17).fill(null) + discoveryHeader[0] = 1 + discoveryHeader[8] = [4, 5, 6, 8] + discoveryHeader[16] = generationId + + throwIfAborted(signal) + throwIfSuperseded(isRequestCurrent) + const response = await fetch(`${endpoint}?${query}`, { + method: 'POST', + credentials: 'include', + redirect: 'error', + signal, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + 'X-Same-Domain': '1', + [MODEL_HEADER_KEY]: JSON.stringify(discoveryHeader), + 'x-goog-ext-73010989-jspb': '[0]', + }, + body: new URLSearchParams({ + at, + 'f.req': `[[["${MODEL_STATUS_RPC}","[]",null,"generic"]]]`, + }), + }) + const text = await readResponse(response, signal) + let status + for (const envelope of decodeEnvelopes(text)) { + if (envelope[0] === 'er' && envelope[1] === MODEL_STATUS_RPC) { + throw protocolError('Gemini could not load the available models for this account.') + } + if (envelope[0] !== 'wrb.fr' || envelope[1] !== MODEL_STATUS_RPC) continue + status = parseJsonString(envelope[2], 'Invalid Gemini model discovery response.') + break + } + if (!Array.isArray(status)) { + throw protocolError('Gemini returned no available model information.') + } + const accountStatus = integer(status[14]) + if (accountStatus != null && accountStatus !== 1000) { + throw protocolError(`This Google account cannot select models (${accountStatus}).`) + } + const capacity = computeModelCapacity(status[16], status[17]) + const models = (Array.isArray(status[15]) ? status[15] : []) + .map((entry) => normalizeModelEntry(entry, capacity)) + .filter(Boolean) + const selected = + requestedModel === 'thinking' + ? selectThinkingMode(status, capacity, models) + : selectModel(models, requestedModel) + const standaloneThinking = requestedModel === 'thinking' + return buildModelSelection(selected, standaloneThinking, extendedThinking, generationId) +} + +export async function requestGeminiText( + prompt, + conversation, + params, + { + model: requestedModel, + temporary = false, + extendedThinking = false, + isRequestCurrent = () => true, + onPromptSubmitted = () => {}, + } = {}, + signal, +) { + throwIfAborted(signal) + const model = normalizeRequestedGeminiWebModel(requestedModel) + const { at, bl, sessionId, language, accountPath } = params + const useExtendedThinking = model !== 'auto' && model !== 'thinking' && extendedThinking === true + const pinnedRouting = Object.hasOwn(conversation?.modelState ?? {}, 'routing') + ? conversation.modelState.routing + : undefined + const selection = await getGeminiModelSelection( + model, + params, + useExtendedThinking, + signal, + isRequestCurrent, + pinnedRouting, + ) + const endpoint = accountUrl(accountPath, GENERATE_PATH) + const query = new URLSearchParams({ + hl: language, + _reqid: String(Math.floor(Math.random() * 90000) + 10000), + rt: 'c', + }) + if (bl) query.set('bl', bl) + if (sessionId) query.set('f.sid', sessionId) + const requestId = createGeminiRequestId() + throwIfAborted(signal) + throwIfSuperseded(isRequestCurrent) + const body = new URLSearchParams({ + at, + 'f.req': buildRequest(prompt, conversation, language, requestId, { + temporary, + modelNumber: selection.modelNumber, + extendedThinking: useExtendedThinking, + thinkingMode: selection.standaloneThinking ? null : undefined, + turnIndex: Number.isInteger(conversation?.turnIndex) ? conversation.turnIndex : 0, + }), + }) + let response + try { + onPromptSubmitted() + response = await fetch(`${endpoint}?${query}`, { + method: 'POST', + credentials: 'include', + redirect: 'manual', + signal, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + 'X-Same-Domain': '1', + 'x-goog-ext-525005358-jspb': JSON.stringify([requestId, 1]), + ...selection.headers, + }, + body, + }) + } catch (error) { + throw markConversationUncertain(error) + } + + const isRedirectResponse = + response.type === 'opaqueredirect' || + (response.status >= 300 && response.status < 400) || + (response.url && new URL(response.url).origin !== ORIGIN) + if (isRedirectResponse) { + await discardBody(response) + throw protocolError('Sign in to Gemini in this browser profile before trying again.') + } + const isDefinitivePreStreamRejection = + response.status >= 400 && response.status < 500 && response.status !== 408 + let text + try { + text = await readResponse(response, signal) + } catch (error) { + if (!isDefinitivePreStreamRejection) throw markConversationUncertain(error) + throw error + } + return { + text, + modelState: { + model, + extendedThinking: useExtendedThinking, + routing: selection.routing, + }, + } +} diff --git a/src/services/apis/gemini-web.mjs b/src/services/apis/gemini-web.mjs new file mode 100644 index 000000000..7b3fd9ca2 --- /dev/null +++ b/src/services/apis/gemini-web.mjs @@ -0,0 +1,215 @@ +import { resolveGeminiWebPreset } from '../../utils/gemini-web-preset.mjs' +import { GEMINI_WEB_MODEL_NUMBERS } from './gemini-web-transport.mjs' +import { pushRecord, setAbortController } from './shared.mjs' +import GeminiWebClient from '../clients/gemini-web/index.mjs' +import { protocolError } from '../clients/gemini-web/protocol.mjs' + +function resolveConversationForTurn(session) { + if (Object.hasOwn(session, 'geminiWeb_retryConversation')) { + const retryConversation = session.geminiWeb_retryConversation + if ( + !retryConversation || + typeof retryConversation !== 'object' || + Array.isArray(retryConversation) + ) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } + return retryConversation + } + return session.geminiWeb_conversation ?? session.bard_conversationObj ?? {} +} + +function getPreTurnConversation(conversation, conversationObj) { + const previousConversation = { ...conversation } + delete previousConversation.accountId + if ( + !Object.hasOwn(previousConversation, 'accountPath') && + typeof conversationObj?.accountPath === 'string' + ) { + previousConversation.accountPath = conversationObj.accountPath + } + return previousConversation +} + +function hasRemoteContinuation(conversation) { + if (!conversation || typeof conversation !== 'object' || Array.isArray(conversation)) return false + const nonempty = (value) => value != null && value !== '' + return ( + ['c', 'r', 'rc'].some((key) => nonempty(conversation[key])) || + (Array.isArray(conversation.metadata) && conversation.metadata.some(nonempty)) + ) +} + +function continuationIds(conversation) { + const metadata = Array.isArray(conversation?.metadata) ? conversation.metadata : [] + return ['c', 'r', 'rc'].map((key, index) => conversation?.[key] || metadata[index] || '') +} + +function hasSameRemoteContinuation(left, right) { + if (!hasRemoteContinuation(left) || !hasRemoteContinuation(right)) return false + const leftIds = continuationIds(left) + const rightIds = continuationIds(right) + return leftIds.every( + (value, index) => typeof value === 'string' && value !== '' && value === rightIds[index], + ) +} + +function persistUnsafeConversation(port, connectionPort, session) { + connectionPort._geminiConversationUnsafe = true + session.geminiWeb_conversationUnsafe = true + try { + port.postMessage({ geminiWebConversationUnsafe: true }) + } catch { + // The current request error/abort remains authoritative if the port disconnected too. + } +} + +function servedModelMatchesRequested(requestedModel, extendedThinking, diagnostics) { + if (requestedModel === 'auto') return true + const expectedModelNumber = GEMINI_WEB_MODEL_NUMBERS[requestedModel] + let servedModelNumber = Number.isInteger(diagnostics?.servedModelNumber) + ? diagnostics.servedModelNumber + : undefined + if (servedModelNumber === undefined) { + const label = String(diagnostics?.servedModelLabel || '').toLowerCase() + if (label.includes('thinking')) servedModelNumber = 5 + else if (label.includes('pro')) servedModelNumber = 3 + else if (label.includes('flash') && label.includes('lite')) servedModelNumber = 6 + else if (label.includes('flash')) servedModelNumber = 1 + } + if (servedModelNumber !== undefined && servedModelNumber !== expectedModelNumber) return false + if ( + requestedModel !== 'thinking' && + Number.isInteger(diagnostics?.servedModelVariant) && + diagnostics.servedModelVariant !== (extendedThinking ? 2 : 1) + ) { + return false + } + return true +} + +function validateAndLogGeminiDiagnostics(requestedModel, extendedThinking, diagnostics) { + if (!diagnostics || typeof diagnostics !== 'object') return + const safeDiagnostics = { + requestedModel, + extendedThinking, + servedModelId: diagnostics.servedModelId, + servedModelLabel: diagnostics.servedModelLabel, + servedModelNumber: diagnostics.servedModelNumber, + servedModelVariant: diagnostics.servedModelVariant, + providerStatusCodes: diagnostics.providerStatusCodes, + } + if (!servedModelMatchesRequested(requestedModel, extendedThinking, diagnostics)) { + console.warn('[gemini-web] Gemini served a different model than requested', safeDiagnostics) + return protocolError('The selected model is not available for this Google account.') + } + console.debug('[gemini-web] generation diagnostics', safeDiagnostics) + return null +} + +export async function generateAnswersWithGeminiWebApi( + port, + question, + session, + config = {}, + isLatestSessionRequest = () => true, + connectionPort = port, +) { + const hasRetryConversation = Object.hasOwn(session, 'geminiWeb_retryConversation') + const conversation = resolveConversationForTurn(session) + const hasContinuation = hasRemoteContinuation(conversation) + const storedConversation = session.geminiWeb_conversation ?? session.bard_conversationObj ?? {} + const hasStoredContinuation = hasRemoteContinuation(storedConversation) + const conversationUnsafe = + connectionPort._geminiConversationUnsafe === true || + session.geminiWeb_conversationUnsafe === true + const retryUsesUnsafeStoredContinuation = + hasRetryConversation && + conversationUnsafe && + hasSameRemoteContinuation(conversation, storedConversation) + if (!hasRetryConversation && !hasContinuation && !hasStoredContinuation) { + delete connectionPort._geminiConversationUnsafe + delete session.geminiWeb_conversationUnsafe + } else if (conversationUnsafe && (!hasRetryConversation || retryUsesUnsafeStoredContinuation)) { + persistUnsafeConversation(port, connectionPort, session) + throw protocolError( + 'The previous Gemini request may have changed the remote conversation. ' + + 'Start a new ChatGPTBox conversation.', + ) + } + const preset = resolveGeminiWebPreset(session?.apiMode, config) + if (preset.source === 'invalid') { + throw protocolError('Invalid Gemini Web model selection.') + } + const { model, extendedThinking } = preset + const { controller, cleanController, isCurrentSessionRequest } = setAbortController(port) + let promptSubmitted = false + const abortSupersededRequest = () => { + if (promptSubmitted && hasContinuation) { + connectionPort._geminiConversationUnsafe = true + } + if (!controller.signal.aborted) { + controller.abort(new DOMException('The operation was superseded.', 'AbortError')) + } + } + connectionPort._abortSupersededSessionRequest = abortSupersededRequest + const client = new GeminiWebClient() + const isRequestCurrent = () => isCurrentSessionRequest() && isLatestSessionRequest() + try { + const { answer, conversationObj, diagnostics } = await client.ask( + question, + conversation, + controller.signal, + { + model, + temporary: config.disableWebModeHistory === true, + extendedThinking, + isRequestCurrent, + onPromptSubmitted: () => { + promptSubmitted = true + if (hasContinuation) { + try { + port.postMessage({ geminiWebContinuationSubmitted: true }) + } catch { + // The request error/abort still decides whether the continuation becomes unsafe. + } + } + }, + }, + ) + if (controller.signal.aborted || !isRequestCurrent()) { + if (promptSubmitted && hasContinuation) { + persistUnsafeConversation(port, connectionPort, session) + } + return + } + const diagnosticsError = validateAndLogGeminiDiagnostics(model, extendedThinking, diagnostics) + if (diagnosticsError) { + if (hasContinuation) { + // The remote turn may have advanced on a different model. Keep the last known-good + // local continuation unchanged and latch it unsafe rather than storing unusable state. + persistUnsafeConversation(port, connectionPort, session) + } + throw diagnosticsError + } + session.geminiWeb_previousConversation = getPreTurnConversation(conversation, conversationObj) + session.geminiWeb_conversation = conversationObj + delete session.geminiWeb_retryConversation + delete session.bard_conversationObj + pushRecord(session, question, answer) + session.geminiWeb_lastRecordIndex = session.conversationRecords.length - 1 + delete session.geminiWeb_conversationUnsafe + delete connectionPort._geminiConversationUnsafe + port.postMessage({ answer, done: true, session }) + } catch (error) { + if (error?.geminiWebConversationUncertain && hasContinuation) { + persistUnsafeConversation(port, connectionPort, session) + } + throw error + } finally { + if (connectionPort._abortSupersededSessionRequest === abortSupersededRequest) { + delete connectionPort._abortSupersededSessionRequest + } + cleanController() + } +} diff --git a/src/services/clients/bard/index.mjs b/src/services/clients/bard/index.mjs deleted file mode 100644 index 240ab6278..000000000 --- a/src/services/clients/bard/index.mjs +++ /dev/null @@ -1,137 +0,0 @@ -// https://github.com/PawanOsman/GoogleBard - -export default class Bard { - cookies = '' - - constructor(cookies) { - this.cookies = cookies - } - - ParseResponse(text) { - let resData = { - r: '', - c: '', - rc: '', - responses: [], - } - try { - let parseData = (data) => { - if (typeof data === 'string') { - if (data?.startsWith('c_')) { - resData.c = data - return - } - if (data?.startsWith('r_')) { - resData.r = data - return - } - if (data?.startsWith('rc_')) { - resData.rc = data - return - } - resData.responses.push(data) - } - if (Array.isArray(data)) { - data.forEach((item) => { - parseData(item) - }) - } - } - try { - const lines = text.split('\n') - for (let i in lines) { - const line = lines[i] - if (line.includes('wrb.fr')) { - let data = JSON.parse(line) - let responsesData = JSON.parse(data[0][2]) - responsesData.forEach((response) => { - parseData(response) - }) - } - } - } catch (e) { - throw new Error( - `Error parsing response: make sure you are using the correct cookie, copy the value of "__Secure-1PSID" cookie and set it like this: \n\nnew Bard("__Secure-1PSID=")\n\nAlso using a US proxy is recommended.\n\nIf this error persists, please open an issue on github.\nhttps://github.com/PawanOsman/GoogleBard`, - ) - } - } catch (err) { - throw new Error( - `Error parsing response: make sure you are using the correct cookie, copy the value of "__Secure-1PSID" cookie and set it like this: \n\nnew Bard("__Secure-1PSID=")\n\nAlso using a US proxy is recommended.\n\nIf this error persists, please open an issue on github.\nhttps://github.com/PawanOsman/GoogleBard`, - ) - } - return resData - } - - async GetRequestParams() { - try { - const response = await fetch('https://gemini.google.com', { - headers: { - Cookie: this.cookies, - }, - }) - const text = await response.text() - const cfb2h = text.match(/"cfb2h":\s*"([^"]+)"/)?.[1] - const SNlM0e = text.match(/"SNlM0e":\s*"([^"]+)"/)?.[1] - const context = { googleData: { cfb2h, SNlM0e } } - const at = context.googleData.SNlM0e - const bl = context.googleData.cfb2h - return { at, bl } - } catch (e) { - throw new Error( - `Error parsing response: make sure you are using the correct cookie, copy the value of "__Secure-1PSID" cookie and set it like this: \n\nnew Bard("__Secure-1PSID=")\n\nAlso using a US proxy is recommended.\n\nIf this error persists, please open an issue on github.\nhttps://github.com/PawanOsman/GoogleBard`, - ) - } - } - - async ask(prompt, conversationObj) { - return await this.send(prompt, conversationObj) - } - - async send(prompt, conversationObj) { - let conversation = { - id: conversationObj.id || '', - c: conversationObj.c || '', - r: conversationObj.r || '', - rc: conversationObj.rc || '', - lastActive: Date.now(), - } - // eslint-disable-next-line - try { - let { at, bl } = await this.GetRequestParams() - const response = await fetch( - 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?' + - new URLSearchParams({ - bl: bl, - rt: 'c', - _reqid: 0, - }), - { - method: 'POST', - body: new URLSearchParams({ - at: at, - 'f.req': JSON.stringify([ - null, - `[[${JSON.stringify(prompt)}],null,${JSON.stringify([ - conversation.c, - conversation.r, - conversation.rc, - ])}]`, - ]), - }), - headers: { - Cookie: this.cookies, - }, - }, - ) - const data = await response.text() - let parsedResponse = this.ParseResponse(data) - conversation.c = parsedResponse.c - conversation.r = parsedResponse.r - conversation.rc = parsedResponse.rc - const conversationObj = { c: conversation.c, r: conversation.r, rc: conversation.rc } - return { answer: parsedResponse.responses[3], conversationObj: conversationObj } - } catch (e) { - throw e - } - } -} diff --git a/src/services/clients/gemini-web/index.mjs b/src/services/clients/gemini-web/index.mjs new file mode 100644 index 000000000..9c0e039aa --- /dev/null +++ b/src/services/clients/gemini-web/index.mjs @@ -0,0 +1,166 @@ +import { parseAnswer, protocolError } from './protocol.mjs' +import { + GEMINI_WEB_MODEL_NUMBERS, + getAbortReason, + getGeminiRequestParams, + normalizeRequestedGeminiWebModel, + requestGeminiText, + throwIfAborted, +} from '../../apis/gemini-web-transport.mjs' + +const REQUEST_TIMEOUT = 240000 + +function requestedModelState(options = {}) { + const model = normalizeRequestedGeminiWebModel(options.model) + return { + model, + extendedThinking: !['auto', 'thinking'].includes(model) && options.extendedThinking === true, + } +} + +function validateConversationRoute(conversation, options = {}) { + if (!conversation || typeof conversation !== 'object' || Array.isArray(conversation)) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } + if (conversation.metadata != null && !Array.isArray(conversation.metadata)) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } + const nonempty = (value) => value != null && value !== '' + const hasContinuation = + ['c', 'r', 'rc'].some((key) => nonempty(conversation[key])) || + (Array.isArray(conversation.metadata) && conversation.metadata.some(nonempty)) + if (!hasContinuation) return + if ( + !Object.prototype.hasOwnProperty.call(conversation, 'accountPath') || + typeof conversation.accountPath !== 'string' + ) { + throw protocolError( + 'This conversation has no stored Google account route. Start a new conversation.', + ) + } + const continuationIds = ['c', 'r', 'rc'].map( + (key, index) => conversation[key] || conversation.metadata?.[index], + ) + if (!continuationIds.every((value) => typeof value === 'string' && value)) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } + + const modelState = conversation.modelState + const expectedModelState = requestedModelState(options) + if (!modelState || typeof modelState !== 'object' || Array.isArray(modelState)) { + throw protocolError('This conversation has no stored Gemini model. Start a new conversation.') + } + if ( + modelState.model !== expectedModelState.model || + modelState.extendedThinking !== expectedModelState.extendedThinking + ) { + throw protocolError( + 'The selected model does not match this conversation. Start a new conversation.', + ) + } + if (expectedModelState.model !== 'auto') { + if ( + !modelState.routing || + typeof modelState.routing !== 'object' || + Array.isArray(modelState.routing) + ) { + throw protocolError('This conversation has no stored Gemini model. Start a new conversation.') + } + const expectedModelNumber = GEMINI_WEB_MODEL_NUMBERS[expectedModelState.model] + if (modelState.routing.modelNumber !== expectedModelNumber) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } + } + if (!Number.isInteger(conversation.turnIndex) || conversation.turnIndex < 1) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } +} + +export default class GeminiWebClient { + parseResponse(text) { + return parseAnswer(text) + } + + async getRequestParams(signal, accountPath) { + return getGeminiRequestParams(signal, accountPath) + } + + async ask(prompt, conversationObj = {}, signal, options = {}) { + if (typeof prompt !== 'string' || !prompt.trim()) throw protocolError('Empty question.') + throwIfAborted(signal) + const controller = new AbortController() + let abortReason + const abort = (reason) => { + if (controller.signal.aborted) return + abortReason = reason + controller.abort(reason) + } + const onAbort = () => abort(getAbortReason(signal)) + signal?.addEventListener('abort', onAbort, { once: true }) + const timeout = setTimeout(() => { + abort(protocolError('Request timed out. Check Gemini before retrying.')) + }, REQUEST_TIMEOUT) + try { + return await this.send(prompt, conversationObj, controller.signal, options) + } catch (error) { + if (controller.signal.aborted) { + if ( + error?.geminiWebConversationUncertain && + abortReason && + typeof abortReason === 'object' + ) { + abortReason.geminiWebConversationUncertain = true + } + throw abortReason + } + throw error + } finally { + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + } + } + + async send(prompt, conversationObj, signal, options = {}) { + throwIfAborted(signal) + validateConversationRoute(conversationObj, options) + const params = await this.getRequestParams(signal, conversationObj.accountPath) + const { text, modelState } = await requestGeminiText( + prompt, + conversationObj, + params, + options, + signal, + ) + let result + try { + result = this.parseResponse(text) + } catch (error) { + if ( + error && + typeof error === 'object' && + error.code === 'GEMINI_WEB_PROTOCOL_ERROR' && + (error.geminiWebProviderRejection !== true || + error.geminiWebRemoteStateObserved === true) + ) { + error.geminiWebConversationUncertain = true + } + throw error + } + if (signal?.aborted) { + const abortError = getAbortReason(signal) + if (abortError && typeof abortError === 'object') { + try { + abortError.geminiWebConversationUncertain = true + } catch { + // Preserve the original abort reason if the browser error object is non-extensible. + } + } + throw abortError + } + result.conversationObj.accountPath = params.accountPath + result.conversationObj.modelState = modelState + result.conversationObj.turnIndex = + (Number.isInteger(conversationObj.turnIndex) ? conversationObj.turnIndex : 0) + 1 + return result + } +} diff --git a/src/services/clients/gemini-web/protocol.mjs b/src/services/clients/gemini-web/protocol.mjs new file mode 100644 index 000000000..7ec8885d5 --- /dev/null +++ b/src/services/clients/gemini-web/protocol.mjs @@ -0,0 +1,252 @@ +const RESPONSE_LIMIT = 16 * 1024 * 1024 + +export function protocolError(message) { + const error = new Error(`Gemini Web: ${message}`) + error.code = 'GEMINI_WEB_PROTOCOL_ERROR' + return error +} + +const NON_FATAL_COMPLETED_STATUS_CODES = new Set([1096]) + +const providerErrorMessages = new Map([ + [1013, 'Gemini encountered a temporary generation error. Please retry.'], + [1037, 'Gemini usage limit reached for the selected model. Check Gemini before retrying.'], + [1050, 'The selected model does not match this conversation. Start a new conversation.'], + [1052, 'The selected model is unavailable or the Gemini website protocol changed.'], + [1060, 'Gemini temporarily blocked requests from this network. Try again later.'], + [1096, 'This conversation can no longer be continued. Start a new conversation.'], +]) + +function providerError(code, { answerObserved = false, continuationObserved = false } = {}) { + const error = providerErrorMessages.has(code) + ? protocolError(providerErrorMessages.get(code)) + : protocolError( + `The website rejected the request${ + typeof code === 'number' ? ` (${code})` : '' + }. Check Gemini in your browser.`, + ) + error.geminiWebProviderRejection = true + error.geminiWebAnswerObserved = answerObserved + error.geminiWebContinuationObserved = continuationObserved + error.geminiWebRemoteStateObserved = answerObserved || continuationObserved + if (typeof code === 'number') error.geminiWebProviderCode = code + return error +} + +function decodeJson(text) { + try { + return JSON.parse(text) + } catch { + // Do not expose response bodies, prompts or session tokens in diagnostics. + throw protocolError('Invalid response format. The website protocol may have changed.') + } +} + +export function decodeEnvelopes(text) { + let rest = text.trimStart() + if (rest.startsWith(")]}'")) rest = rest.slice(4).trimStart() + const envelopes = [] + while (rest) { + const marker = /^(\d+)\n/.exec(rest) + let frame + if (marker) { + const length = Number(marker[1]) + // Google counts the leading LF, JSON payload, and trailing LF in UTF-16 units. + const start = marker[1].length + if (!Number.isSafeInteger(length) || length <= 0 || length > RESPONSE_LIMIT) { + throw protocolError('Invalid response frame length.') + } + if (rest.length < start + length) throw protocolError('Truncated response frame.') + frame = decodeJson(rest.slice(start, start + length)) + rest = rest.slice(start + length).trimStart() + } else { + const end = rest.indexOf('\n') + frame = decodeJson(end < 0 ? rest : rest.slice(0, end)) + rest = end < 0 ? '' : rest.slice(end + 1).trimStart() + } + if (!Array.isArray(frame) || !frame.every(Array.isArray)) { + throw protocolError('Invalid response envelope.') + } + for (const envelope of frame) envelopes.push(envelope) + } + return envelopes +} + +function mergeConversationMetadata(metadata, incoming) { + if (incoming == null) return + if (!Array.isArray(incoming)) throw protocolError('Invalid response payload.') + for (const index of [0, 1]) { + const value = incoming[index] + if (value == null || value === '') continue + if (typeof value !== 'string') throw protocolError('Invalid conversation identifier.') + if (metadata[index] != null && metadata[index] !== '' && metadata[index] !== value) { + throw protocolError('Conversation identifiers changed during generation.') + } + metadata[index] = value + } + incoming.forEach((value, index) => { + if (index < 2 || value == null) return + metadata[index] = value + }) +} + +export function parseAnswer(text) { + let answer + const metadata = Array(10).fill(null) + const providerStatusCodes = [] + let candidateId + let completion + let context + let servedModelId + let servedModelLabel + let servedModelNumber + let servedModelVariant + const getProviderObservation = () => ({ + answerObserved: typeof answer === 'string' && answer.trim() !== '', + continuationObserved: [metadata[0], metadata[1], candidateId].some( + (value) => typeof value === 'string' && value !== '', + ), + }) + for (const envelope of decodeEnvelopes(text)) { + if (envelope[1] != null) continue + const errorCode = envelope[5]?.[2]?.[0]?.[1]?.[0] + if (envelope[0] === 'er') throw providerError(errorCode, getProviderObservation()) + if (envelope[0] !== 'wrb.fr') continue + if (typeof errorCode === 'number') providerStatusCodes.push(errorCode) + if (envelope[2] == null || envelope[2] === '') continue + if (typeof envelope[2] !== 'string') throw protocolError('Invalid response payload.') + const payload = decodeJson(envelope[2]) + if (!Array.isArray(payload)) throw protocolError('Invalid response payload.') + mergeConversationMetadata(metadata, payload[1]) + if (typeof payload[25] === 'string') context = payload[25] + if (typeof payload[39] === 'string' && payload[39]) servedModelId = payload[39] + if (typeof payload[42] === 'string' && payload[42]) servedModelLabel = payload[42] + if (Number.isInteger(payload[58])) servedModelNumber = payload[58] + if (Number.isInteger(payload[59])) servedModelVariant = payload[59] + const candidateContainer = payload[4] + if (candidateContainer == null) continue + if (!Array.isArray(candidateContainer)) throw protocolError('Invalid response payload.') + const candidate = candidateContainer[0] + if (candidate == null) continue + if (!Array.isArray(candidate)) throw protocolError('Invalid text candidate.') + const id = candidate[0] + if (id == null) { + if (candidate[1] != null || candidate[8] != null) { + throw protocolError('Invalid candidate identifier.') + } + continue + } + if (typeof id !== 'string') throw protocolError('Invalid candidate identifier.') + if (candidateId != null && candidateId !== id) { + throw protocolError('Candidate changed during generation.') + } + candidateId = id + const completionContainer = candidate[8] + if (completionContainer != null && !Array.isArray(completionContainer)) { + throw protocolError('Invalid response payload.') + } + const frameCompletion = completionContainer?.[0] + const contentContainer = candidate[1] + if (contentContainer == null) { + if (frameCompletion != null) completion = frameCompletion + continue + } + if (!Array.isArray(contentContainer)) throw protocolError('Invalid text candidate.') + const content = contentContainer[0] + if (content == null) continue + if (typeof content !== 'string') throw protocolError('Invalid text candidate.') + // Each frame is a full snapshot, not a text delta. Completion belongs to the latest snapshot. + if (answer !== content) completion = frameCompletion + else if (frameCompletion != null) completion = frameCompletion + answer = content + } + const fatalProviderStatusCodes = providerStatusCodes.filter( + (code) => !NON_FATAL_COMPLETED_STATUS_CODES.has(code), + ) + const throwLatestFatalProviderError = () => { + if (fatalProviderStatusCodes.length > 0) { + throw providerError(fatalProviderStatusCodes.at(-1), getProviderObservation()) + } + } + const throwLatestProviderError = () => { + const code = fatalProviderStatusCodes.at(-1) ?? providerStatusCodes.at(-1) + if (code != null) throw providerError(code, getProviderObservation()) + } + if (typeof answer !== 'string' || !answer.trim()) { + throwLatestProviderError() + throw protocolError('No text answer was received. Check Gemini in your browser.') + } + if (completion == null) { + throwLatestFatalProviderError() + throw protocolError('Could not confirm that the answer finished generating.') + } + if (completion !== 2) { + throwLatestFatalProviderError() + throw protocolError( + 'The answer was interrupted before completion. Check Gemini before retrying.', + ) + } + if (![metadata[0], metadata[1], candidateId].every((id) => typeof id === 'string' && id)) { + throwLatestFatalProviderError() + throw protocolError('Missing conversation identifiers.') + } + throwLatestFatalProviderError() + metadata[2] = candidateId + if (context !== undefined) metadata[9] = context + return { + answer, + conversationObj: { c: metadata[0], r: metadata[1], rc: candidateId, metadata }, + diagnostics: { + providerStatusCodes, + servedModelId, + servedModelLabel, + servedModelNumber, + servedModelVariant, + }, + } +} + +export function conversationMetadata(conversation = {}) { + const metadata = Array.isArray(conversation.metadata) + ? conversation.metadata.slice() + : ['', '', '', null, null, null, null, null, null, ''] + for (const [index, key] of ['c', 'r', 'rc'].entries()) { + metadata[index] = conversation[key] || metadata[index] || '' + } + return metadata +} + +export function buildRequest( + prompt, + conversation, + language, + requestId, + { + temporary = false, + modelNumber = undefined, + extendedThinking = false, + thinkingMode = undefined, + turnIndex = 0, + } = {}, +) { + if (!Number.isInteger(turnIndex) || turnIndex < 0) { + throw protocolError('Invalid conversation state. Start a new conversation.') + } + const request = Array(81).fill(null) + request[0] = [prompt, 0, null, null, null, null, 0] + request[1] = [language] + request[2] = conversationMetadata(conversation) + for (const index of [7, 10, 27, 68]) request[index] = 1 + for (const index of [11, 18, 53]) request[index] = 0 + for (const index of [6, 41]) request[index] = [1] + request[17] = [[turnIndex]] + request[30] = [4] + if (temporary) request[45] = 1 + request[59] = requestId + request[61] = [] + if (Number.isInteger(modelNumber)) { + request[79] = modelNumber + request[80] = thinkingMode === undefined ? (extendedThinking ? 2 : 1) : thinkingMode + } + return JSON.stringify([null, JSON.stringify(request)]) +} diff --git a/src/services/init-session.mjs b/src/services/init-session.mjs index 72b2955c4..304c91391 100644 --- a/src/services/init-session.mjs +++ b/src/services/init-session.mjs @@ -29,7 +29,7 @@ import { t } from 'i18next' * @property {string|null} bingWeb_jailbreakConversationId * @property {string|null} bingWeb_parentMessageId * @property {Object|null} bingWeb_jailbreakConversationCache - * @property {object|null} bard_conversationObj + * @property {Object|null} geminiWeb_conversation * @property {object|null} claude_conversation * @property {object|null} moonshot_conversation */ @@ -93,8 +93,8 @@ export function initSession({ bingWeb_parentMessageId: null, bingWeb_jailbreakConversationCache: null, - // bard - bard_conversationObj: null, + // gemini + geminiWeb_conversation: null, // claude.ai claude_conversation: null, diff --git a/src/services/wrappers.mjs b/src/services/wrappers.mjs index 09eae487b..cf4cc55e3 100644 --- a/src/services/wrappers.mjs +++ b/src/services/wrappers.mjs @@ -57,12 +57,6 @@ export async function getBingAccessToken() { return (await Browser.cookies.get({ url: 'https://bing.com/', name: '_U' }))?.value } -export async function getBardCookies() { - const token = (await Browser.cookies.get({ url: 'https://google.com/', name: '__Secure-1PSID' })) - ?.value - return '__Secure-1PSID=' + token -} - export async function getClaudeSessionKey() { return (await Browser.cookies.get({ url: 'https://claude.ai/', name: 'sessionKey' }))?.value } @@ -90,6 +84,33 @@ const transportErrorSummaryKeys = { [INVALID_API_ENDPOINT]: 'The configured API endpoint URL is invalid.', } +const geminiWebDynamicErrors = [ + { + pattern: /^Gemini Web: HTTP (\d+)\. Check your login and limits on Gemini\.$/, + key: 'Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.', + valueKey: 'status', + }, + { + pattern: /^Gemini Web: This Google account cannot select models \((\d+)\)\.$/, + key: 'Gemini Web: This Google account cannot select models ({{status}}).', + valueKey: 'status', + }, + { + pattern: + /^Gemini Web: The website rejected the request \((\d+)\)\. Check Gemini in your browser\.$/, + key: 'Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.', + valueKey: 'code', + }, +] + +function translateGeminiWebError(message, translate) { + for (const { pattern, key, valueKey } of geminiWebDynamicErrors) { + const match = pattern.exec(message) + if (match) return translate(key, { [valueKey]: match[1] }) + } + return translate(message) +} + export function handlePortError(session, port, err, translate = t) { const transportErrorSummaryKey = Object.prototype.hasOwnProperty.call( transportErrorSummaryKeys, @@ -122,6 +143,10 @@ export function handlePortError(session, port, err, translate = t) { postError(details.join('\n\n')) return } + if (err?.code === 'GEMINI_WEB_PROTOCOL_ERROR' && message) { + postError(translateGeminiWebError(message, translate)) + return + } const formattedMessage = formatErrorMessage(message) if (message) { if ( @@ -162,6 +187,9 @@ export function handlePortError(session, port, err, translate = t) { } export function claimLatestPortSessionRequest(port) { + if (typeof port._abortSupersededSessionRequest === 'function') { + port._abortSupersededSessionRequest() + } const requestId = (port._latestSessionRequestId ?? 0) + 1 port._latestSessionRequestId = requestId port._sessionRequestGeneration = (port._sessionRequestGeneration ?? 0) + 1 @@ -210,6 +238,9 @@ export function registerPortListener(executor) { const session = msg.session if (!session) return const isLatestSessionRequest = claimLatestPortSessionRequest(port) + if (port._geminiConversationUnsafe === true) { + session.geminiWeb_conversationUnsafe = true + } const requestPort = createSessionRequestPort( port, msg.proxyGenerationId, diff --git a/src/utils/gemini-web-preset.mjs b/src/utils/gemini-web-preset.mjs new file mode 100644 index 000000000..b073eb1fc --- /dev/null +++ b/src/utils/gemini-web-preset.mjs @@ -0,0 +1,105 @@ +export const GEMINI_WEB_MODELS = ['auto', 'flash-lite', 'flash', 'thinking', 'pro'] + +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object ?? {}, key) +} + +function inferGeminiWebModel(customName) { + if (customName.includes('flash-lite')) return 'flash-lite' + if (/(^|\s)flash(\s|$)/.test(customName)) return 'flash' + if (/(^|\s)pro(\s|$)/.test(customName)) return 'pro' + if (customName.includes('thinking') && !customName.includes('flash')) return 'thinking' + if (customName === 'auto') return 'auto' + return undefined +} + +export function resolveGeminiWebPreset(apiMode, fallbackConfig = {}) { + const hasStoredModelField = hasOwn(apiMode, 'geminiWebModel') + const hasStoredModel = GEMINI_WEB_MODELS.includes(apiMode?.geminiWebModel) + const hasStoredThinking = hasOwn(apiMode, 'geminiWebExtendedThinking') + if (hasStoredModelField && !hasStoredModel) { + return { + model: apiMode?.geminiWebModel, + extendedThinking: false, + source: 'invalid', + } + } + + let model = hasStoredModel ? apiMode.geminiWebModel : undefined + let extendedThinking = hasStoredThinking ? apiMode.geminiWebExtendedThinking === true : false + let source = hasStoredModel ? 'stored' : 'default' + const customName = String(apiMode?.customName || '').toLowerCase() + + if (!model) { + model = inferGeminiWebModel(customName) + if (model) source = 'legacy-name' + if ( + model !== 'thinking' && + !hasStoredThinking && + (customName.includes('thinking') || + customName.includes('延伸思考') || + customName.includes('🧠')) + ) { + extendedThinking = true + } + } + + if (!model && hasOwn(fallbackConfig, 'geminiWebModel')) { + if (!GEMINI_WEB_MODELS.includes(fallbackConfig?.geminiWebModel)) { + return { + model: fallbackConfig?.geminiWebModel, + extendedThinking: false, + source: 'invalid', + } + } + model = fallbackConfig.geminiWebModel + source = 'fallback' + if (!hasStoredThinking) { + extendedThinking = fallbackConfig.geminiWebExtendedThinking === true + } + } + + model ||= 'flash' + return { + model, + extendedThinking: !['auto', 'thinking'].includes(model) && extendedThinking, + source, + } +} + +export function hasDuplicateGeminiWebPreset( + apiModes, + candidate, + editingIndex = -1, + fallbackConfig = {}, +) { + if (candidate?.groupName !== 'bardWebModelKeys') return false + + const candidatePreset = resolveGeminiWebPreset(candidate, fallbackConfig) + if (candidatePreset.source === 'invalid') return false + + return (Array.isArray(apiModes) ? apiModes : []).some((apiMode, index) => { + if (index === editingIndex || apiMode?.groupName !== 'bardWebModelKeys') return false + const preset = resolveGeminiWebPreset(apiMode, fallbackConfig) + return ( + preset.source !== 'invalid' && + preset.model === candidatePreset.model && + preset.extendedThinking === candidatePreset.extendedThinking + ) + }) +} + +export function getGeminiWebModelLabel(model, autoLabel = 'Auto') { + switch (model) { + case 'flash-lite': + return 'Flash-Lite' + case 'flash': + return 'Flash' + case 'thinking': + return 'Thinking' + case 'pro': + return 'Pro' + default: + return autoLabel + } +} diff --git a/src/utils/model-name-convert.mjs b/src/utils/model-name-convert.mjs index fdda5457b..1c897cf77 100644 --- a/src/utils/model-name-convert.mjs +++ b/src/utils/model-name-convert.mjs @@ -1,4 +1,13 @@ -import { AlwaysCustomGroups, ModelGroups, ModelMode, Models } from '../config/index.mjs' +import { + AlwaysCustomGroups, + defaultConfig, + ModelGroups, + ModelMode, + Models, +} from '../config/index.mjs' +import { resolveGeminiWebPreset } from './gemini-web-preset.mjs' + +const GEMINI_WEB_DEFAULT_MODEL = 'bardWebFree' function normalizeProviderId(value) { return String(value || '') @@ -174,7 +183,33 @@ export function getApiModesFromConfig(config, onlyActive) { } return Boolean(apiMode.itemName) }) - const activeApiModes = Array.isArray(config.activeApiModes) ? config.activeApiModes : [] + const configuredActiveApiModes = Array.isArray(config.activeApiModes) ? config.activeApiModes : [] + const configuredKnownDefaultIds = Array.isArray(config.knownApiModeDefaultIds) + ? config.knownApiModeDefaultIds + : [] + const hasConfiguredGeminiWebMode = normalizedCustomApiModes.some( + (apiMode) => apiMode.groupName === 'bardWebModelKeys', + ) + const hasHandledGeminiWebDefault = configuredKnownDefaultIds.includes(GEMINI_WEB_DEFAULT_MODEL) + const usesLiveDefaultApiModes = + Number(config.configSchemaVersion) >= 2 && + configuredKnownDefaultIds.length === 0 && + normalizedCustomApiModes.length === 0 && + configuredActiveApiModes.length === defaultConfig.activeApiModes.length && + configuredActiveApiModes.every( + (modelName, index) => modelName === defaultConfig.activeApiModes[index], + ) + const shouldIncludeGeminiWebDefault = + Object.hasOwn(config, 'geminiWebModel') && + usesLiveDefaultApiModes && + !hasConfiguredGeminiWebMode && + !hasHandledGeminiWebDefault && + !configuredActiveApiModes.includes(GEMINI_WEB_DEFAULT_MODEL) + const activeApiModes = hasConfiguredGeminiWebMode + ? configuredActiveApiModes.filter((modelName) => modelName !== GEMINI_WEB_DEFAULT_MODEL) + : shouldIncludeGeminiWebDefault + ? [...configuredActiveApiModes, GEMINI_WEB_DEFAULT_MODEL] + : configuredActiveApiModes const customApiModeIndexesByCanonicalModelName = normalizedCustomApiModes.reduce( (result, apiMode, index) => { const canonicalModelName = apiModeToModelName(apiMode) @@ -296,7 +331,10 @@ export function reconcileMaterializedApiModeDefaults(config, defaultIds, knownDe }, false, ) - const materializedMode = materializedModes[0] + const { canonicalModelName } = resolveCanonicalActiveApiModeInfo(defaultId, config) + const materializedMode = materializedModes.find( + (apiMode) => apiModeToModelName(apiMode) === canonicalModelName, + ) if (!materializedMode) continue const identity = getApiModeDefaultIdentity(materializedMode) @@ -324,11 +362,21 @@ export function isApiModeSelected(apiMode, configOrSession, { sessionCompat = fa const normalizeForCompare = (value, { includeProviderState = true } = {}) => { const normalized = normalizeApiMode(value) if (!normalized) return null + const isGeminiWebMode = normalized.groupName === 'bardWebModelKeys' const normalizedForCompare = { groupName: normalized.groupName, itemName: normalized.itemName, - isCustom: normalized.isCustom, - customName: normalized.customName, + ...(isGeminiWebMode + ? {} + : { + isCustom: normalized.isCustom, + customName: normalized.customName, + }), + } + if (isGeminiWebMode) { + const { model, extendedThinking } = resolveGeminiWebPreset(normalized, configOrSession) + normalizedForCompare.geminiWebModel = model + normalizedForCompare.geminiWebExtendedThinking = extendedThinking } if (includeProviderState) { normalizedForCompare.providerId = normalized.providerId @@ -353,6 +401,18 @@ export function isApiModeSelected(apiMode, configOrSession, { sessionCompat = fa if (!targetApiMode || !selectedApiMode || !rawSelectedApiMode) return false if (selectedApiMode.groupName !== targetApiMode.groupName) return false + const isGeminiWebMode = selectedApiMode.groupName === 'bardWebModelKeys' + if (isGeminiWebMode) { + const selectedPreset = resolveGeminiWebPreset(selectedApiMode, configOrSession) + const targetPreset = resolveGeminiWebPreset(targetApiMode, configOrSession) + if ( + selectedPreset.model !== targetPreset.model || + selectedPreset.extendedThinking !== targetPreset.extendedThinking + ) { + return false + } + } + const isLegacyCustomSession = selectedApiMode.groupName === 'customApiModelKeys' && (!Object.hasOwn(rawSelectedApiMode, 'itemName') || @@ -362,9 +422,10 @@ export function isApiModeSelected(apiMode, configOrSession, { sessionCompat = fa if (targetApiMode.groupName !== 'customApiModelKeys') return false if (selectedApiMode.customName !== targetApiMode.customName) return false } else if ( - selectedApiMode.itemName !== targetApiMode.itemName || - selectedApiMode.isCustom !== targetApiMode.isCustom || - selectedApiMode.customName !== targetApiMode.customName + !isGeminiWebMode && + (selectedApiMode.itemName !== targetApiMode.itemName || + selectedApiMode.isCustom !== targetApiMode.isCustom || + selectedApiMode.customName !== targetApiMode.customName) ) { return false } diff --git a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs index 3744272b5..a9d8e5520 100644 --- a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs +++ b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs @@ -40,7 +40,12 @@ const sources = { return null } `, - 'test:conversation-item': 'export default function ConversationItem() { return null }', + 'test:conversation-item': ` + export default function ConversationItem(props) { + globalThis.__CONVERSATION_LIFECYCLE_TEST__.conversationItemProps = props + return null + } + `, 'test:conversation-utils': ` export const apiModeToModelName = () => 'test-model' export const createElementAtPosition = () => document.createElement('div') diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index 0319d941c..1a98f0aac 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -78,6 +78,19 @@ const baseSession = () => ({ question: null, }) +const geminiContinuationSession = () => ({ + conversationRecords: [{ question: 'Q1', answer: 'A1' }], + modelName: 'bardWebFree', + apiMode: { groupName: 'bardWebModelKeys', itemName: 'bardWebFree' }, + question: null, + geminiWeb_conversation: { + c: 'c-old', + r: 'r-old', + rc: 'rc-old', + accountPath: '', + }, +}) + const createRuntimePort = () => { const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ const onMessage = createEvent() @@ -119,6 +132,7 @@ const resetState = () => { state.config = defaultConfig() state.ports = [] state.inputBoxProps = null + state.conversationItemProps = null state.deleteButtonProps = null state.configReadCount = 0 state.getUserConfig = async () => { @@ -302,6 +316,297 @@ test('unmount disconnects the owned runtime Port without reconnecting', () => { assert.equal(state.ports.length, 1) }) +test('unmount after a submitted Gemini continuation persists unsafe state', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let persistedSession + + mountCard(container, { + session: geminiContinuationSession(), + onUpdate: (_port, session) => { + if (session.geminiWeb_conversationUnsafe === true) persistedSession = session + }, + }) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + const submit = state.inputBoxProps.onSubmit('Q2') + await nextTask() + act(() => { + state.ports[0].onMessage.trigger({ geminiWebContinuationSubmitted: true }) + }) + act(() => render(null, container)) + await submit + + assert.equal(persistedSession?.geminiWeb_conversationUnsafe, true) + assert.equal(persistedSession?.geminiWeb_conversation?.c, 'c-old') +}) + +test('unmount after switching providers preserves submitted Gemini uncertainty', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let latestSession + let persistedSession + + mountCard(container, { + session: geminiContinuationSession(), + onUpdate: (_port, session) => { + latestSession = session + if (session.geminiWeb_conversationUnsafe === true) persistedSession = session + }, + }) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + const submit = state.inputBoxProps.onSubmit('Q2') + await nextTask() + act(() => { + state.ports[0].onMessage.trigger({ geminiWebContinuationSubmitted: true }) + }) + + const selector = container.querySelector('select') + assert.ok(selector) + act(() => { + selector.value = '-1' + selector.dispatchEvent(new Event('change', { bubbles: true })) + }) + await waitFor( + () => latestSession?.modelName === 'customModel', + 'provider switch did not update the session', + ) + + act(() => render(null, container)) + await submit + + assert.equal(persistedSession?.modelName, 'customModel') + assert.equal(persistedSession?.geminiWeb_conversationUnsafe, true) + assert.equal(persistedSession?.geminiWeb_conversation?.c, 'c-old') +}) + +test('auto-regeneration after a provider switch preserves Gemini uncertainty', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let latestSession + + state.config.autoRegenAfterSwitchModel = true + mountCard(container, { + session: geminiContinuationSession(), + onUpdate: (_port, currentSession) => { + latestSession = currentSession + }, + }) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + const submit = state.inputBoxProps.onSubmit('Q2') + await nextTask() + act(() => { + state.ports[0].onMessage.trigger({ geminiWebContinuationSubmitted: true }) + }) + const selector = container.querySelector('select') + act(() => { + selector.value = '-1' + selector.dispatchEvent(new Event('change', { bubbles: true })) + }) + await waitFor( + () => latestSession?.modelName === 'customModel', + 'provider switch did not update the session', + ) + assert.equal(latestSession?.geminiWeb_conversationUnsafe, true) + await submit +}) + +test('unmount during a submitted Gemini retry preserves the original answer', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let persistedSession + const session = geminiContinuationSession() + session.conversationRecords.push({ question: 'Q2', answer: 'A2' }) + session.geminiWeb_previousConversation = { accountPath: '' } + session.geminiWeb_lastRecordIndex = 1 + + mountCard(container, { + session, + onUpdate: (_port, nextSession) => { + if (nextSession.geminiWeb_conversationUnsafe === true) { + persistedSession = nextSession + } + }, + }) + await waitFor( + () => typeof state.conversationItemProps?.onRetry === 'function', + 'Retry handler did not render', + ) + + let retry + act(() => { + retry = state.conversationItemProps.onRetry() + }) + await retry + act(() => { + state.ports[0].onMessage.trigger({ geminiWebContinuationSubmitted: true }) + render(null, container) + }) + + assert.equal(persistedSession?.geminiWeb_conversationUnsafe, true) + assert.deepEqual(persistedSession?.conversationRecords, [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + ]) + assert.equal(Object.hasOwn(persistedSession, 'geminiWeb_retryConversation'), false) +}) + +test('delayed Gemini unsafe notice does not overwrite a finalized retry', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let latestSession + const session = geminiContinuationSession() + session.conversationRecords.push({ question: 'Q2', answer: 'A2' }) + session.geminiWeb_previousConversation = { accountPath: '' } + session.geminiWeb_lastRecordIndex = 1 + + mountCard(container, { + session, + onUpdate: (_port, nextSession) => { + latestSession = nextSession + }, + }) + await waitFor( + () => typeof state.conversationItemProps?.onRetry === 'function', + 'Retry handler did not render', + ) + + let retry + act(() => { + retry = state.conversationItemProps.onRetry() + }) + await retry + act(() => { + state.ports[0].onMessage.trigger({ geminiWebContinuationSubmitted: true }) + state.ports[0].onMessage.trigger({ done: true }) + }) + await waitFor( + () => latestSession?.geminiWeb_conversationUnsafe === true, + 'stopped retry did not retain Gemini uncertainty', + ) + act(() => { + state.ports[0].onMessage.trigger({ geminiWebConversationUnsafe: true }) + }) + + assert.deepEqual(latestSession?.conversationRecords, [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + ]) + assert.equal(Object.hasOwn(latestSession, 'geminiWeb_retryConversation'), false) + assert.equal(latestSession?.geminiWeb_conversationUnsafe, true) +}) + +test('unmount before Gemini prompt submission keeps continuation retry-safe', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let unsafeUpdateCount = 0 + + mountCard(container, { + session: geminiContinuationSession(), + onUpdate: (_port, session) => { + if (session.geminiWeb_conversationUnsafe === true) unsafeUpdateCount += 1 + }, + }) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + const submit = state.inputBoxProps.onSubmit('Q2') + await nextTask() + act(() => render(null, container)) + await submit + + assert.equal(unsafeUpdateCount, 0) +}) + +test('stop acknowledgement after a submitted Gemini continuation marks it unsafe', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let latestSession + + mountCard(container, { + session: geminiContinuationSession(), + onUpdate: (_port, session) => { + latestSession = session + }, + }) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + const submit = state.inputBoxProps.onSubmit('Q2') + await nextTask() + const port = state.ports[0] + act(() => { + port.onMessage.trigger({ geminiWebContinuationSubmitted: true }) + port.onMessage.trigger({ done: true }) + }) + await waitFor( + () => latestSession?.geminiWeb_conversationUnsafe === true, + 'submitted continuation was not marked unsafe after stop acknowledgement', + ) + assert.equal(latestSession.geminiWeb_conversation.c, 'c-old') + await submit +}) + +test('remote runtime disconnect marks an in-flight Gemini continuation unsafe in foreground state', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let latestSession + const geminiSession = geminiContinuationSession() + + mountCard(container, { + session: geminiSession, + onUpdate: (_port, session) => { + latestSession = session + }, + }) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + const submit = state.inputBoxProps.onSubmit('Q2') + await nextTask() + const initialPort = state.ports[0] + + act(() => { + initialPort.onMessage.trigger({ geminiWebContinuationSubmitted: true }) + }) + act(() => initialPort.emitRemoteDisconnect()) + await waitFor( + () => latestSession?.geminiWeb_conversationUnsafe === true, + 'Gemini continuation was not marked unsafe after runtime disconnect', + ) + + assert.equal(state.ports.length, 2) + assert.equal(latestSession.geminiWeb_conversation.c, 'c-old') + await submit + + act(() => render(null, container)) +}) + test('remote runtime Port disconnect reconnects and unmount cleans the replacement', () => { const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ const container = document.createElement('div') diff --git a/tests/unit/prettier-inspection.test.mjs b/tests/unit/prettier-inspection.test.mjs new file mode 100644 index 000000000..8ac3377c9 --- /dev/null +++ b/tests/unit/prettier-inspection.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { test } from 'node:test' +import prettier from 'prettier' + +const paths = [ + 'src/services/clients/gemini-web/index.mjs', + 'tests/unit/services/apis/gemini-web-retry.test.mjs', + 'tests/unit/services/apis/gemini-web.test.mjs', +] + +function diffLines(before, after) { + const a = before.split('\n') + const b = after.split('\n') + const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1)) + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + let i = 0 + let j = 0 + const lines = [] + while (i < a.length || j < b.length) { + if (i < a.length && j < b.length && a[i] === b[j]) { + i++ + j++ + } else if (j < b.length && (i === a.length || dp[i][j + 1] >= dp[i + 1][j])) { + lines.push('+ ' + b[j++]) + } else { + lines.push('- ' + a[i++]) + } + } + return lines +} + +test('inspect Prettier formatting before updating the public PR', async () => { + for (const path of paths) { + const before = await readFile(path, 'utf8') + const after = prettier.format(before, { + parser: 'babel', + printWidth: 100, + semi: false, + tabWidth: 2, + singleQuote: true, + trailingComma: 'all', + bracketSpacing: true, + filepath: path, + }) + console.log('FORMAT_DIFF_START', path) + console.log(diffLines(before, after).join('\n')) + console.log('FORMAT_DIFF_END', path) + assert.ok(after.length > 0) + } +}) diff --git a/tests/unit/services/apis/bard-web.test.mjs b/tests/unit/services/apis/bard-web.test.mjs deleted file mode 100644 index ef7a4366b..000000000 --- a/tests/unit/services/apis/bard-web.test.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import assert from 'node:assert/strict' -import { test } from 'node:test' -import { generateAnswersWithBardWebApi } from '../../../../src/services/apis/bard-web.mjs' -import Bard from '../../../../src/services/clients/bard/index.mjs' -import { createFakePort } from '../../helpers/port.mjs' - -test('generateAnswersWithBardWebApi ignores a superseded response', async (t) => { - let resolveResponse - const response = new Promise((resolve) => { - resolveResponse = resolve - }) - t.mock.method(Bard.prototype, 'ask', () => response) - - const session = { conversationRecords: [] } - const port = createFakePort() - let isLatest = true - const generation = generateAnswersWithBardWebApi( - port, - 'CurrentQ', - session, - 'cookie', - () => isLatest, - ) - - isLatest = false - resolveResponse({ answer: 'Stale answer', conversationObj: { id: 'stale' } }) - await generation - - assert.deepEqual(session, { conversationRecords: [] }) - assert.deepEqual(port.postedMessages, []) -}) diff --git a/tests/unit/services/apis/gemini-web-compat.test.mjs b/tests/unit/services/apis/gemini-web-compat.test.mjs new file mode 100644 index 000000000..2d0defdac --- /dev/null +++ b/tests/unit/services/apis/gemini-web-compat.test.mjs @@ -0,0 +1,814 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import GeminiWebClient from '../../../../src/services/clients/gemini-web/index.mjs' + +const bootstrap = '{"SNlM0e":"test-at"}' +const metadata = ['c_test', 'r_test'] +const candidate = ['rc_test', ['answer']] +candidate[8] = [2] +const responsePayload = JSON.stringify([ + ['wrb.fr', null, JSON.stringify([null, metadata, null, null, [candidate]])], +]) +const reply = `)]}'\n\n${responsePayload.length + 2}\n${responsePayload}\n` + +function mockFetch(t, redirect) { + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + const requestUrl = String(url) + requests.push({ url: requestUrl, init }) + let response + if (init.method !== 'POST') response = new Response(bootstrap) + else if (requestUrl.includes('/batchexecute?')) { + response = new Response( + modelDiscoveryResponse([modelEntry('flash-id', 'Flash', 'Gemini Flash', 1)]), + ) + } else response = new Response(reply) + if (redirect && init.method !== 'POST') { + Object.defineProperty(response, 'url', { value: redirect }) + } + return response + }) + return requests +} + +function autoConversationState(extra = {}) { + return { + modelState: { + model: 'auto', + extendedThinking: false, + routing: null, + }, + turnIndex: 1, + ...extra, + } +} + +function hideProperty(t, object, key) { + const descriptor = Object.getOwnPropertyDescriptor(object, key) + Object.defineProperty(object, key, { configurable: true, value: undefined }) + t.after(() => { + if (descriptor) Object.defineProperty(object, key, descriptor) + else delete object[key] + }) +} + +for (const state of [ + { c: 'c_old' }, + { r: 'r_old' }, + { rc: 'rc_old' }, + { metadata: ['c_old', 'r_old', 'rc_old'] }, +]) { + test('rejects unscoped continuation before any request', async (t) => { + const requests = mockFetch(t) + await assert.rejects(new GeminiWebClient().ask('Q', state), /account route/) + assert.equal(requests.length, 0) + }) +} + +test('pins an explicitly saved default account route', async (t) => { + const requests = mockFetch(t, 'https://gemini.google.com/u/1/app') + await assert.rejects( + new GeminiWebClient().ask( + 'Q', + autoConversationState({ + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + }), + undefined, + { model: 'auto' }, + ), + /account route changed/, + ) + assert.equal(requests.length, 1) +}) + +for (const state of [ + { c: 'c_old', accountPath: '' }, + { c: 'c_old', r: 'r_old', accountPath: '' }, + { metadata: ['c_old', 'r_old', ''], accountPath: '' }, + { metadata: 'BAD', accountPath: '' }, +]) { + test('rejects incomplete or malformed scoped continuation before any request', async (t) => { + const requests = mockFetch(t) + await assert.rejects(new GeminiWebClient().ask('Q', state), /Invalid conversation state/) + assert.equal(requests.length, 0) + }) +} + +test('allows an account-route-only first-turn snapshot', async (t) => { + const requests = mockFetch(t) + const result = await new GeminiWebClient().ask('Q', { accountPath: '/u/1' }) + + assert.equal(result.answer, 'answer') + assert.equal(result.conversationObj.turnIndex, 1) + assert.deepEqual(result.conversationObj.modelState, { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }) + assert.equal(requests.length, 3) +}) + +test('continues an explicitly scoped Google multi-login route', async (t) => { + const requests = mockFetch(t) + const result = await new GeminiWebClient().ask( + 'Q', + autoConversationState({ + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '/u/1', + }), + undefined, + { model: 'auto' }, + ) + assert.equal(result.answer, 'answer') + assert.equal(result.conversationObj.accountPath, '/u/1') + assert.match(requests[0].url, /gemini\.google\.com\/u\/1\/app$/) + assert.match(requests[1].url, /gemini\.google\.com\/u\/1\/_\/BardChatUi\//) +}) + +test('drops obsolete Gaia identity state without querying Google accounts', async (t) => { + const requests = mockFetch(t) + const result = await new GeminiWebClient().ask( + 'Q', + autoConversationState({ + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + accountId: 'obsolete-gaia-id', + }), + undefined, + { model: 'auto' }, + ) + assert.equal(result.answer, 'answer') + assert.equal(Object.hasOwn(result.conversationObj, 'accountId'), false) + assert.equal(requests.length, 2) + assert.equal( + requests.every(({ url }) => url.startsWith('https://gemini.google.com/')), + true, + ) +}) + +test('rejects an older continuation without pinned Gemini model state before network', async (t) => { + const requests = mockFetch(t) + await assert.rejects( + new GeminiWebClient().ask('Q', { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + }), + /no stored Gemini model/, + ) + assert.equal(requests.length, 0) +}) + +test('rejects switching Gemini models inside an existing continuation before network', async (t) => { + const requests = mockFetch(t) + await assert.rejects( + new GeminiWebClient().ask( + 'Q', + { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + }, + undefined, + { model: 'pro' }, + ), + /selected model does not match this conversation/, + ) + assert.equal(requests.length, 0) +}) + +test('rejects pinned routing that contradicts the stored model before network', async (t) => { + const requests = mockFetch(t) + await assert.rejects( + new GeminiWebClient().ask( + 'Q', + { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'pro', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + }, + undefined, + { model: 'pro' }, + ), + /Invalid conversation state/, + ) + assert.equal(requests.length, 0) +}) + +test('uses browser credentials without manually setting cookies', async (t) => { + const requests = mockFetch(t) + const result = await new GeminiWebClient().ask('Q') + assert.equal(result.answer, 'answer') + assert.equal(requests.length, 3) + for (const { url, init } of requests) { + assert.equal(url.startsWith('https://gemini.google.com/'), true) + assert.equal(init.credentials, 'include') + assert.equal(new Headers(init.headers).has('Cookie'), false) + } +}) + +test('does not retry a failed prompt request', async (t) => { + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + const requestUrl = String(url) + requests.push({ url: requestUrl, init }) + if (init.method !== 'POST') return new Response(bootstrap) + if (requestUrl.includes('/batchexecute?')) { + return new Response( + modelDiscoveryResponse([modelEntry('flash-id', 'Flash', 'Gemini Flash', 1)]), + ) + } + return new Response('private-body', { status: 429 }) + }) + + await assert.rejects(new GeminiWebClient().ask('Q'), /HTTP 429/) + assert.equal(requests.length, 3) +}) + +test('login redirect is a definitive rejection and does not poison continuation state', async (t) => { + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), init }) + if (init.method !== 'POST') return new Response(bootstrap) + const response = new Response(null) + Object.defineProperty(response, 'type', { value: 'opaqueredirect' }) + return response + }) + + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + } + + let rejected + try { + await new GeminiWebClient().ask('Q', conversation, undefined, { model: 'flash' }) + } catch (error) { + rejected = error + } + + assert.match(rejected?.message || '', /Sign in to Gemini/) + assert.notEqual(rejected?.geminiWebConversationUncertain, true) + assert.equal(requests.length, 2) + assert.equal(requests[1].init.redirect, 'manual') +}) + +test('successful body-less prompt response marks an existing continuation uncertain', async (t) => { + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), init }) + if (init.method !== 'POST') return new Response(bootstrap) + return new Response(null, { status: 204 }) + }) + + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + } + + let rejected + try { + await new GeminiWebClient().ask('Q', conversation, undefined, { model: 'flash' }) + } catch (error) { + rejected = error + } + + assert.match(rejected?.message || '', /Empty website response/) + assert.equal(rejected?.geminiWebConversationUncertain, true) + assert.equal(requests.length, 2) +}) + +test('pre-stream HTTP rejection does not make an existing conversation uncertain', async (t) => { + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), init }) + if (init.method !== 'POST') return new Response(bootstrap) + return new Response('private-body', { status: 429 }) + }) + + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + } + + let rejected + try { + await new GeminiWebClient().ask('Q', conversation, undefined, { model: 'flash' }) + } catch (error) { + rejected = error + } + assert.match(rejected?.message || '', /HTTP 429/) + assert.notEqual(rejected?.geminiWebConversationUncertain, true) + assert.equal(requests.length, 2) +}) + +test('HTTP 500 after a prompt POST marks an existing continuation uncertain', async (t) => { + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), init }) + if (init.method !== 'POST') return new Response(bootstrap) + return new Response('server failure', { status: 500 }) + }) + + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + } + + let rejected + try { + await new GeminiWebClient().ask('Q', conversation, undefined, { model: 'flash' }) + } catch (error) { + rejected = error + } + assert.match(rejected?.message || '', /HTTP 500/) + assert.equal(rejected?.geminiWebConversationUncertain, true) + assert.equal(requests.length, 2) +}) + +test('status-only provider rejection does not make an existing conversation uncertain', async (t) => { + const providerError = ['er', null, null, null, null, [13, null, [[null, [1037]]]]] + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), init }) + if (init.method !== 'POST') return new Response(bootstrap) + return new Response(framed([providerError])) + }) + + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + } + + let rejected + try { + await new GeminiWebClient().ask('Q', conversation, undefined, { model: 'flash' }) + } catch (error) { + rejected = error + } + assert.match(rejected?.message || '', /usage limit reached/) + assert.equal(rejected?.geminiWebProviderCode, 1037) + assert.equal(rejected?.geminiWebRemoteStateObserved, false) + assert.notEqual(rejected?.geminiWebConversationUncertain, true) + assert.equal(requests.length, 2) +}) + +test('content-bearing provider rejection makes an existing conversation uncertain', async (t) => { + const providerStatus = ['wrb.fr', null, null, null, null, [13, null, [[null, [1037]]]]] + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), init }) + if (init.method !== 'POST') return new Response(bootstrap) + const contentRecord = [ + 'wrb.fr', + null, + JSON.stringify([null, ['c_new', 'r_new'], null, null, [candidate]]), + ] + return new Response(framed([contentRecord, providerStatus])) + }) + + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + } + + let rejected + try { + await new GeminiWebClient().ask('Q', conversation, undefined, { model: 'flash' }) + } catch (error) { + rejected = error + } + assert.match(rejected?.message || '', /usage limit reached/) + assert.equal(rejected?.geminiWebProviderCode, 1037) + assert.equal(rejected?.geminiWebAnswerObserved, true) + assert.equal(rejected?.geminiWebContinuationObserved, true) + assert.equal(rejected?.geminiWebRemoteStateObserved, true) + assert.equal(rejected?.geminiWebConversationUncertain, true) + assert.equal(requests.length, 2) +}) + +test('abort after a submitted response is parsed marks the continuation uncertain', async (t) => { + const requests = mockFetch(t) + const controller = new AbortController() + const client = new GeminiWebClient() + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + } + + t.mock.method(client, 'parseResponse', (text) => { + const result = GeminiWebClient.prototype.parseResponse.call(client, text) + controller.abort(new DOMException('stopped after parse', 'AbortError')) + return result + }) + + let rejected + try { + await client.ask('Q2', conversation, controller.signal, { model: 'flash' }) + } catch (error) { + rejected = error + } + + assert.equal(rejected?.name, 'AbortError') + assert.equal(rejected?.geminiWebConversationUncertain, true) + assert.equal(requests.length, 2) +}) + +test('whole-turn timeout aborts an in-flight request with its timeout diagnostic', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + let signal + t.mock.method(globalThis, 'fetch', async (...[, init]) => { + signal = init.signal + return new Promise((...[, reject]) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + + const pending = new GeminiWebClient().ask('Q') + const rejected = assert.rejects(pending, /timed out/) + t.mock.timers.tick(240000) + await rejected + assert.equal(signal.aborted, true) +}) + +test('works without AbortSignal.throwIfAborted', async (t) => { + hideProperty(t, AbortSignal.prototype, 'throwIfAborted') + const requests = mockFetch(t) + assert.equal((await new GeminiWebClient().ask('Q')).answer, 'answer') + assert.equal(requests.length, 3) +}) + +test('rejects a pre-aborted signal without newer AbortSignal helpers', async (t) => { + hideProperty(t, AbortSignal.prototype, 'throwIfAborted') + hideProperty(t, AbortSignal.prototype, 'reason') + const requests = mockFetch(t) + const controller = new AbortController() + controller.abort() + await assert.rejects(new GeminiWebClient().ask('Q', {}, controller.signal), { + name: 'AbortError', + }) + assert.equal(requests.length, 0) +}) + +test('creates a valid UUID without crypto.randomUUID', async (t) => { + hideProperty(t, crypto, 'randomUUID') + const requests = mockFetch(t) + await new GeminiWebClient().ask('Q') + const requestId = JSON.parse(requests[2].init.headers['x-goog-ext-525005358-jspb'])[0] + assert.match(requestId, /^[\dA-F]{8}-[\dA-F]{4}-4[\dA-F]{3}-[89AB][\dA-F]{3}-[\dA-F]{12}$/) +}) + +function framed(records) { + const payload = JSON.stringify(records) + return `)]}'\n\n${payload.length + 2}\n${payload}\n` +} + +function modelEntry(id, category, displayName, modelNumber) { + const entry = Array(18).fill(null) + entry[0] = id + entry[1] = category + entry[11] = displayName + entry[17] = modelNumber + return entry +} + +function modeEntry(modeNumber, displayName, available = true) { + const info = Array(8).fill(null) + info[1] = modeNumber + info[4] = displayName + info[7] = available + return [info] +} + +function modelDiscoveryResponse(models, modes = []) { + const status = Array(25).fill(null) + status[14] = 1000 + status[15] = models + status[16] = [8] + status[24] = [null, modes] + return framed([['wrb.fr', 'otAQ7b', JSON.stringify(status)]]) +} + +function mockModelFetch(t, discovery) { + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + const requestUrl = String(url) + requests.push({ url: requestUrl, init }) + if (init.method !== 'POST') return new Response(bootstrap) + if (requestUrl.includes('/batchexecute?')) return new Response(discovery) + return new Response(reply) + }) + return requests +} + +test('discovers the selected model and sends temporary extended-thinking fields', async (t) => { + const discovery = modelDiscoveryResponse([ + modelEntry('flash-id', 'Flash', 'Gemini Flash', 1), + modelEntry('pro-id', 'Pro', 'Gemini Pro', 3), + modelEntry('lite-id', 'Flash-Lite', 'Gemini Flash-Lite', 6), + ]) + const requests = mockModelFetch(t, discovery) + + const result = await new GeminiWebClient().ask('Q', {}, undefined, { + model: 'pro', + temporary: true, + extendedThinking: true, + }) + assert.equal(result.answer, 'answer') + assert.equal(requests.length, 3) + + const discoveryRequest = requests[1] + assert.equal(new URL(discoveryRequest.url).searchParams.get('rpcids'), 'otAQ7b') + assert.equal(new URL(discoveryRequest.url).searchParams.get('source-path'), '/app') + + const generation = requests[2].init + const modelHeader = JSON.parse(generation.headers['x-goog-ext-525001261-jspb']) + assert.equal(modelHeader[4], 'pro-id') + assert.equal(modelHeader[11], 2) + assert.equal(modelHeader[14], 3) + assert.equal(modelHeader.at(-2), 2) + assert.match(modelHeader.at(-1), /^[\dA-F-]{36}$/) + + const inner = JSON.parse(JSON.parse(generation.body.get('f.req'))[1]) + assert.equal(inner[45], 1) + assert.equal(inner[79], 3) + assert.equal(inner[80], 2) +}) + +test('pins discovered model routing and increments turn index across a conversation', async (t) => { + const discovery = modelDiscoveryResponse([ + modelEntry('flash-id', 'Flash', 'Gemini Flash', 1), + modelEntry('pro-id', 'Pro', 'Gemini Pro', 3), + ]) + const requests = mockModelFetch(t, discovery) + const client = new GeminiWebClient() + + const first = await client.ask('Q1', {}, undefined, { model: 'pro' }) + assert.equal(first.conversationObj.turnIndex, 1) + assert.deepEqual(first.conversationObj.modelState, { + model: 'pro', + extendedThinking: false, + routing: { + id: 'pro-id', + modelNumber: 3, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }) + + const second = await client.ask('Q2', first.conversationObj, undefined, { model: 'pro' }) + assert.equal(second.conversationObj.turnIndex, 2) + assert.equal(requests.length, 5) + assert.equal(requests.filter(({ url }) => url.includes('/batchexecute?')).length, 1) + + const secondGeneration = requests[4].init + const modelHeader = JSON.parse(secondGeneration.headers['x-goog-ext-525001261-jspb']) + assert.equal(modelHeader[4], 'pro-id') + const inner = JSON.parse(JSON.parse(secondGeneration.body.get('f.req'))[1]) + assert.deepEqual(inner[17], [[1]]) + assert.equal(inner[79], 3) +}) + +test('supports extended thinking with Flash', async (t) => { + const discovery = modelDiscoveryResponse([ + modelEntry('flash-id', 'Flash', 'Gemini Flash', 1), + modelEntry('pro-id', 'Pro', 'Gemini Pro', 3), + ]) + const requests = mockModelFetch(t, discovery) + + await new GeminiWebClient().ask('Q', {}, undefined, { + model: 'flash', + extendedThinking: true, + }) + + const generation = requests[2].init + const modelHeader = JSON.parse(generation.headers['x-goog-ext-525001261-jspb']) + assert.equal(modelHeader[4], 'flash-id') + assert.equal(modelHeader.at(-2), 2) + const inner = JSON.parse(JSON.parse(generation.body.get('f.req'))[1]) + assert.equal(inner[79], 1) + assert.equal(inner[80], 2) +}) + +test('supports extended thinking with Flash-Lite', async (t) => { + const discovery = modelDiscoveryResponse([ + modelEntry('lite-id', 'Flash-Lite', 'Gemini Flash-Lite', 6), + modelEntry('flash-id', 'Flash', 'Gemini Flash', 1), + ]) + const requests = mockModelFetch(t, discovery) + + await new GeminiWebClient().ask('Q', {}, undefined, { + model: 'flash-lite', + extendedThinking: true, + }) + + const generation = requests[2].init + const modelHeader = JSON.parse(generation.headers['x-goog-ext-525001261-jspb']) + assert.equal(modelHeader[4], 'lite-id') + assert.equal(modelHeader.at(-2), 2) + const inner = JSON.parse(JSON.parse(generation.body.get('f.req'))[1]) + assert.equal(inner[79], 6) + assert.equal(inner[80], 2) +}) + +test('discovers the Workspace Thinking mode as a standalone model', async (t) => { + const discovery = modelDiscoveryResponse( + [modelEntry('flash-id', 'Flash', 'Gemini Flash', 1)], + [modeEntry(5, '3.6 Thinking')], + ) + const requests = mockModelFetch(t, discovery) + + await new GeminiWebClient().ask('Q', {}, undefined, { + model: 'thinking', + extendedThinking: true, + }) + + const generation = requests[2].init + const modelHeader = JSON.parse(generation.headers['x-goog-ext-525001261-jspb']) + assert.equal(modelHeader[4], 'e051ce1aa80aa576') + assert.equal(modelHeader[11], 2) + assert.equal(modelHeader[14], 5) + assert.equal(modelHeader[15], null) + assert.match(modelHeader[16], /^[\dA-F-]{36}$/) + + const inner = JSON.parse(JSON.parse(generation.body.get('f.req'))[1]) + assert.equal(inner[79], 5) + assert.equal(inner[80], null) +}) + +test('rejects standalone Thinking when the account mode picker marks it unavailable', async (t) => { + const discovery = modelDiscoveryResponse([], [modeEntry(5, '3.6 Thinking', false)]) + const requests = mockModelFetch(t, discovery) + + await assert.rejects( + new GeminiWebClient().ask('Q', {}, undefined, { model: 'thinking' }), + /selected model is not available/, + ) + assert.equal(requests.length, 2) +}) + +test('does not enable extended thinking with automatic model routing', async (t) => { + const requests = mockFetch(t) + await new GeminiWebClient().ask('Q', {}, undefined, { + model: 'auto', + extendedThinking: true, + }) + + assert.equal(requests.length, 2) + const generation = requests[1].init + assert.equal(generation.headers['x-goog-ext-525001261-jspb'], undefined) + const inner = JSON.parse(JSON.parse(generation.body.get('f.req'))[1]) + assert.equal(inner[79], null) + assert.equal(inner[80], null) +}) + +test('rejects a model that is unavailable to the signed-in account', async (t) => { + const discovery = modelDiscoveryResponse([modelEntry('flash-id', 'Flash', 'Gemini Flash', 1)]) + const requests = mockModelFetch(t, discovery) + + await assert.rejects( + new GeminiWebClient().ask('Q', {}, undefined, { model: 'pro' }), + /selected model is not available/, + ) + assert.equal(requests.length, 2) +}) diff --git a/tests/unit/services/apis/gemini-web-protocol.test.mjs b/tests/unit/services/apis/gemini-web-protocol.test.mjs new file mode 100644 index 000000000..d78fe8155 --- /dev/null +++ b/tests/unit/services/apis/gemini-web-protocol.test.mjs @@ -0,0 +1,343 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + buildRequest, + decodeEnvelopes, + parseAnswer, +} from '../../../../src/services/clients/gemini-web/protocol.mjs' + +const metadata = ['c_test', 'r_test', '', null, null, null, null, null, null, 'ctx'] + +function part(answer, { completion = 2, rc = 'rc_test', meta = metadata } = {}) { + const candidate = [rc, [answer]] + if (completion !== null) candidate[8] = [completion] + return ['wrb.fr', null, JSON.stringify([null, meta, null, null, [candidate]])] +} + +function providerStatus(code) { + return ['wrb.fr', null, null, null, null, [13, null, [[null, [code]]]]] +} + +function wire(...groups) { + return ( + ")]}'\n\n" + + groups + .map((group) => { + const payload = JSON.stringify(group) + return `${payload.length + 2}\n${payload}\n` + }) + .join('') + ) +} + +test('decodes Google frames whose length includes both surrounding line feeds', () => { + const frame = [part('complete')] + assert.deepEqual(decodeEnvelopes(wire(frame)), frame) +}) + +test('frame lengths use JavaScript UTF-16 units', () => { + const text = '台灣 😀 𠮷' + assert.equal(parseAnswer(wire([part(text)])).answer, text) +}) + +test('decodes an independently specified Unicode frame length', () => { + // The frame is 20 UTF-16 units including both line feeds, not 26 UTF-8 bytes. + const response = `)]}'\n\n20\n[["note","台灣 😀"]]\n` + assert.deepEqual(decodeEnvelopes(response), [['note', '台灣 😀']]) +}) + +test('keeps the latest full answer snapshot across frames', () => { + const response = wire( + [part('A', { completion: 1 })], + [part('AB', { completion: 1 })], + [part('ABC')], + ) + assert.equal(parseAnswer(response).answer, 'ABC') +}) + +test('rejects newer answer text that is not independently confirmed complete', () => { + const response = wire([part('A')], [part('AB', { completion: null })]) + assert.throws(() => parseAnswer(response), /confirm.*finished generating/) +}) + +test('processes every RPC record in one frame', () => { + assert.equal( + parseAnswer(wire([part('partial', { completion: 1 }), part('final')])).answer, + 'final', + ) +}) + +test('does not treat ID-looking answer text as protocol metadata', () => { + assert.equal(parseAnswer(wire([part('c_ is plain text')])).answer, 'c_ is plain text') +}) + +test('retains continuation metadata with the selected candidate', () => { + const result = parseAnswer(wire([part('ok')])) + assert.equal(result.conversationObj.c, 'c_test') + assert.equal(result.conversationObj.r, 'r_test') + assert.equal(result.conversationObj.rc, 'rc_test') + assert.deepEqual(result.conversationObj.metadata.slice(0, 3), ['c_test', 'r_test', 'rc_test']) +}) + +test('accepts a completed unframed envelope', () => { + assert.equal(parseAnswer(JSON.stringify([part('legacy')])).answer, 'legacy') +}) + +test('rejects an unframed answer without a completion marker', () => { + assert.throws( + () => parseAnswer(JSON.stringify([part('partial', { completion: null })])), + /confirm.*finished generating/, + ) +}) + +test('rejects a framed answer without a completion marker', () => { + assert.throws( + () => parseAnswer(wire([part('partial', { completion: null })])), + /confirm.*finished generating/, + ) +}) + +test('accepts a status-only final record for the same candidate', () => { + const final = part('unused') + const payload = JSON.parse(final[2]) + payload[4][0][1] = null + final[2] = JSON.stringify(payload) + + assert.equal( + parseAnswer(wire([part('final text', { completion: 1 }), final])).answer, + 'final text', + ) +}) + +test('retains pinned conversation identifiers when later frames omit them', () => { + const result = parseAnswer( + wire([part('partial', { completion: 1 })], [part('final', { meta: [null, null] })]), + ) + assert.equal(result.answer, 'final') + assert.equal(result.conversationObj.c, 'c_test') + assert.equal(result.conversationObj.r, 'r_test') +}) + +test('rejects candidate replacement before mixing continuation state', () => { + const first = part('first', { completion: 1 }) + const second = part('second', { rc: 'rc_other' }) + assert.throws(() => parseAnswer(wire([first, second])), /Candidate changed/) +}) + +for (const [name, changedMetadata] of [ + ['conversation id', ['c_other', 'r_test']], + ['response id', ['c_test', 'r_other']], +]) { + test(`rejects a changed ${name} for the same candidate`, () => { + const first = part('partial', { completion: 1 }) + const second = part('final', { meta: changedMetadata }) + assert.throws(() => parseAnswer(wire([first, second])), /Conversation identifiers changed/) + }) +} + +for (const malformedContent of ['BAD', { 0: 'BAD' }]) { + test('rejects malformed text candidate containers', () => { + const record = part('unused') + const payload = JSON.parse(record[2]) + payload[4][0][1] = malformedContent + record[2] = JSON.stringify(payload) + + assert.throws(() => parseAnswer(wire([record])), /Invalid text candidate/) + }) +} + +test('rejects malformed conversation metadata containers', () => { + const record = part('unused') + const payload = JSON.parse(record[2]) + payload[1] = 'BAD' + record[2] = JSON.stringify(payload) + + assert.throws(() => parseAnswer(wire([record])), /Invalid response payload/) +}) + +test('rejects malformed candidate containers and entries', () => { + for (const malformedCandidates of ['BAD', ['BAD']]) { + const record = part('unused') + const payload = JSON.parse(record[2]) + payload[4] = malformedCandidates + record[2] = JSON.stringify(payload) + + assert.throws(() => parseAnswer(wire([record])), /Invalid/) + } +}) + +test('rejects malformed completion containers', () => { + const record = part('unused') + const payload = JSON.parse(record[2]) + payload[4][0][8] = 'BAD' + record[2] = JSON.stringify(payload) + + assert.throws(() => parseAnswer(wire([record])), /Invalid response payload/) +}) + +for (const [name, response] of [ + ['empty response', ''], + ['HTML response', 'Sign in'], + ['truncated frame', '20\n[]'], + ['bad JSON frame', '2\n??'], + ['empty answer', wire([part('')])], + ['incomplete answer', wire([part('partial', { completion: 1 })])], + ['missing identifiers', wire([part('ok', { meta: [] })])], +]) { + test(`rejects ${name}`, () => { + assert.throws(() => parseAnswer(response), { code: 'GEMINI_WEB_PROTOCOL_ERROR' }) + }) +} + +test('ignores provider-shaped status fields on unrelated envelopes', () => { + const unrelated = ['other.rpc', null, null, null, null, [13, null, [[null, [1037]]]]] + const result = parseAnswer(wire([part('complete'), unrelated])) + + assert.equal(result.answer, 'complete') + assert.deepEqual(result.diagnostics.providerStatusCodes, []) +}) + +test('ignores unrelated named RPC status and error envelopes', () => { + const status = ['wrb.fr', 'otAQ7b', null, null, null, [13, null, [[null, [1037]]]]] + const error = ['er', 'otAQ7b', null, null, null, [13, null, [[null, [1037]]]]] + const result = parseAnswer(wire([part('complete'), status, error])) + + assert.equal(result.answer, 'complete') + assert.deepEqual(result.diagnostics.providerStatusCodes, []) +}) + +test('rejects a later populated candidate without an identifier', () => { + const later = part('new text') + const payload = JSON.parse(later[2]) + payload[4][0][0] = null + later[2] = JSON.stringify(payload) + + assert.throws( + () => parseAnswer(wire([part('complete'), later])), + /Invalid candidate identifier/, + ) +}) + +test('accepts a completed answer with a trailing non-content provider status', () => { + const result = parseAnswer(wire([part('complete'), providerStatus(1096)])) + assert.equal(result.answer, 'complete') + assert.deepEqual(result.diagnostics.providerStatusCodes, [1096]) +}) + +test('keeps fatal content-bearing provider errors fail-closed with remote-state evidence', () => { + let rejected + try { + parseAnswer(wire([part('complete'), providerStatus(1037)])) + } catch (error) { + rejected = error + } + + assert.match(rejected?.message || '', /usage limit reached for the selected model/) + assert.equal(rejected?.geminiWebProviderRejection, true) + assert.equal(rejected?.geminiWebAnswerObserved, true) + assert.equal(rejected?.geminiWebContinuationObserved, true) + assert.equal(rejected?.geminiWebRemoteStateObserved, true) +}) + +test('reports the last fatal provider status instead of a trailing non-fatal status', () => { + assert.throws( + () => parseAnswer(wire([providerStatus(1037), providerStatus(1096)])), + /usage limit reached for the selected model/, + ) +}) + +test('keeps structural errors when only a non-fatal provider status is present', () => { + assert.throws( + () => parseAnswer(wire([part('partial', { completion: 1 }), providerStatus(1096)])), + /interrupted before completion/, + ) +}) + +test('maps provider status when no usable answer was returned', () => { + assert.throws( + () => parseAnswer(wire([providerStatus(1037)])), + /usage limit reached for the selected model/, + ) + assert.throws( + () => parseAnswer(wire([providerStatus(1096)])), + /conversation can no longer be continued/, + ) +}) + +test('extracts safe served-model diagnostics without changing the answer', () => { + const record = part('complete') + const payload = JSON.parse(record[2]) + payload[39] = 'served-model-id' + payload[42] = '3.6 Flash' + payload[58] = 1 + payload[59] = 2 + record[2] = JSON.stringify(payload) + + const result = parseAnswer(wire([record])) + assert.equal(result.answer, 'complete') + assert.deepEqual(result.diagnostics, { + providerStatusCodes: [], + servedModelId: 'served-model-id', + servedModelLabel: '3.6 Flash', + servedModelNumber: 1, + servedModelVariant: 2, + }) +}) + +test('builds temporary and extended-thinking request fields without mutating state', () => { + const conversation = { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + metadata: ['c_old', 'r_old', 'rc_old', ['opaque']], + } + const before = structuredClone(conversation) + const outer = JSON.parse( + buildRequest('Q', conversation, 'zh-TW', 'request-id', { + temporary: true, + modelNumber: 3, + extendedThinking: true, + }), + ) + const inner = JSON.parse(outer[1]) + assert.equal(inner.length, 81) + assert.equal(inner[0][0], 'Q') + assert.deepEqual(inner[1], ['zh-TW']) + assert.deepEqual(inner[2], conversation.metadata) + assert.deepEqual(inner[17], [[0]]) + assert.equal(inner[45], 1) + assert.equal(inner[59], 'request-id') + assert.equal(inner[79], 3) + assert.equal(inner[80], 2) + assert.deepEqual(conversation, before) +}) + +test('builds follow-up turn index from validated conversation state', () => { + const outer = JSON.parse( + buildRequest('Q2', {}, 'zh-TW', 'request-id', { + modelNumber: 1, + turnIndex: 3, + }), + ) + const inner = JSON.parse(outer[1]) + assert.deepEqual(inner[17], [[3]]) +}) + +test('leaves model routing fields unset when account-default routing is requested', () => { + const outer = JSON.parse(buildRequest('Q', {}, 'zh-TW', 'request-id')) + const inner = JSON.parse(outer[1]) + assert.equal(inner[79], null) + assert.equal(inner[80], null) +}) + +test('builds standalone Thinking request fields with no extended-thinking flag', () => { + const outer = JSON.parse( + buildRequest('Q', {}, 'zh-TW', 'request-id', { + modelNumber: 5, + thinkingMode: null, + }), + ) + const inner = JSON.parse(outer[1]) + assert.equal(inner[79], 5) + assert.equal(inner[80], null) +}) diff --git a/tests/unit/services/apis/gemini-web-retry.test.mjs b/tests/unit/services/apis/gemini-web-retry.test.mjs new file mode 100644 index 000000000..92837040c --- /dev/null +++ b/tests/unit/services/apis/gemini-web-retry.test.mjs @@ -0,0 +1,344 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + createRetrySession, + finalizeInterruptedSession, +} from '../../../../src/components/ConversationCard/session.mjs' +import { generateAnswersWithGeminiWebApi } from '../../../../src/services/apis/gemini-web.mjs' +import GeminiWebClient from '../../../../src/services/clients/gemini-web/index.mjs' +import { protocolError } from '../../../../src/services/clients/gemini-web/protocol.mjs' +import { createFakePort } from '../../helpers/port.mjs' + +const priorRecord = { question: 'Q1', answer: 'A1' } +const retryRecord = { question: 'Q2', answer: 'old answer' } + +function geminiSession(overrides = {}) { + return { + question: 'Q2', + conversationRecords: [priorRecord, retryRecord], + apiMode: { groupName: 'bardWebModelKeys' }, + modelName: 'bardWebFree', + isRetry: false, + ...overrides, + } +} + +test('completed Gemini retries use the saved pre-turn continuation', () => { + const previousConversation = { + c: 'c-before', + r: 'r-before', + rc: 'rc-before', + accountPath: '/u/1', + } + const currentConversation = { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '/u/1', + } + const retrySession = createRetrySession( + geminiSession({ + geminiWeb_previousConversation: previousConversation, + geminiWeb_conversation: currentConversation, + geminiWeb_lastRecordIndex: 1, + }), + [priorRecord], + retryRecord, + ) + + assert.equal(retrySession.geminiWeb_retryConversation, previousConversation) + assert.equal(retrySession.geminiWeb_conversation, currentConversation) + assert.equal(retrySession.isRetry, false) +}) + +test('legacy Gemini retries without a pre-turn continuation are marked unsafe', () => { + const retrySession = createRetrySession( + geminiSession({ + geminiWeb_conversation: { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '', + }, + }), + [priorRecord], + retryRecord, + ) + + assert.equal(Object.hasOwn(retrySession, 'geminiWeb_retryConversation'), true) + assert.equal(retrySession.geminiWeb_retryConversation, null) +}) + +test('switching a non-Gemini answer to Gemini starts a clean continuation', () => { + const retrySession = createRetrySession(geminiSession(), [priorRecord], retryRecord) + + assert.deepEqual(retrySession.geminiWeb_retryConversation, {}) +}) + +test('switching back to Gemini keeps valid Gemini context from an older answer', () => { + const currentConversation = { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '', + } + const retrySession = createRetrySession( + geminiSession({ + geminiWeb_previousConversation: { + c: 'c-before', + r: 'r-before', + rc: 'rc-before', + accountPath: '', + }, + geminiWeb_conversation: currentConversation, + geminiWeb_lastRecordIndex: 0, + }), + [priorRecord], + retryRecord, + ) + + assert.equal(retrySession.geminiWeb_retryConversation, currentConversation) +}) + +test('switching back to Gemini refuses unsafe context from an older answer', () => { + const retrySession = createRetrySession( + geminiSession({ + geminiWeb_conversation: { + c: 'c-unsafe', + r: 'r-unsafe', + rc: 'rc-unsafe', + accountPath: '', + }, + geminiWeb_lastRecordIndex: 0, + geminiWeb_conversationUnsafe: true, + }), + [priorRecord], + retryRecord, + ) + + assert.equal(retrySession.geminiWeb_retryConversation, null) +}) + +test('ambiguous intermediate retry snapshots without a record index fail closed', () => { + const retrySession = createRetrySession( + geminiSession({ + geminiWeb_previousConversation: { + c: 'c-before', + r: 'r-before', + rc: 'rc-before', + accountPath: '', + }, + geminiWeb_conversation: { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '', + }, + }), + [priorRecord], + retryRecord, + ) + + assert.equal(retrySession.geminiWeb_retryConversation, null) +}) + +test('failed completed retries restore the local answer and clear the retry override', () => { + const currentConversation = { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '', + } + const session = geminiSession({ + conversationRecords: [priorRecord], + geminiWeb_conversation: currentConversation, + geminiWeb_previousConversation: { c: 'c-before', accountPath: '' }, + geminiWeb_retryConversation: { c: 'c-before', accountPath: '' }, + geminiWeb_lastRecordIndex: 1, + }) + + const restored = finalizeInterruptedSession(session, '', retryRecord) + + assert.deepEqual(restored.conversationRecords, [priorRecord, retryRecord]) + assert.equal(restored.geminiWeb_conversation, currentConversation) + assert.equal(restored.geminiWeb_lastRecordIndex, 1) + assert.equal(Object.hasOwn(restored, 'geminiWeb_retryConversation'), false) +}) + +test('successful completed retries branch from the pre-turn continuation', async (t) => { + const previousConversation = { + c: 'c-before', + r: 'r-before', + rc: 'rc-before', + accountPath: '/u/1', + } + const currentConversation = { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '/u/1', + } + const replacementConversation = { + c: 'c-replacement', + r: 'r-replacement', + rc: 'rc-replacement', + accountPath: '/u/1', + } + t.mock.method(GeminiWebClient.prototype, 'ask', async (...[, conversation]) => { + assert.equal(conversation, previousConversation) + return { answer: 'replacement answer', conversationObj: replacementConversation } + }) + + const session = geminiSession({ + conversationRecords: [priorRecord], + geminiWeb_previousConversation: previousConversation, + geminiWeb_conversation: currentConversation, + geminiWeb_retryConversation: previousConversation, + geminiWeb_lastRecordIndex: 1, + }) + const port = createFakePort() + + await generateAnswersWithGeminiWebApi(port, 'Q2', session) + + assert.deepEqual(session.conversationRecords, [ + priorRecord, + { question: 'Q2', answer: 'replacement answer' }, + ]) + assert.deepEqual(session.geminiWeb_previousConversation, previousConversation) + assert.equal(session.geminiWeb_conversation, replacementConversation) + assert.equal(session.geminiWeb_lastRecordIndex, 1) + assert.equal(Object.hasOwn(session, 'geminiWeb_retryConversation'), false) +}) + +test('first-turn state snapshots pin the resolved Google account route for retry', async (t) => { + const replacementConversation = { + c: 'c-new', + r: 'r-new', + rc: 'rc-new', + accountPath: '/u/2', + } + t.mock.method(GeminiWebClient.prototype, 'ask', async (...[, conversation]) => { + assert.deepEqual(conversation, {}) + return { answer: 'A1', conversationObj: replacementConversation } + }) + + const session = geminiSession({ + question: 'Q1', + conversationRecords: [], + geminiWeb_conversation: null, + }) + const port = createFakePort() + + await generateAnswersWithGeminiWebApi(port, 'Q1', session) + + assert.deepEqual(session.geminiWeb_previousConversation, { accountPath: '/u/2' }) + assert.equal(session.geminiWeb_conversation, replacementConversation) + assert.equal(session.geminiWeb_lastRecordIndex, 0) +}) + +test('empty pre-turn Retry retains unsafe continuation until replacement', async (t) => { + let askCount = 0 + t.mock.method(GeminiWebClient.prototype, 'ask', async (...[, conversation]) => { + askCount += 1 + assert.deepEqual(conversation, { accountPath: '/u/1' }) + throw protocolError('Sign in to Gemini in this browser profile before trying again.') + }) + + const session = geminiSession({ + conversationRecords: [priorRecord], + geminiWeb_previousConversation: { accountPath: '/u/1' }, + geminiWeb_retryConversation: { accountPath: '/u/1' }, + geminiWeb_conversation: { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '/u/1', + }, + geminiWeb_lastRecordIndex: 1, + geminiWeb_conversationUnsafe: true, + }) + const port = createFakePort() + + await assert.rejects(generateAnswersWithGeminiWebApi(port, 'Q2', session), /Sign in to Gemini/) + assert.equal(askCount, 1) + assert.equal(session.geminiWeb_conversationUnsafe, true) + assert.equal(session.geminiWeb_conversation.c, 'c-after') + + const restored = finalizeInterruptedSession(session, '', retryRecord) + assert.equal(restored.geminiWeb_conversationUnsafe, true) + assert.equal(Object.hasOwn(restored, 'geminiWeb_retryConversation'), false) + + await assert.rejects( + generateAnswersWithGeminiWebApi(port, 'Q3', restored), + /previous Gemini request may have changed the remote conversation/, + ) + assert.equal(askCount, 1) +}) + +test('connection unsafe state blocks reuse of stored continuation', async (t) => { + let askCount = 0 + t.mock.method(GeminiWebClient.prototype, 'ask', async () => { + askCount += 1 + throw new Error('unexpected request') + }) + + const currentConversation = { + c: 'c-unsafe', + r: 'r-unsafe', + rc: 'rc-unsafe', + accountPath: '', + } + const session = geminiSession({ + conversationRecords: [priorRecord], + geminiWeb_conversation: currentConversation, + geminiWeb_retryConversation: { ...currentConversation }, + }) + const requestPort = createFakePort() + const connectionPort = createFakePort() + connectionPort._geminiConversationUnsafe = true + + await assert.rejects( + generateAnswersWithGeminiWebApi( + requestPort, + 'Q2', + session, + {}, + () => true, + connectionPort, + ), + /previous Gemini request may have changed the remote conversation/, + ) + + assert.equal(askCount, 0) + assert.equal(connectionPort._geminiConversationUnsafe, true) + assert.equal(session.geminiWeb_conversationUnsafe, true) + assert.deepEqual(requestPort.postedMessages, [{ geminiWebConversationUnsafe: true }]) + assert.deepEqual(requestPort.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('unsafe legacy completed retries fail closed before sending a Gemini request', async (t) => { + let askCount = 0 + t.mock.method(GeminiWebClient.prototype, 'ask', async () => { + askCount += 1 + throw new Error('unexpected request') + }) + + const session = geminiSession({ + conversationRecords: [priorRecord], + geminiWeb_conversation: { + c: 'c-after', + r: 'r-after', + rc: 'rc-after', + accountPath: '', + }, + geminiWeb_retryConversation: null, + }) + const port = createFakePort() + + await assert.rejects( + generateAnswersWithGeminiWebApi(port, 'Q2', session), + /Invalid conversation state/, + ) + assert.equal(askCount, 0) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) diff --git a/tests/unit/services/apis/gemini-web-thinking.test.mjs b/tests/unit/services/apis/gemini-web-thinking.test.mjs new file mode 100644 index 000000000..02cd3fa10 --- /dev/null +++ b/tests/unit/services/apis/gemini-web-thinking.test.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + getGeminiModelSelection, + requestGeminiText, +} from '../../../../src/services/apis/gemini-web-transport.mjs' + +const params = { + at: 'test-at', + accountPath: '', + language: 'en', +} + +function framed(records) { + const payload = JSON.stringify(records) + return `)]}'\n\n${payload.length + 2}\n${payload}\n` +} + +function modelEntry(id, category, displayName, modelNumber) { + const entry = Array(18).fill(null) + entry[0] = id + entry[1] = category + entry[11] = displayName + entry[17] = modelNumber + return entry +} + +function modeEntry(modeNumber, displayName, available = true) { + const info = Array(8).fill(null) + info[1] = modeNumber + info[4] = displayName + info[7] = available + return [info] +} + +function discovery({ models = [], modes = [], tiers = [8], capabilities = [] } = {}) { + const status = Array(25).fill(null) + status[14] = 1000 + status[15] = models + status[16] = tiers + status[17] = capabilities + status[24] = [null, modes] + return framed([['wrb.fr', 'otAQ7b', JSON.stringify(status)]]) +} + +function mockDiscovery(t, responseText) { + t.mock.method(globalThis, 'fetch', async () => new Response(responseText)) +} + +test('missing model selection defaults to account-discovered Flash', async (t) => { + mockDiscovery( + t, + discovery({ + models: [modelEntry('flash-id', 'Flash', 'Gemini Flash', 1)], + }), + ) + + const selection = await getGeminiModelSelection(undefined, params, false) + const header = JSON.parse(selection.headers['x-goog-ext-525001261-jspb']) + assert.equal(selection.modelNumber, 1) + assert.equal(header[4], 'flash-id') +}) + +test('prefers an account-discovered model-number 5 for standalone Thinking', async (t) => { + mockDiscovery( + t, + discovery({ + models: [modelEntry('dynamic-thinking-id', 'Thinking', 'Gemini Thinking', 5)], + }), + ) + + const selection = await getGeminiModelSelection('thinking', params, false) + const header = JSON.parse(selection.headers['x-goog-ext-525001261-jspb']) + assert.equal(selection.modelNumber, 5) + assert.equal(selection.standaloneThinking, true) + assert.equal(header[4], 'dynamic-thinking-id') + assert.equal(header[11], 2) + assert.equal(header[14], 5) +}) + +test('mode-picker Thinking supports capacity-field 13 and numeric availability', async (t) => { + mockDiscovery( + t, + discovery({ + modes: [modeEntry(5, 'Thinking', 1)], + tiers: [22], + }), + ) + + const selection = await getGeminiModelSelection('thinking', params, false) + const header = JSON.parse(selection.headers['x-goog-ext-525001261-jspb']) + assert.equal(selection.modelNumber, 5) + assert.equal(header[4], 'e051ce1aa80aa576') + assert.equal(header[12], 2) + assert.equal(header[15], 5) +}) + +test('mode-picker Thinking still fails closed for an unmapped capacity', async (t) => { + mockDiscovery( + t, + discovery({ + modes: [modeEntry(5, 'Thinking')], + tiers: [16], + }), + ) + + await assert.rejects( + getGeminiModelSelection('thinking', params, false), + /selected model is not available/, + ) +}) + +test('a superseded request stops after model discovery before the prompt POST', async (t) => { + let fetchCount = 0 + t.mock.method(globalThis, 'fetch', async () => { + fetchCount += 1 + return new Response( + discovery({ + models: [modelEntry('flash-id', 'Flash', 'Gemini Flash', 1)], + }), + ) + }) + let freshnessChecks = 0 + + await assert.rejects( + requestGeminiText( + 'Q', + {}, + params, + { + model: 'flash', + isRequestCurrent: () => { + freshnessChecks += 1 + return freshnessChecks === 1 + }, + }, + undefined, + ), + { name: 'AbortError' }, + ) + + assert.equal(fetchCount, 1) +}) diff --git a/tests/unit/services/apis/gemini-web.test.mjs b/tests/unit/services/apis/gemini-web.test.mjs new file mode 100644 index 000000000..4b66d616f --- /dev/null +++ b/tests/unit/services/apis/gemini-web.test.mjs @@ -0,0 +1,722 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateAnswersWithGeminiWebApi } from '../../../../src/services/apis/gemini-web.mjs' +import GeminiWebClient from '../../../../src/services/clients/gemini-web/index.mjs' +import { protocolError } from '../../../../src/services/clients/gemini-web/protocol.mjs' +import { createFakePort } from '../../helpers/port.mjs' + +function assertClean(port) { + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +} + +function wire(records) { + const payload = JSON.stringify(records) + return `)]}'\n\n${payload.length + 2}\n${payload}\n` +} + +function modelEntry(id, category, displayName, modelNumber) { + const entry = Array(18).fill(null) + entry[0] = id + entry[1] = category + entry[11] = displayName + entry[17] = modelNumber + return entry +} + +function modelDiscoveryResponse(models) { + const status = Array(25).fill(null) + status[14] = 1000 + status[15] = models + status[16] = [8] + return wire([['wrb.fr', 'otAQ7b', JSON.stringify(status)]]) +} + +function bootstrapFetch(t, responseText) { + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (init.method !== 'POST') return new Response('{"SNlM0e":"test-at"}') + if (String(url).includes('/batchexecute?')) { + return new Response( + modelDiscoveryResponse([modelEntry('flash-id', 'Flash', 'Gemini Flash', 1)]), + ) + } + return new Response(responseText) + }) +} + +test('generateAnswersWithGeminiWebApi ignores a superseded response', async (t) => { + let resolveResponse + const response = new Promise((resolve) => { + resolveResponse = resolve + }) + t.mock.method(GeminiWebClient.prototype, 'ask', () => response) + + const session = { conversationRecords: [] } + const port = createFakePort() + let isLatest = true + const generation = generateAnswersWithGeminiWebApi(port, 'CurrentQ', session, {}, () => isLatest) + + isLatest = false + resolveResponse({ answer: 'Stale answer', conversationObj: { id: 'stale' } }) + await generation + + assert.deepEqual(session, { conversationRecords: [] }) + assert.deepEqual(port.postedMessages, []) + assertClean(port) +}) + +test('default path ignores a superseded port generation', async (t) => { + let resolveResponse + const response = new Promise((resolve) => { + resolveResponse = resolve + }) + t.mock.method(GeminiWebClient.prototype, 'ask', () => response) + + const session = { conversationRecords: [] } + const port = createFakePort() + port._sessionRequestGeneration = 1 + const generation = generateAnswersWithGeminiWebApi(port, 'CurrentQ', session) + + port._sessionRequestGeneration = 2 + resolveResponse({ answer: 'Stale answer', conversationObj: { id: 'stale' } }) + await generation + + assert.deepEqual(session, { conversationRecords: [] }) + assert.deepEqual(port.postedMessages, []) + assertClean(port) +}) + +test('stale Gemini completion leaves continuation unsafe', async (t) => { + let resolveResponse + const response = new Promise((resolve) => { + resolveResponse = resolve + }) + t.mock.method(GeminiWebClient.prototype, 'ask', (...args) => { + args[3].onPromptSubmitted() + return response + }) + + const session = { + conversationRecords: [{ question: 'Q1', answer: 'A1' }], + geminiWeb_conversation: { + c: 'c-old', + r: 'r-old', + rc: 'rc-old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + }, + apiMode: { + geminiWebModel: 'flash', + geminiWebExtendedThinking: false, + }, + } + const requestPort = createFakePort() + const connectionPort = createFakePort() + let isLatest = true + const pending = generateAnswersWithGeminiWebApi( + requestPort, + 'Q2', + session, + {}, + () => isLatest, + connectionPort, + ) + + isLatest = false + resolveResponse({ + answer: 'stale answer', + conversationObj: { + c: 'c-new', + r: 'r-new', + rc: 'rc-new', + accountPath: '', + turnIndex: 2, + }, + }) + await pending + + assert.equal(connectionPort._geminiConversationUnsafe, true) + assert.equal(session.geminiWeb_conversationUnsafe, true) + assert.equal(session.geminiWeb_conversation.c, 'c-old') + assert.deepEqual(session.conversationRecords, [{ question: 'Q1', answer: 'A1' }]) +}) + +test('superseded session hook aborts an in-flight Gemini request and cleans up', async (t) => { + let requestStarted + const started = new Promise((resolve) => { + requestStarted = resolve + }) + t.mock.method(GeminiWebClient.prototype, 'ask', (...args) => { + const signal = args[2] + requestStarted() + return new Promise((...[, reject]) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + + const session = { conversationRecords: [] } + const port = createFakePort() + const pending = generateAnswersWithGeminiWebApi(port, 'Q', session) + + await started + assert.equal(typeof port._abortSupersededSessionRequest, 'function') + port._abortSupersededSessionRequest() + + await assert.rejects(pending, { name: 'AbortError' }) + assert.equal(Object.hasOwn(port, '_abortSupersededSessionRequest'), false) + assertClean(port) +}) + +test('a superseded submitted turn makes its saved Gemini continuation unsafe', async (t) => { + let requestStarted + const started = new Promise((resolve) => { + requestStarted = resolve + }) + t.mock.method(GeminiWebClient.prototype, 'ask', (...args) => { + const signal = args[2] + args[3].onPromptSubmitted() + requestStarted() + return new Promise((...[, reject]) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + + const session = { + conversationRecords: [{ question: 'Q1', answer: 'A1' }], + geminiWeb_conversation: { + c: 'c-old', + r: 'r-old', + rc: 'rc-old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'flash', + extendedThinking: false, + routing: { + id: 'flash-id', + modelNumber: 1, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + }, + apiMode: { + geminiWebModel: 'flash', + geminiWebExtendedThinking: false, + }, + } + const connectionPort = createFakePort() + const firstRequestPort = createFakePort() + const pending = generateAnswersWithGeminiWebApi( + firstRequestPort, + 'Q2', + session, + {}, + () => true, + connectionPort, + ) + + await started + assert.deepEqual(firstRequestPort.postedMessages, [ + { geminiWebContinuationSubmitted: true }, + ]) + connectionPort._abortSupersededSessionRequest() + await assert.rejects(pending, { name: 'AbortError' }) + assert.equal(connectionPort._geminiConversationUnsafe, true) + + let askCount = 0 + t.mock.restoreAll() + t.mock.method(GeminiWebClient.prototype, 'ask', async () => { + askCount += 1 + throw new Error('unexpected request') + }) + + const secondRequestPort = createFakePort() + await assert.rejects( + generateAnswersWithGeminiWebApi( + secondRequestPort, + 'Q3', + session, + {}, + () => true, + connectionPort, + ), + /previous Gemini request may have changed the remote conversation/, + ) + assert.equal(askCount, 0) + assert.equal(connectionPort._geminiConversationUnsafe, true) + assert.equal(session.geminiWeb_conversationUnsafe, true) + assert.deepEqual(secondRequestPort.postedMessages, [{ geminiWebConversationUnsafe: true }]) + assertClean(firstRequestPort) + assertClean(secondRequestPort) +}) + +test('successful turns strip obsolete Gemini account identity from retry snapshots', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...[, conversation]) => { + assert.equal(conversation.accountId, 'obsolete-gaia-id') + return { + answer: 'ok', + conversationObj: { + c: 'c-new', + r: 'r-new', + rc: 'rc-new', + accountPath: '', + }, + } + }) + + const session = { + conversationRecords: [], + geminiWeb_conversation: { + c: 'c-old', + r: 'r-old', + rc: 'rc-old', + accountPath: '', + accountId: 'obsolete-gaia-id', + }, + } + const port = createFakePort() + + await generateAnswersWithGeminiWebApi(port, 'Q', session) + + assert.equal(Object.hasOwn(session.geminiWeb_previousConversation, 'accountId'), false) + assert.equal(Object.hasOwn(session.geminiWeb_conversation, 'accountId'), false) + assertClean(port) +}) + +test('successful legacy session state migrates to the Gemini field', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...[, conversation]) => { + assert.equal(conversation.c, 'c_old') + return { answer: 'ok', conversationObj: { c: 'c_new' } } + }) + + const session = { + conversationRecords: [], + bard_conversationObj: { c: 'c_old', accountPath: '' }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session) + + assert.equal(session.bard_conversationObj, undefined) + assert.deepEqual(session.geminiWeb_conversation, { c: 'c_new' }) + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'ok' }]) + assertClean(port) +}) + +test('explicit model mismatch fails before committing the returned turn', async (t) => { + t.mock.method(console, 'warn', () => {}) + t.mock.method(GeminiWebClient.prototype, 'ask', async () => ({ + answer: 'served by Flash', + conversationObj: { + c: 'c-new', + r: 'r-new', + rc: 'rc-new', + accountPath: '', + turnIndex: 2, + }, + diagnostics: { + servedModelId: 'flash-id', + servedModelLabel: '3.6 Flash', + servedModelNumber: 1, + servedModelVariant: 1, + providerStatusCodes: [], + }, + })) + + const session = { + conversationRecords: [{ question: 'Q1', answer: 'A1' }], + geminiWeb_conversation: { + c: 'c-old', + r: 'r-old', + rc: 'rc-old', + accountPath: '', + turnIndex: 1, + modelState: { + model: 'pro', + extendedThinking: false, + routing: { + id: 'pro-id', + modelNumber: 3, + capacity: 2, + capacityField: 12, + standaloneThinking: false, + }, + }, + }, + apiMode: { + geminiWebModel: 'pro', + geminiWebExtendedThinking: false, + }, + } + const port = createFakePort() + + await assert.rejects( + generateAnswersWithGeminiWebApi(port, 'Q2', session), + /selected model is not available/, + ) + assert.deepEqual(session.conversationRecords, [{ question: 'Q1', answer: 'A1' }]) + assert.equal(Object.hasOwn(session, 'geminiWeb_previousConversation'), false) + assert.equal(session.geminiWeb_conversation.c, 'c-old') + assert.equal(session.geminiWeb_conversation.turnIndex, 1) + assert.equal(session.geminiWeb_conversationUnsafe, true) + assert.deepEqual(port.postedMessages, [{ geminiWebConversationUnsafe: true }]) + assertClean(port) +}) + +test('served model number wins over cosmetic label changes', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(GeminiWebClient.prototype, 'ask', async () => ({ + answer: 'served by renamed Flash', + conversationObj: { + c: 'c-new', + r: 'r-new', + rc: 'rc-new', + accountPath: '', + turnIndex: 1, + }, + diagnostics: { + servedModelId: 'flash-id', + servedModelLabel: 'Gemini Fast', + servedModelNumber: 1, + servedModelVariant: 1, + providerStatusCodes: [], + }, + })) + + const session = { + conversationRecords: [], + apiMode: { + geminiWebModel: 'flash', + geminiWebExtendedThinking: false, + }, + } + const port = createFakePort() + + await generateAnswersWithGeminiWebApi(port, 'Q1', session) + + assert.deepEqual(session.conversationRecords, [ + { question: 'Q1', answer: 'served by renamed Flash' }, + ]) + assert.equal(session.geminiWeb_conversationUnsafe, undefined) + assertClean(port) +}) + +test('uses model and thinking options from the selected API mode preset', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + const { isRequestCurrent, onPromptSubmitted, ...options } = args[3] + assert.equal(typeof isRequestCurrent, 'function') + assert.equal(isRequestCurrent(), true) + assert.equal(typeof onPromptSubmitted, 'function') + assert.deepEqual(options, { + model: 'flash', + temporary: true, + extendedThinking: true, + }) + return { answer: 'ok', conversationObj: { c: 'c_new' } } + }) + + const session = { + conversationRecords: [], + apiMode: { + geminiWebModel: 'flash', + geminiWebExtendedThinking: true, + }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session, { + disableWebModeHistory: true, + geminiWebModel: 'pro', + geminiWebExtendedThinking: false, + }) + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'ok' }]) + assertClean(port) +}) + +test('uses standalone Thinking without the extended-thinking toggle', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].model, 'thinking') + assert.equal(args[3].extendedThinking, false) + return { answer: 'ok', conversationObj: {} } + }) + + const session = { + conversationRecords: [], + apiMode: { + geminiWebModel: 'thinking', + geminiWebExtendedThinking: true, + }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session) + assertClean(port) +}) + +test('uses the legacy API mode custom label as a compatibility fallback', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].model, 'pro') + assert.equal(args[3].extendedThinking, true) + return { answer: 'ok', conversationObj: {} } + }) + + const session = { + conversationRecords: [], + apiMode: { customName: 'Pro + Extended thinking' }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session) + assertClean(port) +}) + +test('uses a standalone Thinking label as a compatibility fallback', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].model, 'thinking') + assert.equal(args[3].extendedThinking, false) + return { answer: 'ok', conversationObj: {} } + }) + + const session = { + conversationRecords: [], + apiMode: { customName: 'Thinking' }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session) + assertClean(port) +}) + +test('uses the compact thinking marker as a compatibility fallback', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].model, 'flash') + assert.equal(args[3].extendedThinking, true) + return { answer: 'ok', conversationObj: {} } + }) + + const session = { + conversationRecords: [], + apiMode: { customName: 'Flash 🧠' }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session) + assertClean(port) +}) + +test('explicit disabled thinking wins over a compact label hint', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].model, 'flash') + assert.equal(args[3].extendedThinking, false) + return { answer: 'ok', conversationObj: {} } + }) + + const session = { + conversationRecords: [], + apiMode: { + customName: 'Flash 🧠', + geminiWebModel: 'flash', + geminiWebExtendedThinking: false, + }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session) + assertClean(port) +}) + +test('keeps the hidden global model setting only as a compatibility fallback', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].model, 'flash') + assert.equal(args[3].extendedThinking, true) + return { answer: 'ok', conversationObj: {} } + }) + + const port = createFakePort() + await generateAnswersWithGeminiWebApi( + port, + 'Q', + { conversationRecords: [] }, + { + geminiWebModel: 'flash', + geminiWebExtendedThinking: true, + }, + ) + assertClean(port) +}) + +test('explicit disabled thinking wins over the hidden global fallback', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].model, 'flash') + assert.equal(args[3].extendedThinking, false) + return { answer: 'ok', conversationObj: {} } + }) + + const session = { + conversationRecords: [], + apiMode: { + customName: 'My Gemini', + geminiWebExtendedThinking: false, + }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session, { + geminiWebModel: 'flash', + geminiWebExtendedThinking: true, + }) + assertClean(port) +}) + +test('does not pass extended thinking with automatic model routing', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + assert.equal(args[3].extendedThinking, false) + return { answer: 'ok', conversationObj: {} } + }) + + const session = { + conversationRecords: [], + apiMode: { + geminiWebModel: 'auto', + geminiWebExtendedThinking: true, + }, + } + const port = createFakePort() + await generateAnswersWithGeminiWebApi(port, 'Q', session) + assertClean(port) +}) + +test('protocol failures preserve conversation state and do not report success', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async () => { + throw protocolError('Invalid text candidate.') + }) + + const session = { + conversationRecords: [{ question: 'old', answer: 'old answer' }], + geminiWeb_conversation: { c: 'c_old', accountPath: '' }, + } + const before = structuredClone(session) + const port = createFakePort() + + await assert.rejects( + generateAnswersWithGeminiWebApi(port, 'Q', session), + /Invalid text candidate/, + ) + assert.deepEqual(session, before) + assert.deepEqual(port.postedMessages, []) + assertClean(port) +}) + +for (const action of ['stop', 'disconnect']) { + test(`${action} aborts the in-flight Gemini request without changing state`, async (t) => { + let started + const requestStarted = new Promise((resolve) => { + started = resolve + }) + t.mock.method(GeminiWebClient.prototype, 'ask', (...args) => { + const signal = args[2] + started() + return new Promise((...[, reject]) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + + const session = { + conversationRecords: [{ question: 'old', answer: 'old answer' }], + geminiWeb_conversation: { c: 'c_old', accountPath: '' }, + } + const before = structuredClone(session) + const port = createFakePort() + const pending = generateAnswersWithGeminiWebApi(port, 'Q', session) + + await requestStarted + if (action === 'stop') port.emitMessage({ stop: true, stopGenerationId: 7 }) + else port.emitDisconnect() + + await assert.rejects(pending, { name: 'AbortError' }) + assert.deepEqual(session, before) + assert.deepEqual( + port.postedMessages, + action === 'stop' ? [{ done: true, stoppedGenerationId: 7 }] : [], + ) + assertClean(port) + }) +} + +test('malformed candidate content cannot update history or conversation state', async (t) => { + const candidate = ['rc_new', 'BAD'] + candidate[8] = [2] + const payload = [null, ['c_new', 'r_new'], null, null, [candidate]] + bootstrapFetch(t, wire([['wrb.fr', null, JSON.stringify(payload)]])) + + const session = { conversationRecords: [] } + const before = structuredClone(session) + const port = createFakePort() + + await assert.rejects( + generateAnswersWithGeminiWebApi(port, 'private question', session), + /Invalid text candidate/, + ) + assert.deepEqual(session, before) + assert.deepEqual(port.postedMessages, []) + assertClean(port) +}) + +test('unconfirmed framed answers cannot update history or conversation state', async (t) => { + const candidate = ['rc_new', ['partial answer']] + const payload = [null, ['c_new', 'r_new'], null, null, [candidate]] + bootstrapFetch(t, wire([['wrb.fr', null, JSON.stringify(payload)]])) + + const session = { conversationRecords: [] } + const before = structuredClone(session) + const port = createFakePort() + + await assert.rejects( + generateAnswersWithGeminiWebApi(port, 'private question', session), + /confirm.*finished generating/, + ) + assert.deepEqual(session, before) + assert.deepEqual(port.postedMessages, []) + assertClean(port) +}) + +test('an invalid structured Gemini model fails before the client is started', async (t) => { + let askCalls = 0 + let fetchCalls = 0 + t.mock.method(GeminiWebClient.prototype, 'ask', async () => { + askCalls += 1 + throw new Error('Gemini client should not be called') + }) + t.mock.method(globalThis, 'fetch', async () => { + fetchCalls += 1 + throw new Error('Gemini network request should not be made') + }) + + const session = { + conversationRecords: [], + apiMode: { + customName: 'Flash', + geminiWebModel: 'garbage-value', + geminiWebExtendedThinking: false, + }, + } + const port = createFakePort() + + await assert.rejects( + () => + generateAnswersWithGeminiWebApi(port, 'Question', session, { + geminiWebModel: 'flash', + geminiWebExtendedThinking: false, + }), + /Invalid Gemini Web model selection/, + ) + + assert.equal(askCalls, 0) + assert.equal(fetchCalls, 0) + assert.deepEqual(port.postedMessages, []) + assertClean(port) +}) diff --git a/tests/unit/services/gemini-web-i18n.test.mjs b/tests/unit/services/gemini-web-i18n.test.mjs new file mode 100644 index 000000000..5585b4db2 --- /dev/null +++ b/tests/unit/services/gemini-web-i18n.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { test } from 'node:test' +import i18next from 'i18next' +import { handlePortError } from '../../../src/services/wrappers.mjs' +import { createFakePort } from '../helpers/port.mjs' + +const geminiErrorKeys = [ + 'Gemini Web: Invalid response format. The website protocol may have changed.', + 'Gemini Web: Invalid response frame length.', + 'Gemini Web: Truncated response frame.', + 'Gemini Web: Invalid response envelope.', + 'Gemini Web: Invalid conversation identifier.', + 'Gemini Web: Conversation identifiers changed during generation.', + 'Gemini Web: The website rejected the request. Check Gemini in your browser.', + 'Gemini Web: The website rejected the request ({{code}}). Check Gemini in your browser.', + 'Gemini Web: Invalid response payload.', + 'Gemini Web: Invalid candidate identifier.', + 'Gemini Web: Candidate changed during generation.', + 'Gemini Web: Invalid text candidate.', + 'Gemini Web: No text answer was received. Check Gemini in your browser.', + 'Gemini Web: Could not confirm that the answer finished generating.', + 'Gemini Web: The answer was interrupted before completion. Check Gemini before retrying.', + 'Gemini Web: Missing conversation identifiers.', + 'Gemini Web: Invalid Google account route. Start a new conversation.', + 'Gemini Web: Invalid website session data.', + 'Gemini Web: HTTP {{status}}. Check your login and limits on Gemini.', + 'Gemini Web: Sign in to Gemini in this browser profile before trying again.', + 'Gemini Web: Empty website response.', + 'Gemini Web: Website response is too large.', + 'Gemini Web: The Google account route changed. Start a new conversation.', + 'Gemini Web: The selected model is not available for this Google account.', + 'Gemini Web: Invalid Gemini Web model selection.', + 'Gemini Web: Gemini could not load the available models for this account.', + 'Gemini Web: Invalid Gemini model discovery response.', + 'Gemini Web: Gemini returned no available model information.', + 'Gemini Web: This Google account cannot select models ({{status}}).', + 'Gemini Web: Invalid conversation state. Start a new conversation.', + 'Gemini Web: This conversation has no stored Google account route. Start a new conversation.', + 'Gemini Web: Empty question.', + 'Gemini Web: Request timed out. Check Gemini before retrying.', + 'Gemini Web: Gemini encountered a temporary generation error. Please retry.', + 'Gemini Web: Gemini usage limit reached for the selected model. Check Gemini before retrying.', + 'Gemini Web: The selected model does not match this conversation. Start a new conversation.', + 'Gemini Web: The selected model is unavailable or the Gemini website protocol changed.', + 'Gemini Web: Gemini temporarily blocked requests from this network. Try again later.', + 'Gemini Web: This conversation can no longer be continued. Start a new conversation.', + 'Gemini Web: This conversation has no stored Gemini model. Start a new conversation.', + 'Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.', +] + +async function loadLocale(language) { + return JSON.parse( + await readFile(new URL(`../../../src/_locales/${language}/main.json`, import.meta.url), 'utf8'), + ) +} + +async function createTranslator(language, translation) { + const i18n = i18next.createInstance() + await i18n.init({ + lng: language, + fallbackLng: false, + resources: { [language]: { translation } }, + }) + return i18n.t.bind(i18n) +} + +test('Gemini Web errors are registered in English and Traditional Chinese locales', async () => { + const [en, zhHant] = await Promise.all([loadLocale('en'), loadLocale('zh-hant')]) + + for (const key of geminiErrorKeys) { + assert.equal(en[key], key) + assert.equal(typeof zhHant[key], 'string') + assert.notEqual(zhHant[key], key) + } +}) + +test('handlePortError localizes static and dynamic Gemini Web errors', async (t) => { + t.mock.method(console, 'error', () => {}) + const zhHant = await loadLocale('zh-hant') + const translate = await createTranslator('zh-Hant', zhHant) + + for (const [message, expected] of [ + [ + 'Gemini Web: The selected model is not available for this Google account.', + 'Google Gemini 網頁版:此 Google 帳戶無法使用所選模型。', + ], + [ + 'Gemini Web: HTTP 429. Check your login and limits on Gemini.', + 'Google Gemini 網頁版:HTTP 429。請確認 Gemini 的登入狀態與使用限制。', + ], + [ + 'Gemini Web: The website rejected the request (1037). Check Gemini in your browser.', + 'Google Gemini 網頁版:網站拒絕此要求(1037)。請在瀏覽器中檢查 Gemini。', + ], + [ + 'Gemini Web: The selected model does not match this conversation. Start a new conversation.', + 'Google Gemini 網頁版:所選模型與目前對話不相符。請開始新對話。', + ], + [ + 'Gemini Web: The previous Gemini request may have changed the remote conversation. Start a new ChatGPTBox conversation.', + 'Google Gemini 網頁版:上一次 Gemini 要求可能已變更遠端對話狀態。請開始新的 ChatGPTBox 對話。', + ], + ]) { + const port = createFakePort() + handlePortError( + { modelName: 'bardWebFree' }, + port, + Object.assign(new Error(message), { code: 'GEMINI_WEB_PROTOCOL_ERROR' }), + translate, + ) + assert.deepEqual(port.postedMessages, [{ error: expected }]) + } +}) diff --git a/tests/unit/services/init-session.test.mjs b/tests/unit/services/init-session.test.mjs index f18a869c7..913a64dca 100644 --- a/tests/unit/services/init-session.test.mjs +++ b/tests/unit/services/init-session.test.mjs @@ -89,8 +89,8 @@ test('all provider-specific fields default to null', () => { assert.equal(session.bingWeb_parentMessageId, null) assert.equal(session.bingWeb_jailbreakConversationCache, null) - // bard - assert.equal(session.bard_conversationObj, null) + // gemini + assert.equal(session.geminiWeb_conversation, null) // claude.ai assert.equal(session.claude_conversation, null) diff --git a/tests/unit/services/wrappers-register.test.mjs b/tests/unit/services/wrappers-register.test.mjs index cfee3e4f7..403696495 100644 --- a/tests/unit/services/wrappers-register.test.mjs +++ b/tests/unit/services/wrappers-register.test.mjs @@ -39,9 +39,9 @@ globalThis.chrome.cookies = { import { registerPortListener, + claimLatestPortSessionRequest, getChatGptAccessToken, getBingAccessToken, - getBardCookies, getClaudeSessionKey, } from '../../../src/services/wrappers.mjs' import Browser from 'webextension-polyfill' @@ -49,6 +49,19 @@ import { normalizeApiMode } from '../../../src/utils/model-name-convert.mjs' import { FETCH_REQUEST_FAILED } from '../../../src/utils/fetch-sse.mjs' import { formatErrorMessage } from '../../../src/utils/error-text.mjs' +test('claimLatestPortSessionRequest aborts a registered superseded request', () => { + const port = createFakePort() + let abortCount = 0 + port._abortSupersededSessionRequest = () => { + abortCount += 1 + } + + const isLatest = claimLatestPortSessionRequest(port) + + assert.equal(abortCount, 1) + assert.equal(isLatest(), true) +}) + const setStorage = (values) => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) } @@ -116,6 +129,38 @@ test('registerPortListener calls executor with session, port, and config', async assert.equal(port.postedMessages[0].session, result.session) }) +test('replacement provider inherits uncertainty from a submitted Gemini continuation', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ modelName: 'chatgptApi4oMini' }) + + let resolveExec + const execDone = new Promise((resolve) => { + resolveExec = resolve + }) + const executor = t.mock.fn(async (session) => { + resolveExec(session) + }) + + registerPortListener(executor) + const port = createFakePort() + triggerConnect(port) + port._abortSupersededSessionRequest = () => { + port._geminiConversationUnsafe = true + } + + const replacement = { + modelName: 'customModel', + conversationRecords: [{ question: 'Q1', answer: 'A1' }], + geminiWeb_conversation: { c: 'c-old', r: 'r-old', rc: 'rc-old' }, + } + port.emitMessage({ session: replacement }) + const actual = await execDone + + assert.equal(actual.geminiWeb_conversationUnsafe, true) + assert.equal(port.postedMessages[0].session.geminiWeb_conversationUnsafe, true) + assert.deepEqual(actual.conversationRecords, replacement.conversationRecords) +}) + test('registerPortListener scopes error translations to each request', async (t) => { t.mock.method(console, 'debug', () => {}) t.mock.method(console, 'error', () => {}) @@ -627,24 +672,6 @@ test('getBingAccessToken returns undefined when cookie missing', async () => { assert.equal(token, undefined) }) -// --------------------------------------------------------------------------- -// getBardCookies -// --------------------------------------------------------------------------- - -test('getBardCookies returns formatted cookie string', async () => { - cookieJar['https://google.com/'] = [{ name: '__Secure-1PSID', value: 'bard-sid' }] - - const cookies = await getBardCookies() - assert.equal(cookies, '__Secure-1PSID=bard-sid') -}) - -test('getBardCookies returns __Secure-1PSID=undefined when cookie missing', async () => { - cookieJar['https://google.com/'] = [] - - const cookies = await getBardCookies() - assert.equal(cookies, '__Secure-1PSID=undefined') -}) - // --------------------------------------------------------------------------- // getClaudeSessionKey // --------------------------------------------------------------------------- diff --git a/tests/unit/utils/gemini-web-api-mode.test.mjs b/tests/unit/utils/gemini-web-api-mode.test.mjs new file mode 100644 index 000000000..5468a30ab --- /dev/null +++ b/tests/unit/utils/gemini-web-api-mode.test.mjs @@ -0,0 +1,412 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { defaultConfig, ModelGroups } from '../../../src/config/index.mjs' +import { buildApiModeListConfigUpdate } from '../../../src/popup/api-mode-config-utils.mjs' +import { getApiModeDisplayLabel } from '../../../src/popup/sections/api-modes-provider-utils.mjs' +import { + hasDuplicateGeminiWebPreset, + resolveGeminiWebPreset, +} from '../../../src/utils/gemini-web-preset.mjs' +import { + getApiModesFromConfig, + getUniquelySelectedApiModeIndex, + isApiModeSelected, + reconcileMaterializedApiModeDefaults, +} from '../../../src/utils/model-name-convert.mjs' + +const [geminiGroupName, geminiGroup] = Object.entries(ModelGroups).find( + ([, group]) => group.desc === 'Gemini (Web)', +) +const geminiItemName = geminiGroup.value[0] + +function preset(customName, model, extendedThinking = false) { + return { + groupName: geminiGroupName, + itemName: geminiItemName, + isCustom: true, + customName, + customUrl: '', + apiKey: '', + providerId: '', + active: true, + geminiWebModel: model, + geminiWebExtendedThinking: extendedThinking, + } +} + +function legacyPreset(customName) { + return { + groupName: geminiGroupName, + itemName: geminiItemName, + isCustom: true, + customName, + customUrl: '', + apiKey: '', + providerId: '', + active: true, + } +} + +function zhHant(key) { + if (key === 'Gemini (Web)') return 'Gemini (網頁版)' + if (key === 'Extended thinking') return '延伸思考' + return key +} + +test('Gemini Web is available as a default API mode without manual enablement', () => { + const modes = getApiModesFromConfig( + { + ...defaultConfig, + activeApiModes: [...defaultConfig.activeApiModes], + customApiModes: [], + knownApiModeDefaultIds: [], + }, + true, + ) + const gemini = modes.find((mode) => mode.groupName === geminiGroupName) + + assert.ok(gemini) + assert.equal(gemini.itemName, geminiItemName) + assert.equal(gemini.active, true) + assert.equal(resolveGeminiWebPreset(gemini, defaultConfig).model, 'flash') +}) + +test('an explicit API-mode snapshot does not get Gemini Web backfilled', () => { + const modes = getApiModesFromConfig( + { + configSchemaVersion: 2, + activeApiModes: ['claude2WebFree'], + customApiModes: [], + geminiWebModel: 'auto', + knownApiModeDefaultIds: [], + }, + true, + ) + + assert.equal( + modes.some((mode) => mode.groupName === geminiGroupName), + false, + ) +}) + +test('migration-style empty API-mode probing does not synthesize Gemini Web', () => { + const modes = getApiModesFromConfig( + { + activeApiModes: [], + customApiModes: [], + geminiWebModel: 'auto', + knownApiModeDefaultIds: [], + }, + false, + ) + + assert.equal( + modes.some((mode) => mode.groupName === geminiGroupName), + false, + ) +}) + +test('configured Gemini presets suppress a legacy base active entry', () => { + const disabled = { ...preset('Pro', 'pro'), active: false } + const config = { + activeApiModes: [geminiItemName], + customApiModes: [disabled], + geminiWebModel: 'auto', + knownApiModeDefaultIds: [], + } + const allModes = getApiModesFromConfig(config, false) + const activeModes = getApiModesFromConfig(config, true) + const allGeminiModes = allModes.filter((mode) => mode.groupName === geminiGroupName) + + assert.equal(allGeminiModes.length, 1) + assert.equal(allGeminiModes[0].customName, 'Pro') + assert.equal(allGeminiModes[0].active, false) + assert.equal( + activeModes.some((mode) => mode.groupName === geminiGroupName), + false, + ) +}) + +test('an explicitly disabled Gemini Web row stays disabled', () => { + const disabled = { ...preset('Auto', 'auto'), active: false } + const modes = getApiModesFromConfig( + { + activeApiModes: [], + customApiModes: [disabled], + geminiWebModel: 'auto', + }, + true, + ) + + assert.equal( + modes.some((mode) => mode.groupName === geminiGroupName), + false, + ) +}) + +test('a handled Gemini Web default stays absent after its row is removed', () => { + const modes = getApiModesFromConfig( + { + activeApiModes: [], + customApiModes: [], + geminiWebModel: 'auto', + knownApiModeDefaultIds: [geminiItemName], + }, + true, + ) + + assert.equal( + modes.some((mode) => mode.groupName === geminiGroupName), + false, + ) +}) + +test('saving the API mode list marks the Gemini Web default as handled', () => { + const update = buildApiModeListConfigUpdate( + { + knownApiModeDefaultIds: [], + geminiWebModel: 'auto', + }, + [], + ) + + assert.equal(update.knownApiModeDefaultIds.includes(geminiItemName), true) +}) + +test('default reconciliation is not confused by the implicit Gemini Web default', () => { + const result = reconcileMaterializedApiModeDefaults( + { + activeApiModes: [], + customApiModes: [], + geminiWebModel: 'auto', + }, + ['claude2WebFree'], + [], + ) + + assert.equal( + result.customApiModes.some((mode) => mode.itemName === 'claude2WebFree'), + true, + ) + assert.deepEqual(result.knownApiModeDefaultIds, ['claude2WebFree']) +}) + +test('Gemini Web API mode presets can keep multiple model combinations', () => { + const flash = preset('Flash', 'flash') + const extended = preset('Flash + Extended thinking', 'flash', true) + const thinking = preset('Thinking', 'thinking') + const pro = preset('Pro', 'pro') + const config = { + activeApiModes: [], + customApiModes: [flash, extended, thinking, pro], + } + + const modes = getApiModesFromConfig(config, true) + assert.deepEqual( + modes.map((mode) => mode.customName), + ['Flash', 'Flash + Extended thinking', 'Thinking', 'Pro'], + ) +}) + +test('Gemini Web API mode selection distinguishes thinking from the same base model', () => { + const flash = preset('Flash', 'flash') + const extended = preset('Flash + Extended thinking', 'flash', true) + + assert.equal(isApiModeSelected(flash, { apiMode: extended }), false) + assert.equal(isApiModeSelected(extended, { apiMode: extended }), true) + assert.equal(getUniquelySelectedApiModeIndex([flash, extended], { apiMode: extended }), 1) +}) + +test('Gemini Web API mode selection uses structured preset identity when labels collide', () => { + const flash = preset('Same Gemini label', 'flash') + const extended = preset('Same Gemini label', 'flash', true) + const pro = preset('Same Gemini label', 'pro') + const selection = { apiMode: extended } + + assert.equal(isApiModeSelected(flash, selection), false) + assert.equal(isApiModeSelected(extended, selection), true) + assert.equal(isApiModeSelected(pro, selection), false) + assert.equal(getUniquelySelectedApiModeIndex([flash, extended, pro], selection), 1) + assert.equal( + getUniquelySelectedApiModeIndex([flash, extended, pro], selection, { + sessionCompat: true, + }), + 1, + ) +}) + +test('Gemini Web preset identity ignores materialization metadata', () => { + const materialized = { + groupName: geminiGroupName, + itemName: geminiItemName, + isCustom: false, + customName: '', + customUrl: '', + apiKey: '', + providerId: '', + active: true, + } + const structured = preset('Flash', 'flash') + + assert.equal(isApiModeSelected(materialized, { apiMode: structured }), true) + assert.equal(isApiModeSelected(structured, { apiMode: materialized }), true) + assert.equal( + isApiModeSelected(structured, { apiMode: materialized }, { sessionCompat: true }), + true, + ) +}) + +test('Gemini Web preset identity keeps provider mismatches fail-closed', () => { + const first = { ...preset('Auto', 'auto'), providerId: 'provider-a' } + const second = { ...preset('Different label', 'auto'), providerId: 'provider-b' } + + assert.equal(isApiModeSelected(first, { apiMode: second }), false) + assert.equal(isApiModeSelected(first, { apiMode: second }, { sessionCompat: true }), false) +}) + +test('Gemini Web API mode selection keeps legacy preset names compatible', () => { + const legacy = legacyPreset('Flash + Extended thinking') + const structured = preset('Flash + Extended thinking', 'flash', true) + + assert.equal(isApiModeSelected(structured, { apiMode: legacy }), true) + assert.equal(isApiModeSelected(legacy, { apiMode: structured }), true) +}) + +test('Gemini Web API mode custom fields survive normalization', () => { + const thinking = preset('Thinking', 'thinking') + const [normalized] = getApiModesFromConfig( + { activeApiModes: [], customApiModes: [thinking] }, + true, + ) + + assert.equal(normalized.geminiWebModel, 'thinking') + assert.equal(normalized.geminiWebExtendedThinking, false) +}) + +test('Gemini Web display label uses the localized web product name and thinking text', () => { + assert.equal( + getApiModeDisplayLabel(preset('Flash + Extended thinking', 'flash', true), zhHant), + 'Google Gemini (網頁版) (Flash + 延伸思考)', + ) + assert.equal( + getApiModeDisplayLabel(preset('Thinking', 'thinking'), zhHant), + 'Google Gemini (網頁版) (Thinking)', + ) +}) + +test('Gemini Web display labels preserve legacy preset model names', () => { + assert.equal( + getApiModeDisplayLabel(legacyPreset('Flash'), zhHant), + 'Google Gemini (網頁版) (Flash)', + ) + assert.equal( + getApiModeDisplayLabel(legacyPreset('Flash + Extended thinking'), zhHant), + 'Google Gemini (網頁版) (Flash + 延伸思考)', + ) + assert.equal(getApiModeDisplayLabel(legacyPreset('Pro'), zhHant), 'Google Gemini (網頁版) (Pro)') + assert.equal( + getApiModeDisplayLabel(legacyPreset('Pro + Extended thinking'), zhHant), + 'Google Gemini (網頁版) (Pro + 延伸思考)', + ) + assert.equal( + getApiModeDisplayLabel(legacyPreset('Thinking'), zhHant), + 'Google Gemini (網頁版) (Thinking)', + ) +}) + +test('Gemini Web preset resolver uses Flash when no explicit or compatibility choice exists', () => { + assert.deepEqual(resolveGeminiWebPreset({}, {}), { + model: 'flash', + extendedThinking: false, + source: 'default', + }) +}) + +test('Gemini Web preset resolver preserves explicit disabled thinking over global fallback', () => { + assert.deepEqual( + resolveGeminiWebPreset( + { + customName: 'My Gemini', + geminiWebExtendedThinking: false, + }, + { + geminiWebModel: 'flash', + geminiWebExtendedThinking: true, + }, + ), + { + model: 'flash', + extendedThinking: false, + source: 'fallback', + }, + ) +}) + +test('Gemini Web preset resolver rejects an explicitly invalid structured model', () => { + assert.deepEqual( + resolveGeminiWebPreset( + { + customName: 'Flash', + geminiWebModel: 'garbage-value', + }, + { + geminiWebModel: 'flash', + geminiWebExtendedThinking: true, + }, + ), + { + model: 'garbage-value', + extendedThinking: false, + source: 'invalid', + }, + ) +}) + +test('Gemini Web preset resolver rejects an invalid explicit fallback model', () => { + assert.deepEqual( + resolveGeminiWebPreset( + { customName: 'My Gemini' }, + { + geminiWebModel: 'garbage-value', + geminiWebExtendedThinking: true, + }, + ), + { + model: 'garbage-value', + extendedThinking: false, + source: 'invalid', + }, + ) +}) + +test('Gemini Web preset duplicate detection uses semantic model and thinking identity', () => { + const flash = preset('Flash', 'flash') + const sameFlash = preset('Different label', 'flash') + const extended = preset('Flash + Extended thinking', 'flash', true) + const pro = preset('Pro', 'pro') + + assert.equal(hasDuplicateGeminiWebPreset([flash], sameFlash, -1, defaultConfig), true) + assert.equal(hasDuplicateGeminiWebPreset([flash], flash, 0, defaultConfig), false) + assert.equal(hasDuplicateGeminiWebPreset([flash, sameFlash], flash, 0, defaultConfig), true) + assert.equal(hasDuplicateGeminiWebPreset([flash, pro], pro, 0, defaultConfig), true) + assert.equal(hasDuplicateGeminiWebPreset([flash], extended, -1, defaultConfig), false) + assert.equal(hasDuplicateGeminiWebPreset([flash], pro, -1, defaultConfig), false) + assert.equal( + hasDuplicateGeminiWebPreset([legacyPreset('Flash')], sameFlash, -1, defaultConfig), + true, + ) + assert.equal(getUniquelySelectedApiModeIndex([flash, sameFlash], { apiMode: flash }), -1) +}) + +test('Gemini Web display label does not mislabel an invalid structured model as Auto', () => { + assert.equal( + getApiModeDisplayLabel( + { + ...preset('Broken preset', 'flash'), + geminiWebModel: 'garbage-value', + }, + zhHant, + ), + 'Google Gemini (網頁版)', + ) +})