fix: grock
This commit is contained in:
@@ -4,7 +4,8 @@ AI Parallel is a monorepo for comparing and orchestrating multiple AI web experi
|
||||
|
||||
The first app is a Chrome/Edge Manifest V3 extension. It reuses the user's
|
||||
existing authenticated web sessions and embeds the selected model sites as
|
||||
live provider iframes in a single workspace.
|
||||
live provider iframes in a single workspace. Providers that are not iframe-safe,
|
||||
currently Grok, run in a controlled top-level tab through the same adapter contract.
|
||||
|
||||
## Current app
|
||||
|
||||
@@ -35,11 +36,13 @@ AI Parallel Workspace
|
||||
├─ ChatGPT iframe ─ Content Script ─┐
|
||||
├─ DeepSeek iframe ─ Content Script ├─ postMessage bridge
|
||||
├─ Qwen iframe ───── Content Script ├─ native provider rendering
|
||||
└─ Kimi iframe ───── Content Script ┘
|
||||
├─ Kimi iframe ───── Content Script ┘
|
||||
└─ Grok tab ─ Service Worker ─ tabs.sendMessage bridge
|
||||
```
|
||||
|
||||
Prompts are not transported in destination URLs. The workspace sends one-time
|
||||
jobs to the selected iframe through an origin-checked `postMessage` bridge.
|
||||
jobs to iframe providers through an origin-checked `postMessage` bridge and to
|
||||
tab-mode providers through a provider-scoped service-worker bridge.
|
||||
|
||||
## Repository structure
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ Workspace extension page
|
||||
├─ Kimi iframe
|
||||
├─ Claude iframe
|
||||
├─ Gemini iframe
|
||||
└─ Grok iframe
|
||||
└─ Grok controlled top-level tab
|
||||
│
|
||||
├─ content/providers/core.js shared DOM operations
|
||||
├─ content/providers/<provider>.js provider selectors
|
||||
└─ content/frame-bridge.js message boundary + adapter delegation
|
||||
```
|
||||
|
||||
`rules/bypass-headers.json` removes `X-Frame-Options` and framing CSP headers only for matching `sub_frame` responses so the original provider pages can render inside the extension workspace.
|
||||
`rules/bypass-headers.json` removes `X-Frame-Options` and framing CSP headers only for matching `sub_frame` responses so compatible provider pages can render inside the extension workspace. Grok runs in a controlled top-level tab because its authenticated WebSocket channel does not work reliably inside an extension iframe.
|
||||
|
||||
The original provider page remains visible directly. Compare can collect the
|
||||
latest visible response on demand for safe text rendering and export; it does
|
||||
@@ -30,10 +30,9 @@ not inject provider HTML or replace the native iframe view.
|
||||
|
||||
Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select this directory.
|
||||
|
||||
Grok authentication is intentionally completed in a top-level browser tab because
|
||||
`accounts.x.ai` does not permit login inside an iframe. Grok `/sign-in` links are
|
||||
redirected to a top-level tab automatically; alternatively use **登录 ↗** in the
|
||||
Grok panel. Finish signing in, return to the workspace, and reload the Grok panel.
|
||||
Grok authentication and chat run in a top-level browser tab because `accounts.x.ai`
|
||||
does not permit iframe login and Grok's real-time WebSocket is not iframe-safe. AI
|
||||
Parallel reuses that tab for prompt dispatch, response collection, and handoff.
|
||||
|
||||
## Security boundary
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
(() => {
|
||||
if (window.top === window) return;
|
||||
try {
|
||||
if (window.parent !== window.top) return;
|
||||
} catch {
|
||||
return;
|
||||
const isTopLevel = window.top === window;
|
||||
if (!isTopLevel) {
|
||||
try {
|
||||
if (window.parent !== window.top) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_CONTEXT = "ai-parallel-workspace";
|
||||
const PARENT_ORIGIN = new URL(chrome.runtime.getURL("/")).origin;
|
||||
const adapters = globalThis.AIParallelProviderAdapters || {};
|
||||
const providerId = Object.entries(adapters)
|
||||
.find(([, adapter]) => adapter.hosts.includes(location.hostname))?.[0];
|
||||
@@ -15,6 +15,62 @@
|
||||
|
||||
const adapter = adapters[providerId];
|
||||
|
||||
async function executeCommand(message) {
|
||||
const messageType = message.type;
|
||||
const requestId = String(message.requestId || "");
|
||||
if (!requestId) return { ok: false, error: "Missing request ID" };
|
||||
|
||||
if (messageType === "AI_PARALLEL_COLLECT_RESPONSE") {
|
||||
const response = adapter.collectResponse();
|
||||
return {
|
||||
type: "AI_PARALLEL_RESPONSE_RESULT",
|
||||
requestId,
|
||||
ok: Boolean(response),
|
||||
response: response
|
||||
? { provider: providerId, ...response, timestamp: new Date().toISOString() }
|
||||
: null,
|
||||
error: response ? "" : "尚未找到可收集的模型回答"
|
||||
};
|
||||
}
|
||||
|
||||
if (messageType === "AI_PARALLEL_NEW_CHAT") {
|
||||
const ok = adapter.newChat();
|
||||
return {
|
||||
type: "AI_PARALLEL_NEW_CHAT_RESULT",
|
||||
requestId,
|
||||
ok,
|
||||
error: ok ? "" : "未找到新建对话按钮"
|
||||
};
|
||||
}
|
||||
|
||||
if (messageType !== "AI_PARALLEL_SEND") return { ok: false, error: "Unknown provider command" };
|
||||
const prompt = String(message.prompt || "").trim();
|
||||
if (!prompt) return { type: "AI_PARALLEL_SEND_RESULT", requestId, ok: false, error: "Prompt 不能为空" };
|
||||
|
||||
try {
|
||||
await adapter.sendPrompt(prompt);
|
||||
return { type: "AI_PARALLEL_SEND_RESULT", requestId, ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "AI_PARALLEL_SEND_RESULT",
|
||||
requestId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isTopLevel) {
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type !== "AI_PARALLEL_TAB_COMMAND" || message.providerId !== providerId) return false;
|
||||
executeCommand(message.command || {}).then(sendResponse);
|
||||
return true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const PARENT_ORIGIN = new URL(chrome.runtime.getURL("/")).origin;
|
||||
|
||||
function postParent(payload) {
|
||||
window.parent.postMessage(
|
||||
{ ...payload, context: MESSAGE_CONTEXT, providerId },
|
||||
@@ -36,51 +92,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const messageType = event.data.type;
|
||||
if (!["AI_PARALLEL_SEND", "AI_PARALLEL_COLLECT_RESPONSE", "AI_PARALLEL_NEW_CHAT"].includes(messageType)) return;
|
||||
const requestId = String(event.data.requestId || "");
|
||||
if (!requestId) return;
|
||||
|
||||
if (messageType === "AI_PARALLEL_COLLECT_RESPONSE") {
|
||||
const response = adapter.collectResponse();
|
||||
postParent({
|
||||
type: "AI_PARALLEL_RESPONSE_RESULT",
|
||||
requestId,
|
||||
ok: Boolean(response),
|
||||
response: response
|
||||
? { provider: providerId, ...response, timestamp: new Date().toISOString() }
|
||||
: null,
|
||||
error: response ? "" : "尚未找到可收集的模型回答"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === "AI_PARALLEL_NEW_CHAT") {
|
||||
const ok = adapter.newChat();
|
||||
postParent({
|
||||
type: "AI_PARALLEL_NEW_CHAT_RESULT",
|
||||
requestId,
|
||||
ok,
|
||||
error: ok ? "" : "未找到新建对话按钮"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = String(event.data.prompt || "").trim();
|
||||
|
||||
if (!prompt) {
|
||||
postParent({ type: "AI_PARALLEL_SEND_RESULT", requestId, ok: false, error: "Prompt 不能为空" });
|
||||
return;
|
||||
}
|
||||
|
||||
adapter.sendPrompt(prompt)
|
||||
.then(() => postParent({ type: "AI_PARALLEL_SEND_RESULT", requestId, ok: true }))
|
||||
.catch((error) => postParent({
|
||||
type: "AI_PARALLEL_SEND_RESULT",
|
||||
requestId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
if (!["AI_PARALLEL_SEND", "AI_PARALLEL_COLLECT_RESPONSE", "AI_PARALLEL_NEW_CHAT"].includes(event.data.type)) return;
|
||||
executeCommand(event.data).then(postParent);
|
||||
});
|
||||
|
||||
announceReady();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AI Parallel",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.4",
|
||||
"description": "Compare multiple AI web chats side by side in one live workspace.",
|
||||
"minimum_chrome_version": "120",
|
||||
"permissions": [
|
||||
|
||||
@@ -79,14 +79,5 @@
|
||||
{ "header": "Content-Security-Policy", "operation": "remove" }
|
||||
]},
|
||||
"condition": { "urlFilter": "https://chat.openai.com/*", "resourceTypes": ["sub_frame"] }
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"priority": 1,
|
||||
"action": { "type": "modifyHeaders", "responseHeaders": [
|
||||
{ "header": "X-Frame-Options", "operation": "remove" },
|
||||
{ "header": "Content-Security-Policy", "operation": "remove" }
|
||||
]},
|
||||
"condition": { "urlFilter": "https://grok.com/*", "resourceTypes": ["sub_frame"] }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@ const PROVIDERS = {
|
||||
kimi: { name: "Kimi", url: "https://www.kimi.com/" },
|
||||
claude: { name: "Claude", url: "https://claude.ai/new" },
|
||||
gemini: { name: "Gemini", url: "https://gemini.google.com/app" },
|
||||
grok: { name: "Grok", url: "https://grok.com/" }
|
||||
grok: { name: "Grok", url: "https://grok.com/", tabMode: true, hosts: ["grok.com", "www.grok.com"] }
|
||||
};
|
||||
|
||||
const PROVIDER_AUTH_HOSTS = {
|
||||
@@ -14,6 +14,67 @@ const PROVIDER_AUTH_HOSTS = {
|
||||
};
|
||||
|
||||
const WORKSPACE_PATH = "workspace/index.html";
|
||||
const PROVIDER_COMMANDS = new Set([
|
||||
"AI_PARALLEL_SEND",
|
||||
"AI_PARALLEL_COLLECT_RESPONSE",
|
||||
"AI_PARALLEL_NEW_CHAT"
|
||||
]);
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function matchesProviderTab(tab, provider) {
|
||||
if (!tab?.id || typeof tab.url !== "string") return false;
|
||||
try {
|
||||
return provider.hosts?.includes(new URL(tab.url).hostname) || false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProviderTab(providerId, { active = false } = {}) {
|
||||
const provider = PROVIDERS[providerId];
|
||||
if (!provider) throw new Error("Unknown provider");
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const existing = tabs.find((tab) => matchesProviderTab(tab, provider));
|
||||
if (existing) {
|
||||
if (active) {
|
||||
await chrome.tabs.update(existing.id, { active: true });
|
||||
if (existing.windowId) await chrome.windows.update(existing.windowId, { focused: true });
|
||||
}
|
||||
return { tab: existing, created: false };
|
||||
}
|
||||
const tab = await chrome.tabs.create({ url: provider.url, active: true });
|
||||
return { tab, created: true };
|
||||
}
|
||||
|
||||
async function deliverProviderCommand(tabId, message, attempts) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
return await chrome.tabs.sendMessage(tabId, message);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error("Provider tab is not ready");
|
||||
}
|
||||
|
||||
async function sendProviderTabCommand(providerId, command) {
|
||||
const provider = PROVIDERS[providerId];
|
||||
if (!provider?.tabMode) throw new Error("Provider does not support tab mode");
|
||||
if (!PROVIDER_COMMANDS.has(command?.type)) throw new Error("Unknown provider command");
|
||||
|
||||
const { tab, created } = await ensureProviderTab(providerId);
|
||||
const message = { type: "AI_PARALLEL_TAB_COMMAND", providerId, command };
|
||||
try {
|
||||
return await deliverProviderCommand(tab.id, message, created ? 40 : 4);
|
||||
} catch (error) {
|
||||
if (created) throw error;
|
||||
await chrome.tabs.reload(tab.id);
|
||||
return deliverProviderCommand(tab.id, message, 40);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorkspace() {
|
||||
const workspaceUrl = chrome.runtime.getURL(WORKSPACE_PATH);
|
||||
@@ -62,11 +123,18 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
sendResponse({ ok: false, error: "Unknown provider" });
|
||||
return;
|
||||
}
|
||||
const tab = await chrome.tabs.create({ url: provider.url, active: true });
|
||||
const { tab } = await ensureProviderTab(providerId, { active: true });
|
||||
sendResponse({ ok: true, tabId: tab.id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "PROVIDER_TAB_COMMAND") {
|
||||
const providerId = String(message.providerId || "");
|
||||
const result = await sendProviderTabCommand(providerId, message.command);
|
||||
sendResponse(result || { ok: false, error: "Provider did not return a result" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "OPEN_PROVIDER_AUTH") {
|
||||
const providerId = String(message.providerId || "");
|
||||
const allowedHosts = PROVIDER_AUTH_HOSTS[providerId];
|
||||
|
||||
@@ -105,6 +105,12 @@
|
||||
<div class="frame-wrap">
|
||||
<div class="frame-loading">正在加载原站…</div>
|
||||
<iframe referrerpolicy="strict-origin-when-cross-origin" allow="clipboard-read; clipboard-write"></iframe>
|
||||
<div class="external-provider" hidden>
|
||||
<div class="external-provider-mark">G</div>
|
||||
<strong>Grok 使用独立标签页</strong>
|
||||
<p>保持官网登录、Cookie 与 WebSocket 实时连接;AI Parallel 仍可发送 Prompt 和收集回答。</p>
|
||||
<button type="button" class="external-open-btn">打开或聚焦 Grok ↗</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -59,8 +59,15 @@ body { overflow: hidden; }
|
||||
.panel-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.frame-wrap { position: relative; min-height: 0; background: white; }
|
||||
.frame-wrap iframe { width: 100%; height: 100%; min-height: 420px; display: block; border: 0; background: white; }
|
||||
.frame-wrap iframe[hidden], .external-provider[hidden] { display: none; }
|
||||
.frame-loading { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; background: var(--surface); color: var(--muted); font-size: 11px; pointer-events: none; }
|
||||
.provider-panel[data-loaded="true"] .frame-loading { display: none; }
|
||||
.external-provider { width: 100%; height: 100%; min-height: 420px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 28px; background: radial-gradient(circle at 50% 30%, #20202a 0, #111318 42%, #0d0f13 100%); color: var(--text); text-align: center; }
|
||||
.external-provider-mark { width: 42px; height: 42px; display: grid; place-items: center; border: 1px solid var(--border-strong); border-radius: 13px; background: #f4f6f8; color: #090a0c; font-size: 18px; font-weight: 800; }
|
||||
.external-provider strong { font-size: 13px; }
|
||||
.external-provider p { max-width: 360px; margin: 0; color: var(--muted); font-size: 10px; line-height: 1.6; }
|
||||
.external-open-btn { min-height: 32px; border: 1px solid var(--border-strong); border-radius: 8px; padding: 0 12px; background: #191c23; color: var(--text); cursor: pointer; font-size: 10px; font-weight: 700; }
|
||||
.external-open-btn:hover { border-color: var(--accent); background: #211f32; }
|
||||
.composer-shell { padding: 9px 14px 10px; border-top: 1px solid var(--border); background: rgba(12,14,18,.98); }
|
||||
.dispatch-status { width: min(980px, 100%); margin: 0 auto 6px; color: var(--subtle); font-size: 9px; }
|
||||
.composer { width: min(980px, 100%); margin: 0 auto; display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; padding: 6px; border: 1px solid var(--border-strong); border-radius: 12px; background: #0a0c10; box-shadow: 0 10px 30px rgba(0,0,0,.2); }
|
||||
|
||||
@@ -6,7 +6,7 @@ const PROVIDERS = [
|
||||
{ id: "kimi", name: "Kimi", url: "https://www.kimi.com/", origins: ["https://www.kimi.com", "https://kimi.com"], default: true },
|
||||
{ id: "claude", name: "Claude", url: "https://claude.ai/new", default: false },
|
||||
{ id: "gemini", name: "Gemini", url: "https://gemini.google.com/app", default: false },
|
||||
{ id: "grok", name: "Grok", url: "https://grok.com/", loginUrl: "https://grok.com/", default: false }
|
||||
{ id: "grok", name: "Grok", url: "https://grok.com/", mode: "tab", default: false }
|
||||
];
|
||||
|
||||
const MESSAGE_CONTEXT = "ai-parallel-workspace";
|
||||
@@ -112,6 +112,15 @@ function pingFrame(providerId) {
|
||||
postToFrame(providerId, { type: "AI_PARALLEL_PING" });
|
||||
}
|
||||
|
||||
async function openProviderTab(provider) {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: "OPEN_PROVIDER_TAB",
|
||||
providerId: provider.id
|
||||
});
|
||||
if (!result?.ok) throw new Error(result?.error || "无法打开模型页面");
|
||||
return result;
|
||||
}
|
||||
|
||||
function ensurePanel(provider) {
|
||||
if (panels.has(provider.id)) return panels.get(provider.id);
|
||||
const fragment = panelTemplate.content.cloneNode(true);
|
||||
@@ -121,9 +130,42 @@ function ensurePanel(provider) {
|
||||
panel.dataset.ready = "false";
|
||||
panel.dataset.loaded = "false";
|
||||
panel.querySelector(".provider-name").textContent = provider.name;
|
||||
iframe.src = provider.url;
|
||||
iframe.title = provider.name;
|
||||
|
||||
if (provider.mode === "tab") {
|
||||
const externalProvider = panel.querySelector(".external-provider");
|
||||
iframe.hidden = true;
|
||||
externalProvider.hidden = false;
|
||||
panel.dataset.loaded = "true";
|
||||
panel.dataset.ready = "true";
|
||||
panel.querySelector(".provider-state").textContent = "受控标签页";
|
||||
panel.querySelector(".reload-btn").title = "打开或聚焦独立标签页";
|
||||
panel.querySelector(".reload-btn").addEventListener("click", () => {
|
||||
openProviderTab(provider).catch((error) => showError(error.message));
|
||||
});
|
||||
externalProvider.querySelector(".external-open-btn").addEventListener("click", () => {
|
||||
openProviderTab(provider).catch((error) => showError(error.message));
|
||||
});
|
||||
} else {
|
||||
iframe.src = provider.url;
|
||||
|
||||
iframe.addEventListener("load", () => {
|
||||
panel.dataset.loaded = "true";
|
||||
panel.dataset.ready = "false";
|
||||
panel.querySelector(".provider-state").textContent = "等待桥接";
|
||||
for (const delay of [80, 400, 1200, 2500]) {
|
||||
setTimeout(() => pingFrame(provider.id), delay);
|
||||
}
|
||||
});
|
||||
|
||||
panel.querySelector(".reload-btn").addEventListener("click", () => {
|
||||
panel.dataset.loaded = "false";
|
||||
panel.dataset.ready = "false";
|
||||
panel.querySelector(".provider-state").textContent = "重新加载";
|
||||
iframe.src = provider.url;
|
||||
});
|
||||
}
|
||||
|
||||
const authButton = panel.querySelector(".auth-btn");
|
||||
if (provider.loginUrl) {
|
||||
authButton.hidden = false;
|
||||
@@ -143,25 +185,9 @@ function ensurePanel(provider) {
|
||||
});
|
||||
}
|
||||
|
||||
iframe.addEventListener("load", () => {
|
||||
panel.dataset.loaded = "true";
|
||||
panel.dataset.ready = "false";
|
||||
panel.querySelector(".provider-state").textContent = "等待桥接";
|
||||
for (const delay of [80, 400, 1200, 2500]) {
|
||||
setTimeout(() => pingFrame(provider.id), delay);
|
||||
}
|
||||
});
|
||||
|
||||
panel.querySelector(".reload-btn").addEventListener("click", () => {
|
||||
panel.dataset.loaded = "false";
|
||||
panel.dataset.ready = "false";
|
||||
panel.querySelector(".provider-state").textContent = "重新加载";
|
||||
iframe.src = provider.url;
|
||||
});
|
||||
|
||||
panel.querySelector(".open-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await chrome.tabs.create({ url: provider.url, active: true });
|
||||
await openProviderTab(provider);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "无法打开模型页面");
|
||||
}
|
||||
@@ -312,12 +338,31 @@ async function requestFrameMessage(providerId, type, payload = {}, timeoutMs = 3
|
||||
});
|
||||
}
|
||||
|
||||
function sendPromptToFrame(providerId, prompt) {
|
||||
return requestFrameMessage(providerId, "AI_PARALLEL_SEND", { prompt });
|
||||
async function requestProviderMessage(providerId, type, payload = {}, timeoutMs = 30000) {
|
||||
const provider = providerById(providerId);
|
||||
if (provider?.mode !== "tab") return requestFrameMessage(providerId, type, payload, timeoutMs);
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
try {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: "PROVIDER_TAB_COMMAND",
|
||||
providerId,
|
||||
command: { type, requestId, ...payload }
|
||||
});
|
||||
return result?.ok === true
|
||||
? result
|
||||
: { ok: false, error: result?.error || "独立标签页命令执行失败" };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function collectResponseFromFrame(providerId) {
|
||||
return requestFrameMessage(providerId, "AI_PARALLEL_COLLECT_RESPONSE", {}, 12000);
|
||||
function sendPromptToProvider(providerId, prompt) {
|
||||
return requestProviderMessage(providerId, "AI_PARALLEL_SEND", { prompt });
|
||||
}
|
||||
|
||||
function collectResponseFromProvider(providerId) {
|
||||
return requestProviderMessage(providerId, "AI_PARALLEL_COLLECT_RESPONSE", {}, 12000);
|
||||
}
|
||||
|
||||
async function dispatchPrompt() {
|
||||
@@ -336,7 +381,7 @@ async function dispatchPrompt() {
|
||||
await chrome.storage.local.set({ draftPrompt: promptInput.value });
|
||||
const pairs = await Promise.all([...selected].map(async (providerId) => [
|
||||
providerId,
|
||||
await sendPromptToFrame(providerId, prompt)
|
||||
await sendPromptToProvider(providerId, prompt)
|
||||
]));
|
||||
const results = Object.fromEntries(pairs);
|
||||
|
||||
@@ -429,7 +474,7 @@ async function collectResponses() {
|
||||
try {
|
||||
const pairs = await Promise.all([...selected].map(async (providerId) => [
|
||||
providerId,
|
||||
await collectResponseFromFrame(providerId)
|
||||
await collectResponseFromProvider(providerId)
|
||||
]));
|
||||
for (const [providerId, result] of pairs) responseBundles.set(providerId, result);
|
||||
const count = pairs.filter(([, result]) => result.ok).length;
|
||||
@@ -620,13 +665,13 @@ async function sendToAgent() {
|
||||
renderProviderBar();
|
||||
renderPanels();
|
||||
updateMeta();
|
||||
compareStatus.textContent = `正在打开 ${providerName(target)} iframe…`;
|
||||
compareStatus.textContent = `正在打开 ${providerName(target)}…`;
|
||||
}
|
||||
|
||||
sendAgentBtn.disabled = true;
|
||||
compareStatus.textContent = `正在发送上下文到 ${providerName(target)}…`;
|
||||
try {
|
||||
const result = await sendPromptToFrame(target, buildHandoffPrompt());
|
||||
const result = await sendPromptToProvider(target, buildHandoffPrompt());
|
||||
compareStatus.textContent = result.ok
|
||||
? `上下文已发送到 ${providerName(target)}`
|
||||
: result.error || "Agent handoff 失败";
|
||||
|
||||
+19
-14
@@ -15,7 +15,7 @@ AI Parallel Workspace (extension page)
|
||||
├─ Kimi iframe
|
||||
├─ Claude iframe
|
||||
├─ Gemini iframe
|
||||
└─ Grok iframe
|
||||
└─ Grok controlled top-level tab
|
||||
│
|
||||
└─ content/frame-bridge.js
|
||||
├─ providers/core.js
|
||||
@@ -27,10 +27,11 @@ AI Parallel Workspace (extension page)
|
||||
└─ host and selector knowledge per provider
|
||||
```
|
||||
|
||||
The original provider page remains the primary renderer inside the workspace
|
||||
panel. When the user opens Compare, the selected iframe adapter can collect
|
||||
the latest visible response on demand for the comparison drawer; the drawer
|
||||
renders safe text and never injects provider HTML.
|
||||
The original provider page remains the primary renderer. Iframe-compatible
|
||||
providers render inside the workspace panel. Grok remains in a normal top-level
|
||||
tab so its login state and WebSocket channel retain first-party browser behavior.
|
||||
When the user opens Compare, the selected provider adapter can collect the latest
|
||||
visible response on demand; the drawer renders safe text and never injects provider HTML.
|
||||
|
||||
## Framing model
|
||||
|
||||
@@ -38,15 +39,19 @@ Most AI web applications send `X-Frame-Options` and/or CSP `frame-ancestors` hea
|
||||
|
||||
Rules live in `apps/browser-extension/rules/bypass-headers.json`.
|
||||
|
||||
The extension does not rewrite provider HTML or JavaScript. It does not request cookie permission; the provider iframe uses the browser's normal authenticated session behavior.
|
||||
The extension does not rewrite provider HTML or JavaScript. It does not request
|
||||
cookie permission. Grok is excluded from framing-header bypass rules because its
|
||||
authenticated real-time channel is not reliable in an extension iframe.
|
||||
|
||||
## Message flow
|
||||
|
||||
```text
|
||||
workspace/workspace.js
|
||||
│ postMessage(AI_PARALLEL_SEND)
|
||||
▼
|
||||
provider iframe / content/frame-bridge.js
|
||||
├─ postMessage ─ provider iframe
|
||||
└─ runtime message ─ service worker ─ tabs.sendMessage ─ Grok tab
|
||||
│
|
||||
▼
|
||||
content/frame-bridge.js
|
||||
│ adapter.sendPrompt(prompt)
|
||||
├─ fill editor
|
||||
└─ submit
|
||||
@@ -55,10 +60,10 @@ provider iframe / content/frame-bridge.js
|
||||
workspace/workspace.js
|
||||
```
|
||||
|
||||
Each direct provider iframe announces `AI_PARALLEL_FRAME_READY`. The active
|
||||
workspace path routes directly to the iframe and validates both the iframe
|
||||
source and provider origin. The service worker owns workspace opening and
|
||||
standalone provider-tab actions; it does not route iframe prompts.
|
||||
Each direct provider iframe announces `AI_PARALLEL_FRAME_READY`. The workspace
|
||||
validates both iframe source and provider origin. For Grok only, the service
|
||||
worker finds or creates an allowlisted `grok.com` tab and routes provider-scoped
|
||||
commands to the same adapter contract. It never receives provider credentials.
|
||||
|
||||
## Provider boundaries
|
||||
|
||||
@@ -77,7 +82,7 @@ protocol.
|
||||
- Prompt contents are not put into destination URLs.
|
||||
- Response contents are not copied to extension storage.
|
||||
- No cookie/history/webRequest permission is requested.
|
||||
- Opening a provider in a normal top-level tab does not activate the iframe bridge workflow.
|
||||
- Tab-mode commands are restricted to providers explicitly marked for tab mode and to allowlisted command types.
|
||||
|
||||
## Inspiration
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
```text
|
||||
Workspace extension page
|
||||
├─ provider selection + panel layout
|
||||
├─ one iframe per selected provider
|
||||
├─ one iframe per selected iframe-compatible provider
|
||||
├─ controlled top-level tab for Grok
|
||||
├─ postMessage(AI_PARALLEL_SEND)
|
||||
└─ receives FRAME_READY / SEND_RESULT
|
||||
│
|
||||
@@ -24,16 +25,17 @@ Provider iframe content scripts (all_frames)
|
||||
Manifest V3 service worker
|
||||
├─ opens/focuses the workspace
|
||||
├─ opens standalone provider tabs
|
||||
├─ routes allowlisted commands to the Grok top-level tab
|
||||
└─ forwards queued launcher prompts to the workspace
|
||||
|
||||
declarativeNetRequest
|
||||
└─ removes framing headers for configured provider sub_frames
|
||||
```
|
||||
|
||||
The active workspace path is direct `postMessage` between the extension page and
|
||||
the provider iframe. Provider responses are still rendered natively by each
|
||||
provider page; the workspace collects them only when Compare is explicitly
|
||||
opened.
|
||||
The active workspace path uses direct `postMessage` for iframe-compatible
|
||||
providers and a service-worker `tabs.sendMessage` bridge for Grok. Provider
|
||||
responses are still rendered natively by each provider page; the workspace
|
||||
collects them only when Compare is explicitly opened.
|
||||
|
||||
## 2. Risk analysis
|
||||
|
||||
@@ -49,7 +51,8 @@ opened.
|
||||
|
||||
The origin checks in the active workspace/bridge path are a sound baseline:
|
||||
the workspace validates iframe source and provider origin, while the bridge
|
||||
validates its parent source, extension origin, and provider id.
|
||||
validates its parent source, extension origin, and provider id. Grok tab-mode
|
||||
commands are separately restricted by provider and command allowlists.
|
||||
|
||||
## 3. v2.1 development plan
|
||||
|
||||
@@ -84,5 +87,6 @@ validates its parent source, extension origin, and provider id.
|
||||
- Full response collector fixtures for each provider
|
||||
- Provider API integrations or credential handling
|
||||
|
||||
The adapter boundary intentionally keeps iframe ownership with the provider
|
||||
page and keeps workspace orchestration outside provider-specific DOM code.
|
||||
The adapter boundary keeps provider rendering ownership with the provider page,
|
||||
whether iframe or controlled tab, and keeps workspace orchestration outside
|
||||
provider-specific DOM code.
|
||||
|
||||
@@ -7,8 +7,10 @@ const vm = require("node:vm");
|
||||
function loadServiceWorker() {
|
||||
let messageListener;
|
||||
const openedUrls = [];
|
||||
const sentMessages = [];
|
||||
const context = {
|
||||
console,
|
||||
setTimeout,
|
||||
URL,
|
||||
chrome: {
|
||||
action: { onClicked: { addListener() {} } },
|
||||
@@ -22,8 +24,13 @@ function loadServiceWorker() {
|
||||
openedUrls.push(url);
|
||||
return { id: openedUrls.length, url };
|
||||
},
|
||||
async query() { return []; },
|
||||
async sendMessage() {}
|
||||
async query() { return [{ id: 42, url: "https://grok.com/", windowId: 7 }]; },
|
||||
async update() {},
|
||||
async reload() {},
|
||||
async sendMessage(tabId, message) {
|
||||
sentMessages.push({ tabId, message });
|
||||
return { ok: true, response: { provider: "grok", content: "answer" } };
|
||||
}
|
||||
},
|
||||
windows: { async update() {} }
|
||||
}
|
||||
@@ -34,7 +41,7 @@ function loadServiceWorker() {
|
||||
"utf8"
|
||||
);
|
||||
vm.runInContext(source, context, { filename: "service-worker.js" });
|
||||
return { messageListener, openedUrls };
|
||||
return { messageListener, openedUrls, sentMessages };
|
||||
}
|
||||
|
||||
function sendMessage(listener, message) {
|
||||
@@ -69,3 +76,22 @@ test("Grok authentication only opens allowlisted HTTPS URLs", async () => {
|
||||
assert.equal(insecure.ok, false);
|
||||
assert.equal(openedUrls.length, 1);
|
||||
});
|
||||
|
||||
test("Grok tab mode reuses the official top-level tab", async () => {
|
||||
const { messageListener, openedUrls, sentMessages } = loadServiceWorker();
|
||||
const result = await sendMessage(messageListener, {
|
||||
type: "PROVIDER_TAB_COMMAND",
|
||||
providerId: "grok",
|
||||
command: {
|
||||
type: "AI_PARALLEL_COLLECT_RESPONSE",
|
||||
requestId: "request-1"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(openedUrls.length, 0);
|
||||
assert.equal(sentMessages.length, 1);
|
||||
assert.equal(sentMessages[0].tabId, 42);
|
||||
assert.equal(sentMessages[0].message.type, "AI_PARALLEL_TAB_COMMAND");
|
||||
assert.equal(sentMessages[0].message.providerId, "grok");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension");
|
||||
|
||||
test("Grok top-level page exposes the provider command bridge", async () => {
|
||||
let messageListener;
|
||||
const context = {
|
||||
console,
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
URL,
|
||||
location: { hostname: "grok.com" },
|
||||
document: { querySelectorAll() { return []; } },
|
||||
chrome: {
|
||||
runtime: {
|
||||
getURL: (value) => `chrome-extension://test/${value}`,
|
||||
onMessage: { addListener(listener) { messageListener = listener; } }
|
||||
}
|
||||
}
|
||||
};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.top = context;
|
||||
context.parent = context;
|
||||
vm.createContext(context);
|
||||
|
||||
for (const file of [
|
||||
"content/providers/core.js",
|
||||
"content/providers/grok.js",
|
||||
"content/frame-bridge.js"
|
||||
]) {
|
||||
vm.runInContext(fs.readFileSync(path.join(extensionRoot, file), "utf8"), context, { filename: file });
|
||||
}
|
||||
|
||||
assert.equal(typeof messageListener, "function");
|
||||
const result = await new Promise((resolve) => {
|
||||
const asynchronous = messageListener({
|
||||
type: "AI_PARALLEL_TAB_COMMAND",
|
||||
providerId: "grok",
|
||||
command: { type: "AI_PARALLEL_COLLECT_RESPONSE", requestId: "request-1" }
|
||||
}, {}, resolve);
|
||||
assert.equal(asynchronous, true);
|
||||
});
|
||||
|
||||
assert.equal(result.type, "AI_PARALLEL_RESPONSE_RESULT");
|
||||
assert.equal(result.requestId, "request-1");
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
Reference in New Issue
Block a user