pending changes for chrome extension

This commit is contained in:
Salman Paracha 2025-06-26 11:48:00 -07:00
parent 23d0574a7c
commit ae52cf5a14
8 changed files with 337 additions and 145 deletions

1
.gitignore vendored
View file

@ -24,6 +24,7 @@ wheels/
.installed.cfg
*.egg
MANIFEST
node_modules/
# PyInstaller
# Usually these files are written by a python script from a template

View file

@ -1,19 +1,28 @@
{
"manifest_version": 3,
"name": "ChatGPT Preference-Based Model Selector",
"version": "1.0",
"description": "Define usage preferences for your chatGPT models.",
"version": "1.2",
"description": "Define usage preferences for your ChatGPT models and proxy conversation requests through ArchGW.",
"permissions": [
"storage",
"tabs"
"tabs",
"scripting"
],
"host_permissions": [
"https://chatgpt.com/*"
"https://chatgpt.com/*",
"http://localhost:12000/*"
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http://localhost:12000"
},
"web_accessible_resources": [
{
"resources": ["index.html"],
"matches": ["https://chat.openai.com/*"]
"matches": ["https://chatgpt.com/*"]
},
{
"resources": ["pageFetchOverride.js"],
"matches": ["https://chatgpt.com/*"]
}
],
"action": {
@ -22,7 +31,8 @@
"content_scripts": [
{
"matches": ["https://chatgpt.com/*"],
"js": ["./static/js/content.js"]
"js": ["static/js/content.js"],
"run_at": "document_start"
}
]
}

View file

@ -4,10 +4,10 @@ import PreferenceBasedModelSelector from './components/PreferenceBasedModelSelec
export default function App() {
return (
<div className="bg-gray-100 min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-4xl">
<div className="w-full max-w-6xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800">Set</h1>
<p className="text-gray-600 mt-2">This is an interactive preview of the Preference-Based Model Selector.</p>
<h1 className="text-3xl font-bold text-gray-800">ChatGPT Usage Preferences</h1>
<p className="text-gray-600 mt-2">Define your usage preferences for each type of ChatGPT model.</p>
</div>
<PreferenceBasedModelSelector />
</div>

View file

@ -4,32 +4,43 @@ const path = require('path');
console.log('Starting the custom build process for the Chrome Extension...');
// Define paths
const contentScriptSource = path.join(__dirname, '..', 'src', 'scripts', 'content.js');
const buildDir = path.join(__dirname, '..', 'build');
const contentScriptDestDir = path.join(buildDir, 'static', 'js');
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');
// Step 1: Run the standard React build script
// 1⃣ Run React build
try {
console.log('Running react-scripts build...');
execSync('react-scripts build', { stdio: 'inherit' });
console.log('React build completed successfully.');
} catch (error) {
console.error('React build failed. Please check the errors above.');
} catch (err) {
console.error('React build failed:', err);
process.exit(1);
}
// Step 2: Copy the content script to the build directory
// 2⃣ Copy content.js
try {
// Ensure the destination directory exists (it should after the build)
if (fs.existsSync(contentScriptDestDir)) {
fs.copyFileSync(contentScriptSource, path.join(contentScriptDestDir, 'content.js'));
console.log(`Successfully copied content.js to ${contentScriptDestDir}`);
} else {
throw new Error(`Destination directory not found: ${contentScriptDestDir}. The build might have failed.`);
if (!fs.existsSync(contentScriptDest)) {
throw new Error(`Missing directory: ${contentScriptDest}`);
}
} catch (error) {
console.error('Failed to copy content script:', error);
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);
}

View file

@ -1,6 +1,21 @@
/*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',
'o3-pro',
'o4-mini',
'o4-mini-high',
'o1',
'o1-mini',
'o1-pro'
];
// --- 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}>
@ -20,9 +35,7 @@ const PlusCircle = ({ className }) => (
// --- Mocked UI Components ---
const Card = ({ children, className = '' }) => (
<div className={`bg-white border border-gray-200 rounded-lg shadow-sm ${className}`}>
{children}
</div>
<div className={`bg-white border border-gray-200 rounded-lg shadow-sm ${className}`}>{children}</div>
);
const CardContent = ({ children, className = '' }) => (
<div className={`p-4 ${className}`}>{children}</div>
@ -30,7 +43,7 @@ const CardContent = ({ children, className = '' }) => (
const Input = (props) => (
<input
{...props}
className={`w-full px-3 py-2 text-sm text-gray-800 bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${props.className || ''}`}
className={`w-full h-9 px-3 text-sm text-gray-800 bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${props.className || ''}`}
/>
);
const Button = ({ children, variant = 'default', size = 'default', className = '', ...props }) => {
@ -72,129 +85,109 @@ const Label = (props) => (
export default function PreferenceBasedModelSelector() {
const [routingEnabled, setRoutingEnabled] = useState(false);
const [preferences, setPreferences] = useState([{ id: 1, naturalLanguage: "write poems", model: "gpt-4" }]);
const [defaultModel, setDefaultModel] = useState("gpt-4");
const modelOptions = ["gpt-3.5-turbo", "gpt-4", "gpt-4o"];
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 settings from chrome.storage when the component mounts
// Load saved settings
useEffect(() => {
if (chrome.storage) {
chrome.storage.sync.get(['routingEnabled', 'preferences', 'defaultModel'], (result) => {
if (result.routingEnabled !== undefined) setRoutingEnabled(result.routingEnabled);
if (result.preferences) setPreferences(result.preferences);
if (result.defaultModel) setDefaultModel(result.defaultModel);
});
}
chrome.storage.sync.get(['routingEnabled', 'preferences', 'defaultModel'], (result) => {
if (result.routingEnabled !== undefined) setRoutingEnabled(result.routingEnabled);
if (result.preferences) setPreferences(result.preferences);
if (result.defaultModel) setDefaultModel(result.defaultModel);
});
}, []);
const updatePreference = (id, key, value) => {
setPreferences((prev) =>
prev.map((p) => (p.id === id ? { ...p, [key]: value } : p))
);
setPreferences((prev) => prev.map((p) => (p.id === id ? { ...p, [key]: value } : p)));
};
const addPreference = () => {
const newId =
preferences.length > 0 ? Math.max(...preferences.map((p) => p.id)) + 1 : 1;
setPreferences([...preferences, { id: newId, naturalLanguage: "", model: defaultModel }]);
const newId = preferences.length ? Math.max(...preferences.map((p) => p.id)) + 1 : 1;
setPreferences((prev) => [...prev, { id: newId, usage: '', model: defaultModel }]);
};
const removePreference = (id) => {
if (preferences.length > 1) {
setPreferences(preferences.filter((p) => p.id !== id));
setPreferences((prev) => prev.filter((p) => p.id !== id));
}
};
// Save settings: generate name slug and store tuples
const handleSave = () => {
const settings = { routingEnabled, preferences, defaultModel };
// Save to chrome.storage
if (chrome.storage) {
chrome.storage.sync.set(settings, () => {
console.log("[PBMS] Settings saved.");
});
}
// Send a message to the active tab's content script
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const modelToApply = defaultModel; // Replace with your routing logic
chrome.tabs.sendMessage(tabs[0].id, { action: "applyModelSelection", model: modelToApply });
const tuples = preferences.map((p) => {
const slug = p.usage.split(/\s+/).slice(0, 3).join('-').toLowerCase() || 'route';
return { name: slug, usage: p.usage, model: p.model };
});
// Close the injected modal by messaging the parent page
console.log("[PBMS] Save clicked → closing modal");
window.parent.postMessage({ action: "CLOSE_PBMS_MODAL" }, "*");
chrome.storage.sync.set({ routingEnabled, preferences: tuples, defaultModel }, () => {
console.log('[PBMS] Saved tuples:', tuples);
});
// Notify content script
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 = () => {
console.log("[PBMS] Cancel clicked → closing modal");
window.parent.postMessage({ action: "CLOSE_PBMS_MODAL" }, "*");
window.parent.postMessage({ action: 'CLOSE_PBMS_MODAL' }, '*');
};
return (
<div className="w-[450px] bg-gray-50 p-4">
<div className="w-full max-w-[600px] bg-gray-50 p-4 mx-auto">
<h2 className="text-lg font-semibold text-center mb-4">Model Preferences</h2>
<div className="space-y-4">
<Card>
<Card className="w-full">
<CardContent>
<div className="flex items-center justify-between">
<Label htmlFor="routingEnabled" className="font-medium">
Enable preference-based routing
</Label>
<Switch id="routingEnabled" checked={routingEnabled} onCheckedChange={setRoutingEnabled} />
<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">
{preferences.map((pref) => (
<div key={pref.id} className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
<div key={pref.id} className="grid grid-cols-[3fr_1fr_auto] gap-4 items-center">
<Input
placeholder="e.g., summarize articles"
value={pref.naturalLanguage}
onChange={(e) => updatePreference(pref.id, "naturalLanguage", e.target.value)}
placeholder="Usage (e.g. generate tests)"
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="w-full px-3 py-2 text-sm text-gray-800 bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => updatePreference(pref.id, 'model', e.target.value)}
className="h-9 w-full px-3 text-sm bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option disabled value="">
Select Model
</option>
{modelOptions.map((model) => (
<option key={model} value={model}>
{model}
{modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<Button
variant="ghost"
size="icon"
onClick={() => removePreference(pref.id)}
className="text-gray-500 hover:text-red-600 disabled:opacity-50"
disabled={preferences.length <= 1}
>
<Trash2 className="h-4 w-4" />
<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 gap-2 items-center text-sm mt-2">
<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>
<Card>
<Card className="w-full">
<CardContent>
<Label htmlFor="defaultModel" className="font-medium">
Default Model on Page Load
</Label>
<Label>Default Model on Page Load</Label>
<select
id="defaultModel"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
className="w-full mt-2 px-3 py-2 text-sm text-gray-800 bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="h-9 w-full mt-2 px-3 text-sm bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{modelOptions.map((model) => (
<option key={model} value={model}>
{model}
{modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
@ -209,4 +202,4 @@ export default function PreferenceBasedModelSelector() {
</div>
</div>
);
}
}

View file

@ -0,0 +1,36 @@
// background.js
chrome.runtime.onMessage.addListener((msg, sender) => {
if (msg.type === 'ARCHGW_STREAM') {
const { url, init, port } = msg;
fetch(url, {
method: init.method || 'POST',
headers: init.headers,
body: init.body,
credentials: init.credentials ?? 'same-origin',
// ...copy other init options as needed
}).then(res => {
const reader = res.body.getReader();
function read() {
reader.read().then(({ done, value }) => {
if (done) {
port.postMessage({ done: true });
port.close();
} else {
// Send each chunk (Uint8Array) to the content script
port.postMessage({ chunk: value.buffer }, [value.buffer]);
read();
}
});
}
read();
}).catch(err => {
// In case of error, signal done
port.postMessage({ done: true });
port.close();
});
// Indicate were handling this asynchronously
return true;
}
});

View file

@ -1,56 +1,118 @@
// content.js
(() => {
const TAG = "[ModelSelector]";
const TAG = '[ModelSelector]';
// Find the model selector button by visible label
const findSelectorButton = () =>
[...document.querySelectorAll('button')].find(
btn => btn.textContent.match(/GPT|Default|4o/i)
);
let selectorButton = findSelectorButton();
if (!selectorButton) {
console.warn(`${TAG} Model selector button not found—will retry on DOM changes.`);
} else {
console.log(`${TAG} Listener attached to model selector.`);
selectorButton.addEventListener('click', () => {
console.log(`${TAG} Model selector clicked (dropdown opening).`);
/**─────────────────────── 0⃣ Broadcast initial settings ───────────────────────**/
chrome.storage.sync.get(['preferences','defaultModel'], settings => {
window.postMessage({ type: 'PBMS_SETTINGS', settings }, '*');
});
chrome.storage.onChanged.addListener(() => {
chrome.storage.sync.get(['preferences','defaultModel'], settings => {
window.postMessage({ type: 'PBMS_SETTINGS', settings }, '*');
});
}
// Observe for late loads or UI updates
const initObserver = new MutationObserver(() => {
if (!selectorButton) {
selectorButton = findSelectorButton();
if (selectorButton) {
console.log(`${TAG} (late) Listener attached to model selector.`);
selectorButton.addEventListener('click', () => {
console.log(`${TAG} Model selector clicked (dropdown opening).`);
});
}
}
});
initObserver.observe(document.body, { childList: true, subtree: true });
// Observe dropdown insertions
const menuObserver = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (
node.nodeType === 1 &&
node.querySelector &&
node.querySelector('[role="menu"]')
) {
console.log(`${TAG} Dropdown menu opened.`);
node.querySelectorAll('[role="menuitem"]').forEach(item => {
item.addEventListener('click', () => {
console.log(`${TAG} Model selected →`, item.innerText.trim());
});
});
/**─────────────────────── 1⃣ Inject page-context fetch override ───────────────────────**/
(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);
})();
/**─────────────────────── 2⃣ Handle proxied fetch from the page ───────────────────────**/
window.addEventListener('message', ev => {
console.log('[ModelSelector] page→content message', ev.data, ev.ports);
if (ev.source !== window || ev.data?.type !== 'ARCHGW_FETCH') return;
const { url, init } = ev.data;
const port = ev.ports[0];
(async () => {
try {
const res = await fetch(url, init);
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
port.postMessage({ done: true });
break;
} else {
port.postMessage({ chunk: value.buffer }, [value.buffer]);
}
}
} catch (err) {
console.error(`${TAG} proxy fetch error`, err);
port.postMessage({ done: true });
}
})();
});
/**─────────────────────── 3⃣ DOM patch for model selector label ───────────────────────**/
let desiredModel = null;
function patchDom() {
if (desiredModel == null) return;
const btn = document.querySelector('[data-testid="model-switcher-dropdown-button"]');
if (!btn) return;
const span = btn.querySelector('span.text-token-text-tertiary') || btn.querySelector('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();
}
});
menuObserver.observe(document.body, { childList: true, subtree: true });
console.log(`${TAG} Content script injected.`);
/**─────────────────────── 4⃣ Modal / dropdown interception ───────────────────────**/
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) {
if (!ev.target.closest('button[aria-haspopup="menu"]')) 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,79 @@
// pageFetchOverride.js
(function() {
const TAG = '[ModelSelector][Page]';
console.log(`${TAG} installing fetch override`);
window.archgwSettings = window.archgwSettings || { preferences: [], defaultModel: null };
window.addEventListener('message', ev => {
if (ev.source === window && ev.data?.type === 'PBMS_SETTINGS') {
window.archgwSettings = ev.data.settings;
console.log(`${TAG} got updated settings`, window.archgwSettings);
}
});
const origFetch = window.fetch;
window.fetch = async function(input, init = {}) {
const urlString = typeof input === 'string' ? input : input.url;
console.log(`${TAG} fetch →`, urlString);
let pathname;
try {
pathname = new URL(urlString).pathname;
} catch {
pathname = urlString;
}
if (pathname === '/backend-api/conversation') {
console.log(`${TAG} matched conversation → proxy via content script`);
// patch metadata
let body = {};
try { body = JSON.parse(init.body); } catch {}
body.metadata = {
archgw_preference_config: window.archgwSettings.preferences
.map(p => `- name: ${p.name}\n model: ${p.model}\n usage: ${p.usage}`)
.join('\n')
};
init.body = JSON.stringify(body);
// send only the serializable parts of `init`
const safeInit = {
method: init.method,
headers: init.headers,
body: init.body,
credentials: init.credentials
};
// set up MessageChannel
const { port1, port2 } = new MessageChannel();
window.postMessage({
type: 'ARCHGW_FETCH',
url: 'http://localhost:12000/v1/chat/completions',
init: safeInit
}, '*', [port2]);
// return streaming response
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' }
});
}
return origFetch(input, init);
};
console.log(`${TAG} fetch override installed`);
})();