mirror of
https://github.com/katanemo/plano.git
synced 2026-06-23 15:38:07 +02:00
working version with fixed styling and better tested
This commit is contained in:
parent
a872f9eec2
commit
bb2eb02030
3 changed files with 118 additions and 74 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue