⚠️ LANGUAGE NOTE: Headings and critical instructions are in English for maximum compatibility with coding agents. Explanations are in Russian. If you, the agent, do not understand a Russian comment — ask before coding.
🧠 MAIN RULE: This document is the source of truth. If your idea contradicts this document, stop and ask. Do not silently invent another architecture.
Dual-LLM Bridge is a Chrome Manifest V3 extension with a built-in Web UI.
The product goal:
The user has two LLM web chats open in browser tabs, for example:
https://chat.qwen.aihttps://chatgpt.comThe extension UI shows a split-screen control panel:
Prefix routing:
-1 hello → send only to Window 1.-2 hello → send only to Window 2.hello → send to both windows.Auto-Relay:
The system must work without official APIs as the primary mode. Official APIs are optional fallback adapters, not the main path.
The UI is not a random standalone website on localhost trying to talk to content scripts.
The UI is an extension page, opened via chrome-extension://<extension-id>/index.html.
Reason:
chat.qwen.ai / chatgpt.com.chrome.runtime.BroadcastChannel cannot connect different origins.chrome.runtime, chrome.storage, and extension messaging.Therefore:
UI = extension page
Background Service Worker = message router and command dispatcher
Content Scripts = adapters inside LLM chat tabs
Use this architecture exactly.
┌────────────────────────────┐ ┌────────────────────────────┐
│ Qwen tab │ │ ChatGPT tab │
│ https://chat.qwen.ai │ │ https://chatgpt.com │
│ │ │ │
│ MAIN-world interceptor │ │ MAIN-world interceptor │
│ patches fetch/SSE/stream │ │ patches fetch/SSE/stream │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ISOLATED content bridge │ │ ISOLATED content bridge │
└───────────┬────────────────┘ └───────────┬────────────────┘
│ │
│ chrome.runtime.sendMessage │ chrome.runtime.sendMessage
▼ ▼
┌───────────────────────────────────────────────┐
│ Background Service Worker │
│ - routes events │
│ - knows tab/window assignment │
│ - executes SEND_PROMPT commands │
│ - reports tab status │
└───────────────────────┬───────────────────────┘
│ chrome.runtime.connect / sendMessage
▼
┌─────────────────────────┐
│ Extension Web UI │
│ Split-screen control app│
│ Relay controller │
│ Settings / Stop button │
└─────────────────────────┘
DO NOT use plain <iframe src="https://chatgpt.com"> or similar.
Major LLM chats block embedding using X-Frame-Options / CSP.
DO NOT use BroadcastChannel as the main bridge between chat tabs and UI.
It does not work across different origins.
It may only be used optionally between multiple extension UI pages of the same origin.
DO NOT rely on localStorage for real-time synchronization.
Use extension messaging and in-memory state.
DO NOT parse by random CSS class names. CSS classes change often. Prefer:
data-testid;DO NOT use MutationObserver polling as the main streaming mechanism.
Streaming must be intercepted at network layer when possible.
DOM observation is only a fallback.
DO NOT use Puppeteer / Playwright as the main product architecture. They are testing/fallback tools only.
DO NOT propose another tech stack. The stack is fixed below.
DO NOT generate code for the next phase before the current phase is accepted.
DO NOT try to bypass CAPTCHA, anti-bot, payment walls, or authentication. If the site requires human action, show a clear user-facing error.
DO NOT send chat content, cookies, tokens, or API keys anywhere except:
CRITICAL: Do not invent integration architecture from scratch. Study and adapt these patterns.
| Task | Reference | What to adopt | Notes |
|---|---|---|---|
| Intercepting chat requests/responses | ChatALL | Content script / bot adapter pattern, network interception | Study adapter structure, not exact selectors |
| Chat UI adapters | NextChat / ChatGPT-Next-Web | Adapter abstraction, message normalization | Do not copy whole app |
| MV3 content scripts | Chrome Extensions docs | content_scripts, world: MAIN, service worker |
Official docs are priority |
| Programmatic React input filling | React controlled input pattern | native value setter + input event | See section “Programmatic Message Injection” |
| Extension build | CRXJS Vite Plugin | Build MV3 extension with Vite | If unstable, use manual Vite build + static manifest |
| State management | Zustand | UI state, relay state, window stores | No Redux |
| Local persistence | IndexedDB | Transcript/settings cache | No sensitive data in localStorage |
Agent: verify URLs before relying on them. If moved, find the current repository.
https://github.com/ai-shifu/ChatALLhttps://github.com/ChatGPTNextWeb/NextChathttps://developer.chrome.com/docs/extensions/develop/concepts/content-scriptsscripting.executeScript: https://developer.chrome.com/docs/extensions/reference/api/scriptingwebRequest: https://developer.chrome.com/docs/extensions/reference/api/webRequesthttps://docs.pmnd.rs/zustandhttps://crxjs.dev/vite-pluginFrontend:
framework: React 18.3.x
language: TypeScript 5.5.x
strict_mode: true
bundler: Vite 5.4.x
State:
library: zustand 4.5.x
pattern: UI stores + relay controller store
Styling:
framework: tailwindcss 3.4.x
note: Do not switch to Tailwind 4 without user approval.
Extension:
manifest: V3
minimum_chrome: "120"
build_tool: >
Prefer @crxjs/vite-plugin if a known-good version works.
If CRXJS beta is unstable, stop and ask the user.
Fallback: plain Vite build + static manifest.json.
IPC:
content_to_background: chrome.runtime.sendMessage
background_to_content: chrome.tabs.sendMessage or chrome.scripting.executeScript
ui_to_background: chrome.runtime.connect / chrome.runtime.sendMessage
main_world_to_isolated: window.postMessage with schema validation
Storage:
settings: chrome.storage.local
api_keys: chrome.storage.local or IndexedDB
transcript_cache: IndexedDB
forbidden_for_sensitive_data: localStorage
Testing:
unit: vitest
e2e: playwright
e2e_policy: Use local mock pages, not live LLM sites.
Runtime:
node: ">=20"
package_manager: npm workspaces
package.json.latest.Use this structure unless the user explicitly approves changes.
dual-llm-bridge/
├── packages/
│ ├── extension/
│ │ ├── src/
│ │ │ ├── background/
│ │ │ │ └── index.ts # Service worker/router
│ │ │ │
│ │ │ ├── content/
│ │ │ │ ├── isolated/
│ │ │ │ │ └── index.ts # Isolated-world bridge
│ │ │ │ ├── main-world/
│ │ │ │ │ └── injector.ts # MAIN-world fetch/SSE interceptor
│ │ │ │ └── adapters/
│ │ │ │ ├── base.ts # Shared adapter helpers
│ │ │ │ ├── qwen.ts # Qwen adapter
│ │ │ │ └── chatgpt.ts # ChatGPT adapter
│ │ │ │
│ │ │ ├── ui/
│ │ │ │ ├── components/
│ │ │ │ │ ├── SplitView.tsx
│ │ │ │ │ ├── ChatPanel.tsx
│ │ │ │ │ ├── MessageList.tsx
│ │ │ │ │ ├── UnifiedInput.tsx
│ │ │ │ │ ├── StatusBar.tsx
│ │ │ │ │ ├── RelayControls.tsx
│ │ │ │ │ └── WarningModal.tsx
│ │ │ │ ├── store/
│ │ │ │ │ ├── windows.ts
│ │ │ │ │ ├── relay.ts
│ │ │ │ │ └── settings.ts
│ │ │ │ ├── lib/
│ │ │ │ │ ├── parser.ts # Prefix parsing: -1 / -2
│ │ │ │ │ ├── format.ts
│ │ │ │ │ └── ipc.ts # chrome.runtime wrapper
│ │ │ │ ├── App.tsx
│ │ │ │ ├── main.tsx
│ │ │ │ └── index.html
│ │ │ │
│ │ │ └── shared-imports.ts # Re-export shared package if needed
│ │ │
│ │ ├── manifest.json
│ │ ├── vite.config.ts
│ │ └── package.json
│ │
│ └── shared/
│ ├── src/
│ │ ├── types.ts # Protocol types
│ │ ├── guards.ts # Runtime validation guards
│ │ ├── protocol.ts # Constants/topics
│ │ └── constants.ts # Domains/timeouts/limits
│ └── package.json
│
├── tests/
│ ├── unit/
│ ├── e2e/
│ └── mocks/
│ ├── qwen-mock.html
│ └── chatgpt-mock.html
│
├── docs/
│ └── architecture.md
│
├── AGENTS.md
├── package.json
└── README.md
Primary targets:
| Window | Model | Primary URL | Fallback URL |
|---|---|---|---|
| 1 | Qwen | https://chat.qwen.ai/* |
https://qwen.ai/chat/* if applicable |
| 2 | ChatGPT | https://chatgpt.com/* |
https://chat.openai.com/* |
Manifest must register both ChatGPT patterns because chat.openai.com may redirect.
All IPC messages must use typed structures from packages/shared/src/types.ts.
Every message must have:
export const PROTOCOL_VERSION = 1;
export type WindowId = 1 | 2;
export type ModelId =
| 'qwen'
| 'chatgpt'
| (string & {}); // allow future models, but constants must define known ones
export type MessageOrigin =
| 'main-world'
| 'content-isolated'
| 'background'
| 'ui';
export type ChatStatus =
| 'unknown'
| 'tab-not-found'
| 'loading'
| 'ready'
| 'unauthenticated'
| 'generating'
| 'error';
export interface BridgeMeta {
/** Unix timestamp in milliseconds */
ts: number;
/** Unique message id */
messageId: string;
/** Which UI window this belongs to */
windowId: WindowId;
/** Model id */
model: ModelId;
/** Who caused this message */
source: 'user-ui' | 'user-chat-tab' | 'relay' | 'system';
/** Auto-relay iteration number, 0 for user-initiated */
iteration: number;
/** true if this message was forwarded by relay */
isAutoRelay: boolean;
}
export type BridgeEventType =
| 'TAB_STATUS'
| 'PROMPT_OBSERVED'
| 'RESPONSE_CHUNK'
| 'RESPONSE_DONE'
| 'INJECTION_RESULT'
| 'ERROR';
export interface BridgeEvent<T extends BridgeEventType = BridgeEventType> {
kind: 'event';
v: typeof PROTOCOL_VERSION;
type: T;
origin: MessageOrigin;
meta: BridgeMeta;
payload: BridgeEventPayload;
}
export interface BridgeEventPayload {
status?: ChatStatus;
text?: string;
streamDelta?: string;
fullText?: string;
doneReason?: 'complete' | 'stopped' | 'error' | 'timeout';
error?: {
code: string;
message: string;
retryable: boolean;
};
url?: string;
}
export type BridgeCommandType =
| 'SEND_PROMPT'
| 'REQUEST_STATUS'
| 'STOP_GENERATION'
| 'ASSIGN_WINDOW';
export interface BridgeCommand<T extends BridgeCommandType = BridgeCommandType> {
kind: 'command';
v: typeof PROTOCOL_VERSION;
type: T;
origin: MessageOrigin;
meta: BridgeMeta;
payload: BridgeCommandPayload;
}
export interface BridgeCommandPayload {
text?: string;
windowId?: WindowId;
tabId?: number;
reason?: string;
}
packages/shared/src/guards.ts.[bridge:invalid];Each supported model must have an adapter.
export interface ChatAdapterConfig {
model: ModelId;
windowId: WindowId;
hostPatterns: string[];
selectors: {
input: string[];
sendButton: string[];
stopButton?: string[];
loginIndicator?: string[];
responseContainer?: string[];
};
}
export interface ChatAdapter {
config: ChatAdapterConfig;
/** Return true if current URL belongs to this adapter */
matches(url: string): boolean;
/** Start network interception in MAIN world */
initInterceptor(): void;
/** Detect if user is logged in */
detectAuth(): boolean;
/** Extract last visible messages as fallback */
extractVisibleHistory?(): Array<{
role: 'user' | 'assistant' | 'system';
text: string;
}>;
}
Selectors must be arrays, tried in order.
Example:
selectors: {
input: [
'[data-testid="chat-input"]',
'textarea[aria-label]',
'textarea',
'[contenteditable="true"]'
],
sendButton: [
'[data-testid="send-button"]',
'button[aria-label*="send" i]',
'button[type="submit"]'
]
}
Rules:
INJECTION_RESULT error.This is mandatory.
To send text into a React-based chat input, do not do only:
textarea.value = text;
That often does not work with React controlled components.
Use the native setter pattern:
function setNativeValue(element: HTMLElement, value: string) {
const el = element as HTMLTextAreaElement | HTMLInputElement;
const prototype =
el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
const setter = descriptor?.set;
if (!setter) {
throw new Error('Native value setter is unavailable');
}
// React internal value tracker workaround
const tracker = (el as any)._valueTracker;
if (tracker) {
tracker.setValue(el.value);
}
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
Then:
input and change.Enter key events on input.Preferred execution method:
chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
func: injectPromptIntoChat,
args: [adapterId, text, messageId]
});
Fallback if chrome.scripting MAIN world is unavailable:
Primary method:
window.fetch in MAIN world.response.body with a ReadableStream tee if needed.RESPONSE_CHUNKRESPONSE_DONESecondary method:
XMLHttpRequest if the site uses it.Tertiary fallback:
Rules:
ERROR with code STREAM_PARSE_ERROR.Relay controller lives in the UI store, but all actual tab operations go through background.
Auto-relay is disabled by default.
Before first enablement, show a warning modal:
«Авто-релей будет отправлять ответы одной модели в качестве ввода другой модели. Содержимое чата может передаваться между разными провайдерами. Продолжить?»
User must explicitly confirm.
When relay is enabled:
RESPONSE_DONE from Window A.800ms.unauthenticated;SEND_PROMPT to Window B.isAutoRelay: true;
iteration: previousIteration + 1;
source: 'relay';
RESPONSE_DONE.RESPONSE_DONE.GENERATION_TIMEOUT, pause relay and show error.export const DEFAULT_DEBOUNCE_MS = 800;
export const DEFAULT_MAX_ITERATIONS = 10;
export const MAX_HARD_ITERATIONS = 100;
export const CONNECTION_TIMEOUT_MS = 30_000;
export const GENERATION_TIMEOUT_MS = 300_000;
export const INJECTION_TIMEOUT_MS = 15_000;
10.100.100ms UI-wise.STOP_GENERATION to both adapters if possible.Do not forward a message if:
The UI is an extension page.
┌────────────────────────────────────────────────────┐
│ StatusBar: window statuses, relay iteration, Stop │
├────────────────────────┬───────────────────────────┤
│ Window 1 transcript │ Window 2 transcript │
│ Qwen mirror │ ChatGPT mirror │
│ │ │
├────────────────────────┴───────────────────────────┤
│ Unified input: -1 / -2 / no prefix │
└────────────────────────────────────────────────────┘
Each panel must show:
extensionapiEach message must show:
Input parsing:
"-1 hello" -> window 1 only
"-2 hello" -> window 2 only
"hello" -> both windows
Rules:
Stop button must:
stopped.No data exfiltration. Chat content must not be sent anywhere except:
API keys local only.
Store API keys in chrome.storage.local or IndexedDB.
Never store secrets in localStorage.
No remote code execution.
No eval() of remote code.
No injecting remote scripts.
Minimal permissions. Required manifest permissions:
storagetabsscriptingDo not request <all_urls> unless user explicitly approves.
Untrusted MAIN-world data. Data from page context must be schema-validated. It must never directly invoke privileged extension commands.
Relay privacy warning. Auto-relay must require explicit user confirmation.
No anti-bot bypass. If login, CAPTCHA, or consent screen is required, stop and notify user.
Every async operation must have error handling. Silent failures are forbidden.
| Scenario | Required behavior |
|---|---|
| Extension UI cannot reach background | Show fatal error, suggest reloading extension |
| Target tab not found | Show tab-not-found, suggest opening the chat URL |
| User not authenticated | Show unauthenticated, provide button to open chat tab |
| Site layout changed | Show site structure changed, log selector failure |
| Injection failed | Retry once after 2 seconds, then show error |
| Stream interrupted | Retry once after 2 seconds, then show error |
| Rate limit / 429 | Pause relay, show cooldown warning |
| Generation timeout | Stop waiting, mark error, pause relay |
| Invalid protocol message | Log warning, discard, do not crash |
| Service worker restarted | UI should re-request status and reset transient state |
Logging prefixes:
[bridge]
[bridge:error]
[bridge:relay]
[bridge:inject]
[bridge:stream]
Test at least:
-1 text-2 textUse mock pages:
tests/mocks/qwen-mock.htmltests/mocks/chatgpt-mock.htmlMock pages should simulate:
Playwright may test:
Do not run E2E against live LLM sites by default.
Manual acceptance may use live sites.
# install dependencies from repo root
npm install
# development build/watch
npm run dev
# production build
npm run build
# unit tests
npm run test
# e2e tests
npm run test:e2e
Load extension manually:
npm run build.chrome://extensions.dist/extension or the build output folder.Firefox is not supported in the current scope.
Do not skip phases. After each phase, present results and wait for user approval.
Goal: prove that the extension can observe prompts and responses from real chat tabs.
Tasks:
packages/shared protocol types and guards.https://chat.qwen.ai/*https://chatgpt.com/*https://chat.openai.com/*BridgeEvent objects in console.[bridge] log prefixes.Acceptance:
PROMPT_OBSERVEDRESPONSE_CHUNKRESPONSE_DONENo UI is required yet.
Goal: UI can see tab statuses and send messages into chat tabs.
Tasks:
chrome.runtime.connect between UI and background.-1 / -2 parsing.SEND_PROMPT command flow:
UI → background → target tab → MAIN-world injection.Acceptance:
-1 hello → message is sent only to Qwen tab.-2 hello → message is sent only to ChatGPT tab.hello → message is sent to both tabs.Goal: models can talk to each other safely.
Tasks:
Acceptance:
Goal: allow official API mode without changing UI.
Tasks:
BotAdapter abstraction.ExtensionAdapter.ApiAdapter interface.Acceptance:
These rules are mandatory.
Before starting each phase, confirm the plan in 1–2 sentences.
Example:
«Начинаю Phase 1: создаю монорепо, shared-типы и MAIN-world interceptor для fetch/SSE. Приступаю.»
If anything in this document is ambiguous, ask before coding.
After each phase, present:
Do not proceed to the next phase without explicit user approval.
If a site changed DOM or network format, report it and propose adapter update.
If a dependency is broken or abandoned, report it before replacing.
Default communication language with the user: Russian.
The project is considered functionally complete when:
-1, -2, and no-prefix routing works;If you are about to do any of the following, stop immediately and ask:
This project values predictable architecture over clever hacks.