mirror of
https://github.com/katanemo/plano.git
synced 2026-06-29 15:49:40 +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
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -24,6 +24,7 @@ wheels/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
# Usually these files are written by a python script from a template
|
# Usually these files are written by a python script from a template
|
||||||
|
|
|
||||||
116
demos/use_cases/chatgpt-preference-model-selector/README.md
Normal file
116
demos/use_cases/chatgpt-preference-model-selector/README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# 🗝️ RouteGPT (Beta)
|
||||||
|
|
||||||
|
**RouteGPT** is a dynamic model selector Chrome extension for ChatGPT. It intercepts your prompts, detects the user's intent, and automatically routes requests to the most appropriate model — based on preferences you define. Powered by the lightweight [Arch-Router](https://huggingface.co/katanemo/Arch-Router-1.5B.gguf), it makes multi-model usage seamless.
|
||||||
|
|
||||||
|
Think of it this way: changing models manually is like shifting gears on your bike every few pedals. RouteGPT automates that for you — so you can focus on the ride, not the mechanics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Name
|
||||||
|
|
||||||
|
Folder: `chatgpt-preference-model-selector`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
* 🧠 Preference-based routing (e.g., "code generation" → GPT-4, "travel help" → Gemini)
|
||||||
|
* 🤖 Local inference using [Ollama](https://ollama.com)
|
||||||
|
* 📙 Chrome extension interface for setting route preferences
|
||||||
|
* ⚡ Runs with [Arch-Router-1.5B.gguf](https://huggingface.co/katanemo/Arch-Router-1.5B.gguf)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### 1. Clone and install dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/katanemo/archgw/
|
||||||
|
cd demos/use_cases/chatgpt-preference-model-selector
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the extension
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a `build/` directory that contains the unpacked Chrome extension.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Set Up Arch-Router in Ollama
|
||||||
|
|
||||||
|
Ensure [Ollama](https://ollama.com/download) is installed and running.
|
||||||
|
|
||||||
|
Then pull the Arch-Router model:
|
||||||
|
|
||||||
|
```
|
||||||
|
ollama pull hf.co/katanemo/Arch-Router-1.5B.gguf:Q4_K_M
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🌐 Allow Chrome to Access Ollama
|
||||||
|
|
||||||
|
Start Ollama with appropriate network settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
OLLAMA_ORIGINS=* ollama serve
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
* Sets CORS to allow requests from Chrome
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📩 Load the Extension into Chrome
|
||||||
|
|
||||||
|
1. Open `chrome://extensions`
|
||||||
|
2. Enable **Developer mode** (top-right toggle)
|
||||||
|
3. Click **"Load unpacked"**
|
||||||
|
4. Select the `build` folder inside `chatgpt-preference-model-selector`
|
||||||
|
|
||||||
|
Once loaded, RouteGPT will begin intercepting and routing your ChatGPT messages based on the preferences you define.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configure Routing Preferences
|
||||||
|
|
||||||
|
1. In ChatGPT, click the model dropdown.
|
||||||
|
2. A RouteGPT modal will appear.
|
||||||
|
3. Define your routing logic using natural language (e.g., `brainstorm startup ideas → gpt-4`, `summarize news articles → claude`).
|
||||||
|
4. Save your preferences. Routing begins immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💸 Profit
|
||||||
|
|
||||||
|
RouteGPT helps you:
|
||||||
|
|
||||||
|
* Use expensive models only when needed
|
||||||
|
* Automatically shift to cheaper, faster, or more capable models based on task type
|
||||||
|
* Streamline multi-model workflows without extra clicks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Troubleshooting
|
||||||
|
|
||||||
|
* Make sure Ollama is reachable at `http://localhost:11434`
|
||||||
|
* If routing doesn’t seem to trigger, check DevTools console logs for `[ModelSelector]`
|
||||||
|
* Reload the extension and refresh the ChatGPT tab after updating preferences
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Built With
|
||||||
|
|
||||||
|
* 🧠 [Arch-Router (1.5B)](https://huggingface.co/katanemo/Arch-Router-1.5B.gguf)
|
||||||
|
* 📙 Chrome Extensions API
|
||||||
|
* 🛠️ Ollama
|
||||||
|
* ⚛️ React + TypeScript
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
|
Apache 2.0 © Katanemo Labs, Inc.
|
||||||
18680
demos/use_cases/chatgpt-preference-model-selector/package-lock.json
generated
Normal file
18680
demos/use_cases/chatgpt-preference-model-selector/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "preference-selector-extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"homepage": ".",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "node src/build.js",
|
||||||
|
"test": "react-scripts test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"tailwindcss": "^3.4.4"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="Web site created using create-react-app" />
|
||||||
|
<!-- ✅ External JS to configure Tailwind and set dark mode -->
|
||||||
|
<script src="%PUBLIC_URL%/init-theme.js"></script>
|
||||||
|
|
||||||
|
<title>RouteGPT</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-100">
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Apply dark mode based on system preference
|
||||||
|
if (
|
||||||
|
localStorage.theme === 'dark' ||
|
||||||
|
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "RouteGPT (beta)",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "RouteGPT: Smart Model Routing for ChatGPT.",
|
||||||
|
"permissions": [
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"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", "logo.png"],
|
||||||
|
"matches": ["https://chatgpt.com/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resources": ["pageFetchOverride.js"],
|
||||||
|
"matches": ["https://chatgpt.com/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "index.html"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://chatgpt.com/*"],
|
||||||
|
"js": ["static/js/content.js"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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`);
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: 'class', // ✅ Add this line
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./public/index.html",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue