diff --git a/docs/providers-and-models.md b/docs/providers-and-models.md index 4f4810f44..01df43460 100644 --- a/docs/providers-and-models.md +++ b/docs/providers-and-models.md @@ -351,6 +351,8 @@ await pm.save(); // Persist to chrome.storage.local pm.getActive(); // Get the active provider instance await pm.setActive('openai'); // Switch active provider await pm.updateProvider('openai', { model: 'gpt-5' }); // Update config +await pm.duplicateProvider('openai'); // Create openai__duplicate +await pm.removeDuplicateProvider('openai__duplicate'); // Remove it pm.getAll(); // All provider configs (for Settings UI) await pm.testProvider('openai'); // Test connection ``` @@ -362,6 +364,17 @@ state and is separate from `activeProvider`, which is the provider currently configured. Connection tests report reachability but do not control the Active flag. +Settings can create one independent duplicate of each configurable endpoint +provider. A duplicate is stored as a normal provider entry with the stable ID +`__duplicate` and a `duplicateOf` reference to the source definition, +so credentials, models, endpoint URLs, compatibility options, export/import, +and active-provider selection continue to use the existing provider schema. +The manager rejects duplicate-of-duplicate, second, orphaned, type-mismatched, +and forged duplicate entries when loading storage. WebBrain Cloud and the +Chromium-only WebGPU runtime are not duplicable because they do not represent +independent user-managed API credentials or endpoints; their cards keep the +Duplicate affordance disabled with an explanatory tooltip. + ### Settings Search The Settings search index includes provider IDs, labels, type/category, model, @@ -372,7 +385,7 @@ ties, and the selected provider remains visible across category filters. ### Config Persistence -Configs are stored in `chrome.storage.local` under the `providers` key, merged against defaults. Defaults provide the SHAPE (which provider keys exist); stored configs override per-key values. This allows upgrades that introduce new provider entries to work without users clearing storage. +Configs are stored in `chrome.storage.local` under the `providers` key, merged against defaults. Defaults provide the SHAPE (which provider keys exist); stored configs override per-key values. This allows upgrades that introduce new provider entries to work without users clearing storage. Duplicate entries share this same persistence path and therefore remain portable through Settings config export/import. Deprecated provider entries (`webbrain`, `openai_subscription`, `claude_subscription`) are filtered out. @@ -417,7 +430,7 @@ this option because its build has no MV3 offscreen document. ### Transcription Provider -Used by Tab Recorder for Whisper transcription. Falls back through configured providers in priority order: OpenAI → Groq → LM Studio → llama.cpp. Blocklist excludes providers known not to host Whisper (Anthropic, Gemini, Mistral, DeepSeek, xAI, Nvidia). +Used by Tab Recorder for Whisper transcription. Falls back through configured providers in priority order: OpenAI → Groq → LM Studio → llama.cpp. Blocklist excludes providers known not to host Whisper (Anthropic, Gemini, Mistral, DeepSeek, xAI, Nvidia, Kimi), including duplicates of those providers. --- diff --git a/src/chrome/src/agent/transcribe.js b/src/chrome/src/agent/transcribe.js index 216cf453c..8f5ff8a6d 100644 --- a/src/chrome/src/agent/transcribe.js +++ b/src/chrome/src/agent/transcribe.js @@ -70,13 +70,14 @@ function pickProvider(providers) { // …then any other OpenAI-compatible provider not on the blocklist. const iter = providers.entries ? providers.entries() : Object.entries(providers); for (const [id, p] of iter) { - if (NO_WHISPER.has(id)) continue; const cfg = p.config || p; + const sourceProviderId = cfg.duplicateOf || id; + if (NO_WHISPER.has(sourceProviderId)) continue; if (cfg.type !== 'openai') continue; if (cfg.enabled === false) continue; if (!cfg.baseUrl) continue; if (!cfg.apiKey) continue; - return { id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey }; + return { id, sourceProviderId, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey }; } return null; } @@ -139,7 +140,8 @@ export async function transcribeAudio(providers, audioBlob, opts = {}) { }; } - const model = opts.modelOverride || picked.explicitModel || WHISPER_MODEL_BY_PROVIDER[picked.id] || 'whisper-1'; + const sourceProviderId = picked.sourceProviderId || picked.id; + const model = opts.modelOverride || picked.explicitModel || WHISPER_MODEL_BY_PROVIDER[sourceProviderId] || 'whisper-1'; const filename = opts.filename || 'recording.webm'; // Explicit overrides may be imported from an older version as a bare local @@ -196,7 +198,7 @@ export async function transcribeAudio(providers, audioBlob, opts = {}) { res.status === 415 && /application\/json|must.*use.*json/i.test(detail); if (isChatOnlyEndpoint) { - const isLocal = picked.id === 'lmstudio' || picked.id === 'llamacpp'; + const isLocal = sourceProviderId === 'lmstudio' || sourceProviderId === 'llamacpp'; return { ok: false, error: diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 1b38828c2..fcf333bc2 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -3311,6 +3311,12 @@ async function handleMessage(msg, sender) { return { ok: true }; } + case 'duplicate_provider': + return await providerManager.duplicateProvider(msg.providerId); + + case 'remove_duplicate_provider': + return await providerManager.removeDuplicateProvider(msg.providerId); + case 'ollama_launch_handoff': { const handoff = normalizeOllamaLaunchHandoff(msg.handoff || {}); await providerManager.updateProvider(handoff.providerId, handoff.config); diff --git a/src/chrome/src/providers/manager.js b/src/chrome/src/providers/manager.js index f9392bb32..a6e44bf78 100644 --- a/src/chrome/src/providers/manager.js +++ b/src/chrome/src/providers/manager.js @@ -58,6 +58,7 @@ import { } from './connection-test-assets.js'; const WEBBRAIN_CLOUD_PROVIDER_ID = 'webbrain_cloud'; +const DUPLICATE_PROVIDER_SUFFIX = '__duplicate'; const LOCAL_MODEL_LIST_PROVIDER_IDS = ['llamacpp', 'ollama', 'lmstudio', 'jan', 'vllm', 'sglang', 'localai', 'gpt4all', 'local_openai_proxy']; const WEBBRAIN_CLOUD_CONTEXT_WINDOW = 1000000; const WEBBRAIN_CLOUD_LEGACY_CONTEXT_WINDOW = 256000; @@ -109,7 +110,7 @@ export class ProviderManager { || Object.hasOwn(rawStoredOllama, 'supportsVision') ); const genericVisionConfigMigrated = Object.entries(data.providers || {}).some(([id, config]) => { - return !!visionProviderKind(id, config) && ( + return !!visionProviderKind(config?.duplicateOf || id, config) && ( !VISION_MODES.has(config.visionMode) || Object.hasOwn(config, 'supportsVision') || (!String(config.model || '').trim() && config.visionDetection != null) @@ -141,6 +142,10 @@ export class ProviderManager { ...this._storedDefaultOverride(config, storedConfig), configured, }; + if (Object.hasOwn(configs[id], 'duplicateOf')) { + delete configs[id].duplicateOf; + providerStateMigrated = true; + } if (storedConfig && !hasConfiguredMarker) providerStateMigrated = true; } // Carry over any stored-only entries (e.g. legacy provider ids). @@ -154,6 +159,12 @@ export class ProviderManager { if (!hasConfiguredMarker) providerStateMigrated = true; } } + for (const [id, config] of Object.entries(configs)) { + if (config.duplicateOf && !this._isValidDuplicateConfig(id, config, configs)) { + delete configs[id]; + providerStateMigrated = true; + } + } delete configs.webbrain; delete configs.openai_subscription; delete configs.claude_subscription; @@ -680,7 +691,7 @@ export class ProviderManager { delete migrated.ollama.supportsVision; } for (const [id, config] of Object.entries(migrated)) { - if (!visionProviderKind(id, config)) continue; + if (!visionProviderKind(config.duplicateOf || id, config)) continue; const legacySupportsVision = config.supportsVision; const hasConfiguredModel = !!String(config.model || '').trim(); migrated[id] = { @@ -754,6 +765,29 @@ export class ProviderManager { SUPPORTED_PROVIDER_TYPES.has(config.type); } + _providerDefinitionId(id, config = this.providers.get(id)?.config) { + return String(config?.duplicateOf || id || ''); + } + + _duplicateProviderId(id) { + return `${id}${DUPLICATE_PROVIDER_SUFFIX}`; + } + + _canDuplicateProvider(id, config = this.providers.get(id)?.config) { + return !!config && !config.duplicateOf && id !== WEBBRAIN_CLOUD_PROVIDER_ID && config.type !== 'webgpu'; + } + + _isValidDuplicateConfig(id, config, configs) { + const sourceId = config?.duplicateOf; + const sourceConfig = configs[sourceId]; + return typeof sourceId === 'string' && + SAFE_PROVIDER_ID_RE.test(sourceId) && + id === this._duplicateProviderId(sourceId) && + !!sourceConfig && + this._canDuplicateProvider(sourceId, sourceConfig) && + sourceConfig.type === config.type; + } + /** * Provider category for filter UI. Returns one of: * 'local' — runs in-browser or connects through a local endpoint @@ -771,6 +805,7 @@ export class ProviderManager { } _createProvider(id, config) { + const definitionId = this._providerDefinitionId(id, config); const normalizedConfig = { ...config, category: ProviderManager.categoryFor(id, config), @@ -783,13 +818,13 @@ export class ProviderManager { 'vertex_anthropic', ].includes(config.type), }; - if (id === 'ollama') { + if (definitionId === 'ollama') { normalizedConfig.visionMode = OLLAMA_VISION_MODES.has(normalizedConfig.visionMode) ? normalizedConfig.visionMode : 'auto'; delete normalizedConfig.supportsVision; } - if (visionProviderKind(id, normalizedConfig)) { + if (visionProviderKind(definitionId, normalizedConfig)) { const legacySupportsVision = normalizedConfig.supportsVision; normalizedConfig.visionMode = VISION_MODES.has(normalizedConfig.visionMode) ? normalizedConfig.visionMode @@ -903,7 +938,7 @@ export class ProviderManager { async ensureVisionCapability(id) { const provider = this.providers.get(id); - const providerKind = visionProviderKind(id, provider?.config); + const providerKind = visionProviderKind(this._providerDefinitionId(id, provider?.config), provider?.config); if (!provider || !providerKind) return { ok: false, skipped: true }; const mode = VISION_MODES.has(provider.config.visionMode) ? provider.config.visionMode : 'auto'; if (mode !== 'auto') return { ok: true, skipped: true, supportsVision: mode === 'on' }; @@ -928,7 +963,7 @@ export class ProviderManager { this._visionCapabilityChecks.delete(checkKey); } const current = this.providers.get(id); - const currentKind = visionProviderKind(id, current?.config); + const currentKind = visionProviderKind(this._providerDefinitionId(id, current?.config), current?.config); const currentIdentity = visionCapabilityIdentity(currentKind, current?.config); if (!current || current.config.visionMode !== 'auto' || currentKind !== providerKind @@ -1054,9 +1089,12 @@ export class ProviderManager { } async prepareActiveProviderCapabilities() { - if (this.activeProviderId === 'ollama') return this.ensureOllamaVisionCapability('ollama'); const activeProvider = this.providers.get(this.activeProviderId); - if (visionProviderKind(this.activeProviderId, activeProvider?.config)) { + const definitionId = this._providerDefinitionId(this.activeProviderId, activeProvider?.config); + if (definitionId === 'ollama') { + return this.ensureOllamaVisionCapability(this.activeProviderId); + } + if (visionProviderKind(definitionId, activeProvider?.config)) { return this.ensureVisionCapability(this.activeProviderId); } return { ok: true, skipped: true }; @@ -1211,12 +1249,16 @@ export class ProviderManager { throw new Error(`Provider not found: ${id}`); } const current = this.providers.get(id).config; + const updates = this._storedDefaultOverride(current, config); + for (const key of ['id', 'duplicateOf', 'sourceProviderId', 'isDuplicate', 'hasDuplicate', 'canDuplicate']) { + delete updates[key]; + } const merged = { ...current, - ...this._storedDefaultOverride(current, config), + ...updates, configured: id !== WEBBRAIN_CLOUD_PROVIDER_ID && (markConfigured || current.configured === true), }; - if (id === 'ollama') { + if (this._providerDefinitionId(id, current) === 'ollama') { merged.visionMode = OLLAMA_VISION_MODES.has(merged.visionMode) ? merged.visionMode : 'auto'; delete merged.supportsVision; const currentIdentity = this._ollamaVisionIdentity(current); @@ -1225,7 +1267,8 @@ export class ProviderManager { merged.visionDetection = null; } } - const genericVisionKind = visionProviderKind(id, merged); + const definitionId = this._providerDefinitionId(id, merged); + const genericVisionKind = visionProviderKind(definitionId, merged); if (genericVisionKind) { const explicitLegacyVision = Object.hasOwn(config, 'supportsVision') && !Object.hasOwn(config, 'visionMode') ? config.supportsVision @@ -1234,7 +1277,7 @@ export class ProviderManager { ? (explicitLegacyVision ? 'on' : 'off') : (VISION_MODES.has(merged.visionMode) ? merged.visionMode : 'auto'); delete merged.supportsVision; - const currentKind = visionProviderKind(id, current); + const currentKind = visionProviderKind(this._providerDefinitionId(id, current), current); const currentIdentity = visionCapabilityIdentity(currentKind, current); const nextIdentity = visionCapabilityIdentity(genericVisionKind, merged); if (currentKind !== genericVisionKind || currentIdentity?.key !== nextIdentity?.key || current.visionMode !== merged.visionMode) { @@ -1249,6 +1292,65 @@ export class ProviderManager { await this.save(); } + /** + * Clone one configurable provider into a second independently persisted + * instance. The duplicate keeps its source definition ID so UIs can reuse + * the source card fields and branding without introducing another config + * schema. + */ + async duplicateProvider(id) { + const source = this.providers.get(id); + if (!source) throw new Error(`Provider not found: ${id}`); + if (!this._canDuplicateProvider(id, source.config)) { + throw new Error(`Provider cannot be duplicated: ${id}`); + } + const duplicateId = this._duplicateProviderId(id); + if (this.providers.has(duplicateId) || [...this.providers.values()].some(provider => provider.config?.duplicateOf === id)) { + throw new Error(`${source.config.label || id} already has a duplicate.`); + } + + const duplicateConfig = structuredClone(source.config); + duplicateConfig.duplicateOf = id; + duplicateConfig.label = `${source.config.label || id} 2`; + this.providers.set(duplicateId, this._createProvider(duplicateId, duplicateConfig)); + try { + await this.save(); + } catch (error) { + this.providers.delete(duplicateId); + throw error; + } + return { providerId: duplicateId, sourceProviderId: id }; + } + + /** Keep the persisted active ID valid when the selected duplicate disappears. */ + async removeDuplicateProvider(id) { + const duplicate = this.providers.get(id); + if (!duplicate) throw new Error(`Provider not found: ${id}`); + const sourceId = duplicate.config?.duplicateOf; + if (!sourceId) throw new Error('Only duplicate providers can be removed.'); + + const previousActiveProviderId = this.activeProviderId; + this.providers.delete(id); + if (previousActiveProviderId === id) { + const source = this.providers.get(sourceId); + this.activeProviderId = source && (sourceId === WEBBRAIN_CLOUD_PROVIDER_ID || source.config?.configured === true) + ? sourceId + : WEBBRAIN_CLOUD_PROVIDER_ID; + } + try { + await this.save(); + } catch (error) { + this.providers.set(id, duplicate); + this.activeProviderId = previousActiveProviderId; + throw error; + } + this._visionCapabilityEpochs.delete(id); + for (const key of this._visionCapabilityChecks.keys()) { + if (key.startsWith(`${id}\n`)) this._visionCapabilityChecks.delete(key); + } + return { removedProviderId: id, activeProviderId: this.activeProviderId }; + } + /** * Get all provider configs for the settings UI. Each entry includes a * `category` field ('local' | 'cloud' | 'router') so the UI can filter @@ -1256,12 +1358,21 @@ export class ProviderManager { */ getAll() { const result = {}; + const duplicatedSourceIds = new Set( + [...this.providers.values()].map(provider => provider.config?.duplicateOf).filter(Boolean), + ); for (const [id, provider] of this.providers) { const config = provider.config; + const isDuplicate = !!config.duplicateOf; + const hasDuplicate = !isDuplicate && duplicatedSourceIds.has(id); result[id] = { id, ...config, category: ProviderManager.categoryFor(id, config), + sourceProviderId: this._providerDefinitionId(id, config), + isDuplicate, + hasDuplicate, + canDuplicate: this._canDuplicateProvider(id, config) && !hasDuplicate, }; } return result; @@ -1443,7 +1554,8 @@ export class ProviderManager { async listProviderModels(id) { const provider = this.providers.get(id); if (!provider) return { ok: false, error: 'Provider not found' }; - if (!LOCAL_MODEL_LIST_PROVIDER_IDS.includes(id)) { + const definitionId = this._providerDefinitionId(id, provider.config); + if (!LOCAL_MODEL_LIST_PROVIDER_IDS.includes(definitionId)) { return { ok: false, error: 'Model loading is only supported for local providers' }; } if (provider.config.requiresApiKey && !String(provider.config.apiKey || '').trim()) { @@ -1464,7 +1576,7 @@ export class ProviderManager { // Studio), we fall through to /v1/models below. const headers = this._modelListHeaders(provider); - if (id === 'lmstudio') { + if (definitionId === 'lmstudio') { const host = rawBaseUrl.replace(/\/v1\/?$/, ''); try { const res = await fetchWithFallback(`${host}/api/v0/models`, { method: 'GET', headers }); @@ -1486,8 +1598,8 @@ export class ProviderManager { } let firstFailure = null; - for (const candidate of this._modelListCandidates(id, rawBaseUrl)) { - const url = id === 'ollama' ? `${candidate.requestBaseUrl}/api/tags` : `${candidate.requestBaseUrl}/models`; + for (const candidate of this._modelListCandidates(definitionId, rawBaseUrl)) { + const url = definitionId === 'ollama' ? `${candidate.requestBaseUrl}/api/tags` : `${candidate.requestBaseUrl}/models`; try { const res = await fetchWithFallback(url, { method: 'GET', headers }); if (!res.ok) { @@ -1498,7 +1610,7 @@ export class ProviderManager { // the other local providers sharing this path (llamacpp, // lmstudio, jan, vllm, sglang, localai) a 403 means something // else (auth proxy, --api-key, ...), so report it generically. - if (!firstFailure) firstFailure = id === 'ollama' + if (!firstFailure) firstFailure = definitionId === 'ollama' ? { ok: false, error: @@ -1511,7 +1623,7 @@ export class ProviderManager { continue; } const data = await res.json(); - const models = this._extractModelIds(id, data); + const models = this._extractModelIds(definitionId, data); const result = { ok: true, models }; if (candidate.configBaseUrl !== observedBaseUrl) { if (await this._updateProviderBaseUrl(id, candidate.configBaseUrl, observedBaseUrl)) { @@ -1665,7 +1777,8 @@ export class ProviderManager { } async _attachDetectedContextWindow(id, provider, result, options = {}) { - if (!result?.ok || !LOCAL_MODEL_LIST_PROVIDER_IDS.includes(id)) return result; + const definitionId = this._providerDefinitionId(id, provider?.config); + if (!result?.ok || !LOCAL_MODEL_LIST_PROVIDER_IDS.includes(definitionId)) return result; const observed = { model: provider?.config?.model, baseUrl: provider?.config?.baseUrl, @@ -1692,17 +1805,18 @@ export class ProviderManager { * came from live/runtime allocation (safe to shrink a manual override). */ async _detectLocalContextWindow(id, provider, options = {}) { - if (!LOCAL_MODEL_LIST_PROVIDER_IDS.includes(id)) return null; + const definitionId = this._providerDefinitionId(id, provider?.config); + if (!LOCAL_MODEL_LIST_PROVIDER_IDS.includes(definitionId)) return null; const headers = this._modelListHeaders(provider); const rawBaseUrl = String(options.requestBaseUrl || provider?.config?.baseUrl || '') .trim() .replace(/\/+$/, ''); - if (!rawBaseUrl && id !== 'lmstudio') return null; + if (!rawBaseUrl && definitionId !== 'lmstudio') return null; const root = rawBaseUrl.replace(/\/v1$/i, ''); const model = String(options.model || provider?.config?.model || '').trim(); try { - if (id === 'lmstudio') { + if (definitionId === 'lmstudio') { const payload = options.lmStudioData || null; let data = payload; if (!data) { @@ -1720,7 +1834,7 @@ export class ProviderManager { }; } - if (id === 'llamacpp') { + if (definitionId === 'llamacpp') { const res = await fetchWithFallback(`${root}/props`, { method: 'GET', headers }); if (!res.ok) return null; const contextWindow = parseLlamaCppPropsContextWindow(await res.json()); @@ -1728,7 +1842,7 @@ export class ProviderManager { return { contextWindow, shrinkOverride: true }; } - if (id === 'ollama') { + if (definitionId === 'ollama') { // Prefer live allocated context from /api/ps (honors runtime num_ctx / // OLLAMA_CONTEXT_LENGTH). Fall back to /api/show num_ctx — never the // architecture max in model_info.*.context_length (overstates). @@ -1754,7 +1868,7 @@ export class ProviderManager { return { contextWindow, shrinkOverride: false }; } - if (id === 'vllm' || id === 'sglang') { + if (definitionId === 'vllm' || definitionId === 'sglang') { let data = options.modelListData || null; if (!data) { const openAiBase = /\/v1$/i.test(rawBaseUrl) ? rawBaseUrl : `${root}/v1`; @@ -1767,7 +1881,7 @@ export class ProviderManager { return { contextWindow, shrinkOverride: true }; } - if (id === 'localai') { + if (definitionId === 'localai') { if (!model) return null; const res = await fetchWithFallback(`${root}/api/models/config-json/${encodeURIComponent(model)}`, { method: 'GET', diff --git a/src/chrome/src/ui/locales/ar.js b/src/chrome/src/ui/locales/ar.js index 2190015c2..01be9ac66 100644 --- a/src/chrome/src/ui/locales/ar.js +++ b/src/chrome/src/ui/locales/ar.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'يتضمّن الاستخدام اليومي المجاني لـ WebBrain Cloud. تمر الطلبات عبر api.webbrain.one؛ افتراضيًا نسجّل البيانات الوصفية للحصة وتصحيح الأخطاء، وليس نص المطالبات أو محتوى الصفحة أو لقطات الشاشة أو ردود النموذج. {privacyLink}. لمزيد من الاستخدام، اشترك عبر {subscribeLink}. أدِر الفوترة عبر {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'سياسة الخصوصية', 'st.providers.test': 'اختبار الاتصال', + 'st.providers.duplicate': 'نسخ', + 'st.providers.duplicate_limit': 'يُسمح بنسخة واحدة فقط', + 'st.providers.duplicate_unavailable': 'لا يمكن نسخ مزود الخدمة هذا', + 'st.providers.remove_duplicate': 'حذف النسخة', + 'st.providers.remove_duplicate_confirm': 'هل تريد حذف نسخة مزود الخدمة هذه وإعداداتها المحفوظة؟', 'st.providers.set_active': 'تعيينه نشطًا', 'st.providers.active': 'نشط', 'st.providers.saved': 'تم الحفظ!', diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 65ae92caf..bdef9955e 100644 --- a/src/chrome/src/ui/locales/bn.js +++ b/src/chrome/src/ui/locales/bn.js @@ -619,6 +619,11 @@ export default { 'st.providers.search.empty': "কোন প্রদানকারী এই অনুসন্ধান এবং ফিল্টার মেলে না.", 'st.providers.save': "সংরক্ষণ করুন", 'st.providers.test': "পরীক্ষা সংযোগ", + 'st.providers.duplicate': 'ডুপ্লিকেট', + 'st.providers.duplicate_limit': 'শুধু একটি ডুপ্লিকেট অনুমোদিত', + 'st.providers.duplicate_unavailable': 'এই প্রদানকারীকে ডুপ্লিকেট করা যাবে না', + 'st.providers.remove_duplicate': 'ডুপ্লিকেট মুছুন', + 'st.providers.remove_duplicate_confirm': 'এই ডুপ্লিকেট প্রদানকারী ও এর সংরক্ষিত সেটিংস মুছবেন?', 'st.providers.set_active': "সক্রিয় সেট করুন", 'st.providers.active': "সক্রিয়", 'st.providers.saved': "সংরক্ষিত !", diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index 743a43be8..eb30e5199 100644 --- a/src/chrome/src/ui/locales/de.js +++ b/src/chrome/src/ui/locales/de.js @@ -614,6 +614,11 @@ export default { 'st.providers.search.empty': 'Kein Anbieter passt zu dieser Suche und diesem Filter.', 'st.providers.save': 'Speichern', 'st.providers.test': 'Verbindung testen', + 'st.providers.duplicate': 'Duplizieren', + 'st.providers.duplicate_limit': 'Es ist nur ein Duplikat zulässig', + 'st.providers.duplicate_unavailable': 'Dieser Anbieter kann nicht dupliziert werden', + 'st.providers.remove_duplicate': 'Duplikat entfernen', + 'st.providers.remove_duplicate_confirm': 'Diesen duplizierten Anbieter und seine gespeicherte Konfiguration entfernen?', 'st.providers.set_active': 'Als aktiv setzen', 'st.providers.active': 'AKTIV', 'st.providers.saved': 'Gespeichert!', diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index 7c74ba736..d052b3d84 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -620,6 +620,11 @@ export default { 'st.providers.search.empty': 'No providers match this search and filter.', 'st.providers.save': 'Save', 'st.providers.test': 'Test Connection', + 'st.providers.duplicate': 'Duplicate', + 'st.providers.duplicate_limit': 'Only one duplicate is allowed', + 'st.providers.duplicate_unavailable': 'This provider cannot be duplicated', + 'st.providers.remove_duplicate': 'Remove duplicate', + 'st.providers.remove_duplicate_confirm': 'Remove this duplicate provider and its saved configuration?', 'st.providers.set_active': 'Set Active', 'st.providers.active': 'ACTIVE', 'st.providers.saved': 'Saved!', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index 79c88890f..8bfd1e3a2 100644 --- a/src/chrome/src/ui/locales/es.js +++ b/src/chrome/src/ui/locales/es.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'El uso diario gratuito de WebBrain Cloud está incluido. Las solicitudes pasan por api.webbrain.one; de forma predeterminada registramos metadatos para la cuota y la depuración, no el texto de las indicaciones, el contenido de la página, las capturas de pantalla ni las respuestas del modelo. {privacyLink}. Para más uso, suscríbete en {subscribeLink}. Gestiona la facturación en {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Política de privacidad', 'st.providers.test': 'Probar conexión', + 'st.providers.duplicate': 'Duplicar', + 'st.providers.duplicate_limit': 'Solo se permite un duplicado', + 'st.providers.duplicate_unavailable': 'Este proveedor no se puede duplicar', + 'st.providers.remove_duplicate': 'Eliminar duplicado', + 'st.providers.remove_duplicate_confirm': '¿Eliminar este proveedor duplicado y su configuración guardada?', 'st.providers.set_active': 'Establecer activo', 'st.providers.active': 'ACTIVO', 'st.providers.saved': '¡Guardado!', diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index 0f8fefc13..94a32da3c 100644 --- a/src/chrome/src/ui/locales/fa.js +++ b/src/chrome/src/ui/locales/fa.js @@ -619,6 +619,11 @@ export default { 'st.providers.search.empty': "هیچ ارائه دهنده ای با این جستجو و فیلتر مطابقت ندارد.", 'st.providers.save': "ذخیره کنید", 'st.providers.test': "تست اتصال", + 'st.providers.duplicate': 'تکثیر', + 'st.providers.duplicate_limit': 'فقط یک نسخهٔ تکراری مجاز است', + 'st.providers.duplicate_unavailable': 'امکان تکثیر این ارائه‌دهنده وجود ندارد', + 'st.providers.remove_duplicate': 'حذف نسخهٔ تکراری', + 'st.providers.remove_duplicate_confirm': 'این ارائه‌دهندهٔ تکراری و تنظیمات ذخیره‌شدهٔ آن حذف شود؟', 'st.providers.set_active': "فعال را تنظیم کنید", 'st.providers.active': "فعال", 'st.providers.saved': "ذخیره شد!", diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index 0559db3a0..cd5ea7aa3 100644 --- a/src/chrome/src/ui/locales/fr.js +++ b/src/chrome/src/ui/locales/fr.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'Une utilisation quotidienne gratuite de WebBrain Cloud est incluse. Les requêtes passent par api.webbrain.one ; par défaut, nous enregistrons des métadonnées pour le quota et le débogage, pas le texte des invites, le contenu des pages, les captures d\'écran ni les réponses du modèle. {privacyLink}. Pour en faire plus, abonnez-vous sur {subscribeLink}. Gérez la facturation sur {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Politique de confidentialité', 'st.providers.test': 'Tester la connexion', + 'st.providers.duplicate': 'Dupliquer', + 'st.providers.duplicate_limit': 'Un seul doublon est autorisé', + 'st.providers.duplicate_unavailable': 'Ce fournisseur ne peut pas être dupliqué', + 'st.providers.remove_duplicate': 'Supprimer le doublon', + 'st.providers.remove_duplicate_confirm': 'Supprimer ce fournisseur dupliqué et sa configuration enregistrée ?', 'st.providers.set_active': 'Définir comme actif', 'st.providers.active': 'ACTIF', 'st.providers.saved': 'Enregistré !', diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index f75513826..0e65adfa6 100644 --- a/src/chrome/src/ui/locales/he.js +++ b/src/chrome/src/ui/locales/he.js @@ -588,6 +588,11 @@ export default { "st.providers.search.empty": "אין ספקים שתואמים לחיפוש ולמסנן אלה.", "st.providers.save": "שמור", "st.providers.test": "בדיקת חיבור", + 'st.providers.duplicate': 'שכפול', + 'st.providers.duplicate_limit': 'מותר שכפול אחד בלבד', + 'st.providers.duplicate_unavailable': 'לא ניתן לשכפל ספק זה', + 'st.providers.remove_duplicate': 'הסרת השכפול', + 'st.providers.remove_duplicate_confirm': 'להסיר את הספק המשוכפל ואת ההגדרות השמורות שלו?', "st.providers.set_active": "הגדר פעיל", "st.providers.active": "ACTIVE", "st.providers.saved": "נשמר!", diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index 2522f4a55..e9bcf79b1 100644 --- a/src/chrome/src/ui/locales/hi.js +++ b/src/chrome/src/ui/locales/hi.js @@ -619,6 +619,11 @@ export default { 'st.providers.search.empty': "कोई भी प्रदाता इस खोज और फ़िल्टर से मेल नहीं खाता।", 'st.providers.save': "सहेजें", 'st.providers.test': "परीक्षण कनेक्शन", + 'st.providers.duplicate': 'डुप्लिकेट करें', + 'st.providers.duplicate_limit': 'केवल एक डुप्लिकेट की अनुमति है', + 'st.providers.duplicate_unavailable': 'इस प्रदाता को डुप्लिकेट नहीं किया जा सकता', + 'st.providers.remove_duplicate': 'डुप्लिकेट हटाएँ', + 'st.providers.remove_duplicate_confirm': 'क्या इस डुप्लिकेट प्रदाता और इसकी सहेजी गई सेटिंग हटानी हैं?', 'st.providers.set_active': "सक्रिय सेट करें", 'st.providers.active': "सक्रिय", 'st.providers.saved': "सहेजा गया!", diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index 7b04c40f8..773ea7bcf 100644 --- a/src/chrome/src/ui/locales/id.js +++ b/src/chrome/src/ui/locales/id.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'Penggunaan harian gratis WebBrain Cloud sudah termasuk. Permintaan melewati api.webbrain.one; secara default kami mencatat metadata untuk kuota dan debugging, bukan teks prompt, konten halaman, tangkapan layar, atau respons model. {privacyLink}. Untuk penggunaan lebih, berlangganan di {subscribeLink}. Kelola penagihan di {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Kebijakan privasi', 'st.providers.test': 'Uji koneksi', + 'st.providers.duplicate': 'Duplikat', + 'st.providers.duplicate_limit': 'Hanya satu duplikat yang diizinkan', + 'st.providers.duplicate_unavailable': 'Penyedia ini tidak dapat diduplikasi', + 'st.providers.remove_duplicate': 'Hapus duplikat', + 'st.providers.remove_duplicate_confirm': 'Hapus penyedia duplikat ini beserta konfigurasi tersimpannya?', 'st.providers.set_active': 'Jadikan aktif', 'st.providers.active': 'AKTIF', 'st.providers.saved': 'Tersimpan!', diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index 63e3f975e..7abd04c5a 100644 --- a/src/chrome/src/ui/locales/ja.js +++ b/src/chrome/src/ui/locales/ja.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': '無料の毎日の WebBrain Cloud 利用が含まれています。リクエストは api.webbrain.one を経由します。デフォルトでは、割り当てとデバッグのためにメタデータを記録し、プロンプトテキスト、ページの内容、スクリーンショット、モデルの応答は記録しません。{privacyLink}。さらに利用するには {subscribeLink} で登録してください。請求は {accountLink} で管理できます。', 'st.providers.webbrain_note.privacy_link': 'プライバシーポリシー', 'st.providers.test': '接続テスト', + 'st.providers.duplicate': '複製', + 'st.providers.duplicate_limit': '複製は1つだけ作成できます', + 'st.providers.duplicate_unavailable': 'このプロバイダーは複製できません', + 'st.providers.remove_duplicate': '複製を削除', + 'st.providers.remove_duplicate_confirm': 'この複製プロバイダーと保存済み設定を削除しますか?', 'st.providers.set_active': '有効化', 'st.providers.active': '有効', 'st.providers.saved': '保存しました!', diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index 6afba1238..a181e7219 100644 --- a/src/chrome/src/ui/locales/ko.js +++ b/src/chrome/src/ui/locales/ko.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': '무료 일일 WebBrain Cloud 사용량이 포함되어 있습니다. 요청은 api.webbrain.one을 통해 처리됩니다. 기본적으로 할당량과 디버깅을 위한 메타데이터만 기록하며, 프롬프트 텍스트, 페이지 콘텐츠, 스크린샷, 모델 응답은 기록하지 않습니다. {privacyLink}. 더 많은 사용량이 필요하면 {subscribeLink}에서 구독하세요. 청구는 {accountLink}에서 관리하세요.', 'st.providers.webbrain_note.privacy_link': '개인정보 처리방침', 'st.providers.test': '연결 테스트', + 'st.providers.duplicate': '복제', + 'st.providers.duplicate_limit': '복제본은 하나만 만들 수 있습니다', + 'st.providers.duplicate_unavailable': '이 공급자는 복제할 수 없습니다', + 'st.providers.remove_duplicate': '복제본 삭제', + 'st.providers.remove_duplicate_confirm': '이 복제 공급자와 저장된 설정을 삭제할까요?', 'st.providers.set_active': '활성으로 설정', 'st.providers.active': '활성', 'st.providers.saved': '저장되었습니다!', diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index 89e328073..a84bb044c 100644 --- a/src/chrome/src/ui/locales/ms.js +++ b/src/chrome/src/ui/locales/ms.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'Penggunaan harian percuma WebBrain Cloud disertakan. Permintaan melalui api.webbrain.one; secara lalai kami mencatat metadata untuk kuota dan penyahpepijatan, bukan teks gesaan, kandungan halaman, tangkapan skrin atau respons model. {privacyLink}. Untuk penggunaan lebih, langgan di {subscribeLink}. Urus pengebilan di {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Dasar privasi', 'st.providers.test': 'Uji sambungan', + 'st.providers.duplicate': 'Duplikasi', + 'st.providers.duplicate_limit': 'Hanya satu duplikasi dibenarkan', + 'st.providers.duplicate_unavailable': 'Penyedia ini tidak boleh diduplikasi', + 'st.providers.remove_duplicate': 'Alih keluar duplikasi', + 'st.providers.remove_duplicate_confirm': 'Alih keluar penyedia duplikasi ini dan konfigurasi tersimpannya?', 'st.providers.set_active': 'Jadikan aktif', 'st.providers.active': 'AKTIF', 'st.providers.saved': 'Disimpan!', diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index cbed65105..6c0444f2a 100644 --- a/src/chrome/src/ui/locales/nl.js +++ b/src/chrome/src/ui/locales/nl.js @@ -595,6 +595,11 @@ export default { 'st.providers.search.empty': 'Geen providers komen overeen met deze zoekopdracht en filter.', 'st.providers.save': 'Opslaan', 'st.providers.test': 'Verbinding testen', + 'st.providers.duplicate': 'Dupliceren', + 'st.providers.duplicate_limit': 'Er is slechts één duplicaat toegestaan', + 'st.providers.duplicate_unavailable': 'Deze provider kan niet worden gedupliceerd', + 'st.providers.remove_duplicate': 'Duplicaat verwijderen', + 'st.providers.remove_duplicate_confirm': 'Deze gedupliceerde provider en de opgeslagen configuratie verwijderen?', 'st.providers.set_active': 'Actief instellen', 'st.providers.active': 'ACTIEF', 'st.providers.saved': 'Opgeslagen!', diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index c16b853a0..828cf3500 100644 --- a/src/chrome/src/ui/locales/pl.js +++ b/src/chrome/src/ui/locales/pl.js @@ -432,6 +432,11 @@ export default { 'st.providers.search.empty': 'Żaden dostawca nie pasuje do tego wyszukiwania i filtra.', 'st.providers.save': 'Zapisz', 'st.providers.test': 'Testuj połączenie', + 'st.providers.duplicate': 'Duplikuj', + 'st.providers.duplicate_limit': 'Dozwolony jest tylko jeden duplikat', + 'st.providers.duplicate_unavailable': 'Tego dostawcy nie można zduplikować', + 'st.providers.remove_duplicate': 'Usuń duplikat', + 'st.providers.remove_duplicate_confirm': 'Usunąć tego zduplikowanego dostawcę i jego zapisaną konfigurację?', 'st.providers.set_active': 'Ustaw jako aktywny', 'st.providers.active': 'AKTYWNY', 'st.providers.saved': 'Zapisano!', diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 1523efed2..d3a6ee3b9 100644 --- a/src/chrome/src/ui/locales/pt.js +++ b/src/chrome/src/ui/locales/pt.js @@ -619,6 +619,11 @@ export default { 'st.providers.search.empty': "Nenhum provedor corresponde a esta pesquisa e filtro.", 'st.providers.save': "Salvar", 'st.providers.test': "Conexão de teste", + 'st.providers.duplicate': 'Duplicar', + 'st.providers.duplicate_limit': 'Só é permitida uma duplicata', + 'st.providers.duplicate_unavailable': 'Este provedor não pode ser duplicado', + 'st.providers.remove_duplicate': 'Remover duplicata', + 'st.providers.remove_duplicate_confirm': 'Remover este provedor duplicado e a configuração salva?', 'st.providers.set_active': "Definir ativo", 'st.providers.active': "ATIVO", 'st.providers.saved': "Salvo!", diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 37049cbfc..c5089e0af 100644 --- a/src/chrome/src/ui/locales/ru.js +++ b/src/chrome/src/ui/locales/ru.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'Бесплатное ежедневное использование WebBrain Cloud включено. Запросы проходят через api.webbrain.one; по умолчанию мы записываем метаданные для квоты и отладки, а не текст запросов, содержимое страниц, скриншоты или ответы модели. {privacyLink}. Для большего объёма оформите подписку на {subscribeLink}. Управляйте оплатой на {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Политика конфиденциальности', 'st.providers.test': 'Проверить соединение', + 'st.providers.duplicate': 'Дублировать', + 'st.providers.duplicate_limit': 'Разрешён только один дубликат', + 'st.providers.duplicate_unavailable': 'Этого провайдера нельзя дублировать', + 'st.providers.remove_duplicate': 'Удалить дубликат', + 'st.providers.remove_duplicate_confirm': 'Удалить этого дублированного провайдера и его сохранённые настройки?', 'st.providers.set_active': 'Сделать активным', 'st.providers.active': 'АКТИВЕН', 'st.providers.saved': 'Сохранено!', diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index f01d11e7c..455c44955 100644 --- a/src/chrome/src/ui/locales/th.js +++ b/src/chrome/src/ui/locales/th.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'รวมการใช้งาน WebBrain Cloud รายวันฟรีไว้แล้ว คำขอจะผ่าน api.webbrain.one โดยค่าเริ่มต้นเราจะบันทึกข้อมูลเมตาสำหรับโควตาและการแก้จุดบกพร่อง ไม่ใช่ข้อความพรอมต์ เนื้อหาหน้าเว็บ ภาพหน้าจอ หรือการตอบกลับของโมเดล {privacyLink} หากต้องการใช้งานเพิ่มเติม สมัครสมาชิกที่ {subscribeLink} จัดการการเรียกเก็บเงินที่ {accountLink}', 'st.providers.webbrain_note.privacy_link': 'นโยบายความเป็นส่วนตัว', 'st.providers.test': 'ทดสอบการเชื่อมต่อ', + 'st.providers.duplicate': 'ทำสำเนา', + 'st.providers.duplicate_limit': 'อนุญาตให้มีสำเนาได้เพียงหนึ่งรายการ', + 'st.providers.duplicate_unavailable': 'ไม่สามารถทำสำเนาผู้ให้บริการนี้ได้', + 'st.providers.remove_duplicate': 'ลบสำเนา', + 'st.providers.remove_duplicate_confirm': 'ลบผู้ให้บริการสำเนานี้และการตั้งค่าที่บันทึกไว้หรือไม่', 'st.providers.set_active': 'ตั้งเป็นใช้งาน', 'st.providers.active': 'ใช้งานอยู่', 'st.providers.saved': 'บันทึกแล้ว!', diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index 0e2972da9..016667154 100644 --- a/src/chrome/src/ui/locales/tl.js +++ b/src/chrome/src/ui/locales/tl.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'Kasama ang libreng pang-araw-araw na paggamit ng WebBrain Cloud. Dumadaan ang mga request sa api.webbrain.one; bilang default ay nagla-log kami ng metadata para sa quota at debugging, hindi ang teksto ng prompt, nilalaman ng pahina, mga screenshot, o mga tugon ng modelo. {privacyLink}. Para sa higit pang paggamit, mag-subscribe sa {subscribeLink}. Pamahalaan ang pagsingil sa {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Patakaran sa privacy', 'st.providers.test': 'Subukan ang Koneksyon', + 'st.providers.duplicate': 'I-duplicate', + 'st.providers.duplicate_limit': 'Isang duplicate lang ang pinapayagan', + 'st.providers.duplicate_unavailable': 'Hindi maaaring i-duplicate ang provider na ito', + 'st.providers.remove_duplicate': 'Alisin ang duplicate', + 'st.providers.remove_duplicate_confirm': 'Alisin ang duplicate na provider na ito at ang naka-save nitong configuration?', 'st.providers.set_active': 'Itakda Bilang Aktibo', 'st.providers.active': 'AKTIBO', 'st.providers.saved': 'Na-save!', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index a62c62efc..bb80af5fe 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -303,6 +303,11 @@ export default { 'st.providers.webbrain_note.body': 'Ücretsiz günlük WebBrain Cloud kullanımı dahildir. İstekler api.webbrain.one üzerinden geçer; varsayılan olarak kota ve hata ayıklama için meta verileri kaydederiz; istem metnini, sayfa içeriğini, ekran görüntülerini veya model yanıtlarını kaydetmeyiz. {privacyLink}. Daha fazla kullanım için {subscribeLink} adresinden abone olun. Faturalandırmayı {accountLink} adresinden yönetin.', 'st.providers.webbrain_note.privacy_link': 'Gizlilik politikası', 'st.providers.test': 'Bağlantıyı sına', + 'st.providers.duplicate': 'Çoğalt', + 'st.providers.duplicate_limit': 'Yalnızca bir kopyaya izin verilir', + 'st.providers.duplicate_unavailable': 'Bu sağlayıcı çoğaltılamaz', + 'st.providers.remove_duplicate': 'Kopyayı kaldır', + 'st.providers.remove_duplicate_confirm': 'Bu çoğaltılmış sağlayıcı ve kayıtlı yapılandırması kaldırılsın mı?', 'st.providers.set_active': 'Etkinleştir', 'st.providers.active': 'ETKİN', 'st.providers.saved': 'Kaydedildi!', diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index a396030d0..1759716fc 100644 --- a/src/chrome/src/ui/locales/uk.js +++ b/src/chrome/src/ui/locales/uk.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': 'Безкоштовне щоденне використання WebBrain Cloud включено. Запити проходять через api.webbrain.one; за замовчуванням ми записуємо метадані для квоти та налагодження, а не текст запитів, вміст сторінок, знімки екрана чи відповіді моделі. {privacyLink}. Для більшого обсягу оформіть підписку на {subscribeLink}. Керуйте оплатою на {accountLink}.', 'st.providers.webbrain_note.privacy_link': 'Політика конфіденційності', 'st.providers.test': 'Перевірити з\'єднання', + 'st.providers.duplicate': 'Дублювати', + 'st.providers.duplicate_limit': 'Дозволено лише один дублікат', + 'st.providers.duplicate_unavailable': 'Цього постачальника не можна дублювати', + 'st.providers.remove_duplicate': 'Видалити дублікат', + 'st.providers.remove_duplicate_confirm': 'Видалити цього дубльованого постачальника та його збережені налаштування?', 'st.providers.set_active': 'Зробити активним', 'st.providers.active': 'АКТИВНИЙ', 'st.providers.saved': 'Збережено!', diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index 6d528f7ac..43a2496a3 100644 --- a/src/chrome/src/ui/locales/vi.js +++ b/src/chrome/src/ui/locales/vi.js @@ -619,6 +619,11 @@ export default { 'st.providers.search.empty': "Không có nhà cung cấp nào phù hợp với tìm kiếm và bộ lọc này.", 'st.providers.save': "Lưu", 'st.providers.test': "Kiểm tra kết nối", + 'st.providers.duplicate': 'Nhân bản', + 'st.providers.duplicate_limit': 'Chỉ được phép có một bản sao', + 'st.providers.duplicate_unavailable': 'Không thể nhân bản nhà cung cấp này', + 'st.providers.remove_duplicate': 'Xóa bản sao', + 'st.providers.remove_duplicate_confirm': 'Xóa nhà cung cấp nhân bản này và cấu hình đã lưu?', 'st.providers.set_active': "Đặt hoạt động", 'st.providers.active': "HOẠT ĐỘNG", 'st.providers.saved': "Đã lưu!", diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index 27df79cc9..cfcdad8b1 100644 --- a/src/chrome/src/ui/locales/zh.js +++ b/src/chrome/src/ui/locales/zh.js @@ -264,6 +264,11 @@ export default { 'st.providers.webbrain_note.body': '免费的每日 WebBrain Cloud 用量已包含在内。请求通过 api.webbrain.one 处理;默认情况下,我们仅记录用于配额和调试的元数据,不记录提示文本、页面内容、屏幕截图或模型响应。{privacyLink}。如需更多用量,请在 {subscribeLink} 订阅。在 {accountLink} 管理账单。', 'st.providers.webbrain_note.privacy_link': '隐私政策', 'st.providers.test': '测试连接', + 'st.providers.duplicate': '复制', + 'st.providers.duplicate_limit': '最多只能创建一个副本', + 'st.providers.duplicate_unavailable': '此服务商不可复制', + 'st.providers.remove_duplicate': '删除副本', + 'st.providers.remove_duplicate_confirm': '要删除此服务商副本及其已保存的配置吗?', 'st.providers.set_active': '设为启用', 'st.providers.active': '已启用', 'st.providers.saved': '已保存!', diff --git a/src/chrome/src/ui/settings.html b/src/chrome/src/ui/settings.html index b53e384e3..e3d394575 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -294,6 +294,7 @@ .provider-card.collapsed { padding: 12px 16px; } .provider-card.collapsed .provider-header { margin-bottom: 0; } + .provider-card .btn-duplicate[aria-disabled="true"] { cursor: not-allowed; opacity: 0.48; transform: none; } .provider-header { display: flex; align-items: center; diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index 59c624256..e9597eef9 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -336,18 +336,15 @@ if (globalThis.chrome?.storage?.onChanged) { if (area !== 'local') return; if (changes[WEBGPU_VISION_ENABLED_KEY]) loadVisionConfig().catch(() => {}); if (!changes.providers?.newValue) return; - const nextOllama = changes.providers?.newValue?.ollama; - if (nextOllama && providersData.ollama) { - providersData.ollama.visionDetection = nextOllama.visionDetection || null; - refreshOllamaVisionStatus(); - } - for (const id of AUTO_VISION_PROVIDER_IDS) { - const next = changes.providers.newValue[id]; - if (!next || !providersData[id]) continue; + for (const [id, next] of Object.entries(changes.providers.newValue)) { + if (!providersData[id] || next.visionDetection === undefined) continue; // Background detection may finish while the settings page contains // unsaved drafts. Refresh only the detected result. providersData[id].visionDetection = next.visionDetection || null; - refreshVisionStatus(id); + const definitionId = providerDefinitionId(id); + if (VISION_UI_PROVIDER_IDS.has(definitionId)) { + refreshVisionStatus(id); + } } }); } @@ -2154,6 +2151,10 @@ const VISION_MODE_FIELD = { }; const OLLAMA_VISION_MODE_FIELD = VISION_MODE_FIELD; +function providerDefinitionId(id, config = providersData[id]) { + return String(config?.sourceProviderId || config?.duplicateOf || id || ''); +} + function visionStatusKey(id, config) { if (!providerVisionDetectionMatches(id, config)) return 'st.provider.field.vision_pending'; return config.visionDetection.supportsVision @@ -2162,9 +2163,10 @@ function visionStatusKey(id, config) { } function refreshVisionStatus(id) { - if (!VISION_UI_PROVIDER_IDS.has(id)) return; - const hint = id === 'ollama' - ? document.querySelector('[data-ollama-vision-status]') + const definitionId = providerDefinitionId(id); + if (!VISION_UI_PROVIDER_IDS.has(definitionId)) return; + const hint = definitionId === 'ollama' + ? document.querySelector(`[data-ollama-vision-status="${id}"]`) : document.querySelector(`[data-vision-status="${id}"]`); if (!hint) return; const mode = document.querySelector(`select[data-provider="${id}"][data-key="visionMode"]`)?.value || 'auto'; @@ -2184,7 +2186,8 @@ function refreshOllamaVisionStatus() { } function providerVisionDetectionMatches(id, config, detection = config?.visionDetection) { - if (id !== 'ollama') return visionDetectionMatches(id, config, detection, { allowTransient: true }); + const definitionId = providerDefinitionId(id, config); + if (definitionId !== 'ollama') return visionDetectionMatches(definitionId, config, detection, { allowTransient: true }); const model = String(config?.model || '').trim(); const baseUrl = canonicalizeOllamaBaseUrl(config?.baseUrl); return !!model && !!baseUrl @@ -2838,7 +2841,8 @@ function renderProviders() { for (const [id, config] of entries) { const isSelected = id === activeProviderId; const isConfigured = id !== 'webbrain_cloud' && config.configured === true; - const fieldDefs = providerConfigs[id]?.fields || []; + const definitionId = providerDefinitionId(id, config); + const fieldDefs = providerConfigs[definitionId]?.fields || []; // Active is a strict status filter. Category filters keep the selected // provider visible so users never lose track of the provider in use. @@ -2865,8 +2869,8 @@ function renderProviders() { `; - if (VISION_UI_PROVIDER_IDS.has(id) && field.key === 'visionMode') { - const statusAttribute = id === 'ollama' ? 'data-ollama-vision-status' : `data-vision-status="${id}"`; + if (VISION_UI_PROVIDER_IDS.has(definitionId) && field.key === 'visionMode') { + const statusAttribute = definitionId === 'ollama' ? `data-ollama-vision-status="${id}"` : `data-vision-status="${id}"`; fieldsHTML += `
${escapeHtml(t(visionStatusKey(id, config)))}
`; } } else if (field.type === 'checkbox') { @@ -2899,7 +2903,7 @@ function renderProviders() { `; } else { const localModelProviders = ['llamacpp', 'ollama', 'lmstudio', 'jan', 'vllm', 'sglang', 'localai', 'gpt4all', 'local_openai_proxy']; - const canLoadModels = localModelProviders.includes(id) && field.key === 'model'; + const canLoadModels = localModelProviders.includes(definitionId) && field.key === 'model'; const listAttr = canLoadModels ? `list="models-${id}"` : ''; const datalistHTML = canLoadModels ? `` : ''; const loadedModelsDialogHTML = canLoadModels @@ -2962,11 +2966,12 @@ function renderProviders() { `; } const extensionOrigin = chrome.runtime.getURL('').replace(/\/$/, ''); - const ollamaWarning = id === 'ollama' + const ollamaWarningTitleId = `ollama-warning-title-${id}`; + const ollamaWarning = definitionId === 'ollama' ? `