working version with fixed styling and better tested

This commit is contained in:
Salman Paracha 2025-07-02 16:45:56 -07:00
parent a872f9eec2
commit bb2eb02030
3 changed files with 118 additions and 74 deletions

View file

@ -6,8 +6,9 @@ export default function App() {
<div className="bg-gray-100 min-h-screen flex items-center justify-center p-4"> <div className="bg-gray-100 min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-6xl"> <div className="w-full max-w-6xl">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800">ChatGPT Model Selector</h1> <h1 className="text-3xl font-bold text-gray-800">RouteGPT</h1>
<p className="text-gray-600 mt-2">Define usage preferences to optimize model selection.</p> <p className="text-gray-600 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 hover:underline">powered by Arch Router</a>
</div> </div>
<PreferenceBasedModelSelector /> <PreferenceBasedModelSelector />
</div> </div>

View file

@ -5,8 +5,8 @@ import React, { useState, useEffect } from 'react';
const MODEL_LIST = [ const MODEL_LIST = [
'gpt-4o', 'gpt-4o',
'gpt-4.1', 'gpt-4.1',
'gpt-4.1 mini', 'gpt-4.1-mini',
'gpt-4.5 preview', 'gpt-4.5-preview',
'o3', 'o3',
'o3-pro', 'o3-pro',
'o4-mini', 'o4-mini',
@ -129,35 +129,52 @@ export default function PreferenceBasedModelSelector() {
// Save settings: generate name slug and store tuples // Save settings: generate name slug and store tuples
const handleSave = () => { const handleSave = () => {
// Only keep valid preferences with non-empty usage const slugCounts = {};
const tuples = preferences const tuples = [];
preferences
.filter(p => p.usage?.trim()) .filter(p => p.usage?.trim())
.map((p) => { .forEach(p => {
const slug = p.usage.split(/\s+/).slice(0, 3).join('-').toLowerCase() || 'route'; const baseSlug = p.usage
return { name: slug, usage: p.usage, model: p.model }; .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 }, () => { chrome.storage.sync.set({ routingEnabled, preferences: tuples, defaultModel }, () => {
console.log('[PBMS] Saved tuples:', tuples); if (chrome.runtime.lastError) {
console.error('[PBMS] Storage error:', chrome.runtime.lastError);
} else {
console.log('[PBMS] Saved tuples:', tuples);
}
}); });
// Notify content script
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.sendMessage(tabs[0].id, { action: 'applyModelSelection', model: defaultModel }); chrome.tabs.sendMessage(tabs[0].id, { action: 'applyModelSelection', model: defaultModel });
}); });
// Close the modal
window.parent.postMessage({ action: 'CLOSE_PBMS_MODAL' }, '*'); window.parent.postMessage({ action: 'CLOSE_PBMS_MODAL' }, '*');
}; };
const handleCancel = () => { const handleCancel = () => {
window.parent.postMessage({ action: 'CLOSE_PBMS_MODAL' }, '*'); window.parent.postMessage({ action: 'CLOSE_PBMS_MODAL' }, '*');
}; };
return ( return (
<div className="w-full max-w-[600px] bg-gray-50 p-4 mx-auto"> <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"> <div className="space-y-4">
<Card className="w-full"> <Card className="w-full">
<CardContent> <CardContent>

View file

@ -1,11 +1,26 @@
(() => { (() => {
const TAG = '[ModelSelector]'; const TAG = '[ModelSelector]';
/**─────────────────────── 🔧 Utility: Scrape Current Messages ───────────────────────**/ async function streamToPort(response, port) {
function getMessagesFromDom() { 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]);
}
}
function getMessagesFromDom(requestMessages = null) {
const bubbles = [...document.querySelectorAll('[data-message-author-role]')]; const bubbles = [...document.querySelectorAll('[data-message-author-role]')];
return bubbles const domMessages = bubbles
.map(b => { .map(b => {
const role = b.getAttribute('data-message-author-role'); const role = b.getAttribute('data-message-author-role');
const content = const content =
@ -15,29 +30,44 @@
return content ? { role, content } : null; return content ? { role, content } : null;
}) })
.filter(Boolean); .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;
} }
/**─────────────────────── 🔧 Utility: Prepare Request to Proxy ───────────────────────**/
function prepareProxyRequest(messages, routes, maxTokenLength = 2048) { function prepareProxyRequest(messages, routes, maxTokenLength = 2048) {
const SYSTEM_PROMPT_TEMPLATE = ` const SYSTEM_PROMPT_TEMPLATE = `
You are a helpful assistant designed to find the best suited route. You are a helpful assistant designed to find the best suited route.
You are provided with route description within <routes></routes> XML tags: You are provided with route description within <routes></routes> XML tags:
<routes> <routes>
{routes} {routes}
</routes> </routes>
<conversation> <conversation>
{conversation} {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: 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"}. 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. 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>. 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: Based on your analysis, provide your response in the following JSON formats if you decide to match any route:
{"route": "route_name"} {"route": "route_name"}
`; `;
const TOKEN_DIVISOR = 4; const TOKEN_DIVISOR = 4;
const filteredMessages = messages.filter( const filteredMessages = messages.filter(
@ -72,7 +102,6 @@
return systemPrompt; return systemPrompt;
} }
/**─────────────────────── 🔧 Get routes from storage ───────────────────────**/
function getRoutesFromStorage() { function getRoutesFromStorage() {
return new Promise(resolve => { return new Promise(resolve => {
chrome.storage.sync.get(['preferences'], ({ preferences }) => { chrome.storage.sync.get(['preferences'], ({ preferences }) => {
@ -91,8 +120,6 @@
}); });
} }
/**─────────────────────── 🔧 Get model ID by route name ───────────────────────**/
function getModelIdForRoute(routeName) { function getModelIdForRoute(routeName) {
return new Promise(resolve => { return new Promise(resolve => {
chrome.storage.sync.get(['preferences'], ({ preferences }) => { chrome.storage.sync.get(['preferences'], ({ preferences }) => {
@ -103,7 +130,6 @@
}); });
} }
/**─────────────────────── 1⃣ Inject page-context fetch override ───────────────────────**/
(function injectPageFetchOverride() { (function injectPageFetchOverride() {
const injectorTag = '[ModelSelector][Injector]'; const injectorTag = '[ModelSelector][Injector]';
const s = document.createElement('script'); const s = document.createElement('script');
@ -115,7 +141,6 @@
(document.head || document.documentElement).appendChild(s); (document.head || document.documentElement).appendChild(s);
})(); })();
/**─────────────────────── 2⃣ Intercept fetch and reroute via Ollama ───────────────────────**/
window.addEventListener('message', ev => { window.addEventListener('message', ev => {
if (ev.source !== window || ev.data?.type !== 'ARCHGW_FETCH') return; if (ev.source !== window || ev.data?.type !== 'ARCHGW_FETCH') return;
@ -133,11 +158,23 @@
console.warn(`${TAG} Could not parse original fetch body`); console.warn(`${TAG} Could not parse original fetch body`);
} }
const scrapedMessages = getMessagesFromDom(); const { routingEnabled, preferences } = await new Promise(resolve => {
const routes = await getRoutesFromStorage(); chrome.storage.sync.get(['routingEnabled', 'preferences'], resolve);
});
if (!routingEnabled) {
console.log(`${TAG} Routing disabled — forwarding original request`);
await streamToPort(await fetch(url, init), port);
return;
}
const scrapedMessages = getMessagesFromDom(originalBody.messages);
const routes = (preferences || []).map(p => ({
name: p.name,
description: p.usage
}));
const prompt = prepareProxyRequest(scrapedMessages, routes); const prompt = prepareProxyRequest(scrapedMessages, routes);
// 🔁 Call Ollama router
let selectedRoute = null; let selectedRoute = null;
try { try {
const res = await fetch('http://localhost:11434/api/generate', { const res = await fetch('http://localhost:11434/api/generate', {
@ -146,32 +183,32 @@
body: JSON.stringify({ body: JSON.stringify({
model: 'hf.co/katanemo/Arch-Router-1.5B.gguf:Q4_K_M', model: 'hf.co/katanemo/Arch-Router-1.5B.gguf:Q4_K_M',
prompt: prompt, prompt: prompt,
temperature: 0.1, temperature: 0.01,
top_p: 0.95,
top_k: 20,
stream: false stream: false
}) })
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log(`${TAG} Ollama router response:`, data.response); console.log(`${TAG} Ollama router response:`, data.response);
try { try {
let parsed = data.response; let parsed = data.response;
if (typeof data.response === 'string') { if (typeof data.response === 'string') {
try { try {
parsed = JSON.parse(data.response); parsed = JSON.parse(data.response);
} catch (jsonErr) { } catch (jsonErr) {
// Try to recover from single quotes const safe = data.response.replace(/'/g, '"');
const safe = data.response.replace(/'/g, '"'); parsed = JSON.parse(safe);
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);
} }
selectedRoute = parsed.route || null; } else {
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); console.warn(`${TAG} Ollama router failed:`, res.status);
} }
} catch (err) { } catch (err) {
@ -184,7 +221,6 @@
console.log(`${TAG} Resolved model for route "${selectedRoute}" →`, targetModel); console.log(`${TAG} Resolved model for route "${selectedRoute}" →`, targetModel);
} }
// 🧠 Replace model if we found one
const modifiedBody = { ...originalBody }; const modifiedBody = { ...originalBody };
if (targetModel) { if (targetModel) {
modifiedBody.model = targetModel; modifiedBody.model = targetModel;
@ -193,22 +229,12 @@
console.warn(`${TAG} No route/model override applied`); console.warn(`${TAG} No route/model override applied`);
} }
const upstreamRes = await fetch(url, { await streamToPort(await fetch(url, {
method: init.method, method: init.method,
headers: init.headers, headers: init.headers,
credentials: init.credentials, credentials: init.credentials,
body: JSON.stringify(modifiedBody) body: JSON.stringify(modifiedBody)
}); }), port);
const reader = upstreamRes.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
port.postMessage({ done: true });
break;
}
port.postMessage({ chunk: value.buffer }, [value.buffer]);
}
} catch (err) { } catch (err) {
console.error(`${TAG} Proxy fetch error`, err); console.error(`${TAG} Proxy fetch error`, err);
port.postMessage({ done: true }); port.postMessage({ done: true });
@ -216,7 +242,6 @@
})(); })();
}); });
/**─────────────────────── 3⃣ DOM patch for model selector label ───────────────────────**/
let desiredModel = null; let desiredModel = null;
function patchDom() { function patchDom() {
if (!desiredModel) return; if (!desiredModel) return;
@ -252,7 +277,6 @@
} }
}); });
/**─────────────────────── 4⃣ Modal / dropdown interception ───────────────────────**/
function showModal() { function showModal() {
if (document.getElementById('pbms-overlay')) return; if (document.getElementById('pbms-overlay')) return;
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@ -278,7 +302,9 @@
} }
function interceptDropdown(ev) { function interceptDropdown(ev) {
if (!ev.target.closest('button[aria-haspopup="menu"]')) return; const btn = ev.target.closest('button[data-testid="model-switcher-dropdown-button"]');
if (!btn) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
showModal(); showModal();