mirror of
https://github.com/katanemo/plano.git
synced 2026-05-10 08:12:48 +02:00
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:
parent
5fb7ce576c
commit
c0748718f1
17 changed files with 19806 additions and 0 deletions
|
|
@ -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 we’ve 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`);
|
||||
})();
|
||||
|
|
@ -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`);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue