Salmanap/chrome extension routing (#511)

* first commit of the chatGPT selector

* stashing changes as checkpoint

* pending changes for chrome extension

* commiting a working version

* converting conversation into messages object

* working version of the extension

* working version with fixed styling and better tested

* fixed the issue that the drop down was too small, and fixed the issue where the route was not displayed on the screen

* updating folder with README.md

* fixes for default model, and to update the manifest.json file

* made changes to the dark mode. improved styles

* fix installation bug

* added dark mode

* fixed default model selection

* fixed the scrolling issue

* Update README.md

* updated content.js to update the labels even when default model

* fixed readme

* upddated the title of the packag

* removing the unnecessary permissions

---------

Co-authored-by: Salman Paracha <salmanparacha@MacBook-Pro-329.local>
Co-authored-by: cotran <cotran2@utexas.edu>
Co-authored-by: Shuguang Chen <54548843+nehcgs@users.noreply.github.com>
This commit is contained in:
Salman Paracha 2025-07-08 16:16:32 -07:00 committed by GitHub
parent 5fb7ce576c
commit c0748718f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 19806 additions and 0 deletions

View file

@ -0,0 +1,28 @@
import React from 'react';
import PreferenceBasedModelSelector from './components/PreferenceBasedModelSelector';
export default function App() {
return (
<div className="bg-gray-100 dark:bg-gray-900 min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-6xl">
<div className="text-center mb-8">
<div className="flex justify-center items-center gap-3 -ml-12">
<img src="/logo.png" alt="RouteGPT Logo" className="w-10 h-10" />
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">RouteGPT</h1>
</div>
<p className="text-gray-600 dark:text-gray-300 mt-2">
Dynamically route to GPT models based on usage preferences.
</p>
<a
target="_blank"
href="https://github.com/katanemo/archgw"
className="text-blue-500 dark:text-blue-400 hover:underline"
>
powered by Arch Router
</a>
</div>
<PreferenceBasedModelSelector />
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,64 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
console.log('Starting the custom build process for the Chrome Extension...');
const reactAppDir = path.join(__dirname, '..');
const contentScriptSource = path.join(reactAppDir, 'src', 'scripts', 'content.js');
const pageOverrideSource = path.join(reactAppDir, 'src', 'scripts', 'pageFetchOverride.js');
const buildDir = path.join(reactAppDir, 'build');
const contentScriptDest = path.join(buildDir, 'static', 'js');
// 1⃣ Run React build
try {
console.log('Running react-scripts build...');
execSync('react-scripts build', { stdio: 'inherit' });
console.log('React build completed successfully.');
} catch (err) {
console.error('React build failed:', err);
process.exit(1);
}
// 2⃣ Copy content.js
try {
if (!fs.existsSync(contentScriptDest)) {
throw new Error(`Missing directory: ${contentScriptDest}`);
}
fs.copyFileSync(contentScriptSource, path.join(contentScriptDest, 'content.js'));
console.log(`Copied content.js → ${contentScriptDest}`);
} catch (err) {
console.error('Failed to copy content.js:', err);
process.exit(1);
}
// 3⃣ Copy pageFetchOverride.js
try {
if (!fs.existsSync(buildDir)) {
throw new Error(`Missing build directory: ${buildDir}`);
}
fs.copyFileSync(pageOverrideSource, path.join(buildDir, 'pageFetchOverride.js'));
console.log(`Copied pageFetchOverride.js → ${buildDir}`);
} catch (err) {
console.error('Failed to copy pageFetchOverride.js:', err);
process.exit(1);
}
// 4⃣ Copy logo.png from src/assets to build root
try {
const logoSource = path.join(reactAppDir, 'src', 'assets', 'logo.png');
const logoDest = path.join(buildDir, 'logo.png');
if (!fs.existsSync(logoSource)) {
throw new Error(`Missing logo.png at ${logoSource}`);
}
fs.copyFileSync(logoSource, logoDest);
console.log(`Copied logo.png → ${logoDest}`);
} catch (err) {
console.error('Failed to copy logo.png:', err);
process.exit(1);
}
console.log('Extension build process finished successfully!');

View file

@ -0,0 +1,328 @@
/*global chrome*/
import React, { useState, useEffect } from 'react';
// --- Hardcoded list of ChatGPT models ---
const MODEL_LIST = [
'gpt-4o',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-4.5-preview',
'o3',
'o4-mini',
'o4-mini-high'
];
// --- Mocked lucide-react icons as SVG components ---
const Trash2 = ({ className }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
const PlusCircle = ({ className }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
);
// --- Mocked UI Components ---
const Card = ({ children, className = '' }) => (
<div className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm ${className}`}>
{children}
</div>
);
const CardContent = ({ children, className = '' }) => (
<div className={`p-4 text-gray-800 dark:text-gray-100 ${className}`}>
{children}
</div>
);
const Input = (props) => (
<input
{...props}
className={`w-full h-9 px-3 text-sm
text-gray-800 dark:text-white
bg-white dark:bg-gray-700
border border-gray-300 dark:border-gray-600
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500
${props.className || ''}`}
/>
);
const Button = ({ children, variant = 'default', size = 'default', className = '', ...props }) => {
const baseClasses = `
inline-flex items-center justify-center
rounded-md text-sm font-medium
transition-colors
focus:outline-none focus:ring-2 focus:ring-offset-2
`;
const variantClasses = {
default: `
bg-gray-900 text-white
hover:bg-gray-800
focus:ring-gray-900
`,
outline: `
border border-gray-300 dark:border-gray-600
bg-transparent
text-gray-800 dark:text-white
hover:bg-gray-100 dark:hover:bg-gray-700
focus:ring-blue-500
focus:ring-offset-2
dark:focus:ring-offset-gray-900
`,
ghost: `
text-gray-800 dark:text-gray-200
hover:bg-gray-100 dark:hover:bg-gray-700
focus:ring-gray-400
`
};
const sizeClasses = {
default: 'h-9 px-3',
icon: 'h-9 w-9'
};
return (
<button
{...props}
className={`
${baseClasses}
${variantClasses[variant]}
${sizeClasses[size]}
${className}
`}
>
{children}
</button>
);
};
const Switch = ({ checked, onCheckedChange, id }) => (
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange(!checked)}
id={id}
className={`
relative inline-flex items-center justify-start
h-6 w-11 rounded-full
transition-colors duration-200 ease-in-out
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
border-2 border-transparent
overflow-hidden
${checked ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}
`}
>
<span
aria-hidden="true"
className={`
pointer-events-none
inline-block h-5 w-5 transform rounded-full bg-white
shadow-md ring-0 transition-transform duration-200 ease-in-out
${checked ? 'translate-x-[20px]' : 'translate-x-0'}
`}
/>
</button>
<span className="inline-block w-8 text-sm text-gray-700 dark:text-gray-300 text-center select-none">
{checked ? 'On' : 'Off'}
</span>
</div>
);
const Label = (props) => (
<label {...props} className={`text-sm font-medium leading-none text-gray-700 ${props.className || ''}`} />
);
export default function PreferenceBasedModelSelector() {
const [routingEnabled, setRoutingEnabled] = useState(false);
const [preferences, setPreferences] = useState([
{ id: 1, usage: 'generate code snippets', model: 'gpt-4o' }
]);
const [defaultModel, setDefaultModel] = useState('gpt-4o');
const [modelOptions] = useState(MODEL_LIST); // static list, no dynamic fetch
// Load saved settings
useEffect(() => {
chrome.storage.sync.get(['routingEnabled', 'preferences', 'defaultModel'], (result) => {
if (result.routingEnabled !== undefined) setRoutingEnabled(result.routingEnabled);
if (result.preferences) {
// add ids if they were missing
const withIds = result.preferences.map((p, i) => ({
id: p.id ?? i + 1,
...p,
}));
setPreferences(withIds);
}
if (result.defaultModel) setDefaultModel(result.defaultModel);
});
}, []);
const updatePreference = (id, key, value) => {
setPreferences((prev) => prev.map((p) => (p.id === id ? { ...p, [key]: value } : p)));
};
const addPreference = () => {
const newId = preferences.reduce((max, p) => Math.max(max, p.id ?? 0), 0) + 1;
setPreferences((prev) => [
...prev,
{ id: newId, usage: '', model: defaultModel }
]);
};
const removePreference = (id) => {
if (preferences.length > 1) {
setPreferences((prev) => prev.filter((p) => p.id !== id));
}
};
// Save settings: generate name slug and store tuples
const handleSave = () => {
const slugCounts = {};
const tuples = [];
preferences
.filter(p => p.usage?.trim())
.forEach(p => {
const baseSlug = p.usage
.split(/\s+/)
.slice(0, 3)
.join('-')
.toLowerCase()
.replace(/[^\w-]/g, '');
const count = slugCounts[baseSlug] || 0;
slugCounts[baseSlug] = count + 1;
const dedupedSlug = count === 0 ? baseSlug : `${baseSlug}-${count}`;
tuples.push({
name: dedupedSlug,
usage: p.usage.trim(),
model: p.model?.trim?.() || ''
});
});
chrome.storage.sync.set({ routingEnabled, preferences: tuples, defaultModel }, () => {
if (chrome.runtime.lastError) {
console.error('[PBMS] Storage error:', chrome.runtime.lastError);
} else {
console.log('[PBMS] Saved tuples:', tuples);
}
});
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.sendMessage(tabs[0].id, { action: 'applyModelSelection', model: defaultModel });
});
window.parent.postMessage({ action: 'CLOSE_PBMS_MODAL' }, '*');
};
const handleCancel = () => {
window.parent.postMessage({ action: 'CLOSE_PBMS_MODAL' }, '*');
};
return (
<div className="w-full max-w-[600px] h-[65vh] flex flex-col bg-gray-50 dark:bg-gray-800 p-4 mx-auto">
{/* Scrollable preferences only */}
<div className="space-y-4 overflow-y-auto flex-1 pr-1 max-h-[60vh]">
<Card className="w-full">
<CardContent>
<div className="flex items-center justify-between">
<Label>Enable preference-based routing</Label>
<Switch checked={routingEnabled} onCheckedChange={setRoutingEnabled} />
</div>
{routingEnabled && (
<div className="pt-4 mt-4 space-y-3 border-t border-gray-200 dark:border-gray-700">
{preferences.map((pref) => (
<div key={pref.id} className="grid grid-cols-[3fr_1.5fr_auto] gap-4 items-center">
<Input
placeholder="(e.g. generating fictional stories or poems)"
value={pref.usage}
onChange={(e) => updatePreference(pref.id, 'usage', e.target.value)}
/>
<select
value={pref.model}
onChange={(e) => updatePreference(pref.id, 'model', e.target.value)}
className="h-9 w-full px-3 text-sm
bg-white dark:bg-gray-700
text-gray-800 dark:text-white
border border-gray-300 dark:border-gray-600
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option disabled value="">
Select Model
</option>
{modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<Button
variant="ghost"
size="icon"
onClick={() => removePreference(pref.id)}
disabled={preferences.length <= 1}
>
<Trash2 className="h-4 w-4 text-gray-500 hover:text-red-600" />
</Button>
</div>
))}
<Button
variant="outline"
onClick={addPreference}
className="flex items-center gap-2 text-sm mt-2"
>
<PlusCircle className="h-4 w-4" /> Add Preference
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Default model selector (static) */}
<Card className="w-full mt-4">
<CardContent>
<Label>Default Model</Label>
<select
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
className="h-9 w-full mt-2 px-3 text-sm
bg-white dark:bg-gray-700
text-gray-800 dark:text-white
border border-gray-300 dark:border-gray-600
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</CardContent>
</Card>
{/* Save/Cancel footer (static) */}
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 mt-4">
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave}>Save and Apply</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,391 @@
(() => {
const TAG = '[ModelSelector]';
// Content script to intercept fetch requests and modify them based on user preferences
async function streamToPort(response, port) {
const reader = response.body?.getReader();
if (!reader) {
port.postMessage({ done: true });
return;
}
while (true) {
const { done, value } = await reader.read();
if (done) {
port.postMessage({ done: true });
break;
}
port.postMessage({ chunk: value.buffer }, [value.buffer]);
}
}
// Extract messages from the DOM, falling back to requestMessages if DOM is empty
function getMessagesFromDom(requestMessages = null) {
const bubbles = [...document.querySelectorAll('[data-message-author-role]')];
const domMessages = bubbles
.map(b => {
const role = b.getAttribute('data-message-author-role');
const content =
role === 'assistant'
? (b.querySelector('.markdown')?.innerText ?? b.innerText ?? '').trim()
: (b.innerText ?? '').trim();
return content ? { role, content } : null;
})
.filter(Boolean);
// Fallback: If DOM is empty but we have requestMessages, use those
if (domMessages.length === 0 && requestMessages?.length > 0) {
return requestMessages
.map(msg => {
const role = msg.author?.role;
const parts = msg.content?.parts ?? [];
const textPart = parts.find(p => typeof p === 'string');
return role && textPart ? { role, content: textPart.trim() } : null;
})
.filter(Boolean);
}
return domMessages;
}
// Insert a route label for the last user message in the chat
function insertRouteLabelForLastUserMessage(routeName) {
chrome.storage.sync.get(['preferences'], ({ preferences }) => {
// Find the most recent user bubble
const bubbles = [...document.querySelectorAll('[data-message-author-role="user"]')];
const lastBubble = bubbles[bubbles.length - 1];
if (!lastBubble) return;
// Skip if weve already added a label
if (lastBubble.querySelector('.arch-route-label')) {
console.log('[RouteLabel] Label already exists, skipping');
return;
}
// Default label text
let labelText = 'RouteGPT: preference = default';
// Try to override with preference-based usage if we have a routeName
if (routeName && Array.isArray(preferences)) {
const match = preferences.find(p => p.name === routeName);
if (match && match.usage) {
labelText = `RouteGPT: preference = ${match.usage}`;
} else {
console.log('[RouteLabel] No usage found for route (falling back to default):', routeName);
}
}
// Build and attach the label
const label = document.createElement('span');
label.textContent = labelText;
label.className = 'arch-route-label';
label.style.fontWeight = '350';
label.style.fontSize = '0.85rem';
label.style.marginTop = '2px';
label.style.fontStyle = 'italic';
label.style.alignSelf = 'end';
label.style.marginRight = '5px';
lastBubble.appendChild(label);
console.log('[RouteLabel] Inserted label:', labelText);
});
}
// Prepare the system prompt for the proxy request
function prepareProxyRequest(messages, routes, maxTokenLength = 2048) {
const SYSTEM_PROMPT_TEMPLATE = `
You are a helpful assistant designed to find the best suited route.
You are provided with route description within <routes></routes> XML tags:
<routes>
{routes}
</routes>
<conversation>
{conversation}
</conversation>
Your task is to decide which route is best suit with user intent on the conversation in <conversation></conversation> XML tags. Follow the instruction:
1. If the latest intent from user is irrelevant or user intent is full filled, response with other route {"route": "other"}.
2. You must analyze the route descriptions and find the best match route for user latest intent.
3. You only response the name of the route that best matches the user's request, use the exact name in the <routes></routes>.
Based on your analysis, provide your response in the following JSON formats if you decide to match any route:
{"route": "route_name"}
`;
const TOKEN_DIVISOR = 4;
const filteredMessages = messages.filter(
m => m.role !== 'system' && m.role !== 'tool' && m.content?.trim()
);
let tokenCount = SYSTEM_PROMPT_TEMPLATE.length / TOKEN_DIVISOR;
const selected = [];
for (let i = filteredMessages.length - 1; i >= 0; i--) {
const msg = filteredMessages[i];
tokenCount += msg.content.length / TOKEN_DIVISOR;
if (tokenCount > maxTokenLength) {
if (msg.role === 'user') selected.push(msg);
break;
}
selected.push(msg);
}
if (selected.length === 0 && filteredMessages.length > 0) {
selected.push(filteredMessages[filteredMessages.length - 1]);
}
const selectedOrdered = selected.reverse();
const systemPrompt = SYSTEM_PROMPT_TEMPLATE
.replace('{routes}', JSON.stringify(routes, null, 2))
.replace('{conversation}', JSON.stringify(selectedOrdered, null, 2));
return systemPrompt;
}
function getRoutesFromStorage() {
return new Promise(resolve => {
chrome.storage.sync.get(['preferences'], ({ preferences }) => {
if (!preferences || !Array.isArray(preferences)) {
console.warn('[ModelSelector] No preferences found in storage');
return resolve([]);
}
const routes = preferences.map(p => ({
name: p.name,
description: p.usage
}));
resolve(routes);
});
});
}
function getModelIdForRoute(routeName) {
return new Promise(resolve => {
chrome.storage.sync.get(['preferences'], ({ preferences }) => {
const match = (preferences || []).find(p => p.name === routeName);
if (match) resolve(match.model);
else resolve(null);
});
});
}
(function injectPageFetchOverride() {
const injectorTag = '[ModelSelector][Injector]';
const s = document.createElement('script');
s.src = chrome.runtime.getURL('pageFetchOverride.js');
s.onload = () => {
console.log(`${injectorTag} loaded pageFetchOverride.js`);
s.remove();
};
(document.head || document.documentElement).appendChild(s);
})();
window.addEventListener('message', ev => {
if (ev.source !== window || ev.data?.type !== 'ARCHGW_FETCH') return;
const { url, init } = ev.data;
const port = ev.ports[0];
(async () => {
try {
console.log(`${TAG} Intercepted fetch from page:`, url);
let originalBody = {};
try {
originalBody = JSON.parse(init.body);
} catch {
console.warn(`${TAG} Could not parse original fetch body`);
}
const { routingEnabled, preferences, defaultModel } = await new Promise(resolve => {
chrome.storage.sync.get(['routingEnabled', 'preferences', 'defaultModel'], resolve);
});
if (!routingEnabled) {
console.log(`${TAG} Routing disabled — applying default model if present`);
const modifiedBody = { ...originalBody };
if (defaultModel) {
modifiedBody.model = defaultModel;
console.log(`${TAG} Routing disabled — overriding with default model: ${defaultModel}`);
} else {
console.log(`${TAG} Routing disabled — no default model found`);
}
await streamToPort(await fetch(url, {
method: init.method,
headers: init.headers,
credentials: init.credentials,
body: JSON.stringify(modifiedBody)
}), port);
return;
}
const scrapedMessages = getMessagesFromDom(originalBody.messages);
const routes = (preferences || []).map(p => ({
name: p.name,
description: p.usage
}));
const prompt = prepareProxyRequest(scrapedMessages, routes);
let selectedRoute = null;
try {
const res = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'hf.co/katanemo/Arch-Router-1.5B.gguf:Q4_K_M',
prompt: prompt,
temperature: 0.01,
top_p: 0.95,
top_k: 20,
stream: false
})
});
if (res.ok) {
const data = await res.json();
console.log(`${TAG} Ollama router response:`, data.response);
try {
let parsed = data.response;
if (typeof data.response === 'string') {
try {
parsed = JSON.parse(data.response);
} catch (jsonErr) {
const safe = data.response.replace(/'/g, '"');
parsed = JSON.parse(safe);
}
}
selectedRoute = parsed.route || null;
if (!selectedRoute) console.warn(`${TAG} Route missing in parsed response`);
} catch (e) {
console.warn(`${TAG} Failed to parse or extract route from response`, e);
}
} else {
console.warn(`${TAG} Ollama router failed:`, res.status);
}
} catch (err) {
console.error(`${TAG} Ollama request error`, err);
}
let targetModel = null;
if (selectedRoute) {
targetModel = await getModelIdForRoute(selectedRoute);
if (!targetModel) {
const { defaultModel } = await new Promise(resolve =>
chrome.storage.sync.get(['defaultModel'], resolve)
);
targetModel = defaultModel || null;
if (targetModel) {
console.log(`${TAG} Falling back to default model: ${targetModel}`);
}
} else {
console.log(`${TAG} Resolved model for route "${selectedRoute}" →`, targetModel);
}
}
insertRouteLabelForLastUserMessage(selectedRoute);
const modifiedBody = { ...originalBody };
if (targetModel) {
modifiedBody.model = targetModel;
console.log(`${TAG} Overriding request with model: ${targetModel}`);
} else {
console.log(`${TAG} No route/model override applied`);
}
await streamToPort(await fetch(url, {
method: init.method,
headers: init.headers,
credentials: init.credentials,
body: JSON.stringify(modifiedBody)
}), port);
} catch (err) {
console.error(`${TAG} Proxy fetch error`, err);
port.postMessage({ done: true });
}
})();
});
let desiredModel = null;
function patchDom() {
if (!desiredModel) return;
const btn = document.querySelector('[data-testid="model-switcher-dropdown-button"]');
if (!btn) return;
const span = btn.querySelector('div > span');
const wantLabel = `Model selector, current model is ${desiredModel}`;
if (span && span.textContent !== desiredModel) span.textContent = desiredModel;
if (btn.getAttribute('aria-label') !== wantLabel) {
btn.setAttribute('aria-label', wantLabel);
}
}
const observer = new MutationObserver(patchDom);
observer.observe(document.body || document.documentElement, {
subtree: true, childList: true, characterData: true, attributes: true
});
chrome.storage.sync.get(['defaultModel'], ({ defaultModel }) => {
if (defaultModel) {
desiredModel = defaultModel;
patchDom();
}
});
chrome.runtime.onMessage.addListener(msg => {
if (msg.action === 'applyModelSelection' && msg.model) {
desiredModel = msg.model;
patchDom();
}
});
function showModal() {
if (document.getElementById('pbms-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'pbms-overlay';
Object.assign(overlay.style, {
position: 'fixed', top: 0, left: 0,
width: '100vw', height: '100vh',
background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 2147483647
});
const iframe = document.createElement('iframe');
iframe.src = chrome.runtime.getURL('index.html');
Object.assign(iframe.style, {
width: '500px', height: '600px',
border: 0, borderRadius: '8px',
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
background: 'white', zIndex: 2147483648
});
overlay.addEventListener('click', e => e.target === overlay && overlay.remove());
overlay.appendChild(iframe);
document.body.appendChild(overlay);
}
function interceptDropdown(ev) {
const btn = ev.target.closest('button[data-testid="model-switcher-dropdown-button"]');
if (!btn) return;
ev.preventDefault();
ev.stopPropagation();
showModal();
}
document.addEventListener('pointerdown', interceptDropdown, true);
document.addEventListener('mousedown', interceptDropdown, true);
window.addEventListener('message', ev => {
if (ev.data?.action === 'CLOSE_PBMS_MODAL') {
document.getElementById('pbms-overlay')?.remove();
}
});
console.log(`${TAG} content script initialized`);
})();

View file

@ -0,0 +1,61 @@
(function() {
const TAG = '[ModelSelector][Page]';
console.log(`${TAG} installing fetch override`);
const origFetch = window.fetch;
window.fetch = async function(input, init = {}) {
const urlString = typeof input === 'string' ? input : input.url;
const urlObj = new URL(urlString, window.location.origin);
const pathname = urlObj.pathname;
console.log(`${TAG} fetch →`, pathname);
const method = (init.method || 'GET').toUpperCase();
if (method === 'OPTIONS') {
console.log(`${TAG} OPTIONS request → bypassing completely`);
return origFetch(input, init);
}
// Only intercept conversation fetches
if (pathname === '/backend-api/conversation') {
console.log(`${TAG} matched → proxy via content script`);
const { port1, port2 } = new MessageChannel();
// ✅ Remove non-cloneable properties like 'signal'
const safeInit = { ...init };
delete safeInit.signal;
// Forward the fetch details to the content script
window.postMessage({
type: 'ARCHGW_FETCH',
url: urlString,
init: safeInit
}, '*', [port2]);
// Return a stream response that the content script will fulfill
return new Response(new ReadableStream({
start(controller) {
port1.onmessage = ({ data }) => {
if (data.done) {
controller.close();
port1.close();
} else {
controller.enqueue(new Uint8Array(data.chunk));
}
};
},
cancel() {
port1.close();
}
}), {
headers: { 'Content-Type': 'text/event-stream' }
});
}
// Otherwise, pass through to the original fetch
return origFetch(input, init);
};
console.log(`${TAG} fetch override installed`);
})();