diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index b3845deb5..7f1a3eb13 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Benutzerdefiniertes Modell", + "Custom Provider": "Benutzerdefinierter Anbieter", "Balanced": "Ausgeglichen", "Creative": "Kreativ", "Precise": "Präzise", @@ -96,6 +97,7 @@ "Pin": "Anheften", "Unpin": "Loslösen", "Delete Conversation": "Konversation löschen", + "Delete": "Löschen", "Clear conversations": "Konversationen löschen", "Settings": "Einstellungen", "Feature Pages": "Funktionsseiten", @@ -115,6 +117,7 @@ "Modules": "Module", "API Params": "API-Parameter", "API Url": "API-URL", + "Provider": "Anbieter", "Others": "Andere", "API Modes": "API-Modi", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Deaktivieren Sie die Verlaufsfunktion im Webmodus für besseren Datenschutz. Beachten Sie jedoch, dass die Gespräche nach einer gewissen Zeit nicht mehr verfügbar sind", @@ -138,6 +141,7 @@ "Anthropic API Key": "Anthropic-API-Schlüssel", "Cancel": "Abbrechen", "Name is required": "Name ist erforderlich", + "Please enter a full Chat Completions URL": "Bitte geben Sie eine vollständige Chat Completions URL ein", "Prompt template should include {{selection}}": "Die Vorlage sollte {{selection}} enthalten", "Save": "Speichern", "Name": "Name", @@ -145,6 +149,11 @@ "Prompt Template": "Vorlagen-Template", "Explain this: {{selection}}": "Erkläre das: {{selection}}", "New": "Neu", + "Edit": "Bearbeiten", + "This provider is still used by other API modes": "Dieser Anbieter wird noch von anderen API-Modi verwendet", + "Loading saved conversations…": "Gespeicherte Unterhaltungen werden geladen…", + "Select a provider": "Anbieter auswählen", + "Please select a provider": "Bitte einen Anbieter auswählen", "Always display floating window, disable sidebar for all site adapters": "Immer das schwebende Fenster anzeigen, die Seitenleiste für alle Website-Adapter deaktivieren", "Allow ESC to close all floating windows": "ESC-Taste zum Schließen aller schwebenden Fenster zulassen", "Export All Data": "Alle Daten exportieren", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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", + "This provider endpoint is still needed by saved conversations": "Dieser Anbieter-Endpunkt wird noch von gespeicherten Unterhaltungen benötigt" } diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index a6f0d29e2..c79c7428c 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Custom Model", + "Custom Provider": "Custom Provider", "Balanced": "Balanced", "Creative": "Creative", "Precise": "Precise", @@ -96,6 +97,7 @@ "Pin": "Pin", "Unpin": "Unpin", "Delete Conversation": "Delete Conversation", + "Delete": "Delete", "Clear conversations": "Clear conversations", "Settings": "Settings", "Feature Pages": "Feature Pages", @@ -115,6 +117,7 @@ "Modules": "Modules", "API Params": "API Params", "API Url": "API Url", + "Provider": "Provider", "Others": "Others", "API Modes": "API Modes", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time", @@ -138,6 +141,7 @@ "Anthropic API Key": "Anthropic API Key", "Cancel": "Cancel", "Name is required": "Name is required", + "Please enter a full Chat Completions URL": "Please enter a full Chat Completions URL", "Prompt template should include {{selection}}": "Prompt template should include {{selection}}", "Save": "Save", "Name": "Name", @@ -145,6 +149,11 @@ "Prompt Template": "Prompt Template", "Explain this: {{selection}}": "Explain this: {{selection}}", "New": "New", + "Edit": "Edit", + "This provider is still used by other API modes": "This provider is still used by other API modes", + "Loading saved conversations…": "Loading saved conversations…", + "Select a provider": "Select a provider", + "Please select a provider": "Please select a provider", "Always display floating window, disable sidebar for all site adapters": "Always display floating window, disable sidebar for all site adapters", "Allow ESC to close all floating windows": "Allow ESC to close all floating windows", "Export All Data": "Export All Data", @@ -200,5 +209,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "This provider is still used by other API modes or saved conversations": "This provider is still used by other API modes or saved conversations", + "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.", + "Use shared key": "Use shared key", + "This provider endpoint is still needed by saved conversations": "This provider endpoint is still needed by saved conversations" } diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index 7f916a9ca..c236e94c9 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modelo personalizado", + "Custom Provider": "Proveedor personalizado", "Balanced": "Equilibrado", "Creative": "Creativo", "Precise": "Preciso", @@ -96,6 +97,7 @@ "Pin": "Fijar", "Unpin": "Desfijar", "Delete Conversation": "Eliminar conversación", + "Delete": "Eliminar", "Clear conversations": "Borrar todas las conversaciones", "Settings": "Configuración", "Feature Pages": "Páginas de características", @@ -115,6 +117,7 @@ "Modules": "Módulos", "API Params": "Parámetros de la API", "API Url": "URL de la API", + "Provider": "Proveedor", "Others": "Otros", "API Modes": "Modos de la API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Desactivar el historial del modo web para una mejor protección de la privacidad, pero esto resultará en conversaciones no disponibles después de un período de tiempo.", @@ -138,6 +141,7 @@ "Anthropic API Key": "Clave API de Anthropic", "Cancel": "Cancelar", "Name is required": "Se requiere un nombre", + "Please enter a full Chat Completions URL": "Introduzca una URL completa de Chat Completions", "Prompt template should include {{selection}}": "La plantilla de sugerencias debe incluir {{selection}}", "Save": "Guardar", "Name": "Nombre", @@ -145,6 +149,11 @@ "Prompt Template": "Plantilla de sugerencias", "Explain this: {{selection}}": "Explicar esto: {{selection}}", "New": "Nuevo", + "Edit": "Editar", + "This provider is still used by other API modes": "Este proveedor aún está siendo utilizado por otros modos de API", + "Loading saved conversations…": "Cargando conversaciones guardadas…", + "Select a provider": "Selecciona un proveedor", + "Please select a provider": "Selecciona un proveedor", "Always display floating window, disable sidebar for all site adapters": "Mostrar siempre la ventana flotante, desactivar la barra lateral para todos los adaptadores de sitios", "Allow ESC to close all floating windows": "Permitir que ESC cierre todas las ventanas flotantes", "Export All Data": "Exportar todos los datos", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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", + "This provider endpoint is still needed by saved conversations": "Las conversaciones guardadas todavía necesitan este endpoint del proveedor" } diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index c93419436..526ee5082 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modèle personnalisé", + "Custom Provider": "Fournisseur personnalisé", "Balanced": "Équilibré", "Creative": "Créatif", "Precise": "Précis", @@ -96,6 +97,7 @@ "Pin": "Épingler", "Unpin": "Détacher", "Delete Conversation": "Supprimer la conversation", + "Delete": "Supprimer", "Clear conversations": "Effacer les conversations", "Settings": "Paramètres", "Feature Pages": "Pages de fonctionnalités", @@ -115,6 +117,7 @@ "Modules": "Modules", "API Params": "Paramètres de l'API", "API Url": "URL de l'API", + "Provider": "Fournisseur", "Others": "Autres", "API Modes": "Modes de l'API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Désactivez l'historique du mode web pour une meilleure protection de la vie privée, mais cela entraînera des conversations non disponibles après un certain temps", @@ -138,6 +141,7 @@ "Anthropic API Key": "Clé API Anthropic", "Cancel": "Annuler", "Name is required": "Le nom est requis", + "Please enter a full Chat Completions URL": "Veuillez saisir une URL complète de Chat Completions", "Prompt template should include {{selection}}": "Le modèle de suggestion doit inclure {{selection}}", "Save": "Enregistrer", "Name": "Nom", @@ -145,6 +149,11 @@ "Prompt Template": "Modèle de suggestion", "Explain this: {{selection}}": "Expliquer ceci : {{selection}}", "New": "Nouveau", + "Edit": "Modifier", + "This provider is still used by other API modes": "Ce fournisseur est encore utilisé par d’autres modes API", + "Loading saved conversations…": "Chargement des conversations enregistrées…", + "Select a provider": "Sélectionnez un fournisseur", + "Please select a provider": "Veuillez sélectionner un fournisseur", "Always display floating window, disable sidebar for all site adapters": "Toujours afficher la fenêtre flottante, désactiver la barre latérale pour tous les adaptateurs de site", "Allow ESC to close all floating windows": "Autoriser la touche ESC pour fermer toutes les fenêtres flottantes", "Export All Data": "Exporter toutes les données", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "This provider is still used by other API modes or saved conversations": "Ce fournisseur est encore utilise par d'autres modes d'API ou des conversations enregistrees", + "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", + "This provider endpoint is still needed by saved conversations": "Ce point de terminaison du fournisseur est encore nécessaire pour les conversations enregistrées" } diff --git a/src/_locales/in/main.json b/src/_locales/in/main.json index 1fa5b7d99..bfec9e9e4 100644 --- a/src/_locales/in/main.json +++ b/src/_locales/in/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Model Kustom", + "Custom Provider": "Penyedia Kustom", "Balanced": "Seimbang", "Creative": "Kreatif", "Precise": "Tepat", @@ -96,6 +97,7 @@ "Pin": "Sematkan", "Unpin": "Lepas Sematan", "Delete Conversation": "Hapus Percakapan", + "Delete": "Hapus", "Clear conversations": "Hapus Percakapan", "Settings": "Pengaturan", "Feature Pages": "Halaman Fitur", @@ -115,6 +117,7 @@ "Modules": "Modul", "API Params": "Parameter API", "API Url": "URL API", + "Provider": "Penyedia", "Others": "Lainnya", "API Modes": "Mode API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Nonaktifkan riwayat mode web untuk perlindungan privasi yang lebih baik, tetapi ini akan menyebabkan percakapan tidak tersedia setelah jangka waktu tertentu", @@ -138,6 +141,7 @@ "Anthropic API Key": "Kunci API Anthropic", "Cancel": "Batal", "Name is required": "Nama diperlukan", + "Please enter a full Chat Completions URL": "Masukkan URL Chat Completions lengkap", "Prompt template should include {{selection}}": "Template prompt harus mencakup {{selection}}", "Save": "Simpan", "Name": "Nama", @@ -145,6 +149,11 @@ "Prompt Template": "Template Prompt", "Explain this: {{selection}}": "Jelaskan ini: {{selection}}", "New": "Baru", + "Edit": "Edit", + "This provider is still used by other API modes": "Penyedia ini masih digunakan oleh mode API lain", + "Loading saved conversations…": "Memuat percakapan tersimpan…", + "Select a provider": "Pilih penyedia", + "Please select a provider": "Silakan pilih penyedia", "Always display floating window, disable sidebar for all site adapters": "Selalu tampilkan jendela mengambang, nonaktifkan sidebar untuk semua adapter situs", "Allow ESC to close all floating windows": "Izinkan ESC untuk menutup semua jendela mengambang", "Export All Data": "Ekspor Semua Data", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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 provider khusus untuk mode tersebut.", + "Use shared key": "Gunakan kunci bersama", + "This provider endpoint is still needed by saved conversations": "Endpoint penyedia ini masih diperlukan oleh percakapan yang tersimpan" } diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 400fa5461..3a6e0378d 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modello personalizzato", + "Custom Provider": "Provider personalizzato", "Balanced": "Bilanciato", "Creative": "Creativo", "Precise": "Preciso", @@ -96,6 +97,7 @@ "Pin": "Fissa", "Unpin": "Sblocca", "Delete Conversation": "Elimina la conversazione", + "Delete": "Elimina", "Clear conversations": "Pulisci le conversazioni", "Settings": "Impostazioni", "Feature Pages": "Pagine delle funzionalità", @@ -115,6 +117,7 @@ "Modules": "Moduli", "API Params": "Parametri API", "API Url": "URL API", + "Provider": "Provider", "Others": "Altri", "API Modes": "Modalità API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Disabilita la cronologia della modalità web per una migliore protezione della privacy, ma ciò comporterà conversazioni non disponibili dopo un certo periodo di tempo", @@ -138,6 +141,7 @@ "Anthropic API Key": "Chiave API Anthropic", "Cancel": "Annulla", "Name is required": "Il nome è obbligatorio", + "Please enter a full Chat Completions URL": "Inserisci un URL completo di Chat Completions", "Prompt template should include {{selection}}": "Il modello di prompt dovrebbe includere {{selection}}", "Save": "Salva", "Name": "Nome", @@ -145,6 +149,11 @@ "Prompt Template": "Modello di prompt", "Explain this: {{selection}}": "Spiega questo: {{selection}}", "New": "Nuovo", + "Edit": "Modifica", + "This provider is still used by other API modes": "Questo provider è ancora utilizzato da altre modalità API", + "Loading saved conversations…": "Caricamento delle conversazioni salvate…", + "Select a provider": "Seleziona un provider", + "Please select a provider": "Seleziona un provider", "Always display floating window, disable sidebar for all site adapters": "Mostra sempre la finestra flottante, disabilita la barra laterale per tutti gli adattatori del sito", "Allow ESC to close all floating windows": "Consenti ESC per chiudere tutte le finestre flottanti", "Export All Data": "Esporta tutti i dati", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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", + "This provider endpoint is still needed by saved conversations": "L'endpoint di questo provider è ancora necessario per le conversazioni salvate" } diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index ac19edc8e..a30a6ca76 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "カスタムモデル", + "Custom Provider": "カスタムプロバイダー", "Balanced": "バランスの取れた", "Creative": "創造的な", "Precise": "正確な", @@ -96,6 +97,7 @@ "Pin": "ピン留め", "Unpin": "ピン留め解除", "Delete Conversation": "会話を削除", + "Delete": "削除", "Clear conversations": "会話をクリア", "Settings": "設定", "Feature Pages": "機能ページ", @@ -115,6 +117,7 @@ "Modules": "モジュール", "API Params": "APIパラメータ", "API Url": "API URL", + "Provider": "プロバイダー", "Others": "その他", "API Modes": "APIモード", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "プライバシー保護の向上のためにWebモードの履歴を無効にしますが、一定期間後に会話が利用できなくなります", @@ -138,6 +141,7 @@ "Anthropic API Key": "Anthropic API キー", "Cancel": "キャンセル", "Name is required": "名前は必須です", + "Please enter a full Chat Completions URL": "完全な Chat Completions URL を入力してください", "Prompt template should include {{selection}}": "プロンプトテンプレートには {{selection}} を含める必要があります", "Save": "保存", "Name": "名前", @@ -145,6 +149,11 @@ "Prompt Template": "プロンプトテンプレート", "Explain this: {{selection}}": "これを説明する: {{selection}}", "New": "新規", + "Edit": "編集", + "This provider is still used by other API modes": "このプロバイダーは他の API モードでまだ使用されています", + "Loading saved conversations…": "保存済みの会話を読み込み中…", + "Select a provider": "プロバイダーを選択", + "Please select a provider": "プロバイダーを選択してください", "Always display floating window, disable sidebar for all site adapters": "常にフローティングウィンドウを表示し、すべてのサイトアダプターでサイドバーを無効にします", "Allow ESC to close all floating windows": "ESCキーですべてのフローティングウィンドウを閉じる", "Export All Data": "すべてのデータをエクスポート", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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": "共有キーを使用", + "This provider endpoint is still needed by saved conversations": "このプロバイダーのエンドポイントは保存済みの会話でまだ必要です" } diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 4348a7c16..08acb9009 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "사용자 정의 모델", + "Custom Provider": "사용자 정의 공급자", "Balanced": "균형 잡힌", "Creative": "창의적인", "Precise": "정확한", @@ -96,6 +97,7 @@ "Pin": "고정", "Unpin": "고정 해제", "Delete Conversation": "대화 삭제", + "Delete": "삭제", "Clear conversations": "대화 기록 지우기", "Settings": "설정", "Feature Pages": "기능 페이지", @@ -115,6 +117,7 @@ "Modules": "모듈", "API Params": "API 매개변수", "API Url": "API 주소", + "Provider": "공급자", "Others": "기타", "API Modes": "API 모드", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "개인 정보 보호를 위해 웹 모드 기록을 비활성화하지만 일정 시간 이후에 대화를 사용할 수 없게 됩니다.", @@ -138,6 +141,7 @@ "Anthropic API Key": "Anthropic API 키", "Cancel": "취소", "Name is required": "이름은 필수입니다", + "Please enter a full Chat Completions URL": "전체 Chat Completions URL을 입력하세요", "Prompt template should include {{selection}}": "프롬프트 템플릿에는 {{selection}} 이 포함되어야 합니다", "Save": "저장", "Name": "이름", @@ -145,6 +149,11 @@ "Prompt Template": "프롬프트 템플릿", "Explain this: {{selection}}": "이것을 설명하세요: {{selection}}", "New": "새로 만들기", + "Edit": "편집", + "This provider is still used by other API modes": "이 공급자는 아직 다른 API 모드에서 사용 중입니다", + "Loading saved conversations…": "저장된 대화를 불러오는 중…", + "Select a provider": "공급업체 선택", + "Please select a provider": "공급업체를 선택하세요", "Always display floating window, disable sidebar for all site adapters": "항상 떠다니는 창을 표시하고 모든 사이트 어댑터의 사이드바를 비활성화합니다", "Allow ESC to close all floating windows": "ESC를 눌러 모든 떠다니는 창을 닫도록 허용", "Export All Data": "모든 데이터 내보내기", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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": "공유 키 사용", + "This provider endpoint is still needed by saved conversations": "저장된 대화에 이 공급자 엔드포인트가 아직 필요합니다" } diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index d3a5bc560..dd4088388 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modelo Personalizado", + "Custom Provider": "Provedor Personalizado", "Balanced": "Equilibrado", "Creative": "Criativo", "Precise": "Preciso", @@ -96,6 +97,7 @@ "Pin": "Fixar", "Unpin": "Desafixar", "Delete Conversation": "Excluir Conversa", + "Delete": "Excluir", "Clear conversations": "Limpar conversas", "Settings": "Configurações", "Feature Pages": "Páginas de Recursos", @@ -115,6 +117,7 @@ "Modules": "Módulos", "API Params": "Parâmetros da API", "API Url": "URL da API", + "Provider": "Provedor", "Others": "Outros", "API Modes": "Modos da API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Desative o histórico do modo web para uma melhor proteção de privacidade, mas isso resultará em conversas indisponíveis após um certo tempo.", @@ -138,6 +141,7 @@ "Anthropic API Key": "Chave API Anthropic", "Cancel": "Cancelar", "Name is required": "Nome é obrigatório", + "Please enter a full Chat Completions URL": "Insira uma URL completa de Chat Completions", "Prompt template should include {{selection}}": "O modelo de prompt deve incluir {{selection}}", "Save": "Salvar", "Name": "Nome", @@ -145,6 +149,11 @@ "Prompt Template": "Modelo de Prompt", "Explain this: {{selection}}": "Explique isso: {{selection}}", "New": "Novo", + "Edit": "Editar", + "This provider is still used by other API modes": "Este provedor ainda está sendo usado por outros modos de API", + "Loading saved conversations…": "Carregando conversas salvas…", + "Select a provider": "Selecione um provedor", + "Please select a provider": "Selecione um provedor", "Always display floating window, disable sidebar for all site adapters": "Sempre exibir janela flutuante, desativar barra lateral para todos os adaptadores de site", "Allow ESC to close all floating windows": "Permitir ESC para fechar todas as janelas flutuantes", "Export All Data": "Exportar Todos os Dados", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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", + "This provider endpoint is still needed by saved conversations": "As conversas salvas ainda precisam deste endpoint do provedor" } diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index b6852bbdf..91b043049 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32к)", "GPT-3.5": "GPT-3.5", "Custom Model": "Пользовательская модель", + "Custom Provider": "Пользовательский провайдер", "Balanced": "Сбалансированный", "Creative": "Креативный", "Precise": "Точный", @@ -96,6 +97,7 @@ "Pin": "Закрепить", "Unpin": "Открепить", "Delete Conversation": "Удалить беседу", + "Delete": "Удалить", "Clear conversations": "Очистить историю бесед", "Settings": "Настройки", "Feature Pages": "Страницы функций", @@ -115,6 +117,7 @@ "Modules": "Модули", "API Params": "Параметры API", "API Url": "URL API", + "Provider": "Провайдер", "Others": "Другие", "API Modes": "Режимы API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Отключить историю веб-режима для лучшей защиты конфиденциальности, но это приведет к недоступности разговоров после определенного времени", @@ -138,6 +141,7 @@ "Anthropic API Key": "Ключ API Anthropic", "Cancel": "Отмена", "Name is required": "Имя обязательно", + "Please enter a full Chat Completions URL": "Введите полный URL Chat Completions", "Prompt template should include {{selection}}": "Шаблон запроса должен включать {{selection}}", "Save": "Сохранить", "Name": "Имя", @@ -145,6 +149,11 @@ "Prompt Template": "Шаблон запроса", "Explain this: {{selection}}": "Объяснить это: {{selection}}", "New": "Новый", + "Edit": "Редактировать", + "This provider is still used by other API modes": "Этот провайдер всё ещё используется другими режимами API", + "Loading saved conversations…": "Загрузка сохранённых разговоров…", + "Select a provider": "Выберите провайдера", + "Please select a provider": "Выберите провайдера", "Always display floating window, disable sidebar for all site adapters": "Всегда отображать плавающее окно, отключить боковую панель для всех адаптеров сайтов", "Allow ESC to close all floating windows": "Разрешить ESC для закрытия всех плавающих окон", "Export All Data": "Экспорт всех данных", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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": "Использовать общий ключ", + "This provider endpoint is still needed by saved conversations": "Сохранённым диалогам всё ещё нужен этот endpoint провайдера" } diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 4bf6bb9db..e019ce2e7 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Özel Model", + "Custom Provider": "Özel Sağlayıcı", "Balanced": "Dengeli", "Creative": "Yaratıcı", "Precise": "Duyarlı", @@ -96,6 +97,7 @@ "Pin": "Sabitle", "Unpin": "Sabitlemeyi Kaldır", "Delete Conversation": "Konuşmayı Sil", + "Delete": "Sil", "Clear conversations": "Konuşmaları temizle", "Settings": "Ayarlar", "Feature Pages": "Özellik Sayfaları", @@ -115,6 +117,7 @@ "Modules": "Modüller", "API Params": "API Parametreleri", "API Url": "API Url'si", + "Provider": "Sağlayıcı", "Others": "Diğerleri", "API Modes": "API Modları", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Daha iyi gizlilik koruması için web modu geçmişini devre dışı bırakın, ancak bir süre sonra kullanılamayan konuşmalara neden olacaktır", @@ -138,6 +141,7 @@ "Anthropic API Key": "Anthropic API Anahtarı", "Cancel": "İptal", "Name is required": "İsim gereklidir", + "Please enter a full Chat Completions URL": "Lütfen tam bir Chat Completions URL'si girin", "Prompt template should include {{selection}}": "Prompt şablonu {{selection}} içermelidir", "Save": "Kaydet", "Name": "İsim", @@ -145,6 +149,11 @@ "Prompt Template": "Prompt Şablonu", "Explain this: {{selection}}": "Bunu açıkla: {{selection}}", "New": "Yeni", + "Edit": "Düzenle", + "This provider is still used by other API modes": "Bu sağlayıcı hâlâ diğer API modları tarafından kullanılıyor", + "Loading saved conversations…": "Kaydedilmiş sohbetler yükleniyor…", + "Select a provider": "Bir sağlayıcı seçin", + "Please select a provider": "Lütfen bir sağlayıcı seçin", "Always display floating window, disable sidebar for all site adapters": "Her zaman kayan pencereyi görüntüle, tüm site adaptörleri için kenar çubuğunu devre dışı bırak", "Allow ESC to close all floating windows": "ESC tuşuyla tüm kayan pencereleri kapatmaya izin ver", "Export All Data": "Tüm Verileri Dışa Aktar", @@ -199,5 +208,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "This provider is still used by other API modes or saved conversations": "Bu saglayici hala diger API modlari veya kaydedilmis konusmalar tarafindan kullaniliyor", + "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", + "This provider endpoint is still needed by saved conversations": "Bu sağlayıcı uç noktası hâlâ kaydedilmiş konuşmalar için gerekli" } diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index 01c4299e3..47a9e9ac7 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "自定义模型", + "Custom Provider": "自定义提供商", "Balanced": "平衡", "Creative": "有创造力", "Precise": "精确", @@ -96,6 +97,7 @@ "Pin": "固定侧边", "Unpin": "收缩侧边", "Delete Conversation": "删除对话", + "Delete": "删除", "Clear conversations": "清空记录", "Settings": "设置", "Feature Pages": "功能页", @@ -115,6 +117,7 @@ "Modules": "模块", "API Params": "API参数", "API Url": "API地址", + "Provider": "提供商", "Others": "其他", "API Modes": "API模式", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "禁用网页版模式历史记录以获得更好的隐私保护, 但会导致对话在一段时间后不可用", @@ -138,6 +141,7 @@ "Anthropic API Key": "Anthropic API 密钥", "Cancel": "取消", "Name is required": "名称是必须的", + "Please enter a full Chat Completions URL": "请输入完整的 Chat Completions URL", "Prompt template should include {{selection}}": "提示模板应该包含 {{selection}}", "Save": "保存", "Name": "名称", @@ -145,6 +149,11 @@ "Prompt Template": "提示模板", "Explain this: {{selection}}": "解释这个: {{selection}}", "New": "新建", + "Edit": "编辑", + "This provider is still used by other API modes": "此提供商仍被其他 API 模式使用中", + "Loading saved conversations…": "正在载入已保存的对话…", + "Select a provider": "选择提供商", + "Please select a provider": "请选择提供商", "Always display floating window, disable sidebar for all site adapters": "总是显示浮动窗口, 禁用所有站点适配器的侧边栏", "Allow ESC to close all floating windows": "允许按ESC关闭所有浮动窗口", "Export All Data": "导出所有数据", @@ -206,5 +215,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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": "使用共享 provider 密钥", + "This provider endpoint is still needed by saved conversations": "已保存的对话仍需要此提供商端点" } diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index 25686ca23..5cab7412e 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -79,6 +79,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "自訂模型", + "Custom Provider": "自訂供應商", "Balanced": "平衡", "Creative": "有創意", "Precise": "精確", @@ -96,6 +97,7 @@ "Pin": "固定側邊", "Unpin": "取消固定側邊", "Delete Conversation": "刪除對話", + "Delete": "刪除", "Clear conversations": "清空對話記錄", "Settings": "設定", "Feature Pages": "功能頁面", @@ -115,6 +117,7 @@ "Modules": "模組", "API Params": "API 參數", "API Url": "API 網址", + "Provider": "供應商", "Others": "其他", "API Modes": "API 模式", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "停用網頁版模式歷史記錄以提升隱私保護,但會導致對話記錄在一段時間後無法使用", @@ -138,6 +141,7 @@ "Anthropic API Key": "Anthropic API 金鑰", "Cancel": "取消", "Name is required": "名稱是必填的", + "Please enter a full Chat Completions URL": "請輸入完整的 Chat Completions URL", "Prompt template should include {{selection}}": "提示範本應該包含 {{selection}}", "Save": "儲存", "Name": "名稱", @@ -145,6 +149,11 @@ "Prompt Template": "提示範本", "Explain this: {{selection}}": "解釋這個: {{selection}}", "New": "新增", + "Edit": "編輯", + "This provider is still used by other API modes": "此供應商仍被其他 API 模式使用中", + "Loading saved conversations…": "正在載入已儲存的對話…", + "Select a provider": "選擇供應商", + "Please select a provider": "請選擇供應商", "Always display floating window, disable sidebar for all site adapters": "總是顯示浮動視窗,停用所有網站適配器的側邊欄", "Allow ESC to close all floating windows": "允許按 ESC 關閉所有浮動視窗", "Export All Data": "匯出所有資料", @@ -201,5 +210,9 @@ "OpenAI (GPT-5.4)": "OpenAI (GPT-5.4)", "OpenAI (GPT-5.4 mini)": "OpenAI (GPT-5.4 mini)", "OpenAI (GPT-5.4 nano)": "OpenAI (GPT-5.4 nano)", - "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)" + "Anthropic (Claude Sonnet 4.6)": "Anthropic (Claude Sonnet 4.6)", + "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": "使用共用 provider 金鑰", + "This provider endpoint is still needed by saved conversations": "已儲存的對話仍需要此供應商端點" } diff --git a/src/background/index.mjs b/src/background/index.mjs index 7fcaa4286..aa41ecd4f 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -5,18 +5,10 @@ import { sendMessageFeedback, } from '../services/apis/chatgpt-web' import { generateAnswersWithBingWebApi } from '../services/apis/bing-web.mjs' -import { - generateAnswersWithOpenAiApi, - generateAnswersWithGptCompletionApi, -} from '../services/apis/openai-api' -import { generateAnswersWithCustomApi } from '../services/apis/custom-api.mjs' -import { generateAnswersWithOllamaApi } from '../services/apis/ollama-api.mjs' +import { generateAnswersWithOpenAICompatibleApi } from '../services/apis/openai-api' import { generateAnswersWithAzureOpenaiApi } from '../services/apis/azure-openai-api.mjs' import { generateAnswersWithClaudeApi } from '../services/apis/claude-api.mjs' -import { generateAnswersWithChatGLMApi } from '../services/apis/chatglm-api.mjs' import { generateAnswersWithWaylaidwandererApi } from '../services/apis/waylaidwanderer-api.mjs' -import { generateAnswersWithOpenRouterApi } from '../services/apis/openrouter-api.mjs' -import { generateAnswersWithAimlApi } from '../services/apis/aiml-api.mjs' import { defaultConfig, getUserConfig, @@ -52,10 +44,8 @@ import { refreshMenu } from './menus.mjs' import { registerCommands } from './commands.mjs' import { generateAnswersWithBardWebApi } from '../services/apis/bard-web.mjs' import { generateAnswersWithClaudeWebApi } from '../services/apis/claude-web.mjs' -import { generateAnswersWithMoonshotCompletionApi } from '../services/apis/moonshot-api.mjs' import { generateAnswersWithMoonshotWebApi } from '../services/apis/moonshot-web.mjs' import { isUsingModelName } from '../utils/model-name-convert.mjs' -import { generateAnswersWithDeepSeekApi } from '../services/apis/deepseek-api.mjs' import { redactSensitiveFields } from './redact.mjs' const RECONNECT_CONFIG = { @@ -345,6 +335,20 @@ function setPortProxy(port, proxyTabId) { } } +function isUsingOpenAICompatibleApiSession(session) { + return ( + isUsingCustomModel(session) || + isUsingChatgptApiModel(session) || + isUsingMoonshotApiModel(session) || + isUsingChatGLMApiModel(session) || + isUsingDeepSeekApiModel(session) || + isUsingOllamaApiModel(session) || + isUsingOpenRouterApiModel(session) || + isUsingAimlApiModel(session) || + isUsingGptCompletionApiModel(session) + ) +} + async function executeApi(session, port, config) { console.log( `[background] executeApi called for model: ${session.modelName}, apiMode: ${session.apiMode}`, @@ -360,29 +364,7 @@ async function executeApi(session, port, config) { ) } try { - if (isUsingCustomModel(session)) { - console.debug('[background] Using Custom Model API') - if (!session.apiMode) - await generateAnswersWithCustomApi( - port, - session.question, - session, - config.customModelApiUrl.trim() || 'http://localhost:8000/v1/chat/completions', - config.customApiKey, - config.customModelName, - ) - else - await generateAnswersWithCustomApi( - port, - session.question, - session, - session.apiMode.customUrl?.trim() || - config.customModelApiUrl.trim() || - 'http://localhost:8000/v1/chat/completions', - session.apiMode.apiKey?.trim() || config.customApiKey, - session.apiMode.customName, - ) - } else if (isUsingChatgptWebModel(session)) { + if (isUsingChatgptWebModel(session)) { console.debug('[background] Using ChatGPT Web Model') let tabId if ( @@ -507,46 +489,15 @@ async function executeApi(session, port, config) { console.debug('[background] Using Gemini Web Model') const cookies = await getBardCookies() await generateAnswersWithBardWebApi(port, session.question, session, cookies) - } else if (isUsingChatgptApiModel(session)) { - console.debug('[background] Using OpenAI API Model') - await generateAnswersWithOpenAiApi(port, session.question, session, config.apiKey) + } else if (isUsingOpenAICompatibleApiSession(session)) { + console.debug('[background] Using OpenAI-compatible API provider') + await generateAnswersWithOpenAICompatibleApi(port, session.question, session, config) } else if (isUsingClaudeApiModel(session)) { console.debug('[background] Using Anthropic API Model') await generateAnswersWithClaudeApi(port, session.question, session) - } else if (isUsingMoonshotApiModel(session)) { - console.debug('[background] Using Moonshot API Model') - await generateAnswersWithMoonshotCompletionApi( - port, - session.question, - session, - config.moonshotApiKey, - ) - } else if (isUsingChatGLMApiModel(session)) { - console.debug('[background] Using ChatGLM API Model') - await generateAnswersWithChatGLMApi(port, session.question, session) - } else if (isUsingDeepSeekApiModel(session)) { - console.debug('[background] Using DeepSeek API Model') - await generateAnswersWithDeepSeekApi(port, session.question, session, config.deepSeekApiKey) - } else if (isUsingOllamaApiModel(session)) { - console.debug('[background] Using Ollama API Model') - await generateAnswersWithOllamaApi(port, session.question, session) - } else if (isUsingOpenRouterApiModel(session)) { - console.debug('[background] Using OpenRouter API Model') - await generateAnswersWithOpenRouterApi( - port, - session.question, - session, - config.openRouterApiKey, - ) - } else if (isUsingAimlApiModel(session)) { - console.debug('[background] Using AIML API Model') - await generateAnswersWithAimlApi(port, session.question, session, config.aimlApiKey) } else if (isUsingAzureOpenAiApiModel(session)) { console.debug('[background] Using Azure OpenAI API Model') await generateAnswersWithAzureOpenaiApi(port, session.question, session) - } else if (isUsingGptCompletionApiModel(session)) { - console.debug('[background] Using GPT Completion API Model') - await generateAnswersWithGptCompletionApi(port, session.question, session, config.apiKey) } else if (isUsingGithubThirdPartyApiModel(session)) { console.debug('[background] Using Github Third Party API Model') await generateAnswersWithWaylaidwandererApi(port, session.question, session) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 440bfffe1..bcaf3f2ab 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -7,7 +7,7 @@ import { apiModeToModelName, createElementAtPosition, getApiModesFromConfig, - isApiModeSelected, + getUniquelySelectedApiModeIndex, isFirefox, isMobile, isSafari, @@ -36,8 +36,13 @@ import { initSession } from '../../services/init-session.mjs' import { findLastIndex } from 'lodash-es' import { generateAnswersWithBingWebApi } from '../../services/apis/bing-web.mjs' import { handlePortError } from '../../services/wrappers.mjs' +import { + getApiModeDisplayLabel, + getConversationAiName, +} from '../../popup/sections/api-modes-provider-utils.mjs' const logo = Browser.runtime.getURL('logo.png') +const UNMATCHED_API_MODE_VALUE = '__current-session-api-mode__' class ConversationItemData extends Object { /** @@ -67,9 +72,26 @@ function ConversationCard(props) { /** * @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]} - */ + */ const [conversationItemData, setConversationItemData] = useState([]) const config = useConfig() + const customOpenAIProviders = Array.isArray(config.customOpenAIProviders) + ? config.customOpenAIProviders + : [] + const currentAiName = getConversationAiName(session, t, customOpenAIProviders) + const selectedApiModeIndex = useMemo( + () => getUniquelySelectedApiModeIndex(apiModes, session, { sessionCompat: true }), + [apiModes, session], + ) + const selectedApiModeLabel = + selectedApiModeIndex !== -1 + ? getApiModeDisplayLabel(apiModes[selectedApiModeIndex], t, customOpenAIProviders) + : '' + const selectedApiModeValue = selectedApiModeLabel + ? String(selectedApiModeIndex) + : !session.apiMode && session.modelName === 'customModel' + ? '-1' + : UNMATCHED_API_MODE_VALUE useLayoutEffect(() => { if (session.conversationRecords.length === 0) { @@ -379,42 +401,47 @@ function ConversationCard(props) { style={props.notClampSize ? {} : { width: 0, flexGrow: 1 }} className="normal-button" required + value={selectedApiModeValue} onChange={(e) => { + if (e.target.value === UNMATCHED_API_MODE_VALUE) return + let apiMode = null let modelName = 'customModel' if (e.target.value !== '-1') { - apiMode = apiModes[e.target.value] + const selectedApiMode = apiModes[Number(e.target.value)] + if (!selectedApiMode) return + apiMode = selectedApiMode modelName = apiModeToModelName(apiMode) } const newSession = { ...session, modelName, apiMode, - aiName: modelNameToDesc( - apiMode ? apiModeToModelName(apiMode) : modelName, - t, - config.customModelName, - ), + aiName: apiMode + ? getApiModeDisplayLabel(apiMode, t, customOpenAIProviders) + : modelNameToDesc(modelName, t, config.customModelName), } if (config.autoRegenAfterSwitchModel && conversationItemData.length > 0) getRetryFn(newSession)() else setSession(newSession) }} > + {selectedApiModeValue === UNMATCHED_API_MODE_VALUE && ( + + )} {apiModes.map((apiMode, index) => { - const modelName = apiModeToModelName(apiMode) - const desc = modelNameToDesc(modelName, t, config.customModelName) + const desc = getApiModeDisplayLabel(apiMode, t, customOpenAIProviders) if (desc) { return ( - ) } })} - + {props.draggable && !completeDraggable && ( @@ -554,7 +581,7 @@ function ConversationCard(props) { content={data.content} key={idx} type={data.type} - descName={data.type === 'answer' && session.aiName} + descName={data.type === 'answer' && currentAiName} onRetry={idx === conversationItemData.length - 1 ? retryFn : null} /> ))} diff --git a/src/config/index.mjs b/src/config/index.mjs index cb1e0c3ea..3860b7407 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -7,6 +7,10 @@ import { modelNameToDesc, } from '../utils/model-name-convert.mjs' import { t } from 'i18next' +import { + LEGACY_SECRET_KEY_TO_PROVIDER_ID, + OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID as API_MODE_GROUP_TO_PROVIDER_ID, +} from './openai-provider-mappings.mjs' export const TriggerMode = { always: 'Always', @@ -583,9 +587,13 @@ export const defaultConfig = { customName: '', customUrl: '', apiKey: '', + providerId: '', active: false, }, ], + customOpenAIProviders: [], + providerSecrets: {}, + configSchemaVersion: 1, activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ { @@ -758,6 +766,603 @@ export async function getPreferredLanguageKey() { return config.preferredLanguage } +const CONFIG_SCHEMA_VERSION = 1 + +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeProviderId(value) { + return normalizeText(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function normalizeEndpointUrlForCompare(value) { + return normalizeText(value).replace(/\/+$/, '') +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function areStringRecordValuesEqual(leftRecord, rightRecord) { + const leftIsRecord = isPlainObject(leftRecord) + const rightIsRecord = isPlainObject(rightRecord) + if (!leftIsRecord || !rightIsRecord) { + return !leftIsRecord && !rightIsRecord && leftRecord === rightRecord + } + const left = leftRecord + const right = rightRecord + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) return false + for (const key of leftKeys) { + if (!Object.hasOwn(right, key)) return false + if (normalizeText(left[key]) !== normalizeText(right[key])) return false + } + return true +} + +function ensureUniqueProviderId(providerIdSet, preferredId) { + let id = preferredId || 'custom-provider' + let suffix = 2 + while (providerIdSet.has(id)) { + id = `${preferredId || 'custom-provider'}-${suffix}` + suffix += 1 + } + return id +} + +function normalizeCustomProviderForStorage(provider, index, providerIdSet) { + if (!provider || typeof provider !== 'object') return null + const originalRawId = normalizeText(provider.id) + const originalId = normalizeProviderId(provider.id) + const sourceProviderOriginalRawId = normalizeText(provider.sourceProviderId) + const sourceProviderId = normalizeProviderId(provider.sourceProviderId) + const preferredId = originalId || `custom-provider-${index + 1}` + const id = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(id) + return { + originalId, + originalRawId, + sourceProviderOriginalId: sourceProviderId, + sourceProviderOriginalRawId, + provider: { + id, + name: normalizeText(provider.name) || `Custom Provider ${index + 1}`, + baseUrl: normalizeText(provider.baseUrl), + chatCompletionsPath: normalizeText(provider.chatCompletionsPath) || '/v1/chat/completions', + completionsPath: normalizeText(provider.completionsPath) || '/v1/completions', + chatCompletionsUrl: normalizeText(provider.chatCompletionsUrl), + completionsUrl: normalizeText(provider.completionsUrl), + enabled: provider.enabled !== false, + allowLegacyResponseField: provider.allowLegacyResponseField !== false, + ...(sourceProviderId ? { sourceProviderId } : {}), + }, + } +} + +function migrateUserConfig(options) { + const migrated = { ...options } + let dirty = false + + if (migrated.customChatGptWebApiUrl === 'https://chat.openai.com') { + migrated.customChatGptWebApiUrl = 'https://chatgpt.com' + dirty = true + } + + const hasProviderSecretsRecord = isPlainObject(migrated.providerSecrets) + const providerSecrets = hasProviderSecretsRecord ? { ...migrated.providerSecrets } : {} + if (!hasProviderSecretsRecord) { + dirty = true + } + for (const [legacyKey, providerId] of Object.entries(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { + const legacyKeyValue = normalizeText(migrated[legacyKey]) + const hasProviderSecret = Object.hasOwn(providerSecrets, providerId) + if (legacyKeyValue && !hasProviderSecret) { + providerSecrets[providerId] = legacyKeyValue + dirty = true + } + } + + const builtinProviderIds = new Set( + Object.values(API_MODE_GROUP_TO_PROVIDER_ID) + .map((providerId) => normalizeText(providerId)) + .filter((providerId) => providerId), + ) + const providerIdSet = new Set(builtinProviderIds) + const providerIdRenameLookup = new Map() + const providerIdRenames = [] + const rawCustomOpenAIProviders = Array.isArray(migrated.customOpenAIProviders) + ? migrated.customOpenAIProviders + : [] + const legacyCustomProviderIds = new Set( + rawCustomOpenAIProviders + .map((provider) => normalizeProviderId(provider?.id)) + .filter((providerId) => providerId), + ) + const normalizedProviderResults = rawCustomOpenAIProviders + .map((provider, index) => normalizeCustomProviderForStorage(provider, index, providerIdSet)) + .filter((result) => result && result.provider) + const unchangedProviderIds = new Set( + normalizedProviderResults + .filter( + ({ originalId, provider }) => originalId && originalId === normalizeProviderId(provider.id), + ) + .map(({ provider }) => normalizeProviderId(provider.id)) + .filter((id) => id), + ) + const customOpenAIProviders = normalizedProviderResults.map( + ({ originalId, originalRawId, sourceProviderOriginalRawId, provider }) => { + if (originalId && originalId !== provider.id) { + providerIdRenames.push({ oldId: originalId, oldRawId: originalRawId, newId: provider.id }) + if (!providerIdRenameLookup.has(originalId) && !unchangedProviderIds.has(originalId)) { + providerIdRenameLookup.set(originalId, provider.id) + } + dirty = true + } + if ( + normalizeText(sourceProviderOriginalRawId) && + normalizeText(sourceProviderOriginalRawId) !== normalizeText(provider.sourceProviderId) + ) { + dirty = true + } + return provider + }, + ) + if (!Array.isArray(migrated.customOpenAIProviders)) dirty = true + + for (const { + sourceProviderOriginalId, + sourceProviderOriginalRawId, + provider, + } of normalizedProviderResults) { + const currentSourceProviderId = normalizeProviderId(provider?.sourceProviderId) + if (!currentSourceProviderId) { + continue + } + const renamedSourceProviderByRawId = providerIdRenames.find( + ({ oldRawId }) => + normalizeText(oldRawId) && + normalizeText(oldRawId) === normalizeText(sourceProviderOriginalRawId), + ) + const renamedSourceProviderId = + (renamedSourceProviderByRawId && !unchangedProviderIds.has(sourceProviderOriginalId) + ? renamedSourceProviderByRawId.newId + : '') || + (!builtinProviderIds.has(sourceProviderOriginalId) + ? providerIdRenameLookup.get(sourceProviderOriginalId) + : '') + if (renamedSourceProviderId && currentSourceProviderId !== renamedSourceProviderId) { + provider.sourceProviderId = renamedSourceProviderId + dirty = true + } + } + + for (let index = providerIdRenames.length - 1; index >= 0; index -= 1) { + const { + oldId: oldProviderId, + oldRawId: oldRawProviderId, + newId: newProviderId, + } = providerIdRenames[index] + if (oldProviderId === newProviderId) continue + if (!legacyCustomProviderIds.has(oldProviderId)) continue + const hasRawIdSecret = Object.hasOwn(providerSecrets, oldRawProviderId) + const hasNormalizedIdSecret = Object.hasOwn(providerSecrets, oldProviderId) + const usesBuiltinSecretSlot = builtinProviderIds.has(oldProviderId) + if (usesBuiltinSecretSlot && !hasRawIdSecret) continue + if (!usesBuiltinSecretSlot && !hasRawIdSecret && !hasNormalizedIdSecret) continue + const rawIdSecret = hasRawIdSecret ? providerSecrets[oldRawProviderId] : undefined + const normalizedIdSecret = hasNormalizedIdSecret ? providerSecrets[oldProviderId] : undefined + const oldSecret = usesBuiltinSecretSlot + ? rawIdSecret + : hasRawIdSecret && rawIdSecret !== '' + ? rawIdSecret + : hasNormalizedIdSecret + ? normalizedIdSecret + : rawIdSecret + if ( + !Object.hasOwn(providerSecrets, newProviderId) || + providerSecrets[newProviderId] !== oldSecret + ) { + providerSecrets[newProviderId] = oldSecret + dirty = true + } + if (hasRawIdSecret && oldRawProviderId !== oldProviderId) { + delete providerSecrets[oldRawProviderId] + dirty = true + } + } + + const activeCustomProviderIds = new Set( + customOpenAIProviders.map((provider) => normalizeText(provider?.id)).filter(Boolean), + ) + + for (const { originalRawId, provider } of normalizedProviderResults) { + const rawProviderId = normalizeText(originalRawId) + const normalizedProviderId = normalizeText(provider?.id) + if (!rawProviderId || !normalizedProviderId || rawProviderId === normalizedProviderId) continue + if (!Object.hasOwn(providerSecrets, rawProviderId)) continue + const rawSecret = providerSecrets[rawProviderId] + const shouldPreserveRawSecretSlot = + builtinProviderIds.has(rawProviderId) || activeCustomProviderIds.has(rawProviderId) + if (!Object.hasOwn(providerSecrets, normalizedProviderId)) { + providerSecrets[normalizedProviderId] = rawSecret + dirty = true + } + if (!shouldPreserveRawSecretSlot) { + delete providerSecrets[rawProviderId] + dirty = true + } + } + + const customApiModes = Array.isArray(migrated.customApiModes) + ? migrated.customApiModes.map((apiMode) => ({ ...apiMode })) + : [] + if (!Array.isArray(migrated.customApiModes)) dirty = true + + let customProviderCounter = customOpenAIProviders.length + let customApiModesDirty = false + let customProvidersDirty = false + const migratedCustomModeProviderIds = new Map() + const legacyCustomProviderSecret = normalizeText(providerSecrets['legacy-custom-default']) + const hasOwnProviderSecret = (providerId) => + Object.prototype.hasOwnProperty.call(providerSecrets, providerId) + const getCustomModeMigrationSignature = (apiMode) => + JSON.stringify({ + groupName: normalizeText(apiMode?.groupName), + itemName: normalizeText(apiMode?.itemName), + isCustom: Boolean(apiMode?.isCustom), + customName: normalizeText(apiMode?.customName), + customUrl: normalizeEndpointUrlForCompare(normalizeText(apiMode?.customUrl)), + providerId: normalizeProviderId( + typeof apiMode?.providerId === 'string' ? apiMode.providerId : '', + ), + apiKey: normalizeText(apiMode?.apiKey), + }) + const isProviderSecretCompatibleForCustomMode = (modeApiKey, providerSecret) => { + const effectiveModeKey = normalizeText(modeApiKey) || legacyCustomProviderSecret + if (effectiveModeKey) { + return !providerSecret || providerSecret === effectiveModeKey + } + return !providerSecret + } + const materializeCustomProviderForMode = (targetProviderId, preferredName) => { + customProviderCounter += 1 + const sourceProvider = customOpenAIProviders.find((item) => item.id === targetProviderId) + const providerName = + normalizeText(preferredName) || + normalizeText(sourceProvider?.name) || + `Custom Provider ${customProviderCounter}` + const preferredId = + normalizeProviderId(preferredName) || + normalizeProviderId(sourceProvider?.name) || + `custom-provider-${customProviderCounter}` + const providerId = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(providerId) + const provider = sourceProvider + ? { + ...sourceProvider, + id: providerId, + name: providerName, + } + : { + id: providerId, + name: providerName, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: + normalizeText(migrated.customModelApiUrl) || defaultConfig.customModelApiUrl, + completionsUrl: '', + enabled: true, + allowLegacyResponseField: true, + } + customOpenAIProviders.push(provider) + customProvidersDirty = true + dirty = true + return providerId + } + const promoteCustomModeApiKeyToProvider = (apiMode, apiModeKey) => { + const targetProviderId = normalizeText(apiMode.providerId) || 'legacy-custom-default' + const existingProviderSecret = normalizeText(providerSecrets[targetProviderId]) + if (!hasOwnProviderSecret(targetProviderId)) { + providerSecrets[targetProviderId] = apiModeKey + dirty = true + return targetProviderId + } + if (existingProviderSecret === apiModeKey) { + return targetProviderId + } + const reassignedProviderId = materializeCustomProviderForMode( + targetProviderId, + apiMode.customName, + ) + providerSecrets[reassignedProviderId] = apiModeKey + dirty = true + return reassignedProviderId + } + for (const apiMode of customApiModes) { + if (!apiMode || typeof apiMode !== 'object') continue + if (apiMode.groupName !== 'customApiModelKeys') { + const nonCustomApiModeKey = normalizeText(apiMode.apiKey) + if (nonCustomApiModeKey) { + const targetProviderId = + API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(apiMode.groupName)] || + normalizeText(apiMode.providerId) + if (targetProviderId) { + if (!hasOwnProviderSecret(targetProviderId)) { + providerSecrets[targetProviderId] = nonCustomApiModeKey + dirty = true + } + apiMode.apiKey = '' + customApiModesDirty = true + } + } + if (normalizeText(apiMode.providerId)) { + apiMode.providerId = '' + customApiModesDirty = true + } + continue + } + + const originalCustomModeSignature = getCustomModeMigrationSignature(apiMode) + const existingProviderIdRaw = typeof apiMode.providerId === 'string' ? apiMode.providerId : '' + const existingProviderId = normalizeProviderId(existingProviderIdRaw) + if (existingProviderId && existingProviderIdRaw !== existingProviderId) { + apiMode.providerId = existingProviderId + customApiModesDirty = true + } + let providerIdAssignedFromLegacyCustomUrl = false + const renamedProviderId = providerIdRenameLookup.get(existingProviderId) + if (renamedProviderId && normalizeText(apiMode.providerId) !== renamedProviderId) { + apiMode.providerId = renamedProviderId + customApiModesDirty = true + } + + if (!normalizeText(apiMode.providerId)) { + const customUrl = normalizeText(apiMode.customUrl) + const normalizedCustomUrl = normalizeEndpointUrlForCompare(customUrl) + if (customUrl) { + const apiModeKeyForMatch = normalizeText(apiMode.apiKey) + let provider = customOpenAIProviders.find((item) => { + if (normalizeEndpointUrlForCompare(item.chatCompletionsUrl) !== normalizedCustomUrl) + return false + const existingSecret = normalizeText(providerSecrets[item.id]) + return isProviderSecretCompatibleForCustomMode(apiModeKeyForMatch, existingSecret) + }) + if (!provider) { + customProviderCounter += 1 + const preferredId = + normalizeProviderId(apiMode.customName) || `custom-provider-${customProviderCounter}` + const providerId = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(providerId) + provider = { + id: providerId, + name: normalizeText(apiMode.customName) || `Custom Provider ${customProviderCounter}`, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: customUrl, + completionsUrl: '', + enabled: true, + allowLegacyResponseField: true, + } + customOpenAIProviders.push(provider) + customProvidersDirty = true + } + apiMode.providerId = provider.id + if (normalizeText(apiMode.customUrl)) { + apiMode.customUrl = '' + } + providerIdAssignedFromLegacyCustomUrl = true + } else { + apiMode.providerId = 'legacy-custom-default' + } + customApiModesDirty = true + } + + const apiModeKey = normalizeText(apiMode.apiKey) + if (apiModeKey) { + const promotedProviderId = promoteCustomModeApiKeyToProvider(apiMode, apiModeKey) + if (normalizeText(apiMode.providerId) !== promotedProviderId) { + apiMode.providerId = promotedProviderId + customApiModesDirty = true + } + if (normalizeText(apiMode.apiKey)) { + // Mode-level custom keys are treated as legacy data; after migration, + // providerSecrets is the single source of truth. + apiMode.apiKey = '' + customApiModesDirty = true + } + } else if (legacyCustomProviderSecret && providerIdAssignedFromLegacyCustomUrl) { + if (!hasOwnProviderSecret(apiMode.providerId)) { + providerSecrets[apiMode.providerId] = legacyCustomProviderSecret + dirty = true + } + } + + migratedCustomModeProviderIds.set( + originalCustomModeSignature, + normalizeText(apiMode.providerId), + ) + } + + if (migrated.apiMode && typeof migrated.apiMode === 'object') { + const selectedApiMode = { ...migrated.apiMode } + let selectedApiModeDirty = false + const selectedIsCustom = selectedApiMode.groupName === 'customApiModelKeys' + let selectedProviderIdAssignedFromLegacyCustomUrl = false + const originalSelectedCustomModeSignature = selectedIsCustom + ? getCustomModeMigrationSignature(selectedApiMode) + : '' + + if (selectedIsCustom) { + const existingSelectedProviderIdRaw = + typeof selectedApiMode.providerId === 'string' ? selectedApiMode.providerId : '' + const existingSelectedProviderId = normalizeProviderId(existingSelectedProviderIdRaw) + if ( + existingSelectedProviderId && + existingSelectedProviderIdRaw !== existingSelectedProviderId + ) { + selectedApiMode.providerId = existingSelectedProviderId + selectedApiModeDirty = true + } + const renamedSelectedProviderId = providerIdRenameLookup.get(existingSelectedProviderId) + if ( + renamedSelectedProviderId && + normalizeText(selectedApiMode.providerId) !== renamedSelectedProviderId + ) { + selectedApiMode.providerId = renamedSelectedProviderId + selectedApiModeDirty = true + } + } + + if (selectedIsCustom) { + const migratedProviderId = migratedCustomModeProviderIds.get( + originalSelectedCustomModeSignature, + ) + if (migratedProviderId && normalizeText(selectedApiMode.providerId) !== migratedProviderId) { + selectedApiMode.providerId = migratedProviderId + selectedApiModeDirty = true + } + } + + if (selectedIsCustom && !normalizeText(selectedApiMode.providerId)) { + const customUrl = normalizeText(selectedApiMode.customUrl) + const normalizedCustomUrl = normalizeEndpointUrlForCompare(customUrl) + if (customUrl) { + const selectedApiModeKeyForMatch = normalizeText(selectedApiMode.apiKey) + let provider = customOpenAIProviders.find((item) => { + if (normalizeEndpointUrlForCompare(item.chatCompletionsUrl) !== normalizedCustomUrl) + return false + const existingSecret = normalizeText(providerSecrets[item.id]) + return isProviderSecretCompatibleForCustomMode(selectedApiModeKeyForMatch, existingSecret) + }) + if (!provider) { + customProviderCounter += 1 + const preferredId = + normalizeProviderId(selectedApiMode.customName) || + `custom-provider-${customProviderCounter}` + const providerId = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(providerId) + provider = { + id: providerId, + name: + normalizeText(selectedApiMode.customName) || + `Custom Provider ${customProviderCounter}`, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: customUrl, + completionsUrl: '', + enabled: true, + allowLegacyResponseField: true, + } + customOpenAIProviders.push(provider) + customProvidersDirty = true + } + selectedApiMode.providerId = provider.id + if (normalizeText(selectedApiMode.customUrl)) { + selectedApiMode.customUrl = '' + selectedApiModeDirty = true + } + selectedProviderIdAssignedFromLegacyCustomUrl = true + } else { + selectedApiMode.providerId = 'legacy-custom-default' + } + selectedApiModeDirty = true + } + + const selectedApiModeKey = normalizeText(selectedApiMode.apiKey) + const selectedTargetProviderId = selectedIsCustom + ? normalizeText(selectedApiMode.providerId) || 'legacy-custom-default' + : API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(selectedApiMode.groupName)] || + normalizeText(selectedApiMode.providerId) + if ( + selectedIsCustom && + selectedProviderIdAssignedFromLegacyCustomUrl && + !selectedApiModeKey && + legacyCustomProviderSecret && + selectedTargetProviderId && + !hasOwnProviderSecret(selectedTargetProviderId) + ) { + providerSecrets[selectedTargetProviderId] = legacyCustomProviderSecret + dirty = true + } + if (selectedApiModeKey) { + const migratedProviderId = selectedIsCustom + ? migratedCustomModeProviderIds.get(originalSelectedCustomModeSignature) + : '' + if (migratedProviderId) { + if (normalizeText(selectedApiMode.providerId) !== migratedProviderId) { + selectedApiMode.providerId = migratedProviderId + selectedApiModeDirty = true + } + selectedApiMode.apiKey = '' + selectedApiModeDirty = true + } else { + const targetProviderId = selectedIsCustom + ? promoteCustomModeApiKeyToProvider(selectedApiMode, selectedApiModeKey) + : API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(selectedApiMode.groupName)] || + normalizeText(selectedApiMode.providerId) + if (targetProviderId && normalizeText(selectedApiMode.providerId) !== targetProviderId) { + selectedApiMode.providerId = targetProviderId + selectedApiModeDirty = true + } + if (targetProviderId && !selectedIsCustom && !hasOwnProviderSecret(targetProviderId)) { + providerSecrets[targetProviderId] = selectedApiModeKey + dirty = true + } + if (targetProviderId) { + selectedApiMode.apiKey = '' + selectedApiModeDirty = true + } + } + } + + if (!selectedIsCustom && normalizeText(selectedApiMode.providerId)) { + selectedApiMode.providerId = '' + selectedApiModeDirty = true + } + + if (selectedApiModeDirty) { + migrated.apiMode = selectedApiMode + dirty = true + } + } + + if (customProvidersDirty) dirty = true + if (customApiModesDirty) dirty = true + + if (migrated.configSchemaVersion !== CONFIG_SCHEMA_VERSION) { + migrated.configSchemaVersion = CONFIG_SCHEMA_VERSION + dirty = true + } + + migrated.providerSecrets = providerSecrets + migrated.customOpenAIProviders = customOpenAIProviders + migrated.customApiModes = customApiModes + + // Reverse-sync providerSecrets to legacy fields for backward compatibility + // so that older extension versions can still read the keys. + for (const [legacyKey, providerId] of Object.entries(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { + const hasProviderSecret = Object.hasOwn(providerSecrets, providerId) + const providerSecret = normalizeText(providerSecrets[providerId]) + if (providerSecret && normalizeText(migrated[legacyKey]) !== providerSecret) { + migrated[legacyKey] = providerSecret + dirty = true + } else if (hasProviderSecret && !providerSecret && normalizeText(migrated[legacyKey])) { + migrated[legacyKey] = '' + dirty = true + } + } + + return { migrated, dirty } +} + /** * get user config from local storage * @returns {Promise} @@ -769,8 +1374,6 @@ export async function getUserConfig() { 'claudeApiKey', 'customClaudeApiUrl', ]) - if (options.customChatGptWebApiUrl === 'https://chat.openai.com') - options.customChatGptWebApiUrl = 'https://chatgpt.com' // Migrate legacy Claude-named keys to Anthropic-named keys. // If both old/new keys coexist (for example after a partial migration), @@ -802,7 +1405,46 @@ export async function getUserConfig() { } } - return defaults(options, defaultConfig) + const { migrated, dirty } = migrateUserConfig(options) + if (dirty) { + const payload = {} + if (JSON.stringify(options.customApiModes) !== JSON.stringify(migrated.customApiModes)) { + payload.customApiModes = migrated.customApiModes + } + if ( + JSON.stringify(options.customOpenAIProviders) !== + JSON.stringify(migrated.customOpenAIProviders) + ) { + payload.customOpenAIProviders = migrated.customOpenAIProviders + } + if (!areStringRecordValuesEqual(options.providerSecrets, migrated.providerSecrets)) { + payload.providerSecrets = migrated.providerSecrets + } + if (options.configSchemaVersion !== migrated.configSchemaVersion) { + payload.configSchemaVersion = migrated.configSchemaVersion + } + if (migrated.customChatGptWebApiUrl !== undefined) { + if (options.customChatGptWebApiUrl !== migrated.customChatGptWebApiUrl) { + payload.customChatGptWebApiUrl = migrated.customChatGptWebApiUrl + } + } + if (migrated.apiMode !== undefined) { + if (JSON.stringify(options.apiMode ?? null) !== JSON.stringify(migrated.apiMode ?? null)) { + payload.apiMode = migrated.apiMode + } + } + for (const legacyKey of Object.keys(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { + if (migrated[legacyKey] !== undefined) { + if (options[legacyKey] !== migrated[legacyKey]) { + payload[legacyKey] = migrated[legacyKey] + } + } + } + if (Object.keys(payload).length > 0) { + await Browser.storage.local.set(payload).catch(() => {}) + } + } + return defaults(migrated, defaultConfig) } /** diff --git a/src/config/openai-provider-mappings.mjs b/src/config/openai-provider-mappings.mjs new file mode 100644 index 000000000..b7a534875 --- /dev/null +++ b/src/config/openai-provider-mappings.mjs @@ -0,0 +1,30 @@ +export const LEGACY_API_KEY_FIELD_BY_PROVIDER_ID = { + openai: 'apiKey', + deepseek: 'deepSeekApiKey', + moonshot: 'moonshotApiKey', + openrouter: 'openRouterApiKey', + aiml: 'aimlApiKey', + chatglm: 'chatglmApiKey', + ollama: 'ollamaApiKey', + 'legacy-custom-default': 'customApiKey', +} + +export const LEGACY_SECRET_KEY_TO_PROVIDER_ID = Object.fromEntries( + Object.entries(LEGACY_API_KEY_FIELD_BY_PROVIDER_ID).map(([providerId, legacyKey]) => [ + legacyKey, + providerId, + ]), +) + +export const OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID = { + chatgptApiModelKeys: 'openai', + gptApiModelKeys: 'openai', + moonshotApiModelKeys: 'moonshot', + deepSeekApiModelKeys: 'deepseek', + openRouterApiModelKeys: 'openrouter', + aimlModelKeys: 'aiml', + aimlApiModelKeys: 'aiml', + chatglmApiModelKeys: 'chatglm', + ollamaApiModelKeys: 'ollama', + customApiModelKeys: 'legacy-custom-default', +} diff --git a/src/popup/Popup.jsx b/src/popup/Popup.jsx index d88e93dca..17f4d78ce 100644 --- a/src/popup/Popup.jsx +++ b/src/popup/Popup.jsx @@ -1,5 +1,5 @@ import '@picocss/pico' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { defaultConfig, getPreferredLanguageKey, @@ -14,6 +14,11 @@ import Browser from 'webextension-polyfill' import { useWindowTheme } from '../hooks/use-window-theme.mjs' import { isMobile } from '../utils/index.mjs' import { useTranslation } from 'react-i18next' +import { + buildConfigRollbackPatch, + mergeConfigUpdate, + queueConfigWrite, +} from './popup-config-utils.mjs' import { GeneralPart } from './sections/GeneralPart' import { FeaturePages } from './sections/FeaturePages' import { AdvancedPart } from './sections/AdvancedPart' @@ -60,29 +65,84 @@ function Footer({ currentVersion, latestVersion }) { function Popup() { const { t, i18n } = useTranslation() const [config, setConfig] = useState(defaultConfig) + const [configLoaded, setConfigLoaded] = useState(false) const [currentVersion, setCurrentVersion] = useState('') const [latestVersion, setLatestVersion] = useState('') const [tabIndex, setTabIndex] = useState(0) const theme = useWindowTheme() + const initialConfigLoadGateRef = useRef(null) + if (!initialConfigLoadGateRef.current) { + let resolve + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + initialConfigLoadGateRef.current = { promise, resolve } + } + const updateConfigRequestIdRef = useRef(0) + const latestTouchedRequestByKeyRef = useRef({}) + const writeQueueRef = useRef(initialConfigLoadGateRef.current.promise) + const persistedConfigRef = useRef(defaultConfig) + const latestConfigRef = useRef(defaultConfig) const updateConfig = async (value) => { - setConfig({ ...config, ...value }) - await setUserConfig(value) + const nextValue = value && typeof value === 'object' ? value : {} + const requestId = ++updateConfigRequestIdRef.current + for (const key of Object.keys(nextValue)) { + latestTouchedRequestByKeyRef.current[key] = requestId + } + latestConfigRef.current = mergeConfigUpdate(latestConfigRef.current, nextValue) + setConfig((currentConfig) => { + return mergeConfigUpdate(currentConfig, nextValue) + }) + const { writePromise, nextQueue } = queueConfigWrite(writeQueueRef.current, () => + setUserConfig(nextValue), + ) + writeQueueRef.current = nextQueue + try { + await writePromise + persistedConfigRef.current = mergeConfigUpdate(persistedConfigRef.current, nextValue) + } catch (error) { + const rollbackPatch = buildConfigRollbackPatch( + persistedConfigRef.current, + nextValue, + latestTouchedRequestByKeyRef.current, + requestId, + ) + if (Object.keys(rollbackPatch).length > 0) { + latestConfigRef.current = mergeConfigUpdate(latestConfigRef.current, rollbackPatch) + setConfig((currentConfig) => mergeConfigUpdate(currentConfig, rollbackPatch)) + } + throw error + } } + const getPersistedConfig = () => persistedConfigRef.current + const awaitConfigWritesSettled = () => writeQueueRef.current + useEffect(() => { getPreferredLanguageKey().then((lang) => { i18n.changeLanguage(lang) }) - getUserConfig().then((config) => { - setConfig(config) - setCurrentVersion(Browser.runtime.getManifest().version.replace('v', '')) - fetch('https://api.github.com/repos/josstorer/chatGPTBox/releases/latest').then((response) => - response.json().then((data) => { - setLatestVersion(data.tag_name.replace('v', '')) - }), - ) - }) + setCurrentVersion(Browser.runtime.getManifest().version.replace('v', '')) + fetch('https://api.github.com/repos/josstorer/chatGPTBox/releases/latest').then((response) => + response.json().then((data) => { + setLatestVersion(data.tag_name.replace('v', '')) + }), + ) + const initialConfigLoadGate = initialConfigLoadGateRef.current + getUserConfig() + .then((config) => { + persistedConfigRef.current = config + latestConfigRef.current = config + setConfig(config) + }) + .catch((error) => { + console.error('[popup] Failed to load initial config', error) + }) + .finally(() => { + setConfigLoaded(true) + initialConfigLoadGate.resolve() + }) }, []) useEffect(() => { @@ -94,35 +154,43 @@ function Popup() { return (
-
- { - setTabIndex(index) - }} - > - - {t('General')} - {t('Feature Pages')} - {t('Modules')} - {t('Advanced')} - + {configLoaded ? ( + + { + setTabIndex(index) + }} + > + + {t('General')} + {t('Feature Pages')} + {t('Modules')} + {t('Advanced')} + - - - - - - - - - - - - - - + + + + + + + + + + + + +
+ + ) : null}
diff --git a/src/popup/popup-config-utils.mjs b/src/popup/popup-config-utils.mjs new file mode 100644 index 000000000..007eb679e --- /dev/null +++ b/src/popup/popup-config-utils.mjs @@ -0,0 +1,31 @@ +export function mergeConfigUpdate(currentConfig, value) { + return { ...currentConfig, ...value } +} + +export function queueConfigWrite(currentQueue, writeOperation) { + const baseQueue = currentQueue instanceof Promise ? currentQueue : Promise.resolve() + const writePromise = baseQueue.then(writeOperation) + return { + writePromise, + nextQueue: writePromise.catch(() => {}), + } +} + +export function buildConfigRollbackPatch( + persistedConfig, + value, + latestTouchedRequestByKey = {}, + requestId = 0, +) { + const baseConfig = persistedConfig && typeof persistedConfig === 'object' ? persistedConfig : {} + const nextValue = value && typeof value === 'object' ? value : {} + const keyOwners = + latestTouchedRequestByKey && typeof latestTouchedRequestByKey === 'object' + ? latestTouchedRequestByKey + : {} + return Object.fromEntries( + Object.keys(nextValue) + .filter((key) => keyOwners[key] === requestId) + .map((key) => [key, baseConfig[key]]), + ) +} diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx index 7fdff7f38..db25ad7a3 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' +import Browser from 'webextension-polyfill' import { apiModeToModelName, getApiModesFromConfig, @@ -7,19 +8,50 @@ import { modelNameToDesc, } from '../../utils/index.mjs' import { PencilIcon, TrashIcon } from '@primer/octicons-react' -import { useLayoutEffect, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { AlwaysCustomGroups, ModelGroups } from '../../config/index.mjs' import { - AlwaysCustomGroups, - CustomApiKeyGroups, - CustomUrlGroups, - ModelGroups, -} from '../../config/index.mjs' + getCustomOpenAIProviders, + OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID, +} from '../../services/apis/provider-registry.mjs' +import { + applySelectedProviderToApiMode, + applyDeletedProviderSecrets, + applyPendingProviderChanges, + buildEditedProvider, + createProviderId, + getApiModeDisplayLabel, + getConfiguredCustomApiModesForSessionRecovery, + getProviderDeleteDisabledReasonKey, + getProviderReferenceCheckApiModes, + getReferencedCustomProviderIdsFromSessions, + getSelectableProviders, + isProviderEndpointRewriteBlockedBySavedConversations, + isProviderDeleteDisabled, + isProviderReferencedByApiModes, + loadSavedConversationState, + persistApiModeConfigUpdate, + removePendingProviderDeletion, + resolveEditingProviderSelection, + resolveEditingProviderIdForGroupChange, + resolveSelectableProviderId, + resolveProviderChatEndpointUrl, + sanitizeApiModeForSave, + shouldHandleSavedConversationStorageChange, + shouldIncludeSelectedApiModeInReferenceCheck, + shouldPersistDeletedProviderChanges, + shouldPersistPendingProviderChanges, + shouldRenderApiModeRow, + validateProviderEndpointDraft, +} from './api-modes-provider-utils.mjs' ApiModes.propTypes = { config: PropTypes.object.isRequired, updateConfig: PropTypes.func.isRequired, } +const LEGACY_CUSTOM_PROVIDER_ID = 'legacy-custom-default' + const defaultApiMode = { groupName: 'chatgptWebModelKeys', itemName: 'chatgptFree35', @@ -27,9 +59,21 @@ const defaultApiMode = { customName: '', customUrl: 'http://localhost:8000/v1/chat/completions', apiKey: '', + providerId: '', active: true, } +const defaultProviderDraft = { + name: '', + apiUrl: '', +} + +const defaultProviderDraftValidation = { + name: false, + apiUrl: false, + savedConversations: false, +} + export function ApiModes({ config, updateConfig }) { const { t } = useTranslation() const [editing, setEditing] = useState(false) @@ -37,18 +81,69 @@ export function ApiModes({ config, updateConfig }) { const [editingIndex, setEditingIndex] = useState(-1) const [apiModes, setApiModes] = useState([]) const [apiModeStringArray, setApiModeStringArray] = useState([]) + const [customProviders, setCustomProviders] = useState([]) + const [pendingNewProvider, setPendingNewProvider] = useState(null) + const [pendingEditedProvidersById, setPendingEditedProvidersById] = useState({}) + const [pendingDeletedProviderIds, setPendingDeletedProviderIds] = useState([]) + const [pendingDeletedProviderSecretIds, setPendingDeletedProviderSecretIds] = useState([]) + const [sessions, setSessions] = useState([]) + const [sessionsLoaded, setSessionsLoaded] = useState(false) + const [providerSelector, setProviderSelector] = useState(LEGACY_CUSTOM_PROVIDER_ID) + const [isProviderEditorOpen, setIsProviderEditorOpen] = useState(false) + const [providerEditingId, setProviderEditingId] = useState('') + const [providerDraft, setProviderDraft] = useState(defaultProviderDraft) + const [providerDraftValidation, setProviderDraftValidation] = useState( + defaultProviderDraftValidation, + ) + const [providerSelectionValidation, setProviderSelectionValidation] = useState(false) + const providerNameInputRef = useRef(null) + const providerBaseUrlInputRef = useRef(null) + const providerSelectorRef = useRef(null) useLayoutEffect(() => { - const apiModes = getApiModesFromConfig(config) - setApiModes(apiModes) - setApiModeStringArray(apiModes.map(apiModeToModelName)) + const nextApiModes = getApiModesFromConfig(config) + setApiModes(nextApiModes) + setApiModeStringArray(nextApiModes.map(apiModeToModelName)) + setCustomProviders(getCustomOpenAIProviders(config)) }, [ config.activeApiModes, config.customApiModes, + config.customOpenAIProviders, config.azureDeploymentName, config.ollamaModelName, ]) + useEffect(() => { + let isMounted = true + + const updateSessions = (nextSessions) => { + if (!isMounted) return + setSessions(Array.isArray(nextSessions) ? nextSessions : []) + setSessionsLoaded(true) + } + + loadSavedConversationState(() => Browser.storage.local.get('sessions')).then( + ({ sessions, sessionsLoaded, error }) => { + if (!isMounted) return + if (error) { + console.error('[popup] Failed to load saved conversations for provider checks', error) + } + setSessions(Array.isArray(sessions) ? sessions : []) + setSessionsLoaded(sessionsLoaded) + }, + ) + + const listener = (changes, areaName) => { + if (!shouldHandleSavedConversationStorageChange(changes, areaName)) return + updateSessions(changes.sessions?.newValue) + } + Browser.storage.onChanged.addListener(listener) + return () => { + isMounted = false + Browser.storage.onChanged.removeListener(listener) + } + }, []) + const updateWhenApiModeDisabled = (apiMode) => { if (isApiModeSelected(apiMode, config)) updateConfig({ @@ -61,6 +156,332 @@ export function ApiModes({ config, updateConfig }) { }) } + const shouldEditProvider = editingApiMode.groupName === 'customApiModelKeys' + const effectiveProviders = useMemo( + () => + applyPendingProviderChanges( + customProviders, + pendingEditedProvidersById, + pendingNewProvider, + pendingDeletedProviderIds, + ), + [customProviders, pendingEditedProvidersById, pendingNewProvider, pendingDeletedProviderIds], + ) + const selectedCustomProvider = effectiveProviders.find( + (provider) => provider.id === providerSelector, + ) + const hasPendingProviderChanges = + Boolean(pendingNewProvider) || + Object.keys(pendingEditedProvidersById).length > 0 || + pendingDeletedProviderIds.length > 0 + + const apiModesForProviderReferenceCheck = useMemo(() => { + const referenceCheckApiModes = getProviderReferenceCheckApiModes( + apiModes, + editing, + editingIndex, + ) + if ( + shouldIncludeSelectedApiModeInReferenceCheck(apiModes, editing, editingIndex, config.apiMode) + ) { + return [...referenceCheckApiModes, config.apiMode] + } + return referenceCheckApiModes + }, [apiModes, editing, editingIndex, config.apiMode]) + + const configuredCustomApiModesForSessionRecovery = useMemo(() => { + const recoveryApiModes = getProviderReferenceCheckApiModes(apiModes, editing, editingIndex) + const recoverySelectedApiMode = shouldIncludeSelectedApiModeInReferenceCheck( + apiModes, + editing, + editingIndex, + config.apiMode, + ) + ? config.apiMode + : editingApiMode + + return getConfiguredCustomApiModesForSessionRecovery(recoveryApiModes, recoverySelectedApiMode) + }, [apiModes, config.apiMode, editing, editingApiMode, editingIndex]) + + const configuredCustomApiModesForSaveGuard = useMemo(() => { + let nextApiModes = apiModes + if (editing && editingIndex !== -1) { + nextApiModes = apiModes.map((apiMode, index) => + index === editingIndex ? editingApiMode : apiMode, + ) + } else if ( + editing && + editingIndex === -1 && + editingApiMode.groupName === 'customApiModelKeys' + ) { + nextApiModes = [...apiModes, editingApiMode] + } + const nextSelectedApiMode = + editing && editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config) + ? editingApiMode + : config.apiMode + + return getConfiguredCustomApiModesForSessionRecovery(nextApiModes, nextSelectedApiMode) + }, [apiModes, config, editing, editingApiMode, editingIndex]) + + const sessionReferencedProviderIds = useMemo( + () => + getReferencedCustomProviderIdsFromSessions( + sessions, + customProviders, + configuredCustomApiModesForSessionRecovery, + ), + [sessions, customProviders, configuredCustomApiModesForSessionRecovery], + ) + + const isEditedProviderReferenced = + Boolean(providerEditingId) && + (isProviderReferencedByApiModes(providerEditingId, apiModesForProviderReferenceCheck) || + sessionReferencedProviderIds.includes(providerEditingId)) + const isDeleteProviderDisabled = isProviderDeleteDisabled( + isEditedProviderReferenced, + sessionsLoaded, + ) + const providerDeleteDisabledReasonKey = getProviderDeleteDisabledReasonKey( + isEditedProviderReferenced, + sessionsLoaded, + ) + + const clearPendingProviderChanges = () => { + setPendingNewProvider(null) + setPendingEditedProvidersById({}) + setPendingDeletedProviderIds([]) + setPendingDeletedProviderSecretIds([]) + } + + const persistApiMode = async (nextApiMode) => { + const payload = { + activeApiModes: [], + customApiModes: + editingIndex === -1 + ? [...apiModes, nextApiMode] + : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)), + } + if ( + shouldPersistPendingProviderChanges(hasPendingProviderChanges) || + shouldPersistDeletedProviderChanges(pendingDeletedProviderIds) + ) { + payload.customOpenAIProviders = effectiveProviders + if (pendingDeletedProviderSecretIds.length > 0) { + payload.providerSecrets = applyDeletedProviderSecrets( + config.providerSecrets, + pendingDeletedProviderSecretIds, + ) + } + } + if (editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config)) { + payload.apiMode = nextApiMode + } + await persistApiModeConfigUpdate(updateConfig, payload, clearPendingProviderChanges) + } + + const closeProviderEditor = () => { + setIsProviderEditorOpen(false) + setProviderEditingId('') + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + } + + const openCreateProviderEditor = (event) => { + event.preventDefault() + setProviderEditingId('') + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(true) + } + + const openEditProviderEditor = (event) => { + event.preventDefault() + if (!selectedCustomProvider) return + setProviderEditingId(selectedCustomProvider.id) + setProviderDraft({ + name: selectedCustomProvider.name || '', + apiUrl: resolveProviderChatEndpointUrl(selectedCustomProvider), + }) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(true) + } + + const onSaveProviderEditing = (event) => { + event.preventDefault() + const providerName = providerDraft.name.trim() + const existingProvider = + pendingNewProvider && pendingNewProvider.id === providerEditingId + ? pendingNewProvider + : selectedCustomProvider || {} + const persistedProvider = customProviders.find((provider) => provider.id === providerEditingId) + const endpointDraft = validateProviderEndpointDraft(providerDraft.apiUrl) + const parsedEndpoint = endpointDraft.parsedEndpoint + const providerEndpointChanged = + Boolean(providerEditingId) && + Boolean(persistedProvider) && + parsedEndpoint.valid && + parsedEndpoint.chatCompletionsUrl !== resolveProviderChatEndpointUrl(persistedProvider) + const effectiveProviderSecrets = + pendingDeletedProviderSecretIds.length > 0 + ? applyDeletedProviderSecrets(config.providerSecrets, pendingDeletedProviderSecretIds) + : config.providerSecrets + const nextProviderDraftValidation = { + name: !providerName, + apiUrl: !endpointDraft.valid, + savedConversations: + providerEndpointChanged && + isProviderEndpointRewriteBlockedBySavedConversations( + providerEditingId, + sessionsLoaded, + sessions, + effectiveProviders, + configuredCustomApiModesForSaveGuard, + effectiveProviderSecrets, + ), + } + if ( + nextProviderDraftValidation.name || + nextProviderDraftValidation.apiUrl || + nextProviderDraftValidation.savedConversations + ) { + setProviderDraftValidation(nextProviderDraftValidation) + if (nextProviderDraftValidation.name) { + providerNameInputRef.current?.focus() + } else { + providerBaseUrlInputRef.current?.focus() + } + return + } + setProviderDraftValidation(defaultProviderDraftValidation) + const editedProvider = providerEditingId + ? buildEditedProvider( + existingProvider, + providerEditingId, + providerName, + parsedEndpoint, + providerDraft.apiUrl, + ) + : null + + if (providerEditingId) { + if (pendingNewProvider && pendingNewProvider.id === providerEditingId) { + setPendingNewProvider(editedProvider) + } else { + setPendingEditedProvidersById((currentProviders) => ({ + ...currentProviders, + [providerEditingId]: editedProvider, + })) + } + closeProviderEditor() + return + } + + const providerId = createProviderId(providerName, effectiveProviders, [ + ...Object.values(OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID), + ...pendingDeletedProviderIds, + ]) + const createdProvider = { + id: providerId, + name: providerName, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: parsedEndpoint.chatCompletionsUrl, + completionsUrl: parsedEndpoint.completionsUrl, + enabled: true, + allowLegacyResponseField: true, + } + setPendingNewProvider(createdProvider) + setProviderSelector(providerId) + setProviderSelectionValidation(false) + setEditingApiMode({ ...editingApiMode, providerId }) + closeProviderEditor() + } + + const onDeleteProviderEditing = (event) => { + event.preventDefault() + if (!providerEditingId || isDeleteProviderDisabled) return + const isDeletingPersistedProvider = customProviders.some( + (provider) => provider.id === providerEditingId, + ) + + if (pendingNewProvider && pendingNewProvider.id === providerEditingId) { + setPendingNewProvider(null) + } + setPendingEditedProvidersById((currentProviders) => { + if (!currentProviders[providerEditingId]) return currentProviders + const nextProviders = { ...currentProviders } + delete nextProviders[providerEditingId] + return nextProviders + }) + if (isDeletingPersistedProvider) { + setPendingDeletedProviderIds((currentProviderIds) => [ + ...removePendingProviderDeletion(currentProviderIds, providerEditingId), + providerEditingId, + ]) + setPendingDeletedProviderSecretIds((currentProviderIds) => [ + ...removePendingProviderDeletion(currentProviderIds, providerEditingId), + providerEditingId, + ]) + } else { + setPendingDeletedProviderIds((currentProviderIds) => + removePendingProviderDeletion(currentProviderIds, providerEditingId), + ) + setPendingDeletedProviderSecretIds((currentProviderIds) => + removePendingProviderDeletion(currentProviderIds, providerEditingId), + ) + } + + if (providerSelector === providerEditingId) { + setProviderSelector('') + setProviderSelectionValidation(true) + } + if (editingApiMode.providerId === providerEditingId) { + setEditingApiMode({ + ...editingApiMode, + providerId: '', + }) + } + closeProviderEditor() + } + + const onSaveEditing = async (event) => { + event.preventDefault() + let nextApiMode = { ...editingApiMode } + const previousProviderId = + editingIndex === -1 ? '' : apiModes[editingIndex]?.providerId || LEGACY_CUSTOM_PROVIDER_ID + + if (shouldEditProvider) { + const selectedProviderId = + providerSelector === LEGACY_CUSTOM_PROVIDER_ID + ? LEGACY_CUSTOM_PROVIDER_ID + : resolveSelectableProviderId(providerSelector, effectiveProviders, '') + if (!selectedProviderId) { + setProviderSelectionValidation(true) + providerSelectorRef.current?.focus() + return + } + const shouldClearProviderDerivedFields = + editingIndex !== -1 && selectedProviderId !== previousProviderId + const isEndpointProviderManaged = editingIndex === -1 + nextApiMode = applySelectedProviderToApiMode( + nextApiMode, + selectedProviderId, + shouldClearProviderDerivedFields, + isEndpointProviderManaged, + ) + } + + try { + await persistApiMode(sanitizeApiModeForSave(nextApiMode)) + setEditing(false) + closeProviderEditor() + } catch (error) { + console.error('[popup] Failed to persist API mode changes', error) + } + } + const editingComponent = (
@@ -68,32 +489,15 @@ export function ApiModes({ config, updateConfig }) { onClick={(e) => { e.preventDefault() setEditing(false) + clearPendingProviderChanges() + closeProviderEditor() }} > {t('Cancel')} - +
-
+
{t('Type')}
-
+
{t('Mode')} { + const value = e.target.value + setProviderSelector(value) + setProviderSelectionValidation(false) + setEditingApiMode({ ...editingApiMode, providerId: value }) + if (isProviderEditorOpen) { + closeProviderEditor() + } + setProviderDraftValidation(defaultProviderDraftValidation) + }} + aria-invalid={providerSelectionValidation} + style={providerSelectionValidation ? { borderColor: 'red' } : undefined} + > + + + {getSelectableProviders(effectiveProviders).map((provider) => ( + + ))} + + + +
+ )} + {shouldEditProvider && providerSelectionValidation && ( +
{t('Please select a provider')}
+ )} + {shouldEditProvider && isProviderEditorOpen && ( + <> setEditingApiMode({ ...editingApiMode, customUrl: e.target.value })} + ref={providerNameInputRef} + value={providerDraft.name} + placeholder={t('Provider')} + onChange={(e) => { + setProviderDraft({ ...providerDraft, name: e.target.value }) + if (providerDraftValidation.name || providerDraftValidation.savedConversations) { + setProviderDraftValidation({ + ...providerDraftValidation, + name: false, + savedConversations: false, + }) + } + }} + aria-invalid={providerDraftValidation.name} + style={providerDraftValidation.name ? { borderColor: 'red' } : undefined} /> - )} - {CustomApiKeyGroups.includes(editingApiMode.groupName) && - (editingApiMode.isCustom || AlwaysCustomGroups.includes(editingApiMode.groupName)) && ( setEditingApiMode({ ...editingApiMode, apiKey: e.target.value })} + type="text" + ref={providerBaseUrlInputRef} + value={providerDraft.apiUrl} + placeholder="https://api.example.com/v1/chat/completions" + title={t('API Url')} + onChange={(e) => { + setProviderDraft({ ...providerDraft, apiUrl: e.target.value }) + if (providerDraftValidation.apiUrl || providerDraftValidation.savedConversations) { + setProviderDraftValidation({ + ...providerDraftValidation, + apiUrl: false, + savedConversations: false, + }) + } + }} + aria-invalid={providerDraftValidation.apiUrl} + style={providerDraftValidation.apiUrl ? { borderColor: 'red' } : undefined} /> - )} + {providerDraftValidation.apiUrl && ( +
{t('Please enter a full Chat Completions URL')}
+ )} + {providerDraftValidation.savedConversations && ( +
+ {t( + sessionsLoaded + ? 'This provider endpoint is still needed by saved conversations' + : 'Loading saved conversations…', + )} +
+ )} +
+ + + {providerEditingId && ( + + + + )} +
+ + )}
) @@ -166,8 +675,7 @@ export function ApiModes({ config, updateConfig }) { <> {apiModes.map( (apiMode, index) => - apiMode.groupName && - apiMode.itemName && + shouldRenderApiModeRow(apiMode) && (editing && editingIndex === index ? ( editingComponent ) : ( @@ -182,7 +690,7 @@ export function ApiModes({ config, updateConfig }) { updateConfig({ activeApiModes: [], customApiModes }) }} /> - {modelNameToDesc(apiModeToModelName(apiMode), t)} + {getApiModeDisplayLabel(apiMode, t, effectiveProviders)}
{ e.preventDefault() setEditing(true) - setEditingApiMode(apiMode) + const isCustomApiMode = apiMode.groupName === 'customApiModelKeys' + const providerId = isCustomApiMode + ? resolveEditingProviderSelection( + apiMode.providerId, + effectiveProviders, + LEGACY_CUSTOM_PROVIDER_ID, + ) + : '' + setEditingApiMode({ + ...defaultApiMode, + ...apiMode, + providerId, + }) + setProviderSelector(isCustomApiMode ? providerId : LEGACY_CUSTOM_PROVIDER_ID) + setProviderSelectionValidation(isCustomApiMode && !providerId) + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(false) + setProviderEditingId('') + clearPendingProviderChanges() setEditingIndex(index) }} > @@ -223,6 +750,13 @@ export function ApiModes({ config, updateConfig }) { e.preventDefault() setEditing(true) setEditingApiMode(defaultApiMode) + setProviderSelector(LEGACY_CUSTOM_PROVIDER_ID) + setProviderSelectionValidation(false) + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(false) + setProviderEditingId('') + clearPendingProviderChanges() setEditingIndex(-1) }} > diff --git a/src/popup/sections/GeneralPart.jsx b/src/popup/sections/GeneralPart.jsx index 22b46513d..fdcf7efb2 100644 --- a/src/popup/sections/GeneralPart.jsx +++ b/src/popup/sections/GeneralPart.jsx @@ -1,16 +1,9 @@ import { useTranslation } from 'react-i18next' -import { useLayoutEffect, useState } from 'react' +import { useLayoutEffect, useRef, useState } from 'react' import FileSaver from 'file-saver' +import { openUrl, isApiModeSelected, getApiModesFromConfig } from '../../utils/index.mjs' import { - modelNameToDesc, - isApiModeSelected, - getApiModesFromConfig, - apiModeToModelName, -} from '../../utils/index.mjs' -import { - isUsingOpenAiApiModel, isUsingAzureOpenAiApiModel, - isUsingChatGLMApiModel, isUsingClaudeApiModel, isUsingCustomModel, isUsingOllamaApiModel, @@ -19,11 +12,7 @@ import { ModelMode, ThemeMode, TriggerMode, - isUsingMoonshotApiModel, Models, - isUsingOpenRouterApiModel, - isUsingAimlApiModel, - isUsingDeepSeekApiModel, } from '../../config/index.mjs' import Browser from 'webextension-polyfill' import { languageList } from '../../config/language.mjs' @@ -31,10 +20,37 @@ import PropTypes from 'prop-types' import { config as menuConfig } from '../../content-script/menu-tools' import { PencilIcon } from '@primer/octicons-react' import { importDataIntoStorage } from './import-data-cleanup.mjs' +import { resolveOpenAICompatibleRequest } from '../../services/apis/provider-registry.mjs' +import { + checkBilling, + formatFiniteBalance, + getBalanceCacheKey, + normalizeBillingApiBaseUrl, + resolveOpenAIBalanceContext, + shouldOpenOpenAIUsageFallbackPage, + shouldOpenOpenAIUsagePage, +} from './general-balance-utils.mjs' +import { getApiModeDisplayLabel } from './api-modes-provider-utils.mjs' +import { + buildProviderOverrideFinalConfigUpdate, + resolveOverrideCommitContext, + resolveCommittedMigratedSessions, + resolveCommittedOverrideSourceProvider, +} from './general-provider-override-utils.mjs' +import { + buildSelectedModeProviderSecretOverrideUpdate, + buildProviderSecretUpdate, + createProviderSecretOverrideCommitSelectionSignature, + hasSelectedModeOwnProviderSecretOverride, + resolveProviderSecretTargetId, + rollbackProviderSecretOverrideSessionMigration, +} from './provider-secret-utils.mjs' GeneralPart.propTypes = { config: PropTypes.object.isRequired, updateConfig: PropTypes.func.isRequired, + getPersistedConfig: PropTypes.func.isRequired, + awaitConfigWritesSettled: PropTypes.func.isRequired, setTabIndex: PropTypes.func.isRequired, } @@ -42,9 +58,37 @@ function isUsingSpecialCustomModel(configOrSession) { return isUsingCustomModel(configOrSession) && !configOrSession.apiMode } -export function GeneralPart({ config, updateConfig, setTabIndex }) { +function normalizeLoadedSessionsResult(stored) { + return { + ok: true, + sessions: Array.isArray(stored?.sessions) ? stored.sessions : [], + } +} + +function isOverrideCommitCurrent( + commitGeneration, + currentGeneration, + commitSelectionSignature, + currentSelectionSignature, +) { + return ( + commitGeneration === currentGeneration && commitSelectionSignature === currentSelectionSignature + ) +} + +export function GeneralPart({ + config, + updateConfig, + getPersistedConfig, + awaitConfigWritesSettled, + setTabIndex, +}) { const { t, i18n } = useTranslation() + const [balance, setBalance] = useState(null) const [apiModes, setApiModes] = useState([]) + const [providerApiKeyDraft, setProviderApiKeyDraft] = useState('') + const [isOverrideProviderKeyActionPending, setIsOverrideProviderKeyActionPending] = + useState(false) useLayoutEffect(() => { setApiModes(getApiModesFromConfig(config, true)) @@ -55,6 +99,343 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { config.ollamaModelName, ]) + const selectedProviderSession = + config.apiMode && typeof config.apiMode === 'object' + ? { apiMode: config.apiMode } + : { modelName: config.modelName } + const selectedProviderRequest = resolveOpenAICompatibleRequest(config, selectedProviderSession) + const selectedProviderId = selectedProviderRequest?.providerId || '' + const selectedProviderSecretTargetId = resolveProviderSecretTargetId(selectedProviderRequest) + const selectedOpenAIBalanceContext = resolveOpenAIBalanceContext( + selectedProviderRequest, + selectedProviderSecretTargetId, + config.customOpenAiApiUrl, + config.apiMode?.sourceProviderId, + ) + const selectedProviderApiKey = selectedProviderRequest?.apiKey || '' + const normalizedProviderApiKeyDraft = String(providerApiKeyDraft || '').trim() + const normalizedSelectedProviderApiKey = String(selectedProviderApiKey || '').trim() + const isProviderApiKeyDraftDirty = + normalizedProviderApiKeyDraft !== normalizedSelectedProviderApiKey + const isUsingOpenAICompatibleProvider = Boolean(selectedProviderRequest) + const resolvedOpenAiApiUrl = selectedOpenAIBalanceContext.apiBaseUrl + const isSelectedProviderKeyManagedByModeOverride = hasSelectedModeOwnProviderSecretOverride( + config, + selectedProviderSecretTargetId, + ) + const shouldShowOpenAIBalanceControls = shouldOpenOpenAIUsagePage( + selectedOpenAIBalanceContext.providerId, + selectedOpenAIBalanceContext.sourceProviderId, + ) + const selectedOverrideCommitSelectionSignature = + createProviderSecretOverrideCommitSelectionSignature( + selectedProviderSecretTargetId, + config.apiMode, + ) + const balanceCacheKey = getBalanceCacheKey( + selectedOpenAIBalanceContext.providerId, + selectedProviderApiKey, + resolvedOpenAiApiUrl, + ) + const overrideCommitGenerationRef = useRef(0) + const overrideCommitPendingCountRef = useRef(0) + const overrideCommitQueueRef = useRef(Promise.resolve()) + const overrideCommitSelectionSignatureRef = useRef(selectedOverrideCommitSelectionSignature) + + useLayoutEffect(() => { + overrideCommitSelectionSignatureRef.current = selectedOverrideCommitSelectionSignature + overrideCommitGenerationRef.current += 1 + }, [selectedOverrideCommitSelectionSignature]) + + useLayoutEffect(() => { + setProviderApiKeyDraft(selectedProviderApiKey) + }, [ + resolvedOpenAiApiUrl, + selectedProviderApiKey, + selectedProviderId, + selectedProviderSecretTargetId, + ]) + + useLayoutEffect(() => { + setBalance(null) + }, [balanceCacheKey]) + + const loadLatestSessions = async () => { + try { + const stored = await Browser.storage.local.get('sessions') + return normalizeLoadedSessionsResult(stored) + } catch { + return { ok: false, sessions: [] } + } + } + + const commitSelectedModeProviderKeyOverride = async (nextApiKey) => { + overrideCommitPendingCountRef.current += 1 + setIsOverrideProviderKeyActionPending(true) + const commitGeneration = ++overrideCommitGenerationRef.current + const commitSelectionSignature = selectedOverrideCommitSelectionSignature + const runCommit = async () => { + try { + const { committedConfig, existingProviders } = await resolveOverrideCommitContext( + awaitConfigWritesSettled, + getPersistedConfig, + selectedProviderId, + ) + if ( + !isOverrideCommitCurrent( + commitGeneration, + overrideCommitGenerationRef.current, + commitSelectionSignature, + overrideCommitSelectionSignatureRef.current, + ) + ) { + return + } + const { overrideSourceProvider } = resolveCommittedOverrideSourceProvider( + committedConfig, + selectedProviderSecretTargetId, + ) + + if (!overrideSourceProvider) { + console.warn('[popup] Selected provider disappeared before provider override commit') + return + } + + const { configUpdate, sessionMigration, cleanupCandidateProviderId } = + buildSelectedModeProviderSecretOverrideUpdate( + committedConfig, + selectedProviderSecretTargetId, + nextApiKey, + overrideSourceProvider, + existingProviders, + ) + + const committedSessionsResult = await resolveCommittedMigratedSessions( + loadLatestSessions, + sessionMigration, + ) + if (!committedSessionsResult.ok) { + return + } + const latestSessions = committedSessionsResult.sessions + const updatedSessions = committedSessionsResult.migratedSessions + if ( + !isOverrideCommitCurrent( + commitGeneration, + overrideCommitGenerationRef.current, + commitSelectionSignature, + overrideCommitSelectionSignatureRef.current, + ) + ) { + return + } + + if (updatedSessions !== latestSessions) { + try { + await Browser.storage.local.set({ sessions: updatedSessions }) + } catch (error) { + console.error( + '[popup] Failed to persist migrated sessions for provider override', + error, + ) + return + } + } + + const rollbackMigratedSessions = async (message, error) => { + if (updatedSessions === latestSessions || !sessionMigration) return + if (error) { + console.error(message, error) + } else { + console.error(message) + } + + const currentSessionsResult = await loadLatestSessions() + if (!currentSessionsResult.ok) { + console.error( + '[popup] Failed to reload sessions for provider override selective rollback', + ) + return + } + const rolledBackSessions = rollbackProviderSecretOverrideSessionMigration( + currentSessionsResult.sessions, + latestSessions, + sessionMigration, + ) + if (rolledBackSessions === currentSessionsResult.sessions) return + try { + await Browser.storage.local.set({ sessions: rolledBackSessions }) + } catch (rollbackError) { + console.error( + '[popup] Failed to persist selective rollback for provider override sessions', + rollbackError, + ) + } + } + + const shouldPreserveCurrentSelection = !isOverrideCommitCurrent( + commitGeneration, + overrideCommitGenerationRef.current, + commitSelectionSignature, + overrideCommitSelectionSignatureRef.current, + ) + const finalConfigUpdate = buildProviderOverrideFinalConfigUpdate( + cleanupCandidateProviderId, + committedConfig, + configUpdate, + updatedSessions, + shouldPreserveCurrentSelection, + ) + if (Object.keys(finalConfigUpdate).length === 0) { + await rollbackMigratedSessions( + '[popup] Provider override produced no config update; attempting selective session rollback', + ) + return + } + try { + await updateConfig(finalConfigUpdate) + } catch (error) { + await rollbackMigratedSessions( + '[popup] Failed to persist provider override config update; attempting selective session rollback', + error, + ) + return + } + } finally { + overrideCommitPendingCountRef.current = Math.max( + 0, + overrideCommitPendingCountRef.current - 1, + ) + if (overrideCommitPendingCountRef.current === 0) { + setIsOverrideProviderKeyActionPending(false) + } + } + } + const commitPromise = overrideCommitQueueRef.current.then(runCommit) + overrideCommitQueueRef.current = commitPromise.catch(() => {}) + await commitPromise + } + + const commitProviderApiKeyDraft = async (nextApiKey) => { + if (!selectedProviderId) return + const normalizedNextApiKey = String(nextApiKey || '').trim() + if (normalizedNextApiKey === normalizedSelectedProviderApiKey) { + if (nextApiKey !== selectedProviderApiKey) { + setProviderApiKeyDraft(selectedProviderApiKey) + } + return + } + + if (isSelectedProviderKeyManagedByModeOverride) { + if (!normalizedNextApiKey) { + overrideCommitGenerationRef.current += 1 + return + } + return + } + + await updateConfig( + buildProviderSecretUpdate(config, selectedProviderSecretTargetId, nextApiKey), + ) + } + + const handleProviderApiKeyDraftChange = (nextApiKey) => { + setProviderApiKeyDraft(nextApiKey) + } + + const handleProviderOverrideActionMouseDown = (e) => { + e.preventDefault() + } + + const handleProviderApiKeyBlur = (e) => { + if (isSelectedProviderKeyManagedByModeOverride) { + if (e.relatedTarget?.closest?.('[data-provider-key-action]')) return + if (providerApiKeyDraft !== selectedProviderApiKey) { + setProviderApiKeyDraft(selectedProviderApiKey) + } + return + } + void commitProviderApiKeyDraft(providerApiKeyDraft) + } + + const handleSaveProviderKeyOverride = () => { + if ( + !selectedProviderSecretTargetId || + !isSelectedProviderKeyManagedByModeOverride || + isOverrideProviderKeyActionPending || + normalizedProviderApiKeyDraft.length === 0 || + normalizedProviderApiKeyDraft === normalizedSelectedProviderApiKey + ) { + return + } + void commitSelectedModeProviderKeyOverride(providerApiKeyDraft) + } + + const handleUseSharedProviderKey = () => { + if ( + !selectedProviderSecretTargetId || + !isSelectedProviderKeyManagedByModeOverride || + isOverrideProviderKeyActionPending + ) + return + setProviderApiKeyDraft('') + void commitSelectedModeProviderKeyOverride('') + } + + const handleProviderApiKeyInputKeyDown = (e) => { + if (e.key !== 'Enter') return + if (isSelectedProviderKeyManagedByModeOverride) { + e.preventDefault() + handleSaveProviderKeyOverride() + return + } + e.currentTarget.blur() + } + + const getBalance = async () => { + const isOpenAIProvider = shouldShowOpenAIBalanceControls + if (!isOpenAIProvider) { + setBalance(null) + return + } + + const apiKeyForBalance = normalizedProviderApiKeyDraft + const billingApiBaseUrl = normalizeBillingApiBaseUrl(resolvedOpenAiApiUrl) + try { + const response = await fetch(`${billingApiBaseUrl}/dashboard/billing/credit_grants`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKeyForBalance}`, + }, + }) + if (response.ok) { + const primaryBalance = formatFiniteBalance((await response.json())?.total_available) + if (primaryBalance !== null) { + setBalance(primaryBalance) + return + } + } + + const billing = await checkBilling(apiKeyForBalance, billingApiBaseUrl) + const fallbackBalance = formatFiniteBalance(billing?.[2]) + if (fallbackBalance !== null) { + setBalance(fallbackBalance) + return + } + } catch (error) { + console.error(error) + } + if ( + shouldOpenOpenAIUsageFallbackPage( + selectedOpenAIBalanceContext.providerId, + selectedOpenAIBalanceContext.sourceProviderId, + billingApiBaseUrl, + isSelectedProviderKeyManagedByModeOverride, + ) + ) { + openUrl('https://platform.openai.com/account/usage') + } + } return ( <>