From 4c50041b55f02bb8beee30ad531c2e7f1f8d1569 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:46:38 +0800 Subject: [PATCH] Rework Gemini Web integration for current website protocol Modernize the authenticated Gemini web path around the current website protocol, keeping it text-only and fail-closed. - Structurally parse length-framed RPC responses; validate completion, candidate identity, continuation IDs, provider-status scope, and malformed containers. - Pin discovered model routing and turn indexes to continuation state; reject cross-model or inconsistent routing before prompt submission. - Preserve continuation uncertainty across runtime-port reconnects, component disposal, provider switches, body-less successful submissions, and content-bearing provider errors, so stale continuations cannot be reused. - Keep unsafe continuation state latched across retry branches until a successful replacement turn commits new remote state. - Keep the last known-good local continuation on served-model mismatch, then block further continuation until the user starts fresh. - Use Flash as the default Gemini Web preset while retaining explicit account-default Auto compatibility with model routing fields left unset. - Allow the observed non-fatal 1096 status only after a valid completed answer; keep fatal provider failures and status-only 1096 actionable. - Use browser credentials with bounded reads, timeout, cancellation, stale-generation guards, and no automatic retry of uncertain prompt POSTs. - Map web-history privacy to Temporary Chat and keep route-scoped continuation without stable account identity or selector support. - Discover account-specific Flash-Lite, Flash, Pro, and standalone Workspace Thinking routing instead of hard-coding the primary registry. - Localize Gemini Web errors and drop the obsolete Bard adapter and unused Google-cookie read. Workspace Thinking remains a standalone web mode (mode 5), not Flash plus Extended thinking. Model routing is pinned after the first successful turn so registry refreshes cannot silently move an existing conversation. Omit the ListAccounts/Gaia experiment. Live Firefox returned HTTP 400; Chromium uses a different account-list contract. Document the account-slot limitation (/app or /u/N) instead. Formatting, lint, tests, production build, and browser smoke tests remain merge gates. Fix #723 --- src/_locales/de/main.json | 43 + src/_locales/en/main.json | 43 + src/_locales/es/main.json | 43 + src/_locales/fr/main.json | 43 + src/_locales/id/main.json | 43 + src/_locales/it/main.json | 43 + src/_locales/ja/main.json | 43 + src/_locales/ko/main.json | 43 + src/_locales/pt/main.json | 43 + src/_locales/ru/main.json | 43 + src/_locales/tr/main.json | 43 + src/_locales/zh-hans/main.json | 43 + src/_locales/zh-hant/main.json | 43 + src/background/index.mjs | 9 +- src/components/ConversationCard/index.jsx | 66 +- src/components/ConversationCard/session.mjs | 75 +- src/config/index.mjs | 2 + src/pages/IndependentPanel/App.jsx | 5 +- src/popup/api-mode-config-utils.mjs | 6 + src/popup/sections/ApiModes.jsx | 225 ++++- .../sections/api-modes-provider-utils.mjs | 18 + src/services/apis/bard-web.mjs | 36 - src/services/apis/gemini-web-transport.mjs | 489 +++++++++++ src/services/apis/gemini-web.mjs | 215 +++++ src/services/clients/bard/index.mjs | 137 --- src/services/clients/gemini-web/index.mjs | 166 ++++ src/services/clients/gemini-web/protocol.mjs | 252 ++++++ src/services/init-session.mjs | 6 +- src/services/wrappers.mjs | 43 +- src/utils/gemini-web-preset.mjs | 105 +++ src/utils/model-name-convert.mjs | 77 +- ...nversation-card-lifecycle-loader-hooks.mjs | 7 +- .../conversation-card-lifecycle.test.mjs | 305 +++++++ tests/unit/prettier-inspection.test.mjs | 55 ++ tests/unit/services/apis/bard-web.test.mjs | 31 - .../services/apis/gemini-web-compat.test.mjs | 814 ++++++++++++++++++ .../apis/gemini-web-protocol.test.mjs | 343 ++++++++ .../services/apis/gemini-web-retry.test.mjs | 344 ++++++++ .../apis/gemini-web-thinking.test.mjs | 143 +++ tests/unit/services/apis/gemini-web.test.mjs | 722 ++++++++++++++++ tests/unit/services/gemini-web-i18n.test.mjs | 114 +++ tests/unit/services/init-session.test.mjs | 4 +- .../unit/services/wrappers-register.test.mjs | 65 +- tests/unit/utils/gemini-web-api-mode.test.mjs | 412 +++++++++ 44 files changed, 5554 insertions(+), 296 deletions(-) delete mode 100644 src/services/apis/bard-web.mjs create mode 100644 src/services/apis/gemini-web-transport.mjs create mode 100644 src/services/apis/gemini-web.mjs delete mode 100644 src/services/clients/bard/index.mjs create mode 100644 src/services/clients/gemini-web/index.mjs create mode 100644 src/services/clients/gemini-web/protocol.mjs create mode 100644 src/utils/gemini-web-preset.mjs create mode 100644 tests/unit/prettier-inspection.test.mjs delete mode 100644 tests/unit/services/apis/bard-web.test.mjs create mode 100644 tests/unit/services/apis/gemini-web-compat.test.mjs create mode 100644 tests/unit/services/apis/gemini-web-protocol.test.mjs create mode 100644 tests/unit/services/apis/gemini-web-retry.test.mjs create mode 100644 tests/unit/services/apis/gemini-web-thinking.test.mjs create mode 100644 tests/unit/services/apis/gemini-web.test.mjs create mode 100644 tests/unit/services/gemini-web-i18n.test.mjs create mode 100644 tests/unit/utils/gemini-web-api-mode.test.mjs 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 (網頁版)', + ) +})