diff --git a/PRIVACY.md b/PRIVACY.md index 55ddeea..a4c9717 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,20 +1,20 @@ # Privacy Policy for Panelize -**Last Updated: February 3, 2026** +**Last Updated: July 15, 2026** ## Overview Panelize is committed to protecting your privacy. This privacy policy explains what data the extension collects, how it's used, and your control over it. -**In short:** Panelize stores all data locally on your device. We do not collect, transmit, or sell any of your personal information to third parties or external servers. +**In short:** Panelize stores extension data in browser-managed storage. Chrome may synchronize supported preferences across your signed-in browsers when browser sync is enabled. We do not collect, transmit, or sell your personal information through Panelize-operated servers. --- ## What Data We Collect and Store -### 1. Data Stored Locally on Your Device +### 1. Data Stored in Your Browser -All of the following data is stored **exclusively in your browser's local storage** and never leaves your device: +The following data is stored in browser-managed storage. Prompt library data remains local to the browser profile. Supported preferences may use Chrome's sync storage and therefore may be synchronized by Chrome according to your browser account and sync settings: #### Prompt Library Data - Prompt titles, content, categories, and tags you create @@ -31,6 +31,7 @@ All of the following data is stored **exclusively in your browser's local storag - Enter key behavior preferences - Source URL placement preferences - Language preferences +- Experimental Claude model preference **Purpose:** To maintain your personalized extension configuration. @@ -73,9 +74,11 @@ All data collected by Panelize is used solely for providing extension functional ### AI Provider Websites -Panelize loads AI provider websites (ChatGPT, Claude, Gemini, Grok, DeepSeek, Kimi, and Google AI Mode) inside embedded panels. These providers operate under their own privacy policies. +Panelize loads AI provider websites (ChatGPT, Claude, Gemini, Grok, DeepSeek, Kimi, Doubao, Qwen, and Google AI Mode) inside embedded panels. These providers operate under their own privacy policies. -When you interact with these AI providers through Panelize, you are subject to their respective privacy policies. Panelize does not intercept or store the content of your conversations with these services. +When you interact with these AI providers through Panelize, you are subject to their respective privacy policies. Panelize does not collect or store the content of your conversations with these services. + +If you explicitly select an experimental Claude model in Panelize, the extension locally processes Claude's outgoing completion request only to insert or replace its top-level model identifier. This processing happens in your browser. Panelize does not log, store, collect, or send the request body or conversation content to Panelize developers or Panelize-operated servers. The modified request continues directly to Claude as part of the chat you initiated. Selecting **Claude default** disables this request modification. ### Cookie Access @@ -93,16 +96,16 @@ Panelize loads the Material Symbols font from Google Fonts for UI icons. This re ## Data Storage and Security -### Local Storage Only +### Browser-Managed Storage All extension data is stored in your browser using: - **Chrome Storage API:** For settings and preferences - **IndexedDB:** For prompts This data: -- Remains on your device +- Is managed by your browser; supported preferences may sync through Chrome when browser sync is enabled - Is not accessible to websites you visit -- Is not transmitted over the network +- Is not transmitted to Panelize-operated servers - Is protected by browser security mechanisms ### Data Retention @@ -213,6 +216,7 @@ If you are a California resident: - Store your prompts and settings locally on your device - Use local browser storage to remember your preferences - Temporarily process selected text or page content on-device when you use the context menu +- Locally set Claude's requested model when you explicitly enable experimental model selection - Load AI provider websites inside embedded panels - Check for updates on GitHub diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 4f8c94b..767af16 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -813,6 +813,26 @@ "switchToPopupModeTitle": { "message": "Zum Popup-Modus wechseln" }, + "openProviderTopLevel": { "message": "Claude öffnen" }, + "openProviderTopLevelTitle": { "message": "In neuem Tab öffnen" }, + "embeddedModelLimitNotice": { "message": "Claude schränkt die Modellauswahl in eingebetteten Ansichten ein. Panelize kann experimentell ein Modell anfordern." }, + "embeddedModelOverrideNotice": { + "message": "Experimentelle Anfrage: $MODEL$. Die Verfügbarkeit hängt von deinem Claude-Konto ab.", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelDefault": { "message": "Claude-Standard" }, + "claudeModelExperimentalBadge": { "message": "Experimentell" }, + "claudeModelExperimentalTitle": { "message": "Experimenteller Kompatibilitätsmodus" }, + "claudeModelSelectTitle": { "message": "Claude-Modellanfrage" }, + "claudeModelRequestedStatus": { + "message": "$MODEL$ angefordert", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelOverrideUnavailable": { "message": "Die Modellüberschreibung ist nicht verfügbar. Claude-Standard wurde aktiviert." }, + "claudeModelRequestRejected": { + "message": "Die Modellanforderung wurde abgelehnt (HTTP $STATUS$). Claude-Standard wurde aktiviert.", + "placeholders": { "status": { "content": "$1" } } + }, "msgOpenModeUpdated": { "message": "Öffnungsmodus aktualisiert" }, diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d76af4a..0caebd3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -57,7 +57,46 @@ "message": "Open in new tab" }, "embeddedModelLimitNotice": { - "message": "Claude may show limited model options when embedded. Open Claude in a normal tab to use the regular model selector." + "message": "Claude limits model selection when embedded. Panelize can experimentally request a model." + }, + "embeddedModelOverrideNotice": { + "message": "Experimental request: $MODEL$. Availability depends on your Claude account.", + "placeholders": { + "model": { + "content": "$1" + } + } + }, + "claudeModelDefault": { + "message": "Claude default" + }, + "claudeModelExperimentalBadge": { + "message": "Experimental" + }, + "claudeModelExperimentalTitle": { + "message": "Experimental compatibility mode" + }, + "claudeModelSelectTitle": { + "message": "Claude model request" + }, + "claudeModelRequestedStatus": { + "message": "Requested $MODEL$", + "placeholders": { + "model": { + "content": "$1" + } + } + }, + "claudeModelOverrideUnavailable": { + "message": "Model override is unavailable. Switched to Claude default." + }, + "claudeModelRequestRejected": { + "message": "Model request was rejected (HTTP $STATUS$). Switched to Claude default.", + "placeholders": { + "status": { + "content": "$1" + } + } }, "msgOpenModeUpdated": { "message": "Open mode updated" diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 20b7ade..829401c 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -813,6 +813,26 @@ "switchToPopupModeTitle": { "message": "Cambiar a modo ventana emergente" }, + "openProviderTopLevel": { "message": "Abrir Claude" }, + "openProviderTopLevelTitle": { "message": "Abrir en una pestaña nueva" }, + "embeddedModelLimitNotice": { "message": "Claude limita la selección de modelos cuando está integrado. Panelize puede solicitar un modelo de forma experimental." }, + "embeddedModelOverrideNotice": { + "message": "Solicitud experimental: $MODEL$. La disponibilidad depende de tu cuenta de Claude.", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelDefault": { "message": "Predeterminado de Claude" }, + "claudeModelExperimentalBadge": { "message": "Experimental" }, + "claudeModelExperimentalTitle": { "message": "Modo de compatibilidad experimental" }, + "claudeModelSelectTitle": { "message": "Solicitud de modelo de Claude" }, + "claudeModelRequestedStatus": { + "message": "Solicitado $MODEL$", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelOverrideUnavailable": { "message": "La selección experimental no está disponible. Se cambió al modelo predeterminado de Claude." }, + "claudeModelRequestRejected": { + "message": "La solicitud de modelo fue rechazada (HTTP $STATUS$). Se cambió al modelo predeterminado de Claude.", + "placeholders": { "status": { "content": "$1" } } + }, "msgOpenModeUpdated": { "message": "Modo de apertura actualizado" }, diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index c09c128..48f9651 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -813,6 +813,26 @@ "switchToPopupModeTitle": { "message": "Passer au mode fenêtre popup" }, + "openProviderTopLevel": { "message": "Ouvrir Claude" }, + "openProviderTopLevelTitle": { "message": "Ouvrir dans un nouvel onglet" }, + "embeddedModelLimitNotice": { "message": "Claude limite la sélection des modèles lorsqu’il est intégré. Panelize peut demander un modèle de manière expérimentale." }, + "embeddedModelOverrideNotice": { + "message": "Demande expérimentale : $MODEL$. La disponibilité dépend de votre compte Claude.", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelDefault": { "message": "Modèle Claude par défaut" }, + "claudeModelExperimentalBadge": { "message": "Expérimental" }, + "claudeModelExperimentalTitle": { "message": "Mode de compatibilité expérimental" }, + "claudeModelSelectTitle": { "message": "Demande de modèle Claude" }, + "claudeModelRequestedStatus": { + "message": "$MODEL$ demandé", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelOverrideUnavailable": { "message": "La sélection expérimentale n’est pas disponible. Retour au modèle Claude par défaut." }, + "claudeModelRequestRejected": { + "message": "La demande de modèle a été refusée (HTTP $STATUS$). Retour au modèle Claude par défaut.", + "placeholders": { "status": { "content": "$1" } } + }, "msgOpenModeUpdated": { "message": "Mode d'ouverture mis à jour" }, diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 76f6799..9291be8 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -813,6 +813,26 @@ "switchToPopupModeTitle": { "message": "Passa alla modalità finestra popup" }, + "openProviderTopLevel": { "message": "Apri Claude" }, + "openProviderTopLevelTitle": { "message": "Apri in una nuova scheda" }, + "embeddedModelLimitNotice": { "message": "Claude limita la selezione dei modelli quando è incorporato. Panelize può richiedere un modello in via sperimentale." }, + "embeddedModelOverrideNotice": { + "message": "Richiesta sperimentale: $MODEL$. La disponibilità dipende dal tuo account Claude.", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelDefault": { "message": "Predefinito di Claude" }, + "claudeModelExperimentalBadge": { "message": "Sperimentale" }, + "claudeModelExperimentalTitle": { "message": "Modalità di compatibilità sperimentale" }, + "claudeModelSelectTitle": { "message": "Richiesta modello Claude" }, + "claudeModelRequestedStatus": { + "message": "Richiesto $MODEL$", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelOverrideUnavailable": { "message": "La selezione sperimentale non è disponibile. È stato ripristinato il modello predefinito di Claude." }, + "claudeModelRequestRejected": { + "message": "La richiesta del modello è stata rifiutata (HTTP $STATUS$). È stato ripristinato il modello predefinito di Claude.", + "placeholders": { "status": { "content": "$1" } } + }, "msgOpenModeUpdated": { "message": "Modalità di apertura aggiornata" }, diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index ec9d537..86aa104 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -813,6 +813,26 @@ "switchToPopupModeTitle": { "message": "ポップアップモードに切り替え" }, + "openProviderTopLevel": { "message": "Claude を開く" }, + "openProviderTopLevelTitle": { "message": "新しいタブで開く" }, + "embeddedModelLimitNotice": { "message": "埋め込み表示では Claude のモデル選択が制限されます。Panelize から実験的にモデルを指定できます。" }, + "embeddedModelOverrideNotice": { + "message": "実験的なリクエスト:$MODEL$。利用可否は Claude アカウントによって異なります。", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelDefault": { "message": "Claude のデフォルト" }, + "claudeModelExperimentalBadge": { "message": "実験的" }, + "claudeModelExperimentalTitle": { "message": "実験的な互換モード" }, + "claudeModelSelectTitle": { "message": "Claude モデルのリクエスト" }, + "claudeModelRequestedStatus": { + "message": "$MODEL$ をリクエストしました", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelOverrideUnavailable": { "message": "モデル指定を利用できません。Claude のデフォルトに戻しました。" }, + "claudeModelRequestRejected": { + "message": "モデルのリクエストが拒否されました(HTTP $STATUS$)。Claude のデフォルトに戻しました。", + "placeholders": { "status": { "content": "$1" } } + }, "msgOpenModeUpdated": { "message": "開くモードを更新しました" }, diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 9025ef9..6242189 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -813,6 +813,26 @@ "switchToPopupModeTitle": { "message": "팝업 모드로 전환" }, + "openProviderTopLevel": { "message": "Claude 열기" }, + "openProviderTopLevelTitle": { "message": "새 탭에서 열기" }, + "embeddedModelLimitNotice": { "message": "Claude는 임베드된 화면에서 모델 선택을 제한합니다. Panelize가 실험적으로 모델을 요청할 수 있습니다." }, + "embeddedModelOverrideNotice": { + "message": "실험적 요청: $MODEL$. 사용 가능 여부는 Claude 계정에 따라 달라집니다.", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelDefault": { "message": "Claude 기본값" }, + "claudeModelExperimentalBadge": { "message": "실험적" }, + "claudeModelExperimentalTitle": { "message": "실험적 호환 모드" }, + "claudeModelSelectTitle": { "message": "Claude 모델 요청" }, + "claudeModelRequestedStatus": { + "message": "$MODEL$ 요청됨", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelOverrideUnavailable": { "message": "모델 재정의를 사용할 수 없습니다. Claude 기본값으로 전환했습니다." }, + "claudeModelRequestRejected": { + "message": "모델 요청이 거부되었습니다(HTTP $STATUS$). Claude 기본값으로 전환했습니다.", + "placeholders": { "status": { "content": "$1" } } + }, "msgOpenModeUpdated": { "message": "열기 모드가 업데이트되었습니다" }, diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index dcb9d55..639d6a6 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -813,6 +813,26 @@ "switchToPopupModeTitle": { "message": "Переключиться в режим всплывающего окна" }, + "openProviderTopLevel": { "message": "Открыть Claude" }, + "openProviderTopLevelTitle": { "message": "Открыть в новой вкладке" }, + "embeddedModelLimitNotice": { "message": "Claude ограничивает выбор моделей во встроенном режиме. Panelize может экспериментально запросить определённую модель." }, + "embeddedModelOverrideNotice": { + "message": "Экспериментальный запрос: $MODEL$. Доступность зависит от вашей учётной записи Claude.", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelDefault": { "message": "Модель Claude по умолчанию" }, + "claudeModelExperimentalBadge": { "message": "Экспериментально" }, + "claudeModelExperimentalTitle": { "message": "Экспериментальный режим совместимости" }, + "claudeModelSelectTitle": { "message": "Запрос модели Claude" }, + "claudeModelRequestedStatus": { + "message": "Запрошена модель $MODEL$", + "placeholders": { "model": { "content": "$1" } } + }, + "claudeModelOverrideUnavailable": { "message": "Переопределение модели недоступно. Выбрана модель Claude по умолчанию." }, + "claudeModelRequestRejected": { + "message": "Запрос модели отклонён (HTTP $STATUS$). Выбрана модель Claude по умолчанию.", + "placeholders": { "status": { "content": "$1" } } + }, "msgOpenModeUpdated": { "message": "Режим открытия обновлен" }, diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 138c2f8..c6bd9e2 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -814,7 +814,46 @@ "message": "在新标签页中打开" }, "embeddedModelLimitNotice": { - "message": "Claude 在嵌入时可能只显示有限模型。请在普通标签页中打开 Claude,以使用常规模型选择器。" + "message": "Claude 会限制内嵌页面中的模型选择。Panelize 可以通过实验性方式请求指定模型。" + }, + "embeddedModelOverrideNotice": { + "message": "实验性请求:$MODEL$。是否可用取决于你的 Claude 账号。", + "placeholders": { + "model": { + "content": "$1" + } + } + }, + "claudeModelDefault": { + "message": "Claude 默认模型" + }, + "claudeModelExperimentalBadge": { + "message": "实验性" + }, + "claudeModelExperimentalTitle": { + "message": "实验性兼容模式" + }, + "claudeModelSelectTitle": { + "message": "Claude 模型请求" + }, + "claudeModelRequestedStatus": { + "message": "已请求 $MODEL$", + "placeholders": { + "model": { + "content": "$1" + } + } + }, + "claudeModelOverrideUnavailable": { + "message": "模型覆盖不可用,已切换到 Claude 默认模型。" + }, + "claudeModelRequestRejected": { + "message": "模型请求被拒绝(HTTP $STATUS$),已切换到 Claude 默认模型。", + "placeholders": { + "status": { + "content": "$1" + } + } }, "msgOpenModeUpdated": { "message": "打开模式已更新" diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index a3991ff..e0e8dd0 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -814,7 +814,46 @@ "message": "在新分頁中開啟" }, "embeddedModelLimitNotice": { - "message": "Claude 在嵌入時可能只顯示有限模型。請在一般分頁中開啟 Claude,以使用常規模型選擇器。" + "message": "Claude 會限制內嵌頁面中的模型選擇。Panelize 可以透過實驗性方式請求指定模型。" + }, + "embeddedModelOverrideNotice": { + "message": "實驗性請求:$MODEL$。是否可用取決於你的 Claude 帳號。", + "placeholders": { + "model": { + "content": "$1" + } + } + }, + "claudeModelDefault": { + "message": "Claude 預設模型" + }, + "claudeModelExperimentalBadge": { + "message": "實驗性" + }, + "claudeModelExperimentalTitle": { + "message": "實驗性相容模式" + }, + "claudeModelSelectTitle": { + "message": "Claude 模型請求" + }, + "claudeModelRequestedStatus": { + "message": "已請求 $MODEL$", + "placeholders": { + "model": { + "content": "$1" + } + } + }, + "claudeModelOverrideUnavailable": { + "message": "模型覆寫不可用,已切換至 Claude 預設模型。" + }, + "claudeModelRequestRejected": { + "message": "模型請求遭到拒絕(HTTP $STATUS$),已切換至 Claude 預設模型。", + "placeholders": { + "status": { + "content": "$1" + } + } }, "msgOpenModeUpdated": { "message": "開啟模式已更新" diff --git a/content-scripts/claude-model-request-interceptor.js b/content-scripts/claude-model-request-interceptor.js new file mode 100644 index 0000000..5a8f6e7 --- /dev/null +++ b/content-scripts/claude-model-request-interceptor.js @@ -0,0 +1,192 @@ +(function initializeClaudeModelRequestInterceptor() { + 'use strict'; + + if (window.self === window.top) { + return; + } + + const utils = globalThis.PanelizeClaudeModelRequestUtils; + if (!utils) { + return; + } + + const MESSAGE_OVERRIDE = 'PANELIZE_CLAUDE_MODEL_OVERRIDE'; + const MESSAGE_PROBE = 'PANELIZE_CLAUDE_MODEL_PROBE'; + const DEFAULT_MODE = 'default'; + const MODELS_BY_MODE = Object.freeze({ + 'sonnet-5': 'claude-sonnet-5', + 'opus-4-8': 'claude-opus-4-8', + 'haiku-4-5': 'claude-haiku-4-5-20251001' + }); + const overrideState = { + mode: DEFAULT_MODE, + model: '' + }; + let parentOrigin = ''; + + function reportProbe(event, details = {}) { + window.parent.postMessage({ + type: MESSAGE_PROBE, + event, + ...details + }, parentOrigin || '*'); + } + + function isExtensionParentMessage(event) { + return event.source === window.parent && + typeof event.origin === 'string' && + event.origin.startsWith('chrome-extension://'); + } + + function disableOverride(event, details = {}) { + overrideState.mode = DEFAULT_MODE; + overrideState.model = ''; + reportProbe(event, details); + } + + window.addEventListener('message', event => { + if (!isExtensionParentMessage(event) || event.data?.type !== MESSAGE_OVERRIDE) { + return; + } + + parentOrigin = event.origin; + const mode = typeof event.data.mode === 'string' ? event.data.mode : DEFAULT_MODE; + const expectedModel = MODELS_BY_MODE[mode] || ''; + const requestedModel = typeof event.data.model === 'string' ? event.data.model : ''; + + if (mode === DEFAULT_MODE && !requestedModel) { + overrideState.mode = DEFAULT_MODE; + overrideState.model = ''; + reportProbe('override-configured', { mode: DEFAULT_MODE }); + return; + } + + if (!expectedModel || requestedModel !== expectedModel || !utils.isAllowedModelId(requestedModel)) { + disableOverride('override-config-rejected'); + return; + } + + overrideState.mode = mode; + overrideState.model = requestedModel; + reportProbe('override-configured', { mode }); + }); + + function rewriteBodyIfNeeded(url, body) { + if (!overrideState.model || !utils.isClaudeCompletionUrl(url, window.location.href)) { + return { + body, + overridden: false + }; + } + + const result = utils.rewriteCompletionBody(body, overrideState.model); + if (!result.overridden) { + disableOverride('override-schema-unsupported', { reason: result.reason }); + return { + body, + overridden: false + }; + } + + reportProbe('request-overridden', { + mode: overrideState.mode, + changed: result.changed !== false, + inserted: result.inserted === true + }); + return { + body: result.body, + overridden: true, + mode: overrideState.mode + }; + } + + function handleResponseStatus(status, requestMode) { + const classification = utils.classifyResponseStatus(status); + if (classification === 'accepted' && overrideState.mode === requestMode) { + reportProbe('override-request-accepted', { + mode: requestMode, + status + }); + return; + } + + if (classification === 'rejected' && overrideState.mode === requestMode) { + disableOverride('override-request-rejected', { status }); + } + } + + const nativeFetch = window.fetch; + window.fetch = async function panelizeClaudeModelFetch(input, init) { + const inputUrl = input instanceof Request ? input.url : input; + if (!overrideState.model || !utils.isClaudeCompletionUrl(inputUrl, window.location.href)) { + return nativeFetch.call(this, input, init); + } + + let requestMode = DEFAULT_MODE; + let response; + + if (init && typeof init.body === 'string') { + const result = rewriteBodyIfNeeded(inputUrl, init.body); + requestMode = result.mode || DEFAULT_MODE; + response = await nativeFetch.call(this, input, { + ...init, + body: result.body + }); + } else { + try { + const request = input instanceof Request && !init + ? input + : new Request(input, init); + + if (request.bodyUsed || ['GET', 'HEAD'].includes(request.method)) { + disableOverride('override-schema-unsupported', { reason: 'unreadable-body' }); + return nativeFetch.call(this, input, init); + } + + const originalBody = await request.clone().text(); + const result = rewriteBodyIfNeeded(request.url, originalBody); + requestMode = result.mode || DEFAULT_MODE; + response = result.overridden + ? await nativeFetch.call(this, new Request(request, { body: result.body })) + : await nativeFetch.call(this, input, init); + } catch (_error) { + disableOverride('override-schema-unsupported', { reason: 'request-inspection-failed' }); + return nativeFetch.call(this, input, init); + } + } + + if (requestMode !== DEFAULT_MODE) { + handleResponseStatus(response.status, requestMode); + } + return response; + }; + + const nativeXhrOpen = XMLHttpRequest.prototype.open; + const nativeXhrSend = XMLHttpRequest.prototype.send; + const xhrMetadata = new WeakMap(); + + XMLHttpRequest.prototype.open = function panelizeClaudeModelXhrOpen(method, url, ...rest) { + xhrMetadata.set(this, { + url: new URL(url, window.location.href).href + }); + return nativeXhrOpen.call(this, method, url, ...rest); + }; + + XMLHttpRequest.prototype.send = function panelizeClaudeModelXhrSend(body) { + const metadata = xhrMetadata.get(this); + if (!metadata || !overrideState.model || !utils.isClaudeCompletionUrl(metadata.url)) { + return nativeXhrSend.call(this, body); + } + + const result = rewriteBodyIfNeeded(metadata.url, body); + if (result.overridden) { + const requestMode = result.mode; + this.addEventListener('loadend', () => { + handleResponseStatus(this.status, requestMode); + }, { once: true }); + } + return nativeXhrSend.call(this, result.body); + }; + + reportProbe('probe-ready'); +})(); diff --git a/content-scripts/claude-model-request-utils.js b/content-scripts/claude-model-request-utils.js new file mode 100644 index 0000000..225de43 --- /dev/null +++ b/content-scripts/claude-model-request-utils.js @@ -0,0 +1,116 @@ +(function initializeClaudeModelRequestUtils(globalScope) { + 'use strict'; + + const CLAUDE_ORIGIN = 'https://claude.ai'; + const CLAUDE_COMPLETION_PATH_SUFFIX = '/completion'; + const ALLOWED_MODEL_IDS = new Set([ + 'claude-sonnet-5', + 'claude-opus-4-8', + 'claude-haiku-4-5-20251001' + ]); + + function isAllowedModelId(modelId) { + return typeof modelId === 'string' && ALLOWED_MODEL_IDS.has(modelId); + } + + function isClaudeCompletionUrl(value, baseUrl = `${CLAUDE_ORIGIN}/`) { + try { + const url = new URL(value, baseUrl); + return url.origin === CLAUDE_ORIGIN && + url.pathname.endsWith(CLAUDE_COMPLETION_PATH_SUFFIX); + } catch (_error) { + return false; + } + } + + function rewriteCompletionBody(body, targetModel) { + if (!isAllowedModelId(targetModel)) { + return { + body, + overridden: false, + reason: 'unsupported-model' + }; + } + + if (typeof body !== 'string') { + return { + body, + overridden: false, + reason: 'unsupported-body' + }; + } + + let value; + try { + value = JSON.parse(body); + } catch (_error) { + return { + body, + overridden: false, + reason: 'invalid-json' + }; + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { + body, + overridden: false, + reason: 'unsupported-shape' + }; + } + + if ( + Object.prototype.hasOwnProperty.call(value, 'model') && + typeof value.model !== 'string' + ) { + return { + body, + overridden: false, + reason: 'unsupported-model-field' + }; + } + + if (value.model === targetModel) { + return { + body, + overridden: true, + changed: false, + reason: 'already-selected' + }; + } + + const inserted = !Object.prototype.hasOwnProperty.call(value, 'model'); + value.model = targetModel; + + return { + body: JSON.stringify(value), + overridden: true, + changed: true, + inserted, + reason: inserted ? 'model-injected' : 'model-replaced' + }; + } + + function classifyResponseStatus(status) { + if (!Number.isInteger(status)) { + return 'unknown'; + } + if (status >= 200 && status < 300) { + return 'accepted'; + } + if (status >= 400 && status < 500) { + return 'rejected'; + } + if (status >= 500) { + return 'server-error'; + } + return 'unknown'; + } + + globalScope.PanelizeClaudeModelRequestUtils = { + classifyResponseStatus, + isAllowedModelId, + isClaudeCompletionUrl, + rewriteCompletionBody + }; +})(globalThis); diff --git a/manifest.json b/manifest.json index ca5d188..cc4cc2f 100644 --- a/manifest.json +++ b/manifest.json @@ -69,6 +69,18 @@ "128": "icons/icon-128.png" }, "content_scripts": [ + { + "matches": [ + "https://claude.ai/*" + ], + "js": [ + "content-scripts/claude-model-request-utils.js", + "content-scripts/claude-model-request-interceptor.js" + ], + "run_at": "document_start", + "all_frames": true, + "world": "MAIN" + }, { "matches": [ "https://chatgpt.com/*", diff --git a/modules/claude-model-mode.js b/modules/claude-model-mode.js new file mode 100644 index 0000000..13e0526 --- /dev/null +++ b/modules/claude-model-mode.js @@ -0,0 +1,72 @@ +export const CLAUDE_MODEL_OVERRIDE_MESSAGE_TYPE = 'PANELIZE_CLAUDE_MODEL_OVERRIDE'; + +export const CLAUDE_MODEL_MODE_DEFAULT = 'default'; +export const CLAUDE_MODEL_MODE_SONNET_5 = 'sonnet-5'; +export const CLAUDE_MODEL_MODE_OPUS_4_8 = 'opus-4-8'; +export const CLAUDE_MODEL_MODE_HAIKU_4_5 = 'haiku-4-5'; +export const DEFAULT_CLAUDE_MODEL_MODE = CLAUDE_MODEL_MODE_DEFAULT; + +/** Supported user-facing Claude model modes and their request identifiers. */ +export const CLAUDE_MODEL_OPTIONS = Object.freeze([ + Object.freeze({ + mode: CLAUDE_MODEL_MODE_DEFAULT, + label: 'Claude default', + modelId: '' + }), + Object.freeze({ + mode: CLAUDE_MODEL_MODE_OPUS_4_8, + label: 'Opus 4.8', + modelId: 'claude-opus-4-8' + }), + Object.freeze({ + mode: CLAUDE_MODEL_MODE_SONNET_5, + label: 'Sonnet 5', + modelId: 'claude-sonnet-5' + }), + Object.freeze({ + mode: CLAUDE_MODEL_MODE_HAIKU_4_5, + label: 'Haiku 4.5', + modelId: 'claude-haiku-4-5-20251001' + }) +]); + +const CLAUDE_MODEL_OPTIONS_BY_MODE = new Map( + CLAUDE_MODEL_OPTIONS.map(option => [option.mode, option]) +); + +/** + * Normalize a stored Claude model mode to a supported value. + * + * @param {unknown} mode Stored model mode. + * @returns {string} A supported Claude model mode. + */ +export function normalizeClaudeModelMode(mode) { + return CLAUDE_MODEL_OPTIONS_BY_MODE.has(mode) + ? mode + : DEFAULT_CLAUDE_MODEL_MODE; +} + +/** + * Get the display and request metadata for a Claude model mode. + * + * @param {unknown} mode Claude model mode. + * @returns {{mode: string, label: string, modelId: string}} + */ +export function getClaudeModelOption(mode) { + return CLAUDE_MODEL_OPTIONS_BY_MODE.get(normalizeClaudeModelMode(mode)); +} + +/** + * Build the parent-to-frame message used to configure Claude request overrides. + * + * @param {unknown} mode Claude model mode. + * @returns {{type: string, mode: string, model: string}} + */ +export function createClaudeModelOverrideMessage(mode) { + const option = getClaudeModelOption(mode); + return { + type: CLAUDE_MODEL_OVERRIDE_MESSAGE_TYPE, + mode: option.mode, + model: option.modelId + }; +} diff --git a/modules/settings.js b/modules/settings.js index b0b3fae..bc6421e 100644 --- a/modules/settings.js +++ b/modules/settings.js @@ -1,9 +1,11 @@ import { DEFAULT_GOOGLE_PROVIDER_MODE } from './google-mode.js'; +import { DEFAULT_CLAUDE_MODEL_MODE } from './claude-model-mode.js'; import { DEFAULT_PROVIDER_IDS } from './provider-defaults.js'; const DEFAULT_SETTINGS = { enabledProviders: DEFAULT_PROVIDER_IDS, googleProviderMode: DEFAULT_GOOGLE_PROVIDER_MODE, + claudeModelMode: DEFAULT_CLAUDE_MODEL_MODE, providerOrder: null, defaultProvider: 'chatgpt', lastSelectedProvider: 'chatgpt', diff --git a/multi-panel/multi-panel.css b/multi-panel/multi-panel.css index cdec70b..62e0fef 100644 --- a/multi-panel/multi-panel.css +++ b/multi-panel/multi-panel.css @@ -415,6 +415,7 @@ body { background: #fff; border-radius: 4px; overflow: hidden; + container-type: inline-size; } .panel-header { @@ -431,6 +432,7 @@ body { display: flex; align-items: center; gap: 8px; + min-width: 0; } .panel-header-left img { @@ -442,15 +444,20 @@ body { font-size: 13px; font-weight: 500; color: #333; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .panel-header-right { display: flex; align-items: center; gap: 4px; + min-width: 0; } -.panel-google-mode-select { +.panel-google-mode-select, +.panel-claude-model-select { height: 24px; padding: 0 24px 0 6px; border: 1px solid #d9d9d9; @@ -471,6 +478,64 @@ body { max-width: 100%; } +.claude-model-experimental-badge { + padding: 2px 5px; + border: 1px solid #e0a93f; + border-radius: 999px; + color: #8a5a00; + font-size: 9px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +@container (max-width: 380px) { + .claude-model-experimental-badge { + display: none; + } + + .panel-header { + padding-inline: 8px; + } + + .panel-header-left { + gap: 5px; + } +} + +@container (max-width: 300px) { + .panel-header-left span { + display: none; + } + + .panel-claude-model-select { + max-width: 105px; + } +} + +@container (max-width: 180px) { + .panel-header { + padding-inline: 4px; + } + + .panel-header-left, + .panel-header-right .refresh-panel-btn, + .panel-header-right .switch-provider-btn { + display: none; + } + + .panel-header-right { + width: 100%; + gap: 2px; + } + + .panel-claude-model-select { + flex: 1; + width: auto !important; + max-width: calc(100% - 26px); + } +} + .panel-header-right button { display: flex; align-items: center; @@ -521,9 +586,34 @@ body { line-height: 1.4; } -.embedded-model-limit-notice-text { +.embedded-model-limit-notice-content { + display: flex; flex: 1; min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; +} + +.embedded-model-limit-notice-text { + flex: 1 1 320px; + min-width: 0; +} + +.claude-model-override-status { + color: #7a6740; + white-space: nowrap; +} + +.claude-model-override-status[data-state="active"], +.claude-model-override-status[data-state="success"] { + color: #2e7d32; +} + +.claude-model-override-status[data-state="error"] { + color: #b3261e; + flex: 1 1 100%; + white-space: normal; } .embedded-model-limit-notice .open-provider-top-level-inline-btn { @@ -558,6 +648,19 @@ body { background: #5a4a26; } +[data-theme="dark"] .claude-model-override-status { + color: #d4bd89; +} + +[data-theme="dark"] .claude-model-override-status[data-state="active"], +[data-theme="dark"] .claude-model-override-status[data-state="success"] { + color: #81c784; +} + +[data-theme="dark"] .claude-model-override-status[data-state="error"] { + color: #ff8a80; +} + .panel-loading { position: absolute; top: 0; @@ -1282,13 +1385,19 @@ body { color: #aaa; } -[data-theme="dark"] .panel-google-mode-select { - background: #333; +[data-theme="dark"] .panel-google-mode-select, +[data-theme="dark"] .panel-claude-model-select { + background-color: #333; border-color: #555; color: #e0e0e0; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cpath d='M3.5 5.25L7 8.75l3.5-3.5' fill='none' stroke='%23e0e0e0' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } +[data-theme="dark"] .claude-model-experimental-badge { + border-color: #8b7136; + color: #f0c674; +} + [data-theme="dark"] .panel-header-right button:hover { background: #555; color: #e0e0e0; diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index af96406..38f449c 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -19,6 +19,14 @@ import { isProviderAllowedUrl, isProviderCurrentUrl } from '../modules/provider-open-url.js'; +import { + CLAUDE_MODEL_MODE_DEFAULT, + CLAUDE_MODEL_OPTIONS, + DEFAULT_CLAUDE_MODEL_MODE, + getClaudeModelOption, + normalizeClaudeModelMode, + createClaudeModelOverrideMessage +} from '../modules/claude-model-mode.js'; import { saveSetting } from '../modules/settings.js'; import { applyTheme } from '../modules/theme-manager.js'; import { t, initializeLanguage } from '../modules/i18n.js'; @@ -85,9 +93,16 @@ function getDropdownThemePalette() { }; } -function getLocalizedText(key, fallback) { - const message = t(key); - return message && message !== key ? message : fallback; +function getLocalizedText(key, fallback, substitutions = null) { + const message = t(key, substitutions); + const fallbackValues = substitutions === null + ? [] + : (Array.isArray(substitutions) ? substitutions : [substitutions]); + const resolvedFallback = fallbackValues.reduce( + (text, value, index) => text.replaceAll(`$${index + 1}`, value), + fallback + ); + return message && message !== key ? message : resolvedFallback; } function refreshThemeAwareProviderIcons() { @@ -98,6 +113,7 @@ function refreshThemeAwareProviderIcons() { }); } let currentGoogleProviderMode = DEFAULT_GOOGLE_PROVIDER_MODE; +let currentClaudeModelMode = DEFAULT_CLAUDE_MODEL_MODE; // 提示词编辑器状态 let currentEditingPromptId = null; @@ -119,6 +135,7 @@ const PANELIZE_PROVIDER_IDLE = 'PANELIZE_PROVIDER_IDLE'; const PANELIZE_PROVIDER_USER_INTERACTION = 'PANELIZE_PROVIDER_USER_INTERACTION'; const PANELIZE_TEMP_CHAT_ENABLED = 'PANELIZE_TEMP_CHAT_ENABLED'; const PANELIZE_PROVIDER_LOCATION = 'PANELIZE_PROVIDER_LOCATION'; +const PANELIZE_CLAUDE_MODEL_PROBE = 'PANELIZE_CLAUDE_MODEL_PROBE'; const TEMP_CHAT_RETRY_DELAYS = [1200, 2500, 4000]; const TEMP_CHAT_OPERATION_TIMEOUT_MS = 5000; const TEMP_CHAT_SUPPORTED_PROVIDERS = new Set(['chatgpt', 'gemini', 'claude', 'grok']); @@ -303,6 +320,27 @@ function getGoogleModeSelectHtml(mode = currentGoogleProviderMode) { `; } +function getClaudeModelLabel(option) { + if (option.mode === CLAUDE_MODEL_MODE_DEFAULT) { + return getLocalizedText('claudeModelDefault', 'Claude default'); + } + return option.label; +} + +function getClaudeModelSelectHtml(mode = currentClaudeModelMode) { + const normalizedMode = normalizeClaudeModelMode(mode); + const options = CLAUDE_MODEL_OPTIONS.map(option => ` + + `).join(''); + + return ` + ${getLocalizedText('claudeModelExperimentalBadge', 'Experimental')} + + `; +} + function fitPanelSelectWidth(select) { if (!(select instanceof HTMLSelectElement)) { return; @@ -342,6 +380,9 @@ function getPanelHeaderRightHtml(providerId) { const googleModeSelect = isGoogleProvider(providerId) ? getGoogleModeSelectHtml() : ''; + const claudeModelSelect = providerId === 'claude' + ? getClaudeModelSelectHtml() + : ''; const openTopLevelBtn = ` `; } +function getClaudeModelOverrideElements(panelEl) { + return { + notice: panelEl?.querySelector('.embedded-model-limit-notice-text') || null, + status: panelEl?.querySelector('.claude-model-override-status') || null + }; +} + +function setClaudeModelOverrideStatus(panelEl, text, state = '') { + const { status } = getClaudeModelOverrideElements(panelEl); + if (!status) { + return; + } + + status.textContent = text; + status.dataset.state = state; +} + +function postClaudeModelOverride(panel, mode = currentClaudeModelMode) { + if (!panel?.iframe?.contentWindow || panel.providerId !== 'claude') { + return; + } + + panel.iframe.contentWindow.postMessage( + createClaudeModelOverrideMessage(mode), + 'https://claude.ai' + ); +} + +function resetClaudeModelModeAfterFailure(panelEl, message) { + updateClaudeModelMode(CLAUDE_MODEL_MODE_DEFAULT, { + persist: true, + broadcast: true + }).then(() => { + setClaudeModelOverrideStatus(panelEl, message, 'error'); + }).catch(error => { + console.error('Failed to reset Claude model mode:', error); + setClaudeModelOverrideStatus(panelEl, message, 'error'); + }); +} + +function handleClaudeModelProbeMessage(event, data) { + if (data.type !== PANELIZE_CLAUDE_MODEL_PROBE || event.origin !== 'https://claude.ai') { + return false; + } + + const panel = panels.find(candidate => ( + candidate.providerId === 'claude' && + candidate.iframe?.contentWindow === event.source + )); + const panelEl = panel ? document.getElementById(panel.id) : null; + if (!panel || !panelEl) { + return true; + } + + switch (data.event) { + case 'probe-ready': + postClaudeModelOverride(panel); + break; + case 'override-configured': + setClaudeModelOverrideStatus(panelEl, ''); + break; + case 'request-overridden': { + const option = getClaudeModelOption(data.mode); + setClaudeModelOverrideStatus( + panelEl, + getLocalizedText('claudeModelRequestedStatus', 'Requested $1', option.label), + 'active' + ); + break; + } + case 'override-request-accepted': { + const option = getClaudeModelOption(data.mode); + setClaudeModelOverrideStatus( + panelEl, + getLocalizedText('claudeModelRequestedStatus', 'Requested $1', option.label), + 'success' + ); + break; + } + case 'override-config-rejected': + case 'override-schema-unsupported': + resetClaudeModelModeAfterFailure( + panelEl, + getLocalizedText( + 'claudeModelOverrideUnavailable', + 'Model override is unavailable. Switched to Claude default.' + ) + ); + break; + case 'override-request-rejected': + resetClaudeModelModeAfterFailure( + panelEl, + getLocalizedText( + 'claudeModelRequestRejected', + 'Model request was rejected (HTTP $1). Switched to Claude default.', + String(data.status || '') + ) + ); + break; + default: + break; + } + + return true; +} + // Keep the embedded model limit notice in sync when a panel's provider changes. function syncEmbeddedModelLimitNotice(panelEl, providerId) { const container = panelEl.querySelector('.panel-iframe-container'); @@ -378,6 +545,11 @@ function syncEmbeddedModelLimitNotice(panelEl, providerId) { if (!existing) { const iframe = container.querySelector('iframe'); iframe.insertAdjacentHTML('beforebegin', getEmbeddedModelLimitNoticeHtml()); + } else { + const { notice } = getClaudeModelOverrideElements(panelEl); + if (notice) { + notice.textContent = getClaudeModelNoticeText(); + } } } else if (existing) { existing.remove(); @@ -393,6 +565,22 @@ function syncGoogleModeControls() { }); } +function syncClaudeModelControls() { + document.querySelectorAll('.panel-claude-model-select').forEach(select => { + if (select.value !== currentClaudeModelMode) { + select.value = currentClaudeModelMode; + } + fitPanelSelectWidth(select); + }); + + document.querySelectorAll('.panel-item').forEach(panelEl => { + const { notice } = getClaudeModelOverrideElements(panelEl); + if (notice) { + notice.textContent = getClaudeModelNoticeText(); + } + }); +} + function showPanelLoadingState(panelEl, provider) { const loadingEl = panelEl.querySelector('.panel-loading'); if (!loadingEl || !provider) { @@ -491,6 +679,24 @@ function bindPanelHeaderActions(panelId) { await updateGoogleProviderMode(event.target.value, { persist: true, reloadPanels: true }); }); } + + const claudeModelSelect = panelEl.querySelector('.panel-claude-model-select'); + if (claudeModelSelect) { + fitPanelSelectWidth(claudeModelSelect); + claudeModelSelect.addEventListener('click', event => { + event.stopPropagation(); + }); + claudeModelSelect.addEventListener('mousedown', event => { + event.stopPropagation(); + }); + claudeModelSelect.addEventListener('change', async event => { + fitPanelSelectWidth(event.target); + await updateClaudeModelMode(event.target.value, { + persist: true, + broadcast: true + }); + }); + } } async function updateGoogleProviderMode(mode, { persist = false, reloadPanels = false } = {}) { @@ -510,6 +716,21 @@ async function updateGoogleProviderMode(mode, { persist = false, reloadPanels = } } +async function updateClaudeModelMode(mode, { persist = false, broadcast = false } = {}) { + currentClaudeModelMode = normalizeClaudeModelMode(mode); + syncClaudeModelControls(); + + if (broadcast) { + panels + .filter(panel => panel.providerId === 'claude') + .forEach(panel => postClaudeModelOverride(panel)); + } + + if (persist) { + await saveSetting('claudeModelMode', currentClaudeModelMode); + } +} + function registerStorageChangeListener() { if (!chrome?.storage?.onChanged?.addListener) { return; @@ -520,19 +741,27 @@ function registerStorageChangeListener() { return; } - if (!changes.googleProviderMode || !changes.googleProviderMode.newValue) { - return; + if (changes.googleProviderMode?.newValue) { + const nextMode = normalizeGoogleProviderMode(changes.googleProviderMode.newValue); + if (nextMode === currentGoogleProviderMode) { + syncGoogleModeControls(); + } else { + updateGoogleProviderMode(nextMode, { reloadPanels: true }).catch((error) => { + console.error('Error syncing Google provider mode:', error); + }); + } } - const nextMode = normalizeGoogleProviderMode(changes.googleProviderMode.newValue); - if (nextMode === currentGoogleProviderMode) { - syncGoogleModeControls(); - return; + if (changes.claudeModelMode) { + const nextMode = normalizeClaudeModelMode(changes.claudeModelMode.newValue); + if (nextMode === currentClaudeModelMode) { + syncClaudeModelControls(); + } else { + updateClaudeModelMode(nextMode, { broadcast: true }).catch(error => { + console.error('Error syncing Claude model mode:', error); + }); + } } - - updateGoogleProviderMode(nextMode, { reloadPanels: true }).catch((error) => { - console.error('Error syncing Google provider mode:', error); - }); }); } @@ -825,6 +1054,10 @@ function handleProviderStatusMessage(event) { return; } + if (handleClaudeModelProbeMessage(event, data)) { + return; + } + const isTempChatMessage = data.type === PANELIZE_TEMP_CHAT_ENABLED; const isLocationMessage = data.type === PANELIZE_PROVIDER_LOCATION; if (data.context !== MULTI_PANEL_PROVIDER_STATUS_CONTEXT || (!data.requestId && !isTempChatMessage && !isLocationMessage)) { @@ -967,12 +1200,14 @@ async function loadSettings() { multiPanelLayout: '1x3', multiPanelProviders: DEFAULT_PROVIDERS, openMode: 'tab', - googleProviderMode: DEFAULT_GOOGLE_PROVIDER_MODE + googleProviderMode: DEFAULT_GOOGLE_PROVIDER_MODE, + claudeModelMode: DEFAULT_CLAUDE_MODEL_MODE }); currentLayout = normalizeLayout(settings.multiPanelLayout); currentOpenMode = settings.openMode || 'tab'; currentGoogleProviderMode = normalizeGoogleProviderMode(settings.googleProviderMode); + currentClaudeModelMode = normalizeClaudeModelMode(settings.claudeModelMode); // Apply layout const panelGrid = document.getElementById('panel-grid'); @@ -1028,6 +1263,7 @@ function collectCurrentState() { providerId: p.providerId })), googleProviderMode: currentGoogleProviderMode, + claudeModelMode: currentClaudeModelMode, timestamp: Date.now() }; return state; @@ -1123,6 +1359,12 @@ async function restoreStateIfNeeded() { }); } + if (state.claudeModelMode) { + await chrome.storage.sync.set({ + claudeModelMode: normalizeClaudeModelMode(state.claudeModelMode) + }); + } + // 清除已恢复的状态 await chrome.storage.session.remove('preservedState'); await chrome.storage.local.remove('preservedState'); @@ -1324,6 +1566,9 @@ async function addPanel(providerId) { iframe.addEventListener('load', () => { loadingEl.classList.add('hidden'); const panel = panels.find(p => p.id === panelId); + if (panel?.providerId === 'claude') { + postClaudeModelOverride(panel); + } if (isTemporaryChatModeEnabled && panel && requiresTemporaryChatActivationRetry(panel.providerId)) { startTemporaryChatActivationForPanel(panel); } @@ -1405,6 +1650,9 @@ async function switchPanelProvider(panelId, newProviderId) { if (isGoogleProvider(newProviderId)) { syncGoogleModeControls(); } + if (newProviderId === 'claude') { + syncClaudeModelControls(); + } // Update panel header const headerIcon = panelEl.querySelector('.panel-header-left img'); diff --git a/tests/claude-model-request-interceptor.test.js b/tests/claude-model-request-interceptor.test.js new file mode 100644 index 0000000..3e6ce85 --- /dev/null +++ b/tests/claude-model-request-interceptor.test.js @@ -0,0 +1,398 @@ +import '../content-scripts/claude-model-request-utils.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { + CLAUDE_MODEL_MODE_DEFAULT, + CLAUDE_MODEL_MODE_HAIKU_4_5, + CLAUDE_MODEL_MODE_OPUS_4_8, + CLAUDE_MODEL_MODE_SONNET_5, + CLAUDE_MODEL_OPTIONS, + createClaudeModelOverrideMessage, + getClaudeModelOption, + normalizeClaudeModelMode +} from '../modules/claude-model-mode.js'; + +const utils = globalThis.PanelizeClaudeModelRequestUtils; + +function createInterceptorHarness({ fetchError = null, fetchStatus = 200, xhrStatus = 200 } = {}) { + const messages = []; + const targetOrigins = []; + const fetchCalls = []; + const messageListeners = []; + const xhrInstances = []; + + class FakeXMLHttpRequest { + constructor() { + this.listeners = new Map(); + this.sentBodies = []; + this.status = xhrStatus; + xhrInstances.push(this); + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + open() {} + + send(body) { + this.sentBodies.push(body); + this.listeners.get('loadend')?.(); + } + } + + const parent = { + postMessage(message, targetOrigin) { + messages.push(message); + targetOrigins.push(targetOrigin); + } + }; + const context = { + Request, + URL, + XMLHttpRequest: FakeXMLHttpRequest, + fetch: async (...args) => { + fetchCalls.push(args); + if (fetchError) { + throw fetchError; + } + return { status: fetchStatus }; + }, + location: { href: 'https://claude.ai/new' }, + parent, + top: {}, + addEventListener(type, listener) { + if (type === 'message') { + messageListeners.push(listener); + } + } + }; + context.window = context; + context.self = context; + + vm.runInNewContext( + fs.readFileSync(path.resolve('content-scripts/claude-model-request-utils.js'), 'utf8'), + context + ); + vm.runInNewContext( + fs.readFileSync(path.resolve('content-scripts/claude-model-request-interceptor.js'), 'utf8'), + context + ); + + return { + configure(mode) { + messageListeners[0]({ + source: parent, + origin: 'chrome-extension://panelize-test', + data: createClaudeModelOverrideMessage(mode) + }); + }, + context, + fetchCalls, + messages, + targetOrigins, + xhrInstances + }; +} + +describe('Claude model request override', () => { + it('defines the supported UI modes and exact request model IDs', () => { + expect(CLAUDE_MODEL_OPTIONS).toEqual([ + { mode: 'default', label: 'Claude default', modelId: '' }, + { mode: 'opus-4-8', label: 'Opus 4.8', modelId: 'claude-opus-4-8' }, + { mode: 'sonnet-5', label: 'Sonnet 5', modelId: 'claude-sonnet-5' }, + { mode: 'haiku-4-5', label: 'Haiku 4.5', modelId: 'claude-haiku-4-5-20251001' } + ]); + }); + + it('normalizes unknown stored values to Claude default', () => { + expect(normalizeClaudeModelMode(CLAUDE_MODEL_MODE_SONNET_5)).toBe('sonnet-5'); + expect(normalizeClaudeModelMode('future-model')).toBe(CLAUDE_MODEL_MODE_DEFAULT); + expect(normalizeClaudeModelMode(null)).toBe(CLAUDE_MODEL_MODE_DEFAULT); + expect(getClaudeModelOption('future-model')).toEqual(CLAUDE_MODEL_OPTIONS[0]); + }); + + it.each([ + [CLAUDE_MODEL_MODE_DEFAULT, ''], + [CLAUDE_MODEL_MODE_SONNET_5, 'claude-sonnet-5'], + [CLAUDE_MODEL_MODE_OPUS_4_8, 'claude-opus-4-8'], + [CLAUDE_MODEL_MODE_HAIKU_4_5, 'claude-haiku-4-5-20251001'] + ])('builds a constrained parent message for %s', (mode, model) => { + expect(createClaudeModelOverrideMessage(mode)).toEqual({ + type: 'PANELIZE_CLAUDE_MODEL_OVERRIDE', + mode, + model + }); + }); + + it('uses exact origins after the initial iframe handshake', () => { + const harness = createInterceptorHarness(); + + expect(harness.targetOrigins).toEqual(['*']); + harness.configure(CLAUDE_MODEL_MODE_OPUS_4_8); + expect(harness.targetOrigins.at(-1)).toBe('chrome-extension://panelize-test'); + + const multiPanelSource = fs.readFileSync( + path.resolve('multi-panel/multi-panel.js'), + 'utf8' + ); + expect(multiPanelSource).toContain( + "createClaudeModelOverrideMessage(mode),\n 'https://claude.ai'" + ); + }); + + it('accepts only the three catalog model IDs', () => { + expect(utils.isAllowedModelId('claude-sonnet-5')).toBe(true); + expect(utils.isAllowedModelId('claude-opus-4-8')).toBe(true); + expect(utils.isAllowedModelId('claude-haiku-4-5-20251001')).toBe(true); + expect(utils.isAllowedModelId('claude-future-model')).toBe(false); + }); + + it('matches only Claude completion endpoint URLs', () => { + expect(utils.isClaudeCompletionUrl( + 'https://claude.ai/api/organizations/org/chat_conversations/chat/completion' + )).toBe(true); + expect(utils.isClaudeCompletionUrl('/api/chat/completion', 'https://claude.ai/new')).toBe(true); + expect(utils.isClaudeCompletionUrl('https://claude.ai/api/chat/completion/extra')).toBe(false); + expect(utils.isClaudeCompletionUrl('https://evil.example/api/chat/completion')).toBe(false); + expect(utils.isClaudeCompletionUrl('not a url', 'not a base')).toBe(false); + }); + + it('replaces only the root model field and leaves nested fields untouched', () => { + const result = utils.rewriteCompletionBody(JSON.stringify({ + model: 'claude-sonnet-5', + metadata: { model: 'leave-this-value' }, + messages: [{ content: 'private prompt', modelId: 'leave-this-too' }] + }), 'claude-opus-4-8'); + + expect(result).toMatchObject({ + overridden: true, + changed: true, + inserted: false, + reason: 'model-replaced' + }); + expect(JSON.parse(result.body)).toEqual({ + model: 'claude-opus-4-8', + metadata: { model: 'leave-this-value' }, + messages: [{ content: 'private prompt', modelId: 'leave-this-too' }] + }); + }); + + it('injects a root model field when the embedded request omits it', () => { + const result = utils.rewriteCompletionBody( + '{"prompt":"hello","timezone":"Asia/Shanghai"}', + 'claude-haiku-4-5-20251001' + ); + + expect(result).toMatchObject({ + overridden: true, + changed: true, + inserted: true, + reason: 'model-injected' + }); + expect(JSON.parse(result.body)).toEqual({ + prompt: 'hello', + timezone: 'Asia/Shanghai', + model: 'claude-haiku-4-5-20251001' + }); + }); + + it('passes unsupported bodies through without rewriting them', () => { + expect(utils.rewriteCompletionBody('not-json', 'claude-sonnet-5')).toEqual({ + body: 'not-json', + overridden: false, + reason: 'invalid-json' + }); + expect(utils.rewriteCompletionBody('["prompt"]', 'claude-sonnet-5')).toEqual({ + body: '["prompt"]', + overridden: false, + reason: 'unsupported-shape' + }); + expect(utils.rewriteCompletionBody('{"model":null}', 'claude-sonnet-5')).toEqual({ + body: '{"model":null}', + overridden: false, + reason: 'unsupported-model-field' + }); + expect(utils.rewriteCompletionBody('{"prompt":"hello"}', 'claude-unknown')).toEqual({ + body: '{"prompt":"hello"}', + overridden: false, + reason: 'unsupported-model' + }); + }); + + it('classifies only 4xx responses as model request rejections', () => { + expect(utils.classifyResponseStatus(200)).toBe('accepted'); + expect(utils.classifyResponseStatus(204)).toBe('accepted'); + expect(utils.classifyResponseStatus(400)).toBe('rejected'); + expect(utils.classifyResponseStatus(403)).toBe('rejected'); + expect(utils.classifyResponseStatus(500)).toBe('server-error'); + expect(utils.classifyResponseStatus(0)).toBe('unknown'); + }); + + it('rewrites a Fetch completion request once without resending it', async () => { + const harness = createInterceptorHarness(); + harness.configure(CLAUDE_MODEL_MODE_OPUS_4_8); + + await harness.context.fetch( + 'https://claude.ai/api/organizations/org/chat_conversations/chat/completion', + { method: 'POST', body: '{"prompt":"hello"}' } + ); + + expect(harness.fetchCalls).toHaveLength(1); + expect(JSON.parse(harness.fetchCalls[0][1].body)).toEqual({ + prompt: 'hello', + model: 'claude-opus-4-8' + }); + expect(harness.messages.map(message => message.event)).toContain('override-request-accepted'); + }); + + it('leaves completion requests unchanged in Claude default mode', async () => { + const harness = createInterceptorHarness(); + const body = '{"prompt":"default"}'; + + await harness.context.fetch('https://claude.ai/api/chat/completion', { + method: 'POST', + body + }); + + expect(harness.fetchCalls).toHaveLength(1); + expect(harness.fetchCalls[0][1].body).toBe(body); + expect(harness.messages.map(message => message.event)).not.toContain('request-overridden'); + }); + + it('passes an unsupported Fetch body through once and disables later overrides', async () => { + const harness = createInterceptorHarness(); + harness.configure(CLAUDE_MODEL_MODE_SONNET_5); + const endpoint = 'https://claude.ai/api/chat/completion'; + + await harness.context.fetch(endpoint, { method: 'POST', body: 'not-json' }); + await harness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"second"}' }); + + expect(harness.fetchCalls).toHaveLength(2); + expect(harness.fetchCalls[0][1].body).toBe('not-json'); + expect(harness.fetchCalls[1][1].body).toBe('{"prompt":"second"}'); + expect(harness.messages.map(message => message.event)).toContain('override-schema-unsupported'); + }); + + it('disables the override after a 4xx without automatically retrying', async () => { + const harness = createInterceptorHarness({ fetchStatus: 403 }); + const endpoint = 'https://claude.ai/api/chat/completion'; + harness.configure(CLAUDE_MODEL_MODE_HAIKU_4_5); + + await harness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"first"}' }); + await harness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"second"}' }); + + expect(harness.fetchCalls).toHaveLength(2); + expect(JSON.parse(harness.fetchCalls[0][1].body).model).toBe('claude-haiku-4-5-20251001'); + expect(harness.fetchCalls[1][1].body).toBe('{"prompt":"second"}'); + expect(harness.messages.map(message => message.event)).toContain('override-request-rejected'); + }); + + it('leaves the override enabled after a 5xx and sends each XHR only once', async () => { + const fetchHarness = createInterceptorHarness({ fetchStatus: 500 }); + const endpoint = 'https://claude.ai/api/chat/completion'; + fetchHarness.configure(CLAUDE_MODEL_MODE_SONNET_5); + + await fetchHarness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"first"}' }); + await fetchHarness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"second"}' }); + + expect(fetchHarness.fetchCalls).toHaveLength(2); + expect(JSON.parse(fetchHarness.fetchCalls[1][1].body).model).toBe('claude-sonnet-5'); + + const xhrHarness = createInterceptorHarness({ xhrStatus: 200 }); + xhrHarness.configure(CLAUDE_MODEL_MODE_OPUS_4_8); + const xhr = new xhrHarness.context.XMLHttpRequest(); + xhr.open('POST', endpoint); + xhr.send('{"prompt":"xhr"}'); + + expect(xhrHarness.xhrInstances).toHaveLength(1); + expect(xhr.sentBodies).toHaveLength(1); + expect(JSON.parse(xhr.sentBodies[0]).model).toBe('claude-opus-4-8'); + }); + + it('does not treat a Fetch network error as a model rejection', async () => { + const networkError = new TypeError('network unavailable'); + const harness = createInterceptorHarness({ fetchError: networkError }); + harness.configure(CLAUDE_MODEL_MODE_SONNET_5); + + await expect(harness.context.fetch( + 'https://claude.ai/api/chat/completion', + { method: 'POST', body: '{"prompt":"network"}' } + )).rejects.toThrow('network unavailable'); + + expect(harness.fetchCalls).toHaveLength(1); + expect(harness.messages.map(message => message.event)).not.toContain('override-request-rejected'); + }); + + it('disables an XHR override after one 4xx without resending', () => { + const harness = createInterceptorHarness({ xhrStatus: 403 }); + const endpoint = 'https://claude.ai/api/chat/completion'; + harness.configure(CLAUDE_MODEL_MODE_OPUS_4_8); + + const firstXhr = new harness.context.XMLHttpRequest(); + firstXhr.open('POST', endpoint); + firstXhr.send('{"prompt":"first"}'); + + const secondXhr = new harness.context.XMLHttpRequest(); + secondXhr.open('POST', endpoint); + secondXhr.send('{"prompt":"second"}'); + + expect(firstXhr.sentBodies).toHaveLength(1); + expect(JSON.parse(firstXhr.sentBodies[0]).model).toBe('claude-opus-4-8'); + expect(secondXhr.sentBodies).toEqual(['{"prompt":"second"}']); + expect(harness.messages.map(message => message.event)).toContain('override-request-rejected'); + }); + + it('loads the passive interceptor in the main world for Claude frames', () => { + const manifest = JSON.parse(fs.readFileSync(path.resolve('manifest.json'), 'utf8')); + const registration = manifest.content_scripts.find(entry => + entry.js.includes('content-scripts/claude-model-request-interceptor.js') + ); + + expect(registration).toMatchObject({ + matches: ['https://claude.ai/*'], + run_at: 'document_start', + all_frames: true, + world: 'MAIN' + }); + expect(registration.js).toEqual([ + 'content-scripts/claude-model-request-utils.js', + 'content-scripts/claude-model-request-interceptor.js' + ]); + + const source = fs.readFileSync( + path.resolve('content-scripts/claude-model-request-interceptor.js'), + 'utf8' + ); + expect(source).toContain('if (window.self === window.top)'); + expect(source).not.toContain('console.info'); + expect(source).not.toContain('endpointPattern'); + }); + + it('provides the Claude model UI copy in every supported locale', () => { + const requiredKeys = [ + 'openProviderTopLevel', + 'embeddedModelLimitNotice', + 'embeddedModelOverrideNotice', + 'claudeModelDefault', + 'claudeModelExperimentalBadge', + 'claudeModelExperimentalTitle', + 'claudeModelSelectTitle', + 'claudeModelRequestedStatus', + 'claudeModelOverrideUnavailable', + 'claudeModelRequestRejected' + ]; + const locales = fs.readdirSync(path.resolve('_locales')); + + locales.forEach(locale => { + const messages = JSON.parse(fs.readFileSync( + path.resolve('_locales', locale, 'messages.json'), + 'utf8' + )); + requiredKeys.forEach(key => { + expect(messages[key]?.message, `${locale} is missing ${key}`).toBeTruthy(); + }); + }); + }); +}); diff --git a/tests/e2e/claude-model-selection.e2e.test.js b/tests/e2e/claude-model-selection.e2e.test.js new file mode 100644 index 0000000..00ff0ce --- /dev/null +++ b/tests/e2e/claude-model-selection.e2e.test.js @@ -0,0 +1,142 @@ +import { test, expect, chromium } from '@playwright/test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const EXTENSION_PATH = path.resolve(__dirname, '../..'); + +test.describe('Claude model selection E2E', () => { + test.setTimeout(60000); + + let context; + let extensionId; + let userDataDir; + + test.beforeAll(async () => { + userDataDir = await mkdtemp(path.join(os.tmpdir(), 'panelize-claude-model-')); + context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + viewport: { width: 1200, height: 900 }, + args: [ + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + '--no-sandbox', + '--disable-setuid-sandbox', + ], + }); + + let [serviceWorker] = context.serviceWorkers(); + if (!serviceWorker) { + serviceWorker = await context.waitForEvent('serviceworker'); + } + extensionId = new URL(serviceWorker.url()).host; + + await context.route('https://claude.ai/**', async route => { + await route.fulfill({ + contentType: 'text/html', + body: 'Claude test frame', + }); + }); + }); + + test.afterAll(async () => { + await context?.close(); + await rm(userDataDir, { recursive: true, force: true }); + }); + + test('persists and synchronizes the global selector with responsive themes', async ({}, testInfo) => { + const pageA = await context.newPage(); + const pageB = await context.newPage(); + const panelUrl = `chrome-extension://${extensionId}/multi-panel/multi-panel.html`; + + await pageA.goto(panelUrl); + await pageA.evaluate(async () => { + await chrome.storage.sync.set({ + claudeModelMode: 'default', + enabledProviders: ['claude'], + multiPanelLayout: '1x1', + multiPanelProviders: ['claude'], + providerOrder: ['claude'], + theme: 'light', + }); + }); + await pageA.reload(); + await pageB.goto(panelUrl); + + const selectorA = pageA.locator('.panel-claude-model-select'); + const selectorB = pageB.locator('.panel-claude-model-select'); + await expect(selectorA).toHaveValue('default'); + await expect(selectorB).toHaveValue('default'); + await expect(selectorA.locator('option')).toHaveText([ + 'Claude default', + 'Opus 4.8', + 'Sonnet 5', + 'Haiku 4.5', + ]); + await expect(pageA.locator('.embedded-model-limit-notice')).toBeVisible(); + + await selectorA.selectOption('opus-4-8'); + await expect(selectorA).toHaveValue('opus-4-8'); + await expect(selectorB).toHaveValue('opus-4-8'); + await expect.poll(async () => pageA.evaluate(async () => ( + await chrome.storage.sync.get('claudeModelMode') + ).claudeModelMode)).toBe('opus-4-8'); + + await pageB.reload(); + await expect(pageB.locator('.panel-claude-model-select')).toHaveValue('opus-4-8'); + await pageA.screenshot({ + path: testInfo.outputPath('claude-model-selector-1x1-light.png'), + fullPage: true, + }); + + await pageA.evaluate(async () => { + await chrome.storage.sync.set({ + theme: 'dark', + }); + }); + await pageA.reload(); + await expect(pageA.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(pageA.locator('.panel-claude-model-select')).toHaveCSS('background-repeat', 'no-repeat'); + await pageA.screenshot({ + path: testInfo.outputPath('claude-model-selector-1x1-dark.png'), + fullPage: true, + }); + + await pageA.evaluate(async () => { + await chrome.storage.sync.set({ + multiPanelLayout: '1x10', + theme: 'light', + }); + }); + await pageA.reload(); + await expect(pageA.locator('#panel-grid')).toHaveClass(/layout-1x10/); + await expect(pageA.locator('html')).toHaveAttribute('data-theme', 'light'); + await expect(pageA.locator('.panel-claude-model-select')).toBeVisible(); + await pageA.screenshot({ + path: testInfo.outputPath('claude-model-selector-1x10-light.png'), + fullPage: true, + }); + + await pageA.evaluate(async () => { + await chrome.storage.sync.set({ theme: 'dark' }); + }); + await pageA.reload(); + await expect(pageA.locator('#panel-grid')).toHaveClass(/layout-1x10/); + await expect(pageA.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(pageA.locator('.panel-claude-model-select')).toBeVisible(); + await expect(pageA.locator('.panel-claude-model-select')).toHaveCSS('background-repeat', 'no-repeat'); + await pageA.screenshot({ + path: testInfo.outputPath('claude-model-selector-1x10-dark.png'), + fullPage: true, + }); + + await pageA.locator('.panel-claude-model-select').selectOption('default'); + await expect(pageB.locator('.panel-claude-model-select')).toHaveValue('default'); + + await pageA.close(); + await pageB.close(); + }); +}); diff --git a/tests/settings.test.js b/tests/settings.test.js index c7ea2fe..328ea6e 100644 --- a/tests/settings.test.js +++ b/tests/settings.test.js @@ -9,6 +9,7 @@ import { importSettings, } from '../modules/settings.js'; import { DEFAULT_GOOGLE_PROVIDER_MODE } from '../modules/google-mode.js'; +import { DEFAULT_CLAUDE_MODEL_MODE } from '../modules/claude-model-mode.js'; import { DEFAULT_PROVIDER_IDS } from '../modules/provider-defaults.js'; describe('settings module', () => { @@ -47,12 +48,13 @@ describe('settings module', () => { expect(chrome.storage.local.get).toHaveBeenCalled(); }); - it('should expose the default Google provider mode', async () => { + it('should expose the default provider modes', async () => { chrome.storage.sync.get.mockImplementation(async (defaults) => defaults); const result = await getSettings(); expect(result.googleProviderMode).toBe(DEFAULT_GOOGLE_PROVIDER_MODE); + expect(result.claudeModelMode).toBe(DEFAULT_CLAUDE_MODEL_MODE); expect(result.enabledProviders).toEqual(DEFAULT_PROVIDER_IDS); }); }); @@ -90,6 +92,12 @@ describe('settings module', () => { expect(chrome.storage.sync.set).toHaveBeenCalledWith({ googleProviderMode: 'search' }); }); + + it('should save the Claude model mode', async () => { + await saveSetting('claudeModelMode', 'opus-4-8'); + + expect(chrome.storage.sync.set).toHaveBeenCalledWith({ claudeModelMode: 'opus-4-8' }); + }); }); describe('saveSettings', () => { @@ -114,6 +122,7 @@ describe('settings module', () => { expect.objectContaining({ enabledProviders: expect.any(Array), googleProviderMode: DEFAULT_GOOGLE_PROVIDER_MODE, + claudeModelMode: DEFAULT_CLAUDE_MODEL_MODE, defaultProvider: 'chatgpt', theme: 'auto', })