mirror of
https://github.com/katanemo/plano.git
synced 2026-05-18 13:45:15 +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
28
demos/use_cases/chatgpt-preference-model-selector/src/App.js
Normal file
28
demos/use_cases/chatgpt-preference-model-selector/src/App.js
Normal 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 |
|
|
@ -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!');
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
/*global chrome*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
// --- Hard‑coded 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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