diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 8b0d204bc..a4c31ed56 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -167,6 +167,40 @@ "Type": "Typ", "Mode": "Modus", "Custom": "Benutzerdefiniert", + "Extended thinking": "Erweitertes Denken", + "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.", "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 445148a56..d41bfed97 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -178,6 +178,40 @@ "Type": "Type", "Mode": "Mode", "Custom": "Custom", + "Extended thinking": "Extended thinking", + "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.", "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..2445738f6 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -165,6 +165,40 @@ "Type": "Tipo", "Mode": "Modo", "Custom": "Personalizado", + "Extended thinking": "Pensamiento extendido", + "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.", "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..abdc68d01 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -165,6 +165,40 @@ "Type": "Type", "Mode": "Mode", "Custom": "Personnalisé", + "Extended thinking": "Réflexion étendue", + "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.", "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..4cd8e19c4 100644 --- a/src/_locales/id/main.json +++ b/src/_locales/id/main.json @@ -165,6 +165,40 @@ "Type": "Jenis", "Mode": "Mode", "Custom": "Kustom", + "Extended thinking": "Pemikiran diperluas", + "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.", "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..cc550d91f 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -165,6 +165,40 @@ "Type": "Tipo", "Mode": "Modalità", "Custom": "Personalizzato", + "Extended thinking": "Ragionamento esteso", + "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.", "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..02dfbd844 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -165,6 +165,40 @@ "Type": "タイプ", "Mode": "モード", "Custom": "カスタム", + "Extended thinking": "拡張思考", + "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を確認してください。", "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..43cce85d0 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -169,6 +169,40 @@ "Type": "유형", "Mode": "모드", "Custom": "사용자 정의", + "Extended thinking": "확장 사고", + "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를 확인하세요.", "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..cab5aa26b 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -165,6 +165,40 @@ "Type": "Tipo", "Mode": "Modo", "Custom": "Personalizado", + "Extended thinking": "Pensamento estendido", + "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.", "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..1d1c3817a 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -177,6 +177,40 @@ "Type": "Тип", "Mode": "Режим", "Custom": "Пользовательский", + "Extended thinking": "Расширенное мышление", + "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 перед повторной попыткой.", "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..ce433ec88 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -165,6 +165,40 @@ "Type": "Tür", "Mode": "Mod", "Custom": "Özel", + "Extended thinking": "Genişletilmiş düşünme", + "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.", "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..e36f18174 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -172,6 +172,40 @@ "Type": "类型", "Mode": "模式", "Custom": "自定义", + "Extended thinking": "延伸思考", + "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。", "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..ebfcf3b43 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -172,6 +172,40 @@ "Type": "類型", "Mode": "模式", "Custom": "自訂", + "Extended thinking": "延伸思考", + "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。", "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..8385fb4ca 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,13 +567,12 @@ 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, ) } else if (isUsingOpenAICompatibleApiSession(session)) { diff --git a/src/components/ConversationCard/session.mjs b/src/components/ConversationCard/session.mjs index 40c60f166..f09d8d0a8 100644 --- a/src/components/ConversationCard/session.mjs +++ b/src/components/ConversationCard/session.mjs @@ -1,19 +1,57 @@ 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 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) { + 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 +59,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 +91,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 6267ef67c..affe10e66 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -843,6 +843,8 @@ export const defaultConfig = { customChatGptWebApiPath: '/backend-api/conversation', customOpenAiApiUrl: 'https://api.openai.com', customAnthropicApiUrl: 'https://api.anthropic.com', + geminiWebModel: 'auto', + geminiWebExtendedThinking: false, disableWebModeHistory: true, hideContextMenu: false, cropText: true, 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..a1d35631e 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -5,6 +5,11 @@ 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, + resolveGeminiWebPreset, +} from '../../utils/gemini-web-preset.mjs' import { buildApiModeListConfigUpdate, getSelectionPatchWhenApiModeDisabled, @@ -50,6 +55,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 +79,34 @@ const defaultProviderDraftValidation = { apiUrl: false, } +function withGeminiWebPreset( + apiMode, + model, + extendedThinking, + extendedThinkingLabel = 'Extended thinking', +) { + const normalizedModel = GEMINI_WEB_MODELS.includes(model) ? model : 'auto' + 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) @@ -141,6 +176,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 +427,18 @@ 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) + nextApiMode = withGeminiWebPreset( + nextApiMode, + preset.model, + preset.extendedThinking, + t('Extended thinking'), + ) + } else { + nextApiMode = clearGeminiWebPreset(nextApiMode) + } + if (shouldEditProvider) { const selectedProviderId = providerSelector === LEGACY_CUSTOM_PROVIDER_ID @@ -442,16 +491,29 @@ export function ApiModes({ config, updateConfig }) { onChange={(e) => { const groupName = e.target.value 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, 'auto', 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 +524,89 @@ 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 && ( + <> + + {!['auto', 'thinking'].includes(geminiWebPreset.model) && ( + + )} + + )} {shouldEditProvider && (
{t('Provider')} @@ -643,11 +755,21 @@ export function ApiModes({ config, updateConfig }) { LEGACY_CUSTOM_PROVIDER_ID, ) : '' - setEditingApiMode({ + const editingMode = { ...defaultApiMode, ...apiMode, providerId, - }) + } + const nextEditingMode = + apiMode.groupName === GEMINI_WEB_GROUP + ? withGeminiWebPreset( + editingMode, + resolveGeminiWebPreset(apiMode, config).model, + resolveGeminiWebPreset(apiMode, config).extendedThinking, + t('Extended thinking'), + ) + : editingMode + setEditingApiMode(nextEditingMode) setProviderSelector(isCustomApiMode ? providerId : LEGACY_CUSTOM_PROVIDER_ID) setProviderSelectionValidation(isCustomApiMode && !providerId) setProviderDraft(defaultProviderDraft) diff --git a/src/popup/sections/api-modes-provider-utils.mjs b/src/popup/sections/api-modes-provider-utils.mjs index a4eaf32c0..1de868fd5 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') 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..dc7ea8322 --- /dev/null +++ b/src/services/apis/gemini-web-transport.mjs @@ -0,0 +1,385 @@ +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 +const THINKING_MODE_NUMBER = 5 +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) +} + +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 = { 'flash-lite': 6, flash: 1, pro: 3 }[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) +} + +export async function getGeminiModelSelection( + requestedModel, + params, + extendedThinking, + signal, + isRequestCurrent = () => true, +) { + if (!requestedModel || requestedModel === 'auto') { + return { modelNumber: 1, headers: {}, standaloneThinking: false } + } + if (!['flash-lite', 'flash', 'thinking', 'pro'].includes(requestedModel)) { + throw protocolError('Invalid Gemini Web model selection.') + } + + const { at, bl, sessionId, language, accountPath } = params + const endpoint = accountUrl(accountPath, BATCH_EXEC_PATH) + const generationId = createGeminiRequestId() + 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 { + modelNumber: selected.modelNumber, + standaloneThinking, + 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 requestGeminiText( + prompt, + conversation, + params, + { + model = 'auto', + temporary = false, + extendedThinking = false, + isRequestCurrent = () => true, + } = {}, + signal, +) { + throwIfAborted(signal) + const { at, bl, sessionId, language, accountPath } = params + const useExtendedThinking = model !== 'auto' && model !== 'thinking' && extendedThinking === true + const selection = await getGeminiModelSelection( + model, + params, + useExtendedThinking, + signal, + isRequestCurrent, + ) + 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 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', + 'x-goog-ext-525005358-jspb': JSON.stringify([requestId, 1]), + ...selection.headers, + }, + body: new URLSearchParams({ + at, + 'f.req': buildRequest(prompt, conversation, language, requestId, { + temporary, + modelNumber: selection.modelNumber, + extendedThinking: useExtendedThinking, + thinkingMode: selection.standaloneThinking ? null : undefined, + }), + }), + }) + return readResponse(response, signal) +} diff --git a/src/services/apis/gemini-web.mjs b/src/services/apis/gemini-web.mjs new file mode 100644 index 000000000..a5bbc003b --- /dev/null +++ b/src/services/apis/gemini-web.mjs @@ -0,0 +1,77 @@ +import { resolveGeminiWebPreset } from '../../utils/gemini-web-preset.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 +} + +export async function generateAnswersWithGeminiWebApi( + port, + question, + session, + config = {}, + isLatestSessionRequest = () => true, +) { + const conversation = resolveConversationForTurn(session) + const { model, extendedThinking } = resolveGeminiWebPreset(session?.apiMode, config) + const { controller, cleanController, isCurrentSessionRequest } = setAbortController(port) + const abortSupersededRequest = () => { + if (!controller.signal.aborted) { + controller.abort(new DOMException('The operation was superseded.', 'AbortError')) + } + } + port._abortSupersededSessionRequest = abortSupersededRequest + const client = new GeminiWebClient() + const isRequestCurrent = () => isCurrentSessionRequest() && isLatestSessionRequest() + try { + const { answer, conversationObj } = await client.ask( + question, + conversation, + controller.signal, + { + model, + temporary: config.disableWebModeHistory === true, + extendedThinking, + isRequestCurrent, + }, + ) + if (controller.signal.aborted || !isRequestCurrent()) return + 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 + port.postMessage({ answer, done: true, session }) + } finally { + if (port._abortSupersededSessionRequest === abortSupersededRequest) { + delete port._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..ca757e872 --- /dev/null +++ b/src/services/clients/gemini-web/index.mjs @@ -0,0 +1,84 @@ +import { parseAnswer, protocolError } from './protocol.mjs' +import { + getAbortReason, + getGeminiRequestParams, + requestGeminiText, + throwIfAborted, +} from '../../apis/gemini-web-transport.mjs' + +const REQUEST_TIMEOUT = 240000 + +function validateConversationRoute(conversation) { + 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.') + } +} + +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) throw abortReason + throw error + } finally { + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + } + } + + async send(prompt, conversationObj, signal, options = {}) { + throwIfAborted(signal) + validateConversationRoute(conversationObj) + const params = await this.getRequestParams(signal, conversationObj.accountPath) + const text = await requestGeminiText(prompt, conversationObj, params, options, signal) + const result = this.parseResponse(text) + throwIfAborted(signal) + result.conversationObj.accountPath = params.accountPath + 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..51819bc9d --- /dev/null +++ b/src/services/clients/gemini-web/protocol.mjs @@ -0,0 +1,172 @@ +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 +} + +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) + let candidateId + let completion + let context + for (const envelope of decodeEnvelopes(text)) { + const errorCode = envelope[5]?.[2]?.[0]?.[1]?.[0] + if (envelope[0] === 'er' || errorCode) { + const detail = typeof errorCode === 'number' ? ` (${errorCode})` : '' + throw protocolError( + `The website rejected the request${detail}. Check Gemini in your browser.`, + ) + } + if (envelope[0] !== 'wrb.fr' || 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] + 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) 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 + } + if (typeof answer !== 'string' || !answer.trim()) { + throw protocolError('No text answer was received. Check Gemini in your browser.') + } + if (completion == null) { + throw protocolError('Could not confirm that the answer finished generating.') + } + if (completion !== 2) { + throw protocolError( + 'The answer was interrupted before completion. Check Gemini before retrying.', + ) + } + if (![metadata[0], metadata[1], candidateId].every((id) => typeof id === 'string' && id)) { + throw protocolError('Missing conversation identifiers.') + } + metadata[2] = candidateId + if (context !== undefined) metadata[9] = context + return { + answer, + conversationObj: { c: metadata[0], r: metadata[1], rc: candidateId, metadata }, + } +} + +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 = 1, extendedThinking = false, thinkingMode = undefined } = {}, +) { + 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] = [[0]] + request[30] = [4] + if (temporary) request[45] = 1 + request[59] = requestId + request[61] = [] + 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..535a95deb 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 diff --git a/src/utils/gemini-web-preset.mjs b/src/utils/gemini-web-preset.mjs new file mode 100644 index 000000000..858de5823 --- /dev/null +++ b/src/utils/gemini-web-preset.mjs @@ -0,0 +1,67 @@ +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 hasStoredModel = GEMINI_WEB_MODELS.includes(apiMode?.geminiWebModel) + const hasStoredThinking = hasOwn(apiMode, 'geminiWebExtendedThinking') + 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 && GEMINI_WEB_MODELS.includes(fallbackConfig.geminiWebModel)) { + model = fallbackConfig.geminiWebModel + source = 'fallback' + if (!hasStoredThinking) { + extendedThinking = fallbackConfig.geminiWebExtendedThinking === true + } + } + + model ||= 'auto' + return { + model, + extendedThinking: !['auto', 'thinking'].includes(model) && extendedThinking, + source, + } +} + +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/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..1c108cfe2 --- /dev/null +++ b/tests/unit/services/apis/gemini-web-compat.test.mjs @@ -0,0 +1,363 @@ +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 }) + const response = new Response(init.method === 'POST' ? reply : bootstrap) + if (redirect && init.method !== 'POST') { + Object.defineProperty(response, 'url', { value: redirect }) + } + return response + }) + return requests +} + +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', { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + }), + /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(requests.length, 2) +}) + +test('continues an explicitly scoped Google multi-login route', async (t) => { + const requests = mockFetch(t) + const result = await new GeminiWebClient().ask('Q', { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '/u/1', + }) + 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', { + c: 'c_old', + r: 'r_old', + rc: 'rc_old', + accountPath: '', + accountId: 'obsolete-gaia-id', + }) + 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('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, 2) + 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) => { + requests.push({ url: String(url), init }) + if (init.method !== 'POST') return new Response(bootstrap) + return new Response('private-body', { status: 429 }) + }) + + await assert.rejects(new GeminiWebClient().ask('Q'), /HTTP 429/) + 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, 2) +}) + +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[1].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('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[80], 1) +}) + +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..d8358cc93 --- /dev/null +++ b/tests/unit/services/apis/gemini-web-protocol.test.mjs @@ -0,0 +1,224 @@ +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 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('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('rejects an embedded provider error', () => { + const error = ['wrb.fr', null, null, null, null, [null, null, [[null, [1037]]]]] + assert.throws(() => parseAnswer(wire([error])), /1037/) +}) + +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.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 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..8c3fdcfaa --- /dev/null +++ b/tests/unit/services/apis/gemini-web-retry.test.mjs @@ -0,0 +1,244 @@ +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 { 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('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('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..9d4f36de0 --- /dev/null +++ b/tests/unit/services/apis/gemini-web-thinking.test.mjs @@ -0,0 +1,129 @@ +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('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..254dae013 --- /dev/null +++ b/tests/unit/services/apis/gemini-web.test.mjs @@ -0,0 +1,413 @@ +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 bootstrapFetch(t, responseText) { + t.mock.method(globalThis, 'fetch', async (...[, init]) => { + if (init.method === 'POST') return new Response(responseText) + return new Response('{"SNlM0e":"test-at"}') + }) +} + +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('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('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('uses model and thinking options from the selected API mode preset', async (t) => { + t.mock.method(GeminiWebClient.prototype, 'ask', async (...args) => { + const { isRequestCurrent, ...options } = args[3] + assert.equal(typeof isRequestCurrent, 'function') + assert.equal(isRequestCurrent(), true) + 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) +}) 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..65090d755 --- /dev/null +++ b/tests/unit/services/gemini-web-i18n.test.mjs @@ -0,0 +1,98 @@ +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.', +] + +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。', + ], + ]) { + 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..3b5e296dc 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) } @@ -627,24 +640,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..abd507f1d --- /dev/null +++ b/tests/unit/utils/gemini-web-api-mode.test.mjs @@ -0,0 +1,331 @@ +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 { 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) +}) + +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('Auto', 'auto') + + 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 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', + }, + ) +})