60 changed files with 820 additions and 5665 deletions
@@ -1,71 +0,0 @@
name: Authenticated Browser Smoke
on:
workflow_dispatch:
inputs:
providers:
description: "Comma-separated Provider IDs with configured test-account Secrets"
required: true
default: "chatgpt,grok"
type: string
permissions:
contents: read
concurrency:
group: authenticated-browser-smoke-main
cancel-in-progress: false
jobs:
authenticated-browser-smoke:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: authenticated-provider-smoke
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Playwright
run: npm install --no-save --no-package-lock playwright@1.55.0
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Check authenticated smoke syntax
run: node --check tests/authenticated-browser-smoke.cjs
- name: Run authenticated smoke
env:
AI_PARALLEL_SMOKE_PROVIDER_IDS: ${{ inputs.providers }}
AI_PARALLEL_SMOKE_CHATGPT_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_CHATGPT_USERNAME }}
AI_PARALLEL_SMOKE_CHATGPT_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_CHATGPT_PASSWORD }}
AI_PARALLEL_SMOKE_DEEPSEEK_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_DEEPSEEK_USERNAME }}
AI_PARALLEL_SMOKE_DEEPSEEK_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_DEEPSEEK_PASSWORD }}
AI_PARALLEL_SMOKE_ZHIPU_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_ZHIPU_USERNAME }}
AI_PARALLEL_SMOKE_ZHIPU_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_ZHIPU_PASSWORD }}
AI_PARALLEL_SMOKE_QWEN_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_QWEN_USERNAME }}
AI_PARALLEL_SMOKE_QWEN_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_QWEN_PASSWORD }}
AI_PARALLEL_SMOKE_KIMI_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_KIMI_USERNAME }}
AI_PARALLEL_SMOKE_KIMI_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_KIMI_PASSWORD }}
AI_PARALLEL_SMOKE_CLAUDE_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_CLAUDE_USERNAME }}
AI_PARALLEL_SMOKE_CLAUDE_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_CLAUDE_PASSWORD }}
AI_PARALLEL_SMOKE_GEMINI_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_GEMINI_USERNAME }}
AI_PARALLEL_SMOKE_GEMINI_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_GEMINI_PASSWORD }}
AI_PARALLEL_SMOKE_GROK_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_GROK_USERNAME }}
AI_PARALLEL_SMOKE_GROK_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_GROK_PASSWORD }}
run: xvfb-run --auto-servernum node tests/authenticated-browser-smoke.cjs
- name: Upload sanitized diagnostics
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: authenticated-browser-smoke-diagnostics
path: test-results/authenticated-browser-smoke/diagnostics.json
if-no-files-found: ignore
-42
View File
@@ -1,42 +0,0 @@
name: Browser Smoke
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
browser-smoke:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Playwright
run: npm install --no-save --no-package-lock playwright@1.55.0
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Check smoke script syntax
run: node --check tests/browser-smoke.cjs
- name: Run extension smoke
run: xvfb-run --auto-servernum node tests/browser-smoke.cjs
- name: Upload browser diagnostics
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: browser-smoke-diagnostics
path: test-results/browser-smoke
if-no-files-found: ignore
-8
View File
@@ -21,11 +21,3 @@ jobs:
- name: Syntax checks
run: npm run check
- name: Tests
run: npm test
- name: Validate extension metadata
run: |
jq empty apps/browser-extension/manifest.json
jq empty apps/browser-extension/rules/bypass-headers.json
-1
View File
@@ -13,4 +13,3 @@ coverage/
# local scratch/artifacts
*.zip
test-results/
-132
View File
@@ -1,132 +0,0 @@
# AI Parallel Engineering Instructions
## Role and scope
You are the engineering agent for the AI Parallel repository:
- Repository: `CoderLambert/ai-parallel`
- Primary roles: senior frontend engineer, Chrome extension engineer, and technical lead
- Product: a Manifest V3 Chrome/Edge extension for multi-AI research and collaboration
Work from the user's request and the relevant GitHub Issue. Preserve the existing product boundary and keep changes focused. Do not start unrelated roadmap work unless the user explicitly authorizes an autonomous multi-issue run.
Detailed issue execution rules live in `issue-rule.md`. Read it before
creating or materially changing an Issue. Read
`docs/execution/AUTOMATION-AND-AGENT-ORCHESTRATION.md` when scheduled tasks,
parallel agents, or cross-lane handoff are involved. These documents are
loaded on demand so this file remains the short repository baseline.
## Product boundary
AI Parallel reuses the user's existing authenticated AI web sessions in the browser. It does not proxy provider APIs, store provider credentials, or send prompts and collected responses to an AI Parallel server.
Supported provider pages include ChatGPT, DeepSeek, Qwen, Kimi, Zhipu, Claude, Gemini, and Grok. Provider availability and runtime mode are defined by the repository's provider catalog and manifest; do not duplicate or silently redefine that data in unrelated modules.
The product flow is:
Question → multiple native AI responses → user-triggered comparison → context package → agent handoff → final answer
## Runtime architecture
The architecture is intentional:
- Iframe-compatible provider pages remain responsible for native UI rendering, authentication, and native interaction.
- Provider adapters and `content/frame-bridge.js` handle provider-specific DOM operations, command validation, prompt injection, and user-triggered response collection.
- The workspace handles orchestration, layout, comparison, export, prompt library, and handoff workflows.
- The Manifest V3 service worker owns extension lifecycle tasks and routes the explicitly supported tab-mode provider flow.
- Grok runs in a controlled top-level tab because its authenticated realtime connection and login flow are not reliable inside an iframe.
### Provider page boundary
Keep provider-specific selectors and DOM knowledge in `apps/browser-extension/content/providers/`. Shared DOM operations belong in `content/providers/core.js`. The workspace must not know provider response selectors.
### Message boundary
Validate message origin, source, provider identity, and command type at every bridge boundary. Keep iframe messages and Grok tab messages scoped to the provider and command allowlists already established by the repository.
### Workspace boundary
The workspace may request a response snapshot after an explicit user action. It must render collected content as safe text and must not inject provider HTML into the extension UI.
## Non-negotiable architecture rules
### No background scraping
Do not add hidden tabs that scrape AI responses, continuously mirror provider pages, or duplicate provider rendering. The existing controlled top-level Grok tab is an explicit product path and may only be used through its allowlisted, user-directed bridge flow.
### No realtime response synchronization
Do not add MutationObserver-based streaming synchronization, continuous DOM monitoring, or workspace re-rendering of live provider responses. Compare and collect the currently visible response only when the user requests it, then generate a snapshot.
### Security and privacy
- Preserve Manifest V3 compatibility.
- Keep host permissions and declarativeNetRequest rules narrow and provider-scoped.
- Do not request cookie, history, or equivalent credential access without an explicit architecture review.
- Never put prompt contents in provider URLs.
- Do not persist provider credentials or collected response content unless the feature's design explicitly requires it and the privacy boundary is reviewed.
- Keep user-controlled content separated from executable markup and code.
## Development workflow
For every issue-driven task:
1. Analyze the relevant GitHub Issue, current repository state, recent commits, and architecture documentation before modifying code.
2. Write a short implementation plan covering the goal, architecture impact, affected files, risks, and testing strategy.
3. Implement the smallest coherent change. Prefer incremental edits, focused commits, backward compatibility, and existing utilities.
4. Review the diff for regressions, permission changes, message-boundary issues, and provider-specific leakage into shared code.
5. Run checks appropriate to the change. At minimum use `npm run check` and `npm test` when applicable. Run `npm run package:extension` for packaging-related changes and perform a manual extension-loading check when the change affects runtime loading or the manifest.
6. Update the associated GitHub Issue with progress, changes, commit, tests, and known limitations when the issue workflow and external access are available.
Do not claim that a check passed when the repository does not provide that check or when it was not run. If a required browser-backed check cannot run in the current environment, record that limitation explicitly.
## Coding rules
- Preserve existing behavior unless the issue requires a behavior change.
- Avoid unnecessary dependencies and large rewrites.
- Add explicit error handling at provider, bridge, storage, and asynchronous request boundaries.
- Bound request timeouts and clean up listeners, timers, and pending requests.
- Keep provider-specific behavior isolated behind the adapter contract.
- Keep commits focused and use Conventional Commit subjects such as `feat(scope): description`, `fix(scope): description`, and `refactor(scope): description`.
## Task routing
Handle directly:
- Small bug fixes and selector updates
- UI adjustments and CSS
- Documentation
- Tests and focused utilities
Request architecture review before implementation when a change involves:
- A large refactor or new subsystem
- A change to message/data flow or provider boundaries
- Manifest permissions, host permissions, or declarativeNetRequest rules
- Security, privacy, credential handling, or persistence of response data
## Issue lifecycle and continuity
Every issue-driven change should map to a GitHub Issue. Mark work in progress before implementation when the issue workflow permits it, maintain a progress checklist during development, and mark the issue complete only after verification. Close an issue only when its acceptance criteria and required checks are satisfied.
After completing an authorized issue, continue with the next highest-priority existing issue only when the current run explicitly permits multi-issue work. If no suitable issue exists, report a concrete improvement as a recommendation. Create a new GitHub Issue only when issue creation is explicitly authorized and external access is available.
## Completion report
End each completed task with:
### Completed
### Architecture impact
### Changed files
### Commit
### Issue status
### Tests
### Known limitations
### Next recommended task
+65 -139
View File
@@ -1,157 +1,83 @@
# AI Parallel
AI Parallel 是一个 Chrome / Edge Manifest V3 扩展:在一个工作区中打开多个 AI 网页,一次输入,并行发送,并按需收集、比较和导出回答。
AI Parallel is a monorepo for comparing and orchestrating multiple AI web experiences from one workspace.
> No API keys. No proxy server. AI Parallel reuses your existing provider web sessions locally in the browser.
The first app is a Chrome/Edge Manifest V3 extension. It reuses the user's existing authenticated web sessions, opens model sites as collapsed worker tabs, and mirrors their responses into a single comparison workspace.
## 功能
- 多模型网页工作区:ChatGPT、DeepSeek、智谱清言、Qwen、Kimi、Claude、Gemini、Grok
- 一次输入并行发送到多个 Provider
- Compare Drawer:收集当前回答并安全显示为纯文本
- 导出 Markdown、JSON,或下载 `.md` 文件
- Agent Handoff:把问题和多模型回答交给目标模型继续处理
- 本地 Prompt Library
- 布局切换与 Provider 独立刷新/打开
默认启用 ChatGPT、DeepSeek、智谱清言、Qwen 和 Kimi。Claude、Gemini 与 Grok 可按需开启。
## Provider 运行模式
| Provider | 运行方式 | 说明 |
| --- | --- | --- |
| ChatGPT | 工作区 iframe | 使用原站页面和当前登录会话 |
| DeepSeek | 工作区 iframe | 使用原站页面和当前登录会话 |
| 智谱清言 | 工作区 iframe | 使用原站页面和当前登录会话 |
| Qwen | 工作区 iframe | 使用原站页面和当前登录会话 |
| Kimi | 工作区 iframe | 使用原站页面和当前登录会话 |
| Claude | 工作区 iframe | DOM Adapter 为 best effort |
| Gemini | 工作区 iframe | DOM Adapter 为 best effort |
| Grok | 受控顶层标签页 | 避免登录 iframe 与 WebSocket timeout |
Grok 官网依赖已登录的实时 WebSocket,认证页也禁止 iframe。AI Parallel 会打开或复用正常的 `grok.com` 标签页,然后通过受限消息桥完成 Prompt 发送、回答收集和 Handoff。
## 从 GitHub Release 安装
1. 打开 [Releases](https://github.com/CoderLambert/ai-parallel/releases/latest)。
2. 下载 `ai-parallel-browser-extension-vX.Y.Z.zip`
3. 解压 ZIP,保留解压目录,不要在安装后删除或移动它。
4. Chrome 打开 `chrome://extensions/`Edge 打开 `edge://extensions/`
5. 开启右上角“开发者模式”。
6. 点击“加载已解压的扩展程序”。
7. 选择解压得到的 `ai-parallel-browser-extension` 文件夹。
8. 固定 AI Parallel 图标,点击即可打开工作区。
Chrome 不能直接把普通 ZIP 当成未打包扩展安装,必须先解压。
### 校验下载文件
每个 Release 都提供 SHA-256 文件:
```bash
sha256sum -c ai-parallel-browser-extension-vX.Y.Z.zip.sha256
```
## 首次使用
1. 先在各 Provider 官网完成登录。
2. 点击 AI Parallel 图标打开工作区。
3. 在顶部选择 Provider。
4. 在底部输入 Prompt,按 `Ctrl+Enter` / `Cmd+Enter` 或点击“发送”。
5. 等待原站生成回答,点击 `Compare` 收集结果。
Grok 第一次参与发送时会打开或复用官网标签页。扩展升级后,如果旧 Grok 标签页没有新版桥接脚本,AI Parallel 会自动刷新该标签页一次。
## 更新
### 手动更新
下载新 Release,解压并覆盖原目录,然后在 `chrome://extensions/` / `edge://extensions/` 中点击 AI Parallel 的“重新加载”。如果替换了目录位置,需要删除旧扩展后重新“加载已解压的扩展程序”。
### Linux:使用 `ai-parallel-sync`
从 Release 下载 `ai-parallel-sync` 后安装:
```bash
install -Dm755 ai-parallel-sync ~/.local/bin/ai-parallel-sync
ai-parallel-sync
```
首次运行会把扩展同步到:
## Current app
```text
~/.local/share/ai-parallel/browser-extension
apps/browser-extension
```
第一次在浏览器中加载这个固定目录。以后只需运行 `ai-parallel-sync`,再到扩展管理页点击“重新加载”。命令默认同步最新 `v*` Release 标签,需要 `git``node``npm``rsync`
Workspace v1 supports:
## 常见问题
- ChatGPT
- DeepSeek
- 智谱清言
- Qwen
- Kimi
- Claude (best-effort response adapter)
- Gemini (best-effort response adapter)
### `accounts.x.ai refused to connect`
The default comparison set is ChatGPT, DeepSeek, 智谱清言, Qwen and Kimi.
不要在 iframe 中登录 Grok。当前版本会让 Grok 在正常顶层标签页中运行。请确认已更新扩展,并完全关闭后重新打开旧的 AI Parallel 工作区标签页。
### Grok 显示 WebSocket `timeout`
确认普通 `https://grok.com/` 标签页可以聊天。AI Parallel v2.1.4 起不再把 Grok 嵌入 iframe;如果仍看到旧 iframe,请在扩展管理页重新加载扩展并重新打开工作区。
### `ERR_BLOCKED_BY_CLIENT`
这通常来自广告或隐私扩展。若被阻止的是 Provider 的核心接口,请暂时关闭对应站点的拦截后重试;单纯的统计/广告请求失败通常不影响聊天。
### Provider 显示“未就绪”或发送失败
- 重新加载对应面板。
- 确认已经登录原站。
- 在扩展管理页重新加载 AI Parallel,并重新打开工作区。
- Provider 网页 DOM 会更新;选择器 Adapter 属于 best effort,需要随原站变化维护。
## 权限与隐私
AI Parallel 请求:
- `storage`:保存 Provider 选择、草稿和 Prompt Library。
- `tabs`:打开/聚焦工作区和 Grok 顶层标签页。
- `declarativeNetRequest*`:仅对已配置 Provider 的 `sub_frame` 响应移除 iframe 限制头。
- Provider host permissions:注入本地 Adapter,并与原站页面交互。
扩展不保存 Provider 密码,不请求 Cookie 权限,不把 Prompt 放进目标 URL,也不把回答发送到 AI Parallel 自有服务器。回答只在用户点击 Compare 后收集到当前扩展页面,并按纯文本渲染。
## 本地开发
要求 Node.js 18+
```bash
git clone https://github.com/CoderLambert/ai-parallel.git
cd ai-parallel
npm run check
npm test
```
需要真实 Provider 登录态时,使用独立的手动、受保护 authenticated smoke
流程;默认 Browser Smoke 仍不读取凭据。配置和保留边界见
[Authenticated Provider Smoke](docs/testing/AUTHENTICATED-PROVIDER-SMOKE.md)。
从源码加载时,在扩展管理页选择 `apps/browser-extension`
生成 Release 包:
```bash
npm run package:extension
```
产物位于 `dist/`
## 架构
## Workspace architecture
```text
AI Parallel Workspace
├─ iframe Providers ─ postMessage ─ Provider Adapter
└─ Grok top-level tab ─ service worker ─ tabs.sendMessage ─ Grok Adapter
│ launch(prompt, providers)
Manifest V3 Service Worker
├─ collapsed ChatGPT worker tab ─ Content Script ─┐
├─ collapsed DeepSeek worker tab ─ Content Script ├─ response events
├─ collapsed Qwen worker tab ─ Content Script ────┤
└─ collapsed Kimi worker tab ─ Content Script ────┘
Workspace panels
```
Provider Adapter 统一提供 `sendPrompt()``collectResponse()``newChat()`。详细设计见 [docs/architecture.md](docs/architecture.md)。第三方架构参考和署名见 [ATTRIBUTION.md](ATTRIBUTION.md)。
Prompts are not transported in destination URLs. Worker pages receive one-time jobs through extension messaging/session storage.
## 项目状态
## Repository structure
AI Web UI 不是稳定 APIProvider DOM 或安全策略变化可能导致 Adapter 暂时失效。欢迎通过 GitHub Issues 提交复现步骤、页面截图和控制台错误。
```text
ai-parallel/
├── apps/
│ └── browser-extension/
│ ├── content/
│ ├── workspace/
│ ├── icons/
│ ├── manifest.json
│ └── service-worker.js
├── docs/
│ └── architecture.md
├── .github/workflows/
├── package.json
└── pnpm-workspace.yaml
```
Shared packages will be extracted only when a second client or genuine cross-app duplication requires them.
## Development
```bash
npm run check
```
Load `apps/browser-extension` as an unpacked extension from `chrome://extensions/` or `edge://extensions/`.
Clicking the extension icon opens or focuses the full-page Workspace.
## Security and reliability
- Uses only the host permissions required by supported providers.
- Reuses existing browser login sessions; it does not store provider credentials.
- Does not put prompt text into provider URLs.
- Renders mirrored model output as safe text/structured blocks rather than injecting remote HTML.
- Provider DOM adapters are best-effort because AI web UIs are not stable public APIs.
See [`docs/architecture.md`](docs/architecture.md) for the runtime design and evolution plan.
+6 -37
View File
@@ -6,59 +6,28 @@ AI Parallel v2 uses a live iframe workspace instead of mirroring model responses
```text
Workspace extension page
├─ Provider Task Runtime
│ └─ Provider Adapter Contract → shared iframe/tab transport
├─ Agent Execution Controller
│ └─ bounded planner / executor / reviewer aggregation
├─ ChatGPT iframe
├─ DeepSeek iframe
├─ 智谱 iframe
├─ Qwen iframe
├─ Kimi iframe
├─ Claude iframe
─ Gemini iframe
└─ Grok controlled top-level tab
─ Gemini iframe
─ content/providers/core.js shared DOM operations
├─ content/providers/<provider>.js provider selectors
└─ content/frame-bridge.js message boundary + adapter delegation
─ content/frame-bridge.js handles prompt injection + submit
```
`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.
`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.
The original provider page remains visible directly. Compare can collect the
latest visible response on demand for safe text rendering and export; it does
not inject provider HTML or replace the native iframe view.
Model output is not scraped or re-rendered. You see the original provider page directly, so there is no response-mirroring latency.
Workspace provider operations run through the shared Provider Task Runtime. It
tracks each operation in memory, bounds retry attempts, exposes timeout and
cancellation states, and keeps deterministic provider failures visible. Task
history and response snapshots are not persisted.
## Install
The shared Agent Execution Controller provides an explicit, bounded parallel
execution model for planner, executor, and reviewer roles. It allows at most
eight agents per execution and three concurrent provider tasks by default. Each
agent still runs through the Provider Task Runtime and Adapter Contract; one
failure is isolated and reported in the aggregate. Prompts and responses remain
in memory only, with no autonomous loop or background execution.
## Install from a release
Download and extract `ai-parallel-browser-extension-vX.Y.Z.zip`. Open
`chrome://extensions` or `edge://extensions`, enable Developer mode, choose
**Load unpacked**, and select the extracted `ai-parallel-browser-extension`
directory. Keep that directory in place after installation.
When loading from a source checkout, select `apps/browser-extension` directly.
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.
Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select this directory.
## Security boundary
- No prompt is placed in destination URLs.
- No response content is copied into extension storage.
- DNR rules apply only to configured provider `sub_frame` responses.
- Authentication pages are never added to the iframe header-bypass rules.
- The extension does not request cookie access.
@@ -0,0 +1,15 @@
export function createResponsePayload({ providerId, text = "" }) {
return {
provider: providerId,
text,
markdown: text,
timestamp: Date.now()
};
}
export function postCollectedResponse(postParent, payload) {
postParent({
type: "AI_PARALLEL_RESPONSE_RESULT",
response: payload
});
}
+257 -69
View File
@@ -1,75 +1,72 @@
(() => {
const isTopLevel = window.top === window;
if (!isTopLevel) {
try {
if (window.parent !== window.top) return;
} catch {
return;
}
}
const MESSAGE_CONTEXT = "ai-parallel-workspace";
const adapters = globalThis.AIParallelProviderAdapters || {};
const providerId = Object.entries(adapters)
.find(([, adapter]) => adapter.hosts.includes(location.hostname))?.[0];
if (!providerId) return;
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;
});
if (window.top === window) return;
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 sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const PROVIDERS = {
chatgpt: {
hosts: ["chatgpt.com", "chat.openai.com"],
editorSelectors: [
"#prompt-textarea",
"textarea[data-testid='prompt-textarea']",
"div[contenteditable='true'][data-testid='prompt-textarea']",
"form textarea",
"main textarea"
],
sendSelectors: [
"#composer-submit-button",
"button[data-testid='send-button']",
"button[data-testid*='send-button']",
"button[aria-label='Send prompt']",
"button[aria-label*='Send']",
"button[aria-label*='发送']"
],
sendReadyTimeoutMs: 8000,
inputMode: "paste"
},
deepseek: {
hosts: ["chat.deepseek.com"],
editorSelectors: ["textarea", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"]
},
zhipu: {
hosts: ["chatglm.cn"],
editorSelectors: ["textarea", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='发送']", "button[aria-label*='Send']", "button[type='submit']"]
},
qwen: {
hosts: ["chat.qwen.ai"],
editorSelectors: ["textarea", "div.ProseMirror[contenteditable='true']", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"]
},
kimi: {
hosts: ["www.kimi.com", "kimi.com"],
editorSelectors: ["textarea", "div.ProseMirror[contenteditable='true']", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"]
},
claude: {
hosts: ["claude.ai"],
editorSelectors: ["div.ProseMirror[contenteditable='true']", "div[contenteditable='true'].ProseMirror", "div[contenteditable='true']", "textarea"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"]
},
gemini: {
hosts: ["gemini.google.com"],
editorSelectors: ["rich-textarea div[contenteditable='true']", "div.ql-editor[contenteditable='true']", "div[contenteditable='true']", "textarea"],
sendSelectors: ["button[aria-label*='Send message']", "button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"]
}
};
const providerId = Object.entries(PROVIDERS)
.find(([, provider]) => provider.hosts.includes(location.hostname))?.[0];
if (!providerId) return;
const adapter = PROVIDERS[providerId];
function postParent(payload) {
window.parent.postMessage(
@@ -82,6 +79,181 @@
postParent({ type: "AI_PARALLEL_FRAME_READY", href: location.href });
}
function isVisible(element) {
if (!(element instanceof Element)) return false;
const style = getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
return rect.width > 2 && rect.height > 2;
}
function isUsableEditor(element) {
if (!isVisible(element) || element.closest("[aria-hidden='true']")) return false;
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
return !element.disabled && !element.readOnly;
}
return element.getAttribute("contenteditable") === "true";
}
function queryFirstVisible(selectors, predicate = isVisible) {
for (const selector of selectors) {
let nodes = [];
try { nodes = [...document.querySelectorAll(selector)]; } catch { continue; }
for (const node of nodes) if (predicate(node)) return node;
}
return null;
}
async function waitFor(getter, timeoutMs = 25000, intervalMs = 120) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = await getter();
if (value) return value;
await sleep(intervalMs);
}
return null;
}
function normalizedText(element) {
return [element.textContent, element.getAttribute?.("aria-label"), element.getAttribute?.("title")]
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
}
function setNativeValue(element, value) {
if (element instanceof HTMLTextAreaElement) {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set?.call(element, value);
return;
}
if (element instanceof HTMLInputElement) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(element, value);
return;
}
element.textContent = value;
}
function dispatchInput(element, value) {
try {
element.dispatchEvent(new InputEvent("input", {
bubbles: true,
composed: true,
inputType: "insertText",
data: value
}));
} catch {
element.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
}
element.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
}
function editorText(editor) {
return editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement
? editor.value
: editor.textContent || "";
}
function editorContainsPrompt(editor, prompt) {
const expected = prompt.trim().slice(0, 48);
return Boolean(expected && editorText(editor).includes(expected));
}
async function fillEditor(editor, prompt) {
editor.focus();
if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) {
setNativeValue(editor, prompt);
dispatchInput(editor, prompt);
await sleep(120);
return;
}
if (adapter.inputMode === "paste") {
try {
const dataTransfer = new DataTransfer();
dataTransfer.setData("text/plain", prompt);
editor.dispatchEvent(new ClipboardEvent("paste", {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
}));
await sleep(180);
if (editorContainsPrompt(editor, prompt)) return;
} catch {
// Fall through to generic contenteditable insertion.
}
}
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editor);
selection?.removeAllRanges();
selection?.addRange(range);
let inserted = false;
try { inserted = document.execCommand("insertText", false, prompt); } catch { inserted = false; }
if (!inserted || !editorContainsPrompt(editor, prompt)) {
setNativeValue(editor, prompt);
dispatchInput(editor, prompt);
}
await sleep(160);
}
function findGenericSendButton(editor) {
const scopes = [editor.closest("form"), editor.parentElement, editor.closest("main"), document.body].filter(Boolean);
const hints = ["send", "发送", "提交", "submit"];
for (const scope of scopes) {
const buttons = [...scope.querySelectorAll("button")].filter((button) => {
if (!isVisible(button) || button.disabled) return false;
const text = normalizedText(button).toLowerCase();
return button.getAttribute("type") === "submit" || hints.some((hint) => text.includes(hint));
});
if (buttons.length) return buttons[buttons.length - 1];
}
return null;
}
async function submit(editor) {
const button = await waitFor(() => (
queryFirstVisible(adapter.sendSelectors, (el) => isVisible(el) && !el.disabled)
|| findGenericSendButton(editor)
), adapter.sendReadyTimeoutMs ?? 2500, 100);
if (button) {
button.click();
return;
}
editor.focus();
for (const type of ["keydown", "keypress", "keyup"]) {
editor.dispatchEvent(new KeyboardEvent(type, {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
}));
}
}
async function injectAndSend(prompt) {
const editor = await waitFor(
() => queryFirstVisible(adapter.editorSelectors, isUsableEditor),
25000,
120
);
if (!editor) throw new Error("未找到输入框;可能尚未登录或页面结构已变化");
await fillEditor(editor, prompt);
if (!editorContainsPrompt(editor, prompt)) await fillEditor(editor, prompt);
if (!editorContainsPrompt(editor, prompt)) throw new Error("无法可靠写入 Prompt");
await submit(editor);
return true;
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent || event.origin !== PARENT_ORIGIN) return;
if (!event.data || event.data.context !== MESSAGE_CONTEXT) return;
@@ -92,8 +264,24 @@
return;
}
if (!["AI_PARALLEL_SEND", "AI_PARALLEL_COLLECT_RESPONSE", "AI_PARALLEL_NEW_CHAT"].includes(event.data.type)) return;
executeCommand(event.data).then(postParent);
if (event.data.type !== "AI_PARALLEL_SEND") return;
const requestId = String(event.data.requestId || "");
const prompt = String(event.data.prompt || "").trim();
if (!requestId) return;
if (!prompt) {
postParent({ type: "AI_PARALLEL_SEND_RESULT", requestId, ok: false, error: "Prompt 不能为空" });
return;
}
injectAndSend(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)
}));
});
announceReady();
@@ -0,0 +1,29 @@
export class ProviderAdapter {
constructor(config) {
this.id = config.id;
this.hosts = config.hosts || [];
this.editorSelectors = config.editorSelectors || [];
this.sendSelectors = config.sendSelectors || [];
this.collectSelectors = config.collectSelectors || [];
}
findEditor(documentRef = document) {
for (const selector of this.editorSelectors) {
const element = documentRef.querySelector(selector);
if (element) return element;
}
return null;
}
findAssistantMessages(documentRef = document) {
return this.collectSelectors
.flatMap((selector) => [...documentRef.querySelectorAll(selector)])
.map((element) => element.innerText?.trim())
.filter(Boolean);
}
collectResponse(documentRef = document) {
const messages = this.findAssistantMessages(documentRef);
return messages.at(-1) || null;
}
}
@@ -1,32 +1,19 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
createProviderAdapter({
id: "chatgpt",
hosts: ["chatgpt.com", "chat.openai.com"],
editorSelectors: [
"#prompt-textarea",
"textarea[data-testid='prompt-textarea']",
"div[contenteditable='true'][data-testid='prompt-textarea']",
"form textarea",
"main textarea"
],
sendSelectors: [
"#composer-submit-button",
"button[data-testid='send-button']",
"button[data-testid*='send-button']",
"button[aria-label='Send prompt']",
"button[aria-label*='Send']",
"button[aria-label*='发送']"
],
responseSelectors: ["[data-message-author-role='assistant']"],
newChatSelectors: [
"[data-testid='create-new-chat-button']",
"a[aria-label*='New chat']",
"button[aria-label*='New chat']",
"a[aria-label*='新聊天']",
"button[aria-label*='新聊天']"
],
sendReadyTimeoutMs: 8000,
inputMode: "paste"
});
})();
import { ProviderAdapter } from './base.js';
export class ChatGPTAdapter extends ProviderAdapter {
constructor() {
super({
id: 'chatgpt',
hosts: ['chatgpt.com', 'chat.openai.com'],
editorSelectors: [
'#prompt-textarea',
"textarea[data-testid='prompt-textarea']",
"div[contenteditable='true'][data-testid='prompt-textarea']"
],
collectSelectors: [
"[data-message-author-role='assistant']",
"article[data-testid*='conversation-turn']"
]
});
}
}
@@ -1,11 +0,0 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
createProviderAdapter({
id: "claude",
hosts: ["claude.ai"],
editorSelectors: ["div.ProseMirror[contenteditable='true']", "div[contenteditable='true'].ProseMirror", "div[contenteditable='true']", "textarea"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"],
responseSelectors: ["[data-testid='assistant-message']", "[class*='font-claude-response']"],
newChatSelectors: ["button[aria-label*='New chat']", "button[aria-label*='新对话']"]
});
})();
@@ -1,328 +0,0 @@
(() => {
const root = globalThis;
const adapters = root.AIParallelProviderAdapters ||= {};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function isVisible(element) {
if (!(element instanceof Element)) return false;
const style = getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
return rect.width > 2 && rect.height > 2;
}
function isUsableEditor(element) {
if (!isVisible(element) || element.closest("[aria-hidden='true']")) return false;
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
return !element.disabled && !element.readOnly;
}
return element.getAttribute("contenteditable") === "true";
}
function queryFirstVisible(selectors, predicate = isVisible, rootDocument = document) {
for (const selector of selectors || []) {
let nodes = [];
try { nodes = [...rootDocument.querySelectorAll(selector)]; } catch { continue; }
for (const node of nodes) if (predicate(node)) return node;
}
return null;
}
function queryVisible(selectors, predicate = isVisible, rootDocument = document) {
const matches = [];
for (const selector of selectors || []) {
let nodes = [];
try { nodes = [...rootDocument.querySelectorAll(selector)]; } catch { continue; }
for (const node of nodes) {
if (predicate(node) && !matches.includes(node)) matches.push(node);
}
}
matches.sort((left, right) => {
if (left === right) return 0;
return left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
return matches;
}
async function waitFor(getter, timeoutMs = 25000, intervalMs = 120) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = await getter();
if (value) return value;
await sleep(intervalMs);
}
return null;
}
function normalizedText(element) {
return [element.textContent, element.getAttribute?.("aria-label"), element.getAttribute?.("title")]
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
}
function setNativeValue(element, value) {
if (element instanceof HTMLTextAreaElement) {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set?.call(element, value);
return;
}
if (element instanceof HTMLInputElement) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(element, value);
return;
}
element.textContent = value;
}
function dispatchInput(element, value) {
try {
element.dispatchEvent(new InputEvent("input", {
bubbles: true,
composed: true,
inputType: "insertText",
data: value
}));
} catch {
element.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
}
element.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
}
function editorText(editor) {
return editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement
? editor.value
: editor.textContent || "";
}
function editorContainsPrompt(editor, prompt) {
const expected = prompt.trim().slice(0, 48);
return Boolean(expected && editorText(editor).includes(expected));
}
async function fillEditor(adapter, editor, prompt) {
editor.focus();
if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) {
setNativeValue(editor, prompt);
dispatchInput(editor, prompt);
await sleep(120);
return;
}
if (adapter.inputMode === "paste") {
try {
const dataTransfer = new DataTransfer();
dataTransfer.setData("text/plain", prompt);
editor.dispatchEvent(new ClipboardEvent("paste", {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
}));
await sleep(180);
if (editorContainsPrompt(editor, prompt)) return;
} catch {
// Fall through to generic contenteditable insertion.
}
}
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editor);
selection?.removeAllRanges();
selection?.addRange(range);
let inserted = false;
try { inserted = document.execCommand("insertText", false, prompt); } catch { inserted = false; }
if (!inserted || !editorContainsPrompt(editor, prompt)) {
setNativeValue(editor, prompt);
dispatchInput(editor, prompt);
}
await sleep(160);
}
function findGenericSendButton(editor) {
const scopes = editorScopes(editor);
const hints = ["send", "发送", "提交", "submit"];
for (const scope of scopes) {
const buttons = [...scope.querySelectorAll("button")].filter((button) => {
if (!isEnabledControl(button)) return false;
const text = normalizedText(button).toLowerCase();
return button.getAttribute("type") === "submit" || hints.some((hint) => text.includes(hint));
});
if (buttons.length) return buttons[buttons.length - 1];
}
return null;
}
function isEnabledControl(element) {
return isVisible(element)
&& !element.disabled
&& element.getAttribute("aria-disabled") !== "true"
&& element.getAttribute("aria-busy") !== "true";
}
function editorScopes(editor) {
const scopes = [];
const add = (scope) => {
if (scope && !scopes.includes(scope)) scopes.push(scope);
};
let current = editor.parentElement;
for (let depth = 0; current && depth < 5; depth += 1) {
add(current);
current = current.parentElement;
}
add(editor.closest("form"));
add(editor.closest("[role='form']"));
add(editor.closest("main"));
return scopes;
}
function queryFirstVisibleInEditorScope(selectors, editor) {
for (const scope of editorScopes(editor)) {
const match = queryFirstVisible(selectors, isEnabledControl, scope);
if (match) return match;
}
return null;
}
function findSendButton(adapter, editor) {
return queryFirstVisibleInEditorScope(adapter.sendSelectors, editor) || findGenericSendButton(editor);
}
function responseCount(adapter) {
return queryVisible(
adapter.responseSelectors,
(element) => isVisible(element) && !element.closest("[aria-hidden='true']")
).length;
}
function submissionConfirmed(adapter, editor, prompt, initialButton, initialResponseCount) {
const currentEditor = queryFirstVisible(adapter.editorSelectors, isUsableEditor);
if (!currentEditor || !editorContainsPrompt(currentEditor, prompt)) return true;
if (initialButton && (!initialButton.isConnected
|| initialButton.disabled
|| initialButton.getAttribute("aria-disabled") === "true"
|| initialButton.getAttribute("aria-busy") === "true")) return true;
return responseCount(adapter) > initialResponseCount;
}
function dispatchEnter(editor) {
editor.focus();
for (const type of ["keydown", "keypress", "keyup"]) {
editor.dispatchEvent(new KeyboardEvent(type, {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
}));
}
}
function requestFormSubmit(editor) {
const form = editor.closest("form");
if (typeof form?.requestSubmit !== "function") return false;
form.requestSubmit();
return true;
}
async function submit(adapter, editor, prompt) {
const immediateButton = findSendButton(adapter, editor);
const button = immediateButton || (adapter.sendSelectors?.length
? await waitFor(
() => findSendButton(adapter, editor),
adapter.sendReadyTimeoutMs ?? 2500,
100
)
: null);
const initialResponseCount = responseCount(adapter);
const attempts = button
? [
() => button.click(),
() => requestFormSubmit(editor) || dispatchEnter(editor)
]
: [
() => requestFormSubmit(editor) || dispatchEnter(editor),
() => dispatchEnter(editor)
];
for (const attempt of attempts) {
try { attempt(); } catch { /* Try the next bounded submission path. */ }
const confirmed = await waitFor(
() => submissionConfirmed(adapter, editor, prompt, button, initialResponseCount),
adapter.submitConfirmationTimeoutMs ?? 3000,
100
);
if (confirmed) return true;
}
throw new Error("无法确认 Prompt 已发送;请在模型窗口中重试");
}
async function sendPrompt(adapter, prompt) {
const editor = await waitFor(
() => queryFirstVisible(adapter.editorSelectors, isUsableEditor),
adapter.editorReadyTimeoutMs ?? 25000,
120
);
if (!editor) throw new Error("未找到输入框;可能尚未登录或页面结构已变化");
await fillEditor(adapter, editor, prompt);
if (!editorContainsPrompt(editor, prompt)) await fillEditor(adapter, editor, prompt);
if (!editorContainsPrompt(editor, prompt)) throw new Error("无法可靠写入 Prompt");
return submit(adapter, editor, prompt);
}
function collectResponse(adapter, rootDocument = document) {
const responses = queryVisible(
adapter.responseSelectors,
(element) => isVisible(element) && !element.closest("[aria-hidden='true']"),
rootDocument
);
const response = responses.at(-1);
if (!response) return null;
const content = (response.innerText || response.textContent || "").trim();
if (!content) return null;
return { content, markdown: content };
}
function newChat(adapter, rootDocument = document) {
const button = queryFirstVisible(
adapter.newChatSelectors,
(element) => isVisible(element) && !element.disabled,
rootDocument
);
if (!button) return false;
button.click();
return true;
}
function createProviderAdapter(config) {
const adapter = {
...config,
async sendPrompt(prompt) {
const value = String(prompt || "").trim();
if (!value) throw new Error("Prompt 不能为空");
return sendPrompt(adapter, value);
},
collectResponse(rootDocument = document) {
return collectResponse(adapter, rootDocument);
},
newChat(rootDocument = document) {
return newChat(adapter, rootDocument);
},
healthCheck(rootDocument = document) {
return Boolean(queryFirstVisible(adapter.editorSelectors, isUsableEditor, rootDocument));
}
};
adapters[config.id] = Object.freeze(adapter);
return adapters[config.id];
}
root.AIParallelProviderCore = Object.freeze({ createProviderAdapter });
})();
@@ -1,11 +1,15 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
createProviderAdapter({
id: "deepseek",
hosts: ["chat.deepseek.com"],
editorSelectors: ["textarea", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"],
responseSelectors: [".ds-markdown", "[class*='markdown']"],
newChatSelectors: ["button[aria-label*='New chat']", "button[aria-label*='新对话']"]
});
})();
import { ProviderAdapter } from './base.js';
export class DeepSeekAdapter extends ProviderAdapter {
constructor() {
super({
id: 'deepseek',
hosts: ['chat.deepseek.com'],
editorSelectors: ['textarea', "div[contenteditable='true']"],
collectSelectors: [
'.ds-markdown',
'.markdown-body'
]
});
}
}
@@ -1,11 +0,0 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
createProviderAdapter({
id: "gemini",
hosts: ["gemini.google.com"],
editorSelectors: ["rich-textarea div[contenteditable='true']", "div.ql-editor[contenteditable='true']", "div[contenteditable='true']", "textarea"],
sendSelectors: ["button[aria-label*='Send message']", "button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"],
responseSelectors: ["message-content", ".markdown-main-panel", "[class*='markdown']"],
newChatSelectors: ["a[aria-label*='New chat']", "button[aria-label*='New chat']", "a[aria-label*='新对话']"]
});
})();
@@ -1,79 +0,0 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
function isExternalAuthUrl(value) {
let targetUrl;
try {
targetUrl = new URL(value, "https://grok.com/");
} catch {
return false;
}
if (targetUrl.hostname === "accounts.x.ai") return true;
return ["grok.com", "www.grok.com"].includes(targetUrl.hostname)
&& /^\/(?:sign-in|login)(?:\/|$)/.test(targetUrl.pathname);
}
const adapter = createProviderAdapter({
id: "grok",
hosts: ["grok.com"],
isExternalAuthUrl,
editorSelectors: [
"textarea[aria-label='Ask Grok anything']",
"textarea[placeholder*='Ask']",
"textarea",
"div.ProseMirror[contenteditable='true']",
"div[contenteditable='true'][role='textbox']",
"div[contenteditable='true']"
],
sendSelectors: [
"button[aria-label='Submit']",
"button[data-testid='send-button']",
"button[aria-label*='Send']",
"button[aria-label*='发送']",
"button[type='submit']"
],
responseSelectors: [
"[data-testid='assistant-message']",
"[data-message-author-role='assistant']",
"[class*='markdown']"
],
newChatSelectors: [
"a[aria-label='Home page']",
"a[aria-label*='New chat']",
"button[aria-label*='New chat']",
"a[aria-label*='新对话']",
"button[aria-label*='新对话']"
]
});
if (typeof document === "undefined" || typeof chrome === "undefined") return;
if (window.top === window) return;
document.addEventListener("click", (event) => {
const link = event.target?.closest?.("a[href]");
if (!link) return;
let targetUrl;
try {
targetUrl = new URL(link.href, location.href);
} catch {
return;
}
if (!adapter.isExternalAuthUrl(targetUrl.href)) return;
event.preventDefault();
event.stopImmediatePropagation();
window.parent.postMessage({
context: "ai-parallel-workspace",
providerId: "grok",
type: "AI_PARALLEL_AUTH_REQUIRED"
}, new URL(chrome.runtime.getURL("/")).origin);
chrome.runtime.sendMessage({
type: "OPEN_PROVIDER_AUTH",
providerId: "grok",
url: targetUrl.href
}).catch(() => {
// The workspace login button remains available if the runtime changed.
});
}, true);
})();
@@ -1,11 +0,0 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
createProviderAdapter({
id: "kimi",
hosts: ["www.kimi.com", "kimi.com"],
editorSelectors: ["textarea", "div.ProseMirror[contenteditable='true']", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"],
responseSelectors: [".markdown-body", "[class*='markdown']"],
newChatSelectors: ["button[aria-label*='New chat']", "button[aria-label*='新对话']"]
});
})();
@@ -1,11 +1,15 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
createProviderAdapter({
id: "qwen",
hosts: ["chat.qwen.ai"],
editorSelectors: ["textarea", "div.ProseMirror[contenteditable='true']", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='Send']", "button[aria-label*='发送']", "button[type='submit']"],
responseSelectors: [".markdown-body", "[class*='markdown']"],
newChatSelectors: ["button[aria-label*='New chat']", "button[aria-label*='新对话']"]
});
})();
import { ProviderAdapter } from './base.js';
export class QwenAdapter extends ProviderAdapter {
constructor() {
super({
id: 'qwen',
hosts: ['chat.qwen.ai'],
editorSelectors: ['textarea', "div.ProseMirror[contenteditable='true']"],
collectSelectors: [
'.markdown-body',
'.message-content'
]
});
}
}
@@ -0,0 +1,7 @@
import { ProviderAdapter } from './base.js';
export const providerRegistry = {
create(config) {
return new ProviderAdapter(config);
}
};
@@ -0,0 +1,21 @@
export function normalizeResponseText(value) {
return String(value || "")
.replace(/\s+/g, " ")
.trim();
}
export function toMarkdown(element) {
if (!element) return "";
const clone = element.cloneNode(true);
clone.querySelectorAll?.("button, svg, [aria-hidden='true']")?.forEach((node) => node.remove());
return String(clone.innerText || clone.textContent || "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function pickLatest(nodes) {
if (!nodes || !nodes.length) return null;
return nodes[nodes.length - 1] || null;
}
@@ -1,11 +0,0 @@
(() => {
const { createProviderAdapter } = globalThis.AIParallelProviderCore;
createProviderAdapter({
id: "zhipu",
hosts: ["chatglm.cn"],
editorSelectors: ["textarea", "div[contenteditable='true']"],
sendSelectors: ["button[aria-label*='发送']", "button[aria-label*='Send']", "button[type='submit']"],
responseSelectors: [".markdown-body", "[class*='markdown']"],
newChatSelectors: ["button[aria-label*='新建']", "button[aria-label*='新对话']"]
});
})();
@@ -0,0 +1,15 @@
export const COLLECT_MESSAGE = "AI_PARALLEL_COLLECT_RESPONSE";
export const RESPONSE_MESSAGE = "AI_PARALLEL_RESPONSE_RESULT";
export function createResponsePayload({ providerId, text = "" }) {
return {
type: RESPONSE_MESSAGE,
providerId,
response: {
provider: providerId,
text,
markdown: text,
timestamp: Date.now()
}
};
}
@@ -0,0 +1,26 @@
(() => {
const RESPONSE_PROTOCOL = {
REQUEST: "AI_PARALLEL_COLLECT_RESPONSE",
RESULT: "AI_PARALLEL_RESPONSE_RESULT"
};
function createResponsePayload({ provider, text = "", markdown = null }) {
return {
provider,
text: String(text || "").trim(),
markdown: markdown || String(text || "").trim(),
timestamp: Date.now()
};
}
function postResponse(parent, payload) {
parent.postMessage({
type: RESPONSE_PROTOCOL.RESULT,
response: payload
}, "*");
}
window.AI_PARALLEL_RESPONSE_PROTOCOL = RESPONSE_PROTOCOL;
window.AI_PARALLEL_CREATE_RESPONSE = createResponsePayload;
window.AI_PARALLEL_POST_RESPONSE = postResponse;
})();
+4 -18
View File
@@ -1,9 +1,8 @@
{
"manifest_version": 3,
"name": "AI Parallel",
"version": "2.1.4",
"version": "2.0.1",
"description": "Compare multiple AI web chats side by side in one live workspace.",
"homepage_url": "https://github.com/CoderLambert/ai-parallel",
"minimum_chrome_version": "120",
"permissions": [
"storage",
@@ -20,8 +19,7 @@
"https://www.kimi.com/*",
"https://kimi.com/*",
"https://claude.ai/*",
"https://gemini.google.com/*",
"https://grok.com/*"
"https://gemini.google.com/*"
],
"background": {
"service_worker": "service-worker.js"
@@ -52,21 +50,9 @@
"https://www.kimi.com/*",
"https://kimi.com/*",
"https://claude.ai/*",
"https://gemini.google.com/*",
"https://grok.com/*"
],
"js": [
"content/providers/core.js",
"content/providers/chatgpt.js",
"content/providers/deepseek.js",
"content/providers/qwen.js",
"content/providers/kimi.js",
"content/providers/zhipu.js",
"content/providers/claude.js",
"content/providers/gemini.js",
"content/providers/grok.js",
"content/frame-bridge.js"
"https://gemini.google.com/*"
],
"js": ["content/frame-bridge.js"],
"run_at": "document_idle",
"all_frames": true
}
+3 -3
View File
@@ -1,9 +1,9 @@
{
"name": "@ai-parallel/browser-extension",
"private": true,
"version": "2.1.4",
"description": "Manifest V3 workspace for comparing AI web chats in parallel.",
"version": "2.0.1",
"description": "Manifest V3 iframe workspace for comparing AI web chats in parallel.",
"scripts": {
"check": "node --check shared/provider-catalog.js && node --check shared/provider-adapter-contract.js && node --check shared/provider-task-runtime.js && node --check shared/agent-execution-controller.js && node --check service-worker.js && node --check popup.js && node --check content/frame-bridge.js && node --check workspace/workspace.js"
"check": "node --check service-worker.js && node --check popup.js && node --check content/frame-bridge.js && node --check workspace/workspace.js"
}
}
+6 -4
View File
@@ -40,15 +40,17 @@
</button>
<section class="status-panel">
<div class="section-head"><span>启动方式</span></div>
<div class="status-list empty">点击发送后打开 Workspace,并自动发送到已选模型。</div>
<div class="section-head">
<span>最近一次</span>
<button id="refreshBtn" class="text-button" type="button">刷新</button>
</div>
<div id="statusList" class="status-list empty">暂无运行记录</div>
</section>
<footer>
Prompt 仅保存在浏览器扩展本地存储中,不写入 URL。
Prompt 仅保存在浏览器扩展会话存储中,不写入 URL。
</footer>
</main>
<script src="shared/provider-catalog.js"></script>
<script src="popup.js"></script>
</body>
</html>
+59 -12
View File
@@ -1,4 +1,12 @@
const PROVIDERS = globalThis.AIParallelProviderCatalog;
const PROVIDERS = [
{ id: "chatgpt", name: "ChatGPT", default: true },
{ id: "deepseek", name: "DeepSeek", default: true },
{ id: "zhipu", name: "智谱清言", default: true },
{ id: "qwen", name: "Qwen", default: true },
{ id: "kimi", name: "Kimi", default: true },
{ id: "claude", name: "Claude", default: false },
{ id: "gemini", name: "Gemini", default: false }
];
const $ = (selector) => document.querySelector(selector);
const providerGrid = $("#providerGrid");
@@ -7,6 +15,7 @@ const sendBtn = $("#sendBtn");
const selectedCount = $("#selectedCount");
const charCount = $("#charCount");
const errorBox = $("#errorBox");
const statusList = $("#statusList");
let selected = new Set();
@@ -54,6 +63,48 @@ async function loadPreferences() {
updateCounters();
}
function renderStatus(status) {
if (!status || !status.providers || Object.keys(status.providers).length === 0) {
statusList.className = "status-list empty";
statusList.textContent = "暂无运行记录";
return;
}
statusList.className = "status-list";
statusList.replaceChildren();
for (const [providerId, entry] of Object.entries(status.providers)) {
const providerName = entry.name || PROVIDERS.find((p) => p.id === providerId)?.name || providerId;
const item = document.createElement("div");
item.className = "status-item";
item.dataset.state = entry.state || "waiting";
item.innerHTML = `
<span class="dot"></span>
<span>${providerName}</span>
<span class="status-message" title="${escapeHtml(entry.message || "")}">${escapeHtml(entry.message || "")}</span>
`;
statusList.append(item);
}
}
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function refreshStatus() {
try {
const response = await chrome.runtime.sendMessage({ type: "GET_LAST_STATUS" });
if (response?.ok) renderStatus(response.status);
} catch {
// Popup remains useful even if the service worker is restarting.
}
}
async function launch() {
const prompt = promptInput.value.trim();
if (!prompt) return showError("请输入 Prompt");
@@ -64,19 +115,14 @@ async function launch() {
sendBtn.querySelector("span").textContent = "正在打开…";
try {
await chrome.storage.local.set({
draftPrompt: promptInput.value,
selectedProviders: [...selected],
pendingLaunch: {
prompt,
providerIds: [...selected],
queuedAt: new Date().toISOString()
}
});
await chrome.storage.local.set({ draftPrompt: promptInput.value });
const response = await chrome.runtime.sendMessage({
type: "OPEN_WORKSPACE"
type: "LAUNCH_PARALLEL",
prompt,
providerIds: [...selected]
});
if (!response?.ok) throw new Error(response?.error || "启动失败");
await refreshStatus();
window.close();
} catch (error) {
showError(error instanceof Error ? error.message : String(error));
@@ -115,6 +161,7 @@ $("#toggleAllBtn").addEventListener("click", async () => {
updateCounters();
});
$("#refreshBtn").addEventListener("click", refreshStatus);
sendBtn.addEventListener("click", launch);
loadPreferences();
loadPreferences().then(refreshStatus);
+122 -112
View File
@@ -1,78 +1,16 @@
importScripts("shared/provider-catalog.js");
const PROVIDERS = {
...Object.fromEntries(globalThis.AIParallelProviderCatalog.map((provider) => [
provider.id,
{ ...provider, tabMode: provider.mode === "tab" }
]))
};
const PROVIDER_AUTH_HOSTS = {
grok: new Set(["grok.com", "www.grok.com", "accounts.x.ai"])
chatgpt: { name: "ChatGPT", url: "https://chatgpt.com/" },
deepseek: { name: "DeepSeek", url: "https://chat.deepseek.com/" },
zhipu: { name: "智谱清言", url: "https://chatglm.cn/" },
qwen: { name: "Qwen", url: "https://chat.qwen.ai/" },
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" }
};
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);
}
}
const FRAME_REGISTRY_KEY = "iframeRegistryByTab";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function ensureWorkspace() {
const workspaceUrl = chrome.runtime.getURL(WORKSPACE_PATH);
@@ -86,21 +24,83 @@ async function ensureWorkspace() {
return chrome.tabs.create({ url: workspaceUrl, active: true });
}
async function forwardPendingLaunch(tab) {
if (!tab?.id) return;
const data = await chrome.storage.local.get("pendingLaunch");
if (!data.pendingLaunch) return;
chrome.tabs.sendMessage(tab.id, { type: "RUN_PENDING_LAUNCH", pending: data.pendingLaunch }).catch(() => {
// A newly created workspace may not have loaded its listener yet. It will
// consume pendingLaunch during its own initialization.
});
async function readRegistry() {
const data = await chrome.storage.session.get(FRAME_REGISTRY_KEY);
return data[FRAME_REGISTRY_KEY] || {};
}
async function writeRegistry(registry) {
await chrome.storage.session.set({ [FRAME_REGISTRY_KEY]: registry });
}
async function registerFrame(tabId, providerId, frameId, href) {
const registry = await readRegistry();
registry[String(tabId)] ||= {};
registry[String(tabId)][providerId] = { frameId, href, readyAt: Date.now() };
await writeRegistry(registry);
}
async function clearFrame(tabId, providerId, expectedFrameId) {
const registry = await readRegistry();
const tabFrames = registry[String(tabId)];
if (!tabFrames?.[providerId]) return;
if (expectedFrameId != null && tabFrames[providerId].frameId !== expectedFrameId) return;
delete tabFrames[providerId];
if (!Object.keys(tabFrames).length) delete registry[String(tabId)];
await writeRegistry(registry);
}
async function getFrame(tabId, providerId) {
const registry = await readRegistry();
return registry[String(tabId)]?.[providerId] || null;
}
async function sendToProviderFrame(tabId, providerId, prompt, timeoutMs = 12000) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
const entry = await getFrame(tabId, providerId);
if (!entry?.frameId) {
await sleep(140);
continue;
}
try {
const response = await chrome.tabs.sendMessage(
tabId,
{ type: "AI_PARALLEL_SEND", providerId, prompt },
{ frameId: entry.frameId }
);
if (response?.ok) return { ok: true };
lastError = new Error(response?.error || "Provider frame rejected the prompt");
} catch (error) {
lastError = error;
await clearFrame(tabId, providerId, entry.frameId);
}
await sleep(160);
}
return {
ok: false,
error: lastError instanceof Error ? lastError.message : "模型 iframe 尚未就绪"
};
}
chrome.action.onClicked.addListener(() => {
ensureWorkspace().catch((error) => console.warn("[AI Parallel] Cannot open workspace", error));
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
chrome.tabs.onRemoved.addListener((tabId) => {
(async () => {
const registry = await readRegistry();
if (!registry[String(tabId)]) return;
delete registry[String(tabId)];
await writeRegistry(registry);
})().catch(() => {});
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
if (!message || typeof message.type !== "string") {
sendResponse({ ok: false, error: "Invalid message" });
@@ -109,45 +109,55 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === "OPEN_WORKSPACE") {
const tab = await ensureWorkspace();
await forwardPendingLaunch(tab);
sendResponse({ ok: true, tabId: tab.id });
return;
}
if (message.type === "FRAME_READY") {
if (!sender.tab?.id || !Number.isInteger(sender.frameId) || sender.frameId === 0) {
sendResponse({ ok: false, error: "Invalid iframe sender" });
return;
}
const providerId = String(message.providerId || "");
if (!PROVIDERS[providerId]) {
sendResponse({ ok: false, error: "Unknown provider" });
return;
}
await registerFrame(sender.tab.id, providerId, sender.frameId, String(message.href || sender.url || ""));
sendResponse({ ok: true });
return;
}
if (message.type === "GET_FRAME_STATUS") {
if (!sender.tab?.id) return sendResponse({ ok: false, error: "Missing workspace tab" });
const registry = await readRegistry();
sendResponse({ ok: true, frames: registry[String(sender.tab.id)] || {} });
return;
}
if (message.type === "DISPATCH_PROMPT") {
if (!sender.tab?.id) return sendResponse({ ok: false, error: "Missing workspace tab" });
const prompt = String(message.prompt || "").trim();
const providerIds = Array.isArray(message.providerIds)
? message.providerIds.filter((id) => PROVIDERS[id])
: [];
if (!prompt) return sendResponse({ ok: false, error: "Prompt 不能为空" });
if (!providerIds.length) return sendResponse({ ok: false, error: "至少选择一个模型" });
const pairs = await Promise.all(providerIds.map(async (providerId) => [
providerId,
await sendToProviderFrame(sender.tab.id, providerId, prompt)
]));
const results = Object.fromEntries(pairs);
sendResponse({ ok: true, results });
return;
}
if (message.type === "OPEN_PROVIDER_TAB") {
const providerId = String(message.providerId || "");
const provider = PROVIDERS[providerId];
if (!provider) {
sendResponse({ ok: false, error: "Unknown provider" });
return;
}
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];
let authUrl;
try {
authUrl = new URL(String(message.url || ""));
} catch {
sendResponse({ ok: false, error: "Invalid authentication URL" });
return;
}
if (authUrl.protocol !== "https:" || !allowedHosts?.has(authUrl.hostname)) {
sendResponse({ ok: false, error: "Authentication URL is not allowed" });
return;
}
const tab = await chrome.tabs.create({ url: authUrl.href, active: true });
if (!provider) return sendResponse({ ok: false, error: "Unknown provider" });
const tab = await chrome.tabs.create({ url: provider.url, active: true });
sendResponse({ ok: true, tabId: tab.id });
return;
}
@@ -1,517 +0,0 @@
(() => {
const REQUIRED_ADAPTER_METHODS = Object.freeze([
"sendPrompt",
"collectResponse",
"newChat",
"healthCheck"
]);
const ROLES = Object.freeze(["planner", "executor", "reviewer"]);
const STATUS = Object.freeze({
IDLE: "IDLE",
QUEUED: "QUEUED",
RUNNING: "RUNNING",
SUCCESS: "SUCCESS",
PARTIAL: "PARTIAL",
FAILED: "FAILED",
CANCELLED: "CANCELLED"
});
const AGENT_STATUS = Object.freeze({
PENDING: "PENDING",
RUNNING: "RUNNING",
SUCCESS: "SUCCESS",
FAILED: "FAILED",
TIMEOUT: "TIMEOUT",
CANCELLED: "CANCELLED"
});
const TERMINAL_STATUSES = new Set([
STATUS.SUCCESS,
STATUS.PARTIAL,
STATUS.FAILED,
STATUS.CANCELLED
]);
const DEFAULT_MAX_CONCURRENCY = 3;
const DEFAULT_MAX_AGENTS = 8;
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_MAX_HISTORY = 50;
let executionSequence = 0;
function defaultIdFactory() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
executionSequence += 1;
return `agent-execution-${Date.now()}-${executionSequence}`;
}
function positiveInteger(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.max(1, Math.floor(number)) : fallback;
}
function asMessage(error, fallback = "Agent execution failed") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error.trim()) return error.trim();
if (error && typeof error.message === "string" && error.message.trim()) return error.message.trim();
if (error && typeof error.error === "string" && error.error.trim()) return error.error.trim();
return fallback;
}
function asErrorSnapshot(error, fallback) {
return {
message: asMessage(error, fallback),
code: typeof error?.code === "string" ? error.code : null,
retryable: error?.retryable === true
};
}
function adapterIsValid(adapter) {
const contract = globalThis.AIParallelProviderAdapterContract;
if (contract?.validateProviderAdapter) return contract.validateProviderAdapter(adapter);
return Boolean(
adapter
&& typeof adapter.id === "string"
&& typeof adapter.providerId === "string"
&& REQUIRED_ADAPTER_METHODS.every((method) => typeof adapter[method] === "function")
);
}
function toAdapterMap(adapters) {
if (adapters instanceof Map) return new Map(adapters);
if (adapters && typeof adapters === "object") return new Map(Object.entries(adapters));
return new Map();
}
function snapshotAgent(agent) {
return {
id: agent.id,
providerId: agent.providerId,
role: agent.role,
taskId: agent.taskId,
status: agent.status,
attempt: agent.attempt,
maxAttempts: agent.maxAttempts,
startedAt: agent.startedAt,
finishedAt: agent.finishedAt,
error: agent.error ? { ...agent.error } : null,
response: agent.response
};
}
function snapshotExecution(execution) {
return {
id: execution.id,
taskId: execution.taskId,
strategy: execution.strategy,
status: execution.status,
ok: execution.status === STATUS.SUCCESS || execution.status === STATUS.PARTIAL,
cancelRequested: execution.cancelRequested,
startedAt: execution.startedAt,
finishedAt: execution.finishedAt,
maxConcurrency: execution.maxConcurrency,
scope: { ...execution.scope },
agents: execution.agents.map(snapshotAgent),
responses: execution.responses.map((response) => ({ ...response })),
error: execution.error
};
}
class AgentExecutionController {
constructor({
taskRuntime,
adapters,
maxConcurrency = DEFAULT_MAX_CONCURRENCY,
maxAgents = DEFAULT_MAX_AGENTS,
timeoutMs = DEFAULT_TIMEOUT_MS,
maxHistory = DEFAULT_MAX_HISTORY,
idFactory = defaultIdFactory,
now = () => Date.now()
} = {}) {
if (!taskRuntime || typeof taskRuntime.run !== "function") {
throw new TypeError("Agent execution controller requires a provider task runtime");
}
this.taskRuntime = taskRuntime;
this.adapters = toAdapterMap(adapters);
this.maxConcurrency = positiveInteger(maxConcurrency, DEFAULT_MAX_CONCURRENCY);
this.maxAgents = Math.min(positiveInteger(maxAgents, DEFAULT_MAX_AGENTS), DEFAULT_MAX_AGENTS);
this.timeoutMs = positiveInteger(timeoutMs, DEFAULT_TIMEOUT_MS);
this.maxHistory = positiveInteger(maxHistory, DEFAULT_MAX_HISTORY);
this.idFactory = idFactory;
this.now = now;
this.executions = new Map();
this.finishedExecutionIds = [];
this.listeners = new Set();
for (const adapter of this.adapters.values()) {
if (!adapterIsValid(adapter)) {
throw new TypeError("Agent execution controller received an invalid provider adapter");
}
}
}
subscribe(listener) {
if (typeof listener !== "function") throw new TypeError("Agent execution listener must be a function");
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
createExecution({
taskId,
strategy = "parallel",
prompt,
agents,
scope = {},
maxConcurrency = this.maxConcurrency,
timeoutMs = this.timeoutMs
} = {}) {
if (strategy !== "parallel") throw new TypeError("Only the parallel agent strategy is supported");
if (typeof prompt !== "string" || !prompt.trim()) throw new TypeError("Agent execution requires a prompt");
if (!Array.isArray(agents) || agents.length === 0) {
throw new TypeError("Agent execution requires at least one agent");
}
if (agents.length > this.maxAgents) {
throw new RangeError(`Agent execution is limited to ${this.maxAgents} agents`);
}
const executionId = String(taskId || this.idFactory());
if (!executionId.trim()) throw new TypeError("Agent execution requires a task ID");
if (this.executions.has(executionId)) throw new Error(`Agent execution already exists: ${executionId}`);
const normalizedConcurrency = Math.min(
positiveInteger(maxConcurrency, this.maxConcurrency),
this.maxAgents
);
const normalizedTimeout = positiveInteger(timeoutMs, this.timeoutMs);
const normalizedAgents = agents.map((agent, index) => this.normalizeAgent(agent, index, executionId));
const agentIds = new Set();
for (const agent of normalizedAgents) {
if (agentIds.has(agent.id)) throw new Error(`Agent IDs must be unique: ${agent.id}`);
agentIds.add(agent.id);
}
const scopeInput = scope && typeof scope === "object" ? scope : {};
const execution = {
id: executionId,
taskId: executionId,
strategy,
status: STATUS.IDLE,
cancelRequested: false,
startedAt: null,
finishedAt: null,
maxConcurrency: normalizedConcurrency,
scope: {
id: String(scopeInput.id || executionId),
label: String(scopeInput.label || "bounded-agent-execution"),
providerIds: normalizedAgents.map((agent) => agent.providerId),
roles: normalizedAgents.map((agent) => agent.role)
},
agents: normalizedAgents,
responses: [],
error: null
};
const record = {
execution,
prompt: prompt.trim(),
timeoutMs: normalizedTimeout,
nextAgentIndex: 0,
activeCount: 0,
activeTasks: new Map(),
cancelReason: "Agent execution cancelled",
cancelRequested: false,
completed: false,
resolve: null,
promise: null,
pump: null
};
Object.defineProperty(execution, "run", {
enumerable: false,
value: () => this.start(execution.id)
});
Object.defineProperty(execution, "cancel", {
enumerable: false,
value: (reason) => this.cancel(execution.id, reason)
});
this.executions.set(execution.id, record);
this.notify(execution);
return execution;
}
run(options) {
const execution = this.createExecution(options);
const promise = this.start(execution.id);
promise.execution = execution;
promise.executionId = execution.id;
promise.cancel = execution.cancel;
return promise;
}
start(executionOrId) {
const executionId = typeof executionOrId === "string" ? executionOrId : executionOrId?.id;
const record = this.executions.get(executionId);
if (!record) return Promise.reject(new Error("Unknown agent execution"));
if (record.promise) return record.promise;
record.promise = new Promise((resolve) => {
record.resolve = resolve;
});
this.setStatus(record.execution, STATUS.QUEUED);
Promise.resolve().then(() => this.runRecord(record));
return record.promise;
}
cancel(executionOrId, reason = "Agent execution cancelled") {
const executionId = typeof executionOrId === "string" ? executionOrId : executionOrId?.id;
const record = this.executions.get(executionId);
if (!record || record.completed) return false;
record.cancelRequested = true;
record.cancelReason = asMessage(reason, "Agent execution cancelled");
record.execution.cancelRequested = true;
this.cancelQueuedAgents(record);
for (const task of record.activeTasks.values()) task.cancel?.(record.cancelReason);
this.notify(record.execution);
if (record.activeCount === 0) this.finish(record, STATUS.CANCELLED);
return true;
}
getExecution(executionId) {
const record = this.executions.get(String(executionId));
return record ? snapshotExecution(record.execution) : null;
}
listExecutions({ includeFinished = true } = {}) {
return [...this.executions.values()]
.filter((record) => includeFinished || !TERMINAL_STATUSES.has(record.execution.status))
.map((record) => snapshotExecution(record.execution));
}
normalizeAgent(agent, index, executionId) {
if (!agent || typeof agent !== "object") throw new TypeError(`Agent ${index + 1} is invalid`);
if (typeof agent.providerId !== "string" || !agent.providerId.trim()) {
throw new TypeError(`Agent ${index + 1} requires a provider ID`);
}
if (!ROLES.includes(agent.role)) {
throw new TypeError(`Agent ${index + 1} role must be planner, executor, or reviewer`);
}
const adapter = this.adapterFor(agent.providerId);
if (!adapter) throw new Error(`Provider adapter not found: ${agent.providerId}`);
const id = String(agent.id || `${executionId}:agent-${index + 1}`);
return {
id,
providerId: agent.providerId,
role: agent.role,
taskId: null,
status: AGENT_STATUS.PENDING,
attempt: 0,
maxAttempts: adapter.capabilities?.retry === true ? 2 : 1,
startedAt: null,
finishedAt: null,
error: null,
response: null
};
}
adapterFor(providerId) {
const direct = this.adapters.get(providerId);
if (direct?.providerId === providerId) return direct;
for (const adapter of this.adapters.values()) {
if (adapter?.providerId === providerId) return adapter;
}
return null;
}
runRecord(record) {
if (record.completed) return;
if (record.cancelRequested) {
this.cancelQueuedAgents(record);
this.finish(record, STATUS.CANCELLED);
return;
}
record.execution.startedAt = this.now();
this.setStatus(record.execution, STATUS.RUNNING);
record.pump = () => this.pump(record);
record.pump();
}
pump(record) {
if (record.completed) return;
if (record.cancelRequested) {
this.cancelQueuedAgents(record);
if (record.activeCount === 0) this.finish(record, STATUS.CANCELLED);
return;
}
while (
record.activeCount < record.execution.maxConcurrency
&& record.nextAgentIndex < record.execution.agents.length
) {
const agent = record.execution.agents[record.nextAgentIndex++];
record.activeCount += 1;
const agentPromise = this.startAgent(record, agent);
agentPromise.then(
() => this.agentFinished(record, agent.id),
() => this.agentFinished(record, agent.id)
);
}
if (record.nextAgentIndex >= record.execution.agents.length && record.activeCount === 0) {
this.finish(record, this.finalStatus(record));
}
}
agentFinished(record, agentId) {
record.activeCount = Math.max(0, record.activeCount - 1);
record.activeTasks.delete(agentId);
if (!record.completed) record.pump?.();
}
async startAgent(record, agent) {
const adapter = this.adapterFor(agent.providerId);
agent.status = AGENT_STATUS.RUNNING;
agent.startedAt = this.now();
this.notify(record.execution);
try {
if (record.cancelRequested) {
this.markAgentCancelled(agent, record.cancelReason);
return;
}
const taskPromise = this.taskRuntime.run({
providerId: agent.providerId,
operation: `agent-${agent.role}`,
timeoutMs: record.timeoutMs,
maxAttempts: agent.maxAttempts,
retryOn: (error) => error?.retryable === true,
execute: ({ signal, attempt }) => adapter.sendPrompt(
this.scopedPrompt(record.prompt, record.execution, agent),
{
signal,
attempt,
timeoutMs: record.timeoutMs,
executionId: record.execution.id,
scopeId: record.execution.scope.id,
agentId: agent.id,
role: agent.role
}
)
});
record.activeTasks.set(agent.id, taskPromise);
agent.taskId = taskPromise.taskId || taskPromise.task?.id || null;
this.notify(record.execution);
const result = await taskPromise;
agent.attempt = taskPromise.task?.attempt || agent.attempt;
if (record.cancelRequested || result?.status === "CANCELLED") {
this.markAgentCancelled(agent, record.cancelReason);
} else if (result?.ok === false) {
agent.status = result.status === "TIMEOUT" ? AGENT_STATUS.TIMEOUT : AGENT_STATUS.FAILED;
agent.error = asErrorSnapshot(result, result.status || "Agent task failed");
} else {
agent.status = AGENT_STATUS.SUCCESS;
agent.response = result?.response ?? result;
agent.error = null;
}
} catch (error) {
agent.attempt = agent.attempt || 1;
if (record.cancelRequested || error?.code === "TASK_CANCELLED") {
this.markAgentCancelled(agent, record.cancelReason);
} else {
agent.status = error?.code === "TASK_TIMEOUT" ? AGENT_STATUS.TIMEOUT : AGENT_STATUS.FAILED;
agent.error = asErrorSnapshot(error);
}
} finally {
agent.finishedAt = this.now();
this.notify(record.execution);
}
}
scopedPrompt(prompt, execution, agent) {
return [
`You are the ${agent.role} agent in a bounded AI Parallel execution.`,
`Execution scope: ${execution.scope.label} (${execution.scope.id}).`,
"Follow the requested scope and return only the useful result.",
"",
prompt
].join("\n");
}
markAgentCancelled(agent, reason) {
agent.status = AGENT_STATUS.CANCELLED;
agent.error = asErrorSnapshot({ code: "AGENT_CANCELLED", retryable: false }, reason);
agent.response = null;
}
cancelQueuedAgents(record) {
for (let index = record.nextAgentIndex; index < record.execution.agents.length; index += 1) {
const agent = record.execution.agents[index];
if (agent.status !== AGENT_STATUS.PENDING) continue;
this.markAgentCancelled(agent, record.cancelReason);
agent.finishedAt = this.now();
}
record.nextAgentIndex = record.execution.agents.length;
this.notify(record.execution);
}
finalStatus(record) {
if (record.cancelRequested) return STATUS.CANCELLED;
const succeeded = record.execution.agents.filter((agent) => agent.status === AGENT_STATUS.SUCCESS).length;
return succeeded === record.execution.agents.length
? STATUS.SUCCESS
: succeeded > 0 ? STATUS.PARTIAL : STATUS.FAILED;
}
finish(record, status) {
if (record.completed) return;
record.completed = true;
const execution = record.execution;
execution.status = status;
execution.finishedAt = this.now();
execution.error = status === STATUS.SUCCESS || status === STATUS.PARTIAL
? null
: status === STATUS.CANCELLED ? record.cancelReason : "All agent tasks failed";
execution.responses = execution.agents
.filter((agent) => agent.status === AGENT_STATUS.SUCCESS)
.map((agent) => ({
agentId: agent.id,
providerId: agent.providerId,
role: agent.role,
response: agent.response
}));
const result = snapshotExecution(execution);
record.prompt = null;
record.activeTasks.clear();
this.finishedExecutionIds.push(execution.id);
while (this.finishedExecutionIds.length > this.maxHistory) {
this.executions.delete(this.finishedExecutionIds.shift());
}
this.notify(execution);
record.resolve?.(result);
}
setStatus(execution, status) {
if (execution.status === status) return;
execution.status = status;
this.notify(execution);
}
notify(execution) {
const snapshot = snapshotExecution(execution);
for (const listener of this.listeners) {
try { listener(snapshot); } catch { /* Observers must not affect agent execution. */ }
}
}
}
globalThis.AIParallelAgentExecutionController = Object.freeze({
ROLES,
STATUS,
AGENT_STATUS,
TERMINAL_STATUSES: Object.freeze([...TERMINAL_STATUSES]),
AgentExecutionController,
createAgentExecutionController(options) {
return new AgentExecutionController(options);
}
});
})();
@@ -1,71 +0,0 @@
(() => {
const CONTRACT_VERSION = "provider-adapter-v1";
const REQUIRED_METHODS = Object.freeze([
"sendPrompt",
"collectResponse",
"newChat",
"healthCheck"
]);
const operations = Object.freeze({
AI_PARALLEL_SEND: "sendPrompt",
AI_PARALLEL_COLLECT_RESPONSE: "collectResponse",
AI_PARALLEL_NEW_CHAT: "newChat"
});
function assertTransport(transport) {
if (!transport || typeof transport.request !== "function" || typeof transport.healthCheck !== "function") {
throw new TypeError("Provider adapter transport must implement request and healthCheck");
}
}
function createProviderAdapter({ provider, transport } = {}) {
if (!provider || typeof provider.id !== "string" || !provider.id) {
throw new TypeError("Provider adapter requires provider metadata");
}
if (typeof provider.adapter !== "string" || !provider.adapter) {
throw new TypeError("Provider metadata requires an adapter identifier");
}
assertTransport(transport);
const adapter = {
id: provider.adapter,
providerId: provider.id,
mode: provider.mode,
capabilities: provider.capabilities,
async sendPrompt(prompt, context = {}) {
const value = String(prompt || "").trim();
if (!value) return { ok: false, error: "Prompt 不能为空", code: "INVALID_PROMPT", retryable: false };
return transport.request(provider, "AI_PARALLEL_SEND", { prompt: value }, context);
},
collectResponse(context = {}) {
return transport.request(provider, "AI_PARALLEL_COLLECT_RESPONSE", {}, context);
},
newChat(context = {}) {
return transport.request(provider, "AI_PARALLEL_NEW_CHAT", {}, context);
},
healthCheck(context = {}) {
return transport.healthCheck(provider, context);
}
};
return Object.freeze(adapter);
}
function validateProviderAdapter(adapter) {
return Boolean(
adapter
&& typeof adapter.id === "string"
&& typeof adapter.providerId === "string"
&& REQUIRED_METHODS.every((method) => typeof adapter[method] === "function")
);
}
globalThis.AIParallelProviderAdapterContract = Object.freeze({
CONTRACT_VERSION,
REQUIRED_METHODS,
operations,
createProviderAdapter,
validateProviderAdapter
});
})();
@@ -1,115 +0,0 @@
(() => {
const providers = [
{
id: "chatgpt",
name: "ChatGPT",
url: "https://chatgpt.com/",
hosts: ["chatgpt.com", "chat.openai.com"],
origins: ["https://chatgpt.com", "https://chat.openai.com"],
mode: "iframe",
default: true,
adapter: "chatgpt",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
},
{
id: "deepseek",
name: "DeepSeek",
url: "https://chat.deepseek.com/",
hosts: ["chat.deepseek.com"],
origins: ["https://chat.deepseek.com"],
mode: "iframe",
default: true,
adapter: "deepseek",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
},
{
id: "zhipu",
name: "智谱清言",
url: "https://chatglm.cn/",
hosts: ["chatglm.cn"],
origins: ["https://chatglm.cn"],
mode: "iframe",
default: true,
adapter: "zhipu",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
},
{
id: "qwen",
name: "Qwen",
url: "https://chat.qwen.ai/",
hosts: ["chat.qwen.ai"],
origins: ["https://chat.qwen.ai"],
mode: "iframe",
default: true,
adapter: "qwen",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
},
{
id: "kimi",
name: "Kimi",
url: "https://www.kimi.com/",
hosts: ["www.kimi.com", "kimi.com"],
origins: ["https://www.kimi.com", "https://kimi.com"],
mode: "iframe",
default: true,
adapter: "kimi",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
},
{
id: "claude",
name: "Claude",
url: "https://claude.ai/new",
hosts: ["claude.ai"],
origins: ["https://claude.ai"],
mode: "iframe",
default: false,
adapter: "claude",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
},
{
id: "gemini",
name: "Gemini",
url: "https://gemini.google.com/app",
hosts: ["gemini.google.com"],
origins: ["https://gemini.google.com"],
mode: "iframe",
default: false,
adapter: "gemini",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
},
{
id: "grok",
name: "Grok",
url: "https://grok.com/",
hosts: ["grok.com"],
origins: ["https://grok.com"],
mode: "tab",
default: false,
adapter: "grok",
adapterType: "dom",
adapterContract: "provider-adapter-v1",
capabilities: { send: true, collect: true, newChat: true, retry: true, streaming: false, timeout: true, cancel: true }
}
];
globalThis.AIParallelProviderCatalog = Object.freeze(providers.map((provider) => Object.freeze({
...provider,
hosts: Object.freeze([...provider.hosts]),
origins: Object.freeze([...provider.origins]),
capabilities: Object.freeze({ ...provider.capabilities })
})));
})();
@@ -1,369 +0,0 @@
(() => {
const STATUS = Object.freeze({
IDLE: "IDLE",
QUEUED: "QUEUED",
RUNNING: "RUNNING",
SUCCESS: "SUCCESS",
FAILED: "FAILED",
TIMEOUT: "TIMEOUT",
CANCELLED: "CANCELLED"
});
const TERMINAL_STATUSES = new Set([
STATUS.SUCCESS,
STATUS.FAILED,
STATUS.TIMEOUT,
STATUS.CANCELLED
]);
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_MAX_ATTEMPTS = 1;
let taskSequence = 0;
function defaultIdFactory() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
taskSequence += 1;
return `provider-task-${Date.now()}-${taskSequence}`;
}
function asMessage(error, fallback = "Provider task failed") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error.trim()) return error.trim();
if (error && typeof error.message === "string" && error.message.trim()) return error.message.trim();
return fallback;
}
function asTaskError(error) {
const normalized = error instanceof Error ? error : new Error(asMessage(error));
normalized.message = asMessage(normalized);
if (error && typeof error === "object") {
if (typeof error.code === "string") normalized.code = error.code;
if (typeof error.name === "string") normalized.name = error.name;
normalized.retryable = error.retryable === true;
}
return normalized;
}
function errorFromResult(result) {
const error = new Error(asMessage(result?.error));
error.code = typeof result?.code === "string" ? result.code : "PROVIDER_FAILURE";
error.retryable = result?.retryable === true;
return error;
}
function isTimeoutError(error) {
return error?.code === "TASK_TIMEOUT"
|| error?.code === "REQUEST_TIMEOUT"
|| error?.name === "TimeoutError";
}
function snapshotTask(task) {
return {
id: task.id,
providerId: task.providerId,
operation: task.operation,
status: task.status,
attempt: task.attempt,
maxAttempts: task.maxAttempts,
startedAt: task.startedAt,
finishedAt: task.finishedAt,
error: task.error,
response: task.response,
retryReasons: task.retryReasons.map((entry) => ({ ...entry }))
};
}
function createAbortController() {
if (typeof AbortController === "function") return new AbortController();
let aborted = false;
const listeners = new Set();
return {
signal: {
get aborted() { return aborted; },
addEventListener(_type, listener) { listeners.add(listener); },
removeEventListener(_type, listener) { listeners.delete(listener); }
},
abort() {
if (aborted) return;
aborted = true;
for (const listener of listeners) listener();
}
};
}
function createAbortError(reason = "Provider task cancelled") {
const error = new Error(reason);
error.name = "AbortError";
error.code = "TASK_CANCELLED";
error.retryable = false;
return error;
}
class ProviderTaskRuntime {
constructor({
defaultTimeoutMs = DEFAULT_TIMEOUT_MS,
defaultMaxAttempts = DEFAULT_MAX_ATTEMPTS,
maxHistory = 100,
idFactory = defaultIdFactory,
now = () => Date.now()
} = {}) {
this.defaultTimeoutMs = this.#positiveInteger(defaultTimeoutMs, DEFAULT_TIMEOUT_MS);
this.defaultMaxAttempts = this.#positiveInteger(defaultMaxAttempts, DEFAULT_MAX_ATTEMPTS);
this.maxHistory = this.#positiveInteger(maxHistory, 100);
this.idFactory = idFactory;
this.now = now;
this.tasks = new Map();
this.finishedTaskIds = [];
this.listeners = new Set();
}
#positiveInteger(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.max(1, Math.floor(number)) : fallback;
}
subscribe(listener) {
if (typeof listener !== "function") throw new TypeError("Task listener must be a function");
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
createTask({
providerId,
operation = "provider-request",
execute,
timeoutMs = this.defaultTimeoutMs,
maxAttempts = this.defaultMaxAttempts,
retryOn
} = {}) {
if (typeof providerId !== "string" || !providerId.trim()) throw new TypeError("Provider task requires a provider ID");
if (typeof execute !== "function") throw new TypeError("Provider task requires an executor");
const task = {
id: String(this.idFactory()),
providerId,
operation: String(operation),
status: STATUS.IDLE,
attempt: 0,
maxAttempts: this.#positiveInteger(maxAttempts, this.defaultMaxAttempts),
startedAt: null,
finishedAt: null,
error: null,
response: null,
retryReasons: [],
run: null,
cancel: null
};
const record = {
task,
execute,
timeoutMs: this.#positiveInteger(timeoutMs, this.defaultTimeoutMs),
retryOn: typeof retryOn === "function" ? retryOn : (error) => error?.retryable === true,
controller: null,
cancelRequested: false,
completed: false,
resolve: null,
promise: null
};
task.run = () => this.start(task.id);
task.cancel = (reason) => this.cancel(task.id, reason);
this.tasks.set(task.id, record);
this.notify(task);
return task;
}
run(options) {
const task = this.createTask(options);
const promise = this.start(task.id);
promise.task = task;
promise.taskId = task.id;
promise.cancel = task.cancel;
return promise;
}
execute(options) {
return this.run(options);
}
start(taskOrId) {
const taskId = typeof taskOrId === "string" ? taskOrId : taskOrId?.id;
const record = this.tasks.get(taskId);
if (!record) return Promise.reject(new Error("Unknown provider task"));
if (record.promise) return record.promise;
if (TERMINAL_STATUSES.has(record.task.status)) {
record.promise = Promise.resolve(this.failureResult(record.task));
return record.promise;
}
record.promise = new Promise((resolve) => {
record.resolve = resolve;
});
this.setStatus(record.task, STATUS.QUEUED);
Promise.resolve().then(() => this.runRecord(record));
return record.promise;
}
cancel(taskOrId, reason = "Provider task cancelled") {
const taskId = typeof taskOrId === "string" ? taskOrId : taskOrId?.id;
const record = this.tasks.get(taskId);
if (!record || record.completed) return false;
record.cancelRequested = true;
record.controller?.abort();
this.finish(record, STATUS.CANCELLED, createAbortError(String(reason || "Provider task cancelled")));
return true;
}
getTask(taskId) {
const record = this.tasks.get(String(taskId));
return record ? snapshotTask(record.task) : null;
}
listTasks({ providerId, includeFinished = true } = {}) {
return [...this.tasks.values()]
.filter((record) => !providerId || record.task.providerId === providerId)
.filter((record) => includeFinished || !TERMINAL_STATUSES.has(record.task.status))
.map((record) => snapshotTask(record.task));
}
async runRecord(record) {
const { task } = record;
if (record.cancelRequested || record.completed) return;
while (!record.completed && task.attempt < task.maxAttempts) {
if (record.cancelRequested) {
this.finish(record, STATUS.CANCELLED, createAbortError());
return;
}
task.attempt += 1;
if (!task.startedAt) task.startedAt = this.now();
this.setStatus(task, STATUS.RUNNING);
const controller = createAbortController();
record.controller = controller;
try {
const response = await this.withTimeout(record, controller);
if (record.cancelRequested || record.completed) return;
if (response?.ok === false) throw errorFromResult(response);
task.response = response;
this.finish(record, STATUS.SUCCESS);
return;
} catch (error) {
if (record.cancelRequested || record.completed) return;
const normalized = asTaskError(error);
const shouldRetry = task.attempt < task.maxAttempts && this.shouldRetry(record, normalized, task);
if (shouldRetry) {
task.retryReasons.push({
attempt: task.attempt,
reason: normalized.message,
code: normalized.code || null,
timestamp: this.now()
});
this.notify(task);
this.setStatus(task, STATUS.QUEUED);
continue;
}
this.finish(record, isTimeoutError(normalized) ? STATUS.TIMEOUT : STATUS.FAILED, normalized);
return;
} finally {
if (record.controller === controller) record.controller = null;
}
}
}
shouldRetry(record, error, task) {
try {
return record.retryOn(error, snapshotTask(task)) === true;
} catch {
return false;
}
}
withTimeout(record, controller) {
return new Promise((resolve, reject) => {
let settled = false;
const timeoutId = setTimeout(() => {
if (settled) return;
settled = true;
controller.abort();
const error = new Error(`Provider task timed out after ${record.timeoutMs}ms`);
error.name = "TimeoutError";
error.code = "TASK_TIMEOUT";
error.retryable = true;
reject(error);
}, record.timeoutMs);
Promise.resolve()
.then(() => record.execute({
task: snapshotTask(record.task),
attempt: record.task.attempt,
signal: controller.signal
}))
.then((value) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
reject(error);
});
});
}
setStatus(task, status) {
if (task.status === status) return;
task.status = status;
this.notify(task);
}
finish(record, status, error) {
if (record.completed) return;
const { task } = record;
record.completed = true;
task.status = status;
task.finishedAt = this.now();
task.error = status === STATUS.SUCCESS ? null : asMessage(error, status);
if (status !== STATUS.SUCCESS) task.response = null;
this.finishedTaskIds.push(task.id);
while (this.finishedTaskIds.length > this.maxHistory) {
const oldId = this.finishedTaskIds.shift();
const oldRecord = this.tasks.get(oldId);
if (oldRecord?.completed) this.tasks.delete(oldId);
}
this.notify(task);
record.resolve?.(status === STATUS.SUCCESS ? task.response : this.failureResult(task));
}
failureResult(task) {
return {
ok: false,
error: task.error || task.status,
status: task.status,
taskId: task.id,
attempt: task.attempt,
retryReasons: task.retryReasons.map((entry) => ({ ...entry }))
};
}
notify(task) {
const snapshot = snapshotTask(task);
for (const listener of this.listeners) {
try { listener(snapshot); } catch { /* Observers must not affect task execution. */ }
}
}
}
globalThis.AIParallelProviderTaskRuntime = Object.freeze({
STATUS,
TERMINAL_STATUSES: Object.freeze([...TERMINAL_STATUSES]),
ProviderTaskRuntime,
createProviderTaskRuntime(options) {
return new ProviderTaskRuntime(options);
}
});
})();
@@ -0,0 +1,26 @@
const pendingCollections = new Map();
export function collectFromProviders({ providers, postToFrame }) {
const requestId = crypto.randomUUID();
const promise = Promise.all(
providers.map((providerId) => new Promise((resolve) => {
pendingCollections.set(`${requestId}:${providerId}`, resolve);
postToFrame(providerId, {
type: "AI_PARALLEL_COLLECT_RESPONSE",
requestId
});
}))
);
return promise;
}
export function resolveCollectedResponse(message) {
const key = `${message.requestId}:${message.providerId}`;
const resolve = pendingCollections.get(key);
if (!resolve) return false;
pendingCollections.delete(key);
resolve(message.response || null);
return true;
}
@@ -0,0 +1,29 @@
export function createResponseBundle(prompt, responses = []) {
return {
version: '1',
createdAt: Date.now(),
prompt,
responses: responses.filter(Boolean).map((item) => ({
provider: item.provider,
text: item.text || item,
markdown: item.markdown || item.text || item,
timestamp: item.timestamp || Date.now()
}))
};
}
export function toMarkdown(bundle) {
const sections = bundle.responses.map((response) => (
`## ${response.provider}\n\n${response.markdown}`
));
return [
'# AI Parallel Context',
'',
'## Question',
'',
bundle.prompt,
'',
...sections
].join('\n');
}
@@ -1,52 +0,0 @@
(() => {
function getResult(responses, providerId) {
return typeof responses?.get === "function" ? responses.get(providerId) : responses?.[providerId];
}
function buildComparisonMarkdown(question, providers, responses) {
const sections = ["# AI Parallel Context", "", "## Question", "", question || "(未提供)"];
for (const provider of providers) {
const result = getResult(responses, provider.id);
sections.push(
"",
`## ${provider.name}`,
"",
result?.response?.markdown || result?.response?.content || "(未收集到回答)"
);
}
return `${sections.join("\n")}\n`;
}
function buildComparisonJson(question, providers, responses) {
return JSON.stringify({
question: question || "",
responses: providers.map((provider) => {
const result = getResult(responses, provider.id);
return result?.response
? result.response
: {
provider: provider.id,
content: "",
markdown: "",
timestamp: null,
error: result?.error || "未收集到回答"
};
})
}, null, 2);
}
function buildHandoffPrompt(question, providers, responses) {
return [
"You are the target agent in an AI Parallel workflow.",
"Review the question and the collected model responses below. Synthesize a reliable answer, call out disagreements, and improve the result instead of blindly concatenating responses.",
"",
buildComparisonMarkdown(question, providers, responses)
].join("\n");
}
globalThis.AIParallelWorkspaceUtils = Object.freeze({
buildComparisonMarkdown,
buildComparisonJson,
buildHandoffPrompt
});
})();
+6 -80
View File
@@ -15,23 +15,18 @@
<span class="version">Live Web</span>
</div>
<div class="toolbar-center" id="providerBar"></div>
<div class="toolbar-actions">
<button id="sessionBtn" type="button" class="compare-trigger">Sessions</button>
<button id="promptLibraryBtn" type="button" class="compare-trigger">Prompts</button>
<button id="compareBtn" type="button" class="compare-trigger">Compare</button>
<div class="layout-switch" aria-label="布局">
<button type="button" data-layout="auto" class="active">Auto</button>
<button type="button" data-layout="1">1</button>
<button type="button" data-layout="2">2</button>
<button type="button" data-layout="3">3</button>
</div>
<div class="layout-switch" aria-label="布局">
<button type="button" data-layout="auto" class="active">Auto</button>
<button type="button" data-layout="1">1</button>
<button type="button" data-layout="2">2</button>
<button type="button" data-layout="3">3</button>
</div>
</header>
<main id="panelGrid" class="panel-grid" data-layout="auto" data-count="0"></main>
<section class="composer-shell">
<div id="dispatchStatus" class="dispatch-status">原站实时显示 · Compare 可按需收集回答</div>
<div id="dispatchStatus" class="dispatch-status">原站实时显示 · 不再抓取回答</div>
<div class="composer">
<textarea id="promptInput" rows="1" placeholder="Ask all selected models…"></textarea>
<button id="sendBtn" type="button">
@@ -47,63 +42,6 @@
</section>
</div>
<aside id="compareDrawer" class="compare-drawer" aria-hidden="true">
<header class="drawer-header">
<div>
<div class="drawer-title">Comparison</div>
<div id="compareStatus" class="drawer-status">按需收集已选模型的当前回答</div>
</div>
<button id="closeCompareBtn" type="button" class="drawer-close" title="关闭">×</button>
</header>
<div id="responseList" class="response-list"></div>
<footer class="drawer-footer">
<div class="export-actions">
<button id="copyMarkdownBtn" type="button" class="drawer-btn">Copy Markdown</button>
<button id="copyJsonBtn" type="button" class="drawer-btn">Copy JSON</button>
<button id="downloadMarkdownBtn" type="button" class="drawer-btn">Download md</button>
</div>
<div class="handoff-row">
<select id="handoffTarget" aria-label="目标 Agent">
<option value="chatgpt">ChatGPT</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="grok">Grok</option>
</select>
<button id="sendAgentBtn" type="button" class="drawer-btn drawer-btn-primary">Send Agent</button>
</div>
</footer>
</aside>
<aside id="promptLibraryDrawer" class="prompt-library-drawer" aria-hidden="true">
<header class="drawer-header">
<div>
<div class="drawer-title">Prompt Library</div>
<div id="promptLibraryStatus" class="drawer-status">保存在当前浏览器扩展存储中</div>
</div>
<button id="closePromptLibraryBtn" type="button" class="drawer-close" title="关闭">×</button>
</header>
<div class="prompt-library-create">
<input id="promptTitleInput" type="text" maxlength="80" placeholder="Prompt 名称(可选)" />
<button id="savePromptBtn" type="button" class="drawer-btn drawer-btn-primary">保存当前 Prompt</button>
</div>
<div id="promptList" class="prompt-list"></div>
</aside>
<aside id="sessionDrawer" class="prompt-library-drawer session-drawer" aria-hidden="true">
<header class="drawer-header">
<div>
<div class="drawer-title">Sessions</div>
<div id="sessionStatus" class="drawer-status">只保存问题、模型选择和布局;回答需要重新收集</div>
</div>
<button id="closeSessionBtn" type="button" class="drawer-close" title="关闭">×</button>
</header>
<div class="prompt-library-create">
<input id="sessionTitleInput" type="text" maxlength="80" placeholder="Session 名称(可选)" />
<button id="saveSessionBtn" type="button" class="drawer-btn drawer-btn-primary">保存当前 Session</button>
</div>
<div id="sessionList" class="prompt-list"></div>
</aside>
<template id="panelTemplate">
<article class="provider-panel">
<header class="panel-header">
@@ -113,7 +51,6 @@
<span class="provider-state">加载中</span>
</div>
<div class="panel-actions">
<button type="button" class="panel-auth-btn auth-btn" title="在独立标签页登录" hidden>登录 ↗</button>
<button type="button" class="panel-btn reload-btn" title="重新加载"></button>
<button type="button" class="panel-btn open-btn" title="在独立标签页打开"></button>
</div>
@@ -121,21 +58,10 @@
<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>
<script src="../shared/provider-catalog.js"></script>
<script src="../shared/provider-adapter-contract.js"></script>
<script src="../shared/provider-task-runtime.js"></script>
<script src="../shared/agent-execution-controller.js"></script>
<script src="context-utils.js"></script>
<script src="workspace.js"></script>
</body>
</html>
@@ -28,9 +28,6 @@ body { overflow: hidden; }
.provider-chip { border: 1px solid transparent; border-radius: 7px; padding: 5px 8px; background: transparent; color: var(--muted); cursor: pointer; font-size: 11px; white-space: nowrap; transition: .15s ease; }
.provider-chip:hover { background: var(--surface-2); color: var(--text); }
.provider-chip[data-selected="true"] { border-color: var(--border-strong); background: #191c23; color: var(--text); }
.toolbar-actions { display: flex; align-items: center; gap: 8px; }
.compare-trigger { border: 1px solid var(--border-strong); border-radius: 8px; padding: 6px 10px; background: #191c23; color: var(--text); cursor: pointer; font-size: 10px; font-weight: 700; }
.compare-trigger:hover { border-color: var(--accent); background: #211f32; }
.layout-switch { display: flex; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: #0d0f13; }
.layout-switch button { min-width: 30px; border: 0; border-radius: 6px; padding: 4px 7px; background: transparent; color: var(--subtle); cursor: pointer; font-size: 10px; }
.layout-switch button.active { background: var(--surface-2); color: var(--text); }
@@ -53,21 +50,12 @@ body { overflow: hidden; }
.provider-name { font-size: 11px; font-weight: 700; white-space: nowrap; }
.provider-state { min-width: 0; color: var(--subtle); font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.panel-actions { display: flex; gap: 4px; }
.panel-auth-btn { height: 24px; border: 1px solid var(--border-strong); border-radius: 6px; padding: 0 7px; background: #191c23; color: var(--muted); cursor: pointer; font-size: 9px; font-weight: 700; }
.panel-auth-btn:hover { border-color: var(--accent); color: var(--text); }
.panel-btn { width: 24px; height: 24px; border: 0; border-radius: 6px; background: transparent; color: var(--subtle); cursor: pointer; }
.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); }
@@ -77,46 +65,6 @@ textarea { width: 100%; min-height: 38px; max-height: 130px; resize: none; borde
#sendBtn kbd { font: inherit; font-size: 9px; opacity: .65; }
.composer-footer { width: min(980px, 100%); min-height: 16px; margin: 5px auto 0; display: flex; align-items: center; gap: 10px; color: var(--subtle); font-size: 9px; }
.error { margin-left: auto; color: var(--danger); }
.compare-drawer { position: fixed; z-index: 10; top: 48px; right: 0; bottom: 0; width: min(560px, 100vw); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border-left: 1px solid var(--border-strong); background: rgba(13,15,19,.98); box-shadow: -18px 0 48px rgba(0,0,0,.35); transform: translateX(102%); transition: transform .2s ease; }
.compare-drawer[data-open="true"] { transform: translateX(0); }
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px; border-bottom: 1px solid var(--border); }
.drawer-title { font-size: 14px; font-weight: 750; }
.drawer-status { margin-top: 4px; color: var(--muted); font-size: 10px; }
.drawer-close { width: 28px; height: 28px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; font-size: 22px; line-height: 1; }
.drawer-close:hover { background: var(--surface-2); color: var(--text); }
.response-list { min-height: 0; overflow: auto; padding: 12px; }
.response-card { margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); overflow: hidden; }
.response-card-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 11px; border-bottom: 1px solid var(--border); }
.response-card-title { font-size: 11px; font-weight: 700; }
.response-card-time { color: var(--subtle); font-size: 9px; }
.response-card-content { margin: 0; padding: 11px; color: #d9dde3; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
.response-card-error { display: flex; align-items: center; gap: 10px; padding: 11px; color: var(--danger); font-size: 10px; }
.response-card-error > span { min-width: 0; overflow-wrap: anywhere; }
.response-retry-btn { flex: 0 0 auto; min-height: 26px; margin-left: auto; color: var(--text); }
.response-retry-btn:disabled { opacity: .55; cursor: wait; }
.response-empty { display: grid; place-items: center; min-height: 180px; color: var(--subtle); font-size: 11px; text-align: center; }
.drawer-footer { padding: 12px; border-top: 1px solid var(--border); background: #0e1014; }
.export-actions, .handoff-row { display: flex; gap: 6px; }
.handoff-row { margin-top: 8px; }
.drawer-btn { min-height: 30px; border: 1px solid var(--border-strong); border-radius: 7px; padding: 0 9px; background: #151820; color: var(--muted); cursor: pointer; font-size: 10px; }
.drawer-btn:hover { background: #1b1f29; color: var(--text); }
.drawer-btn-primary { flex: 1; border-color: var(--accent); background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: white; font-weight: 700; }
#handoffTarget { min-width: 125px; border: 1px solid var(--border-strong); border-radius: 7px; padding: 0 8px; background: #151820; color: var(--text); font-size: 10px; }
.prompt-library-drawer { position: fixed; z-index: 11; top: 48px; left: 0; bottom: 0; width: min(420px, 100vw); display: grid; grid-template-rows: auto auto minmax(0, 1fr); border-right: 1px solid var(--border-strong); background: rgba(13,15,19,.98); box-shadow: 18px 0 48px rgba(0,0,0,.35); transform: translateX(-102%); transition: transform .2s ease; }
.prompt-library-drawer[data-open="true"] { transform: translateX(0); }
.prompt-library-create { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 12px; border-bottom: 1px solid var(--border); }
.prompt-library-create input { min-width: 0; border: 1px solid var(--border-strong); border-radius: 7px; padding: 0 9px; outline: 0; background: #0a0c10; color: var(--text); font-size: 10px; }
.prompt-list { min-height: 0; overflow: auto; padding: 12px; }
.prompt-empty { display: grid; place-items: center; min-height: 160px; color: var(--subtle); font-size: 11px; text-align: center; }
.prompt-card { margin-bottom: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }
.prompt-card-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.prompt-card-title { min-width: 0; overflow: hidden; color: var(--text); font-size: 11px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.prompt-card-date { flex: 0 0 auto; color: var(--subtle); font-size: 9px; }
.prompt-card-content { display: -webkit-box; margin: 6px 0 9px; overflow: hidden; color: var(--muted); font-size: 10px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.session-card-content { white-space: pre-line; }
.prompt-card-actions { display: flex; justify-content: flex-end; gap: 6px; }
.prompt-card-actions button { min-height: 25px; border: 1px solid var(--border); border-radius: 6px; padding: 0 8px; background: #151820; color: var(--muted); cursor: pointer; font-size: 9px; }
.prompt-card-actions button:hover { background: var(--surface-2); color: var(--text); }
@media (max-width: 1100px) {
.panel-grid, .panel-grid[data-layout="2"], .panel-grid[data-layout="3"], .panel-grid[data-layout="auto"] { grid-template-columns: 1fr !important; }
body { overflow: auto; }
@@ -127,7 +75,6 @@ textarea { width: 100%; min-height: 38px; max-height: 130px; resize: none; borde
@media (max-width: 720px) {
.toolbar { grid-template-columns: auto 1fr; }
.layout-switch { display: none; }
.toolbar-actions { gap: 0; }
.version { display: none; }
.composer { grid-template-columns: 1fr; }
#sendBtn { min-height: 38px; }
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
# AI Parallel Agent Roadmap
## Purpose
This document records product direction and high-level priorities for AI Parallel. Detailed acceptance criteria, implementation checklists, and status updates belong in GitHub Issues. The repository's long-term engineering rules live in `AGENTS.md`.
## Product direction
AI Parallel is evolving into a multi-AI research workspace that lets users ask one question, receive responses from multiple native AI web sessions, compare those responses, package the useful context, hand it to a target agent, and produce a final answer.
## Priority areas
### P0 — core research workflow
- Provider adapter architecture
- On-demand response collector
- Comparison workspace
- Context export
### P1 — collaboration workflow
- Agent handoff improvements
- Prompt library
- Session management
### P2 — advanced workspace capabilities
- Attachment broadcast
- Advanced workspace features
- MCP integration
## v2.1 status
The v2.1 comparison workspace foundation is implemented across provider adapters, on-demand collection, the comparison drawer, Markdown/JSON export, agent handoff, prompt library foundations, runtime cleanup, and contract/export tests.
The main known follow-up is live selector validation against authenticated provider pages, together with broader browser-backed provider fixtures. See [v2.1 comparison workspace plan](v2.1-comparison-workspace-plan.md) and [v2.1 Phase 0 review](v2.1-phase0-review.md) for the current technical detail.
The first P1 session-management foundation is now implemented: users can save
and restore the question, selected providers, and layout locally. Response
content is intentionally excluded from saved sessions and must be collected
again after restoration.
## Roadmap maintenance
When priorities change, update this document at the high level and create or update the corresponding GitHub Issues with concrete scope, acceptance criteria, dependencies, and verification results. Avoid turning this file into a second issue tracker.
+18 -72
View File
@@ -8,37 +8,21 @@ AI Parallel is a multi-model comparison workspace. The browser extension reuses
```text
AI Parallel Workspace (extension page)
├─ Provider Task Runtime
│ ├─ bounded retry / timeout / cancel
│ └─ in-memory task state and observation
├─ Agent Execution Controller
│ ├─ explicit planner / executor / reviewer scope
│ ├─ bounded parallel scheduling
│ └─ in-memory result aggregation and failure isolation
├─ ChatGPT iframe
├─ DeepSeek iframe
├─ 智谱 iframe
├─ Qwen iframe
├─ Kimi iframe
├─ Claude iframe
─ Gemini iframe
└─ Grok controlled top-level tab
─ Gemini iframe
└─ content/frame-bridge.js
├─ providers/core.js
│ ├─ locates provider editor
│ ├─ injects the shared prompt
│ ├─ submits through the provider UI
│ └─ exposes collectResponse/newChat hooks
└─ providers/<provider>.js
└─ host and selector knowledge per provider
├─ locates provider editor
├─ injects the shared prompt
└─ submits through the provider UI
```
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.
The provider response is never scraped into a second renderer. The original provider page renders its own response directly inside the workspace panel. This removes the latency and fragility introduced by DOM response mirroring.
## Framing model
@@ -46,69 +30,31 @@ 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. Grok is excluded from framing-header bypass rules because its
authenticated real-time channel is not reliable in an extension iframe.
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.
## Message flow
```text
workspace/workspace.js
└─ Provider Task Runtime
└─ Provider Adapter
├─ postMessage ─ provider iframe
└─ runtime message ─ service worker ─ tabs.sendMessage ─ Grok tab
content/frame-bridge.js
adapter.sendPrompt(prompt)
│ DISPATCH_PROMPT
service-worker.js
│ frame registry: workspace tab + provider -> frameId
│ chrome.tabs.sendMessage(..., { frameId })
content/frame-bridge.js
├─ fill editor
└─ submit
│ postMessage(AI_PARALLEL_SEND_RESULT)
workspace/workspace.js
```
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.
All workspace provider operations are represented by an in-memory Provider Task.
Tasks expose `IDLE`, `QUEUED`, `RUNNING`, `SUCCESS`, `FAILED`, `TIMEOUT`, and
`CANCELLED` states. Retry attempts are bounded by the provider capability contract;
only explicitly retryable transport or timeout failures are retried. Task history
and response snapshots are not written to extension storage.
The Agent Execution Controller is a user-triggered, in-memory layer above the
Provider Task Runtime. It accepts an explicit `parallel` scope containing no
more than eight planner, executor, or reviewer agents, schedules at most three
agents concurrently by default, and sends every agent prompt through the
selected Provider Adapter and Runtime. A failed agent is isolated so remaining
agents can complete; the aggregate is `SUCCESS`, `PARTIAL`, `FAILED`, or
`CANCELLED`. It does not create autonomous loops, persist prompts/responses, or
bypass the provider message boundary.
Each direct provider iframe announces `FRAME_READY`. The service worker stores the provider/frame mapping in `chrome.storage.session`, so service-worker suspension does not lose routing state.
## Provider boundaries
Provider-specific DOM knowledge is isolated in `content/providers/<provider>.js`,
with shared DOM operations in `content/providers/core.js`. The bridge only
validates the message boundary and delegates to the selected adapter. The
workspace only knows provider identity, URL, layout, and readiness state; it
never knows response selectors.
Provider-specific DOM knowledge is isolated in `content/frame-bridge.js`. The workspace only knows provider identity, URL, layout, and readiness state. It never knows response selectors.
Shared provider identity, URL, host, origin, mode, and default-selection metadata
lives in `apps/browser-extension/shared/provider-catalog.js`. Popup, workspace,
and service-worker entry points consume that catalog; provider selector knowledge
remains local to each adapter.
The catalog also provides the adapter identifier, adapter type, contract version,
and runtime capabilities. The workspace builds the shared adapter contract from
that metadata; the adapter delegates to one generic iframe/tab transport, so the
Provider Task Runtime does not contain provider-specific branches.
Provider adapters can grow independently without changing the workspace
protocol.
As provider handling grows, split `frame-bridge.js` into provider adapters without changing the workspace protocol.
## Security model
@@ -116,7 +62,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.
- Tab-mode commands are restricted to providers explicitly marked for tab mode and to allowlisted command types.
- Opening a provider in a normal top-level tab does not activate the iframe bridge workflow.
## Inspiration
@@ -1,146 +0,0 @@
# AI Parallel Automation and Agent Orchestration
This document explains how to dispatch scheduled tasks and bounded agents. It
does not create a second Issue contract; `issue-rule.md` remains canonical for
outcome, invariants, scope, acceptance, validation, and dependencies.
## Dispatch gate
Use the smallest useful orchestration mode:
### Single writer
Default for small work, tightly coupled state changes, shared contracts, or
when isolation is unavailable. Read-only reviewers may still explore in
parallel.
### Parallel read
Use for architecture mapping, Provider DOM investigation, security/manifest
audits, dependency analysis, test-gap discovery, or PR review.
### Isolated parallel write
Use only when write domains are independent, ownership is explicit, shared
hotspots have one writer, and each writer has a real branch/worktree or other
filesystem isolation. An integration owner and a composition path must be
known before starting.
```text
small or coupled → single writer
independent research → parallel read + single writer
independent writes → isolated writers + integration owner
uncertain ownership → single writer + review
```
## Shared hotspots
Treat these as semantic conflict domains even when files do not overlap:
- provider catalog, ProviderId taxonomy, and adapter contract;
- messaging payloads and bridge allowlists;
- workspace/provider state and request lifecycle;
- storage keys, schema, and migration behavior;
- manifest, host permissions, CSP, DNR, and release configuration;
- `package.json`, lockfiles, `.github/workflows/**`, and shared test/browser
configuration.
One wave has one writer for each shared hotspot. Provider-specific adapter work
can run independently only when the shared contract is stable and the adapter
does not change permissions, messaging, storage, or shared DOM semantics.
## Scheduled task lanes
A scheduled task is a durable ownership lane, not a timer around a tiny Issue.
Use no more than five active project automations by default; this is a safety
ceiling, not a utilization target.
Each lane must have:
- an owner and stable domain;
- a useful recurrence and a clear lane-level stop condition;
- a resume/idempotency rule that re-reads the latest Issue, `main`, PRs, and
dependencies;
- a known integration owner when it crosses a shared contract;
- focused acceptance and validation evidence.
At every run, skip already-complete work, refresh the Execution Base, select the
highest-priority unblocked work in the lane, advance to a coherent checkpoint,
validate it, and record a handoff. Stop when the lane is complete, blocked by a
specific dependency, enters unresolved scope drift, needs a product/security
decision, or the next item is a distinct review/rollback unit.
## Scheduled task prompt
```text
Repository: CoderLambert/ai-parallel
Issue(s): <numbers or none>
Lane: <stable ownership domain>
Before execution:
- Read AGENTS.md, issue-rule.md, and relevant architecture docs.
- Refresh the latest Issue, decisions, main, PRs, and dependencies.
- Establish the current Execution Base and confirm READY state.
Contract for this lane:
- Target: <relevant outcome>
- Invariants: <relevant boundaries>
- Expected scope: <owner-local scope>
- Sensitive/shared surfaces: <surfaces requiring coordination>
- Acceptance Criteria: <relevant criteria>
- Validation: <required evidence>
- Hard dependencies: <real blockers>
Execution:
1. Skip work that is already complete.
2. Implement only the bounded owner-local outcome.
3. Apply the Scope Drift protocol before expanding a shared contract.
4. Do not duplicate another lane's Provider, message, storage, or security path.
5. Validate and record the standard handoff.
6. Continue only while the next work remains in this lane's safe boundary.
```
Completed lanes should not remain active unless they have a defined monitoring
purpose. Do not create wait-only automation chains or repeatedly retry an
unchanged blocker.
## Parent and sub-agent contract
The parent agent consumes the latest Issue contract, chooses the dispatch mode,
owns scope drift and final integration, and maintains one interpretation of
invariants and acceptance criteria. Sub-agents should be bounded and preferably
read-only.
```text
You are a bounded sub-agent. The Issue contract is authoritative.
Repository: CoderLambert/ai-parallel
Issue/work item: <id>
Execution Base: <sha/ref>
Objective: <one bounded question or outcome>
Expected scope: <files/modules>
Sensitive/shared surfaces: <list>
Acceptance Criteria: <subset>
Validation: <subset>
Output: <evidence, patch, or review result>
Rules:
- Stay inside the assigned scope.
- Report evidence and assumptions separately.
- Stop and report Scope Drift before changing a shared/core contract.
- Do not modify another lane's owned scope.
- Return concise findings to the parent/integration owner.
```
## Handoff and integration
Every handoff should identify Issue, lane, Execution Base, branch/PR, HEAD,
owned scope, changed files, acceptance status, validation, security/permission
delta, risks, blockers, and next action. Do not conflate branch HEAD with the
commit actually validated by CI.
Integration is a deliberate step: refresh the candidate, inspect semantic
conflicts, review manifest/permission and storage changes, run the risk-
appropriate repository/browser checks, then record the merged commit on the
Issue. Closing an Issue or deleting an automation is not implied by a branch
commit alone.
-36
View File
@@ -1,36 +0,0 @@
# AI Parallel v2.1.4
首个公开 GitHub Release,提供可直接解压加载的 Chrome/Edge Manifest V3 扩展包。
## 主要功能
- ChatGPT、DeepSeek、智谱清言、Qwen、Kimi、Claude、Gemini 多面板网页工作区
- Grok 受控顶层标签页模式,保留官网登录状态和 WebSocket 实时连接
- 一次输入,并行发送到已选模型
- Compare Drawer 收集回答并导出 Markdown、JSON 或 `.md` 文件
- Agent Handoff:将比较上下文发送给目标模型
- 本地 Prompt Library
## Grok 兼容性
Grok 的认证页禁止 iframe,且其已登录 WebSocket 在扩展 iframe 中不稳定。此版本不再强行嵌入 Grok,而是复用正常的 `grok.com` 顶层标签页,并通过受限的扩展消息桥执行发送与回答收集。
## 安装
1. 下载 `ai-parallel-browser-extension-v2.1.4.zip`
2. 解压 ZIP。
3. 打开 `chrome://extensions/``edge://extensions/`
4. 开启开发者模式,点击“加载已解压的扩展程序”。
5. 选择解压后的 `ai-parallel-browser-extension` 文件夹。
## 校验
下载同名 `.sha256` 文件后执行:
```bash
sha256sum -c ai-parallel-browser-extension-v2.1.4.zip.sha256
```
## 更新
手动更新时覆盖或重新解压到固定目录,然后在扩展管理页点击“重新加载”。Linux 用户也可以下载本 Release 的 `ai-parallel-sync`,安装到 `~/.local/bin/` 后用同名命令同步后续版本。
@@ -1,61 +0,0 @@
# Authenticated Provider Smoke
The authenticated smoke flow is an opt-in validation path for dedicated
Provider test accounts. It is separate from the default credential-free
Browser Smoke workflow and runs only from `main` through a manually dispatched
GitHub Actions job.
## Protected setup
Create a protected GitHub Environment named `authenticated-provider-smoke` and
require reviewer approval before jobs can start. Add username and password
Secrets for only the dedicated non-personal accounts that will be tested:
```text
AI_PARALLEL_SMOKE_CHATGPT_USERNAME
AI_PARALLEL_SMOKE_CHATGPT_PASSWORD
AI_PARALLEL_SMOKE_DEEPSEEK_USERNAME
AI_PARALLEL_SMOKE_DEEPSEEK_PASSWORD
AI_PARALLEL_SMOKE_ZHIPU_USERNAME
AI_PARALLEL_SMOKE_ZHIPU_PASSWORD
AI_PARALLEL_SMOKE_QWEN_USERNAME
AI_PARALLEL_SMOKE_QWEN_PASSWORD
AI_PARALLEL_SMOKE_KIMI_USERNAME
AI_PARALLEL_SMOKE_KIMI_PASSWORD
AI_PARALLEL_SMOKE_CLAUDE_USERNAME
AI_PARALLEL_SMOKE_CLAUDE_PASSWORD
AI_PARALLEL_SMOKE_GEMINI_USERNAME
AI_PARALLEL_SMOKE_GEMINI_PASSWORD
AI_PARALLEL_SMOKE_GROK_USERNAME
AI_PARALLEL_SMOKE_GROK_PASSWORD
```
The workflow maps these Secrets only to the smoke process. Do not place values
in workflow inputs, command arguments, URLs, repository files, or local shell
history. The selected Provider list is an explicit comma-separated input; the
default is `chatgpt,grok`.
## Run and validation
Use **Actions → Authenticated Browser Smoke → Run workflow** on the `main`
branch. The test launches a fresh temporary persistent Chrome Profile, logs in
to each selected Provider, opens the AI Parallel workspace, selects only those
Providers, sends a fixed health-check Prompt, and collects each visible answer.
Grok additionally must have an allowlisted top-level `grok.com` tab and an
observed `grok.com`/`x.ai` WebSocket connection.
Provider authentication selectors are best-effort test configuration. Accounts
requiring interactive MFA, CAPTCHA, or an unsupported OAuth-only flow must not
be used for this automated path until an explicitly reviewed test strategy is
available.
## Retention boundary
The smoke test never starts Playwright tracing, takes screenshots, writes
`storageState`, or uploads the browser Profile. It removes the temporary Profile
after the run. On failure it writes only provider, stage, status, bounded error
code, and timestamps to `diagnostics.json`; Prompt, response, Cookie, password,
authentication URL, page HTML, and WebSocket URL contents are excluded.
The extension manifest, permissions, runtime message allowlists, and the
credential-free Browser Smoke workflow are unchanged by this validation path.
-33
View File
@@ -1,33 +0,0 @@
# AI Parallel v2.1 Comparison Workspace Plan
## Goal
Upgrade AI Parallel from a multi-model viewer into a multi-model comparison and agent handoff workspace.
## Scope
- Provider adapter architecture
- Collect current assistant responses on demand
- Compare drawer
- Markdown/JSON export
- Agent handoff workflow
- Prompt library foundation
## Architecture
iframe remains responsible for native provider rendering.
Adapter layer handles provider-specific automation and extraction.
Workspace handles orchestration and comparison.
## Implementation status
- [x] Provider adapter architecture
- [x] On-demand response collection contract and bridge message
- [x] Comparison drawer
- [x] Markdown/JSON export
- [x] Basic context builder and target-agent handoff
- [x] Prompt library foundation
- [x] Popup/service-worker runtime path cleanup
- [x] Provider contract and export-format tests
- [x] Grok Web provider integration
- [ ] Live selector validation against authenticated provider pages
-92
View File
@@ -1,92 +0,0 @@
# AI Parallel v2.1 Phase 0 Review
## 1. Current architecture
```text
Workspace extension page
├─ provider selection + panel layout
├─ one iframe per selected iframe-compatible provider
├─ controlled top-level tab for Grok
├─ postMessage(AI_PARALLEL_SEND)
└─ receives FRAME_READY / SEND_RESULT
Provider iframe content scripts (all_frames)
├─ providers/core.js
│ ├─ editor discovery
│ ├─ prompt injection
│ ├─ submit fallback
│ └─ collectResponse / newChat contract
├─ providers/<provider>.js
│ └─ host + provider-specific selectors
└─ frame-bridge.js
└─ validates parent origin and delegates to adapter
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 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
| Risk | Impact | Current observation | Mitigation |
| --- | --- | --- | --- |
| Response collection selector coverage | Medium | Collection is now on demand, but third-party DOM structures can change | Validate selectors in authenticated browser sessions and add provider fixtures |
| Provider DOM selectors are unstable | High | Most providers rely on generic `textarea`, `contenteditable`, or button fallbacks | Keep selectors isolated per adapter; add fallback fixtures and runtime errors |
| Multiple provider definitions drift | High | Provider metadata is duplicated in manifest, service worker, workspace, and content adapters | Introduce a single generated/shared catalog after the adapter contract stabilizes |
| Legacy runtime paths are inconsistent | Resolved | Popup and service worker previously referenced an unused worker-tab/frame-registry protocol | Popup now queues `pendingLaunch`; service worker forwards it to the direct workspace path |
| Limited behavioral test suites | Medium | Provider contract and export-format tests exist; real DOM behavior is not covered in this environment | Add browser-backed provider fixtures when a test browser is available |
| Framing-header bypass is broad | Medium | Rules remove both `X-Frame-Options` and full CSP for matching subframes | Keep URL/resource scoping narrow and verify provider pages after every rule change |
| In-flight request lifecycle | Low | Workspace cancels pending requests when a panel is removed; page unload is still browser-managed | Keep request timeouts bounded and add browser-backed lifecycle coverage |
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. Grok tab-mode
commands are separately restricted by provider and command allowlists.
## 3. v2.1 development plan
1. **Phase 0 — review:** establish the direct iframe path and identify stale
worker-tab/protocol documentation. *(Complete.)*
2. **Phase 1 — adapters:** isolate provider selectors and expose the common
`sendPrompt()`, `collectResponse()`, and `newChat()` contract. *(Complete.)*
3. **Phase 2 — collector:** add on-demand collection for ChatGPT, DeepSeek,
and Qwen and return timestamped response bundles. *(Complete.)*
4. **Phase 3 — comparison drawer:** add comparison state and a drawer without
changing native iframe rendering. *(Complete.)*
5. **Phase 4 — export:** build Markdown, JSON, and downloadable Markdown from
response bundles. *(Complete.)*
6. **Phase 5 — handoff:** build a context builder and target-agent handoff
protocol for ChatGPT, Claude, Gemini, and Grok. *(Complete.)*
7. **Phase 67 — stability and tests:** review MV3/DNR/message lifecycle and
add provider, collector, export, and handoff coverage. *(Runtime cleanup and
contract/export tests complete; live selector validation remains.)*
## 4. Change boundaries
### Phase 1 in scope
- `apps/browser-extension/content/providers/`
- `content/frame-bridge.js` delegation only
- Manifest content-script load order
- Syntax and registration checks
### Deferred
- Live selector validation against authenticated provider pages
- Full response collector fixtures for each provider
- Provider API integrations or credential handling
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.
-184
View File
@@ -1,184 +0,0 @@
# AI Parallel Issue Rules
This document defines how Issues are created, executed, validated, handed off,
and closed. It supplements the long-term repository rules in `AGENTS.md`.
## Source of truth
Use this order when facts disagree:
```text
repository architecture and security rules
latest Issue contract
latest decision or scope-drift record
working direction and historical context
```
The normative Issue contract is its Target, Invariants, Execution Scope,
Acceptance Criteria, Validation, and Dependencies. An implementation hint is
allowed to change when evidence requires it.
## Issue states
```text
DRAFT → READY → IN PROGRESS → DONE
↘ BLOCKED
↘ DEFERRED
```
- `DRAFT`: still being investigated or designed.
- `READY`: target, owner, dependencies, acceptance, and validation are clear.
- `IN PROGRESS`: an agent or developer owns active implementation.
- `BLOCKED`: a specific external dependency, ownership conflict, or decision
prevents safe progress.
- `DEFERRED`: valuable work intentionally postponed.
- `DONE`: the verified change is in `main`; a feature branch alone is not done.
An Open Issue is not automatically READY.
## Minimum Issue contract
Use the smallest useful version of this structure:
```md
## Execution State
DRAFT | READY | IN PROGRESS | BLOCKED | DEFERRED
Observed base: main@<sha>
Execution base: <fresh main or dependency condition>
## Problem
Current user or engineering problem.
## Target
Observable outcome after completion.
## Invariants
- Behavior, compatibility, privacy, security, or validation boundaries.
## Execution Scope
Expected:
- Owner-local files or domains.
Sensitive / coordinate before expanding:
- Shared, cross-domain, permission, security, or contract surfaces.
Out of scope by default:
- Explicit exclusions.
## Acceptance Criteria
- [ ] Observable completion conditions.
## Validation
Focused:
- Most relevant fast checks.
Repository / Regression:
- Affected repository checks.
Browser:
- Extension/browser flow when applicable.
Required CI:
- Required checks, or N/A.
## Dependencies
Hard blocked by:
- Real blockers only.
Conflict / shared surfaces:
- Files, contracts, or domains requiring coordination.
```
Acceptance Criteria describe what must be true. Validation describes the
evidence that proves it; they are not interchangeable.
## Execution preflight
Before implementation:
1. Read `AGENTS.md`, this document, the Issue, related decisions, and related
PRs.
2. Refresh `main`, dependencies, and concurrent work; record the current
Execution Base.
3. Confirm the Issue is READY and identify its owner domain.
4. Confirm invariants, acceptance criteria, validation, and hard dependencies.
5. Identify shared or sensitive surfaces before editing them.
6. Choose one writer, read-only parallel review, or genuinely isolated
parallel writers.
Observed Base records where the problem was found. Execution Base records the
version actually used for implementation; do not use an old observation as a
mechanical branch base.
## Ownership and scope drift
The current domains are Foundation/Build, Extension Runtime, Provider
Adapters, Workspace UI, Popup UI, Messaging, Storage, Security/DNR/Permissions,
Verification/CI, and Release.
Shared hotspots allow one writer per wave:
- provider catalog and public Provider contract;
- messaging protocol and cross-context integration;
- storage schema and migrations;
- manifest, host permissions, CSP, DNR, and release configuration;
- package/lock files and `.github/workflows/**`;
- shared test or browser configuration.
Use this scope-drift protocol:
- Level A: an owner-local implementation adjustment that preserves external
behavior; record it in the PR and continue.
- Level B: a shared, cross-domain, security, permission, or contract change;
pause that portion, record evidence, coordinate the writer/integration owner,
and update the Issue before continuing.
- Level C: a change to product outcome, provider architecture, storage
semantics, security model, or required validation; revise the Issue or split
a prerequisite before implementation.
Zero Git merge conflicts does not prove that parallel changes are semantically
safe.
## Validation and closure
Match validation to risk:
| Change | Expected evidence |
| --- | --- |
| Documentation | references and structure |
| Provider-local adapter | focused contract/fixture checks |
| Workspace or popup UI | focused checks, build, browser smoke when applicable |
| Messaging or storage | consumers, regression, and browser evidence |
| Manifest, DNR, security, or release | generated artifact review and browser/release evidence |
Do not make a check pass by deleting tests, weakening assertions, hiding a
deterministic error behind retries, or expanding permissions without evidence.
Issue comments should record durable state changes using `[PLAN]`, `[DECISION]`,
`[BLOCKED]`, `[HANDOFF]`, or `[CLOSURE]`. A closure comment should include the
merged commit, acceptance result, validation result, scope/security delta, and
follow-ups. Close an Issue only after the verified change is on `main`.
## Standard handoff
```text
STATUS: DONE | PARTIAL | BLOCKED
ISSUE: <number or none>
LANE: <owner domain>
EXECUTION_BASE: <sha/ref>
BRANCH: <branch>
HEAD: <sha>
PR: <number/url or none>
OWNED_SCOPE: <files/modules>
SENSITIVE_SURFACES_TOUCHED: <none or list>
AC_STATUS: <complete or partial with evidence>
VALIDATION: <checks and results>
SECURITY_PERMISSION_DELTA: <none or concise description>
KNOWN_RISKS: <none or concise list>
BLOCKERS: <none or exact blocker>
NEXT: <integration or follow-up action>
```
+2 -4
View File
@@ -1,11 +1,9 @@
{
"name": "ai-parallel",
"private": true,
"version": "2.1.4",
"version": "0.3.0",
"description": "Monorepo for parallel AI workflows across browser, web and desktop clients.",
"scripts": {
"check": "node --check apps/browser-extension/shared/provider-catalog.js && node --check apps/browser-extension/shared/provider-adapter-contract.js && node --check apps/browser-extension/shared/provider-task-runtime.js && node --check apps/browser-extension/shared/agent-execution-controller.js && node --check apps/browser-extension/service-worker.js && node --check apps/browser-extension/popup.js && node --check apps/browser-extension/content/providers/core.js && node --check apps/browser-extension/content/providers/chatgpt.js && node --check apps/browser-extension/content/providers/deepseek.js && node --check apps/browser-extension/content/providers/qwen.js && node --check apps/browser-extension/content/providers/kimi.js && node --check apps/browser-extension/content/providers/zhipu.js && node --check apps/browser-extension/content/providers/claude.js && node --check apps/browser-extension/content/providers/gemini.js && node --check apps/browser-extension/content/providers/grok.js && node --check apps/browser-extension/content/frame-bridge.js && node --check apps/browser-extension/workspace/context-utils.js && node --check apps/browser-extension/workspace/workspace.js && node --check tests/authenticated-browser-smoke.cjs",
"test": "node --test tests/*.test.js",
"package:extension": "bash scripts/package-extension.sh"
"check": "node --check apps/browser-extension/service-worker.js && node --check apps/browser-extension/popup.js && node --check apps/browser-extension/content/frame-bridge.js && node --check apps/browser-extension/workspace/workspace.js"
}
}
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_URL="https://github.com/CoderLambert/ai-parallel.git"
BASE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/ai-parallel"
REPO_DIR="$BASE_DIR/repo"
EXT_DIR="$BASE_DIR/browser-extension"
log() { printf '\033[1;34m[ai-parallel]\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m[✓]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[✗]\033[0m %s\n' "$*" >&2; exit 1; }
command -v git >/dev/null || fail "未找到 git"
command -v node >/dev/null || fail "未找到 node"
command -v npm >/dev/null || fail "未找到 npm"
command -v rsync >/dev/null || fail "未找到 rsync"
mkdir -p "$BASE_DIR"
if [[ ! -d "$REPO_DIR/.git" ]]; then
log "首次安装,克隆仓库..."
git clone "$REPO_URL" "$REPO_DIR"
else
log "检查 GitHub Release..."
if [[ -n "$(git -C "$REPO_DIR" status --porcelain)" ]]; then
fail "同步缓存存在未提交修改:$REPO_DIR"
fi
fi
git -C "$REPO_DIR" fetch origin --tags --prune
LATEST_TAG="$(git -C "$REPO_DIR" tag --list 'v*' --sort=-v:refname | sed -n '1p')"
[[ -n "$LATEST_TAG" ]] || fail "仓库中尚无可用的版本标签"
git -C "$REPO_DIR" checkout --detach "$LATEST_TAG"
ok "使用最新 Release$LATEST_TAG"
log "执行代码检查..."
(cd "$REPO_DIR" && npm run check && npm test)
ok "代码检查通过"
MANIFEST="$REPO_DIR/apps/browser-extension/manifest.json"
[[ -f "$MANIFEST" ]] || fail "找不到 manifest.json"
VERSION="$(node -p "require('$MANIFEST').version")"
log "同步浏览器扩展..."
mkdir -p "$EXT_DIR"
rsync -a --delete --exclude='node_modules' "$REPO_DIR/apps/browser-extension/" "$EXT_DIR/"
COMMIT="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
ok "已同步 AI Parallel v$VERSION ($LATEST_TAG, $COMMIT)"
printf '\n扩展目录:%s\n' "$EXT_DIR"
printf '请在 chrome://extensions/ 或 edge://extensions/ 点击「重新加载」。\n'
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
MANIFEST="$REPO_DIR/apps/browser-extension/manifest.json"
OUTPUT_DIR="${1:-$REPO_DIR/dist}"
command -v git >/dev/null || { echo "git is required" >&2; exit 1; }
command -v node >/dev/null || { echo "node is required" >&2; exit 1; }
command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; }
if ! git -C "$REPO_DIR" diff --quiet -- apps/browser-extension ||
! git -C "$REPO_DIR" diff --cached --quiet -- apps/browser-extension; then
echo "Commit browser-extension changes before packaging" >&2
exit 1
fi
VERSION="$(node -p "require('$MANIFEST').version")"
ARCHIVE_NAME="ai-parallel-browser-extension-v${VERSION}.zip"
ARCHIVE_PATH="$OUTPUT_DIR/$ARCHIVE_NAME"
CHECKSUM_PATH="$ARCHIVE_PATH.sha256"
mkdir -p "$OUTPUT_DIR"
git -C "$REPO_DIR" archive \
--format=zip \
--prefix="ai-parallel-browser-extension/" \
--output="$ARCHIVE_PATH" \
HEAD:apps/browser-extension
(
cd "$OUTPUT_DIR"
sha256sum "$ARCHIVE_NAME" > "$ARCHIVE_NAME.sha256"
)
printf 'Created %s\n' "$ARCHIVE_PATH"
printf 'Created %s\n' "$CHECKSUM_PATH"
-230
View File
@@ -1,230 +0,0 @@
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");
function loadController() {
const context = { console, setTimeout, clearTimeout, AbortController };
context.globalThis = context;
vm.createContext(context);
for (const file of [
"shared/provider-task-runtime.js",
"shared/provider-adapter-contract.js",
"shared/agent-execution-controller.js"
]) {
vm.runInContext(fs.readFileSync(path.join(extensionRoot, file), "utf8"), context, { filename: file });
}
return context;
}
function createAdapter(providerId, sendPrompt, { retry = false } = {}) {
return {
id: providerId,
providerId,
capabilities: { retry },
sendPrompt,
collectResponse() { return Promise.resolve({ ok: true }); },
newChat() { return Promise.resolve({ ok: true }); },
healthCheck() { return Promise.resolve({ ok: true }); }
};
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
test("agent execution runs through provider runtime with bounded parallelism", async () => {
const context = loadController();
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime({
defaultTimeoutMs: 1000,
idFactory: (() => {
let sequence = 0;
return () => `provider-task-${++sequence}`;
})()
});
let active = 0;
let maxActive = 0;
const calls = [];
const adapters = {};
for (const providerId of ["chatgpt", "claude", "gemini", "grok"]) {
adapters[providerId] = createAdapter(providerId, (prompt, adapterContext) => {
active += 1;
maxActive = Math.max(maxActive, active);
calls.push({ providerId, prompt, context: adapterContext });
return new Promise((resolve) => setTimeout(() => {
active -= 1;
resolve({ ok: true, response: { content: `${providerId} result` } });
}, 8));
});
}
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
taskRuntime: runtime,
adapters,
maxConcurrency: 2,
maxAgents: 8,
idFactory: () => "execution-1"
});
const events = [];
controller.subscribe((snapshot) => events.push(snapshot.status));
const promise = controller.run({
taskId: "research-task",
prompt: "Compare the current approaches and identify the strongest result.",
scope: { id: "research-scope", label: "bounded research" },
agents: [
{ providerId: "chatgpt", role: "planner" },
{ providerId: "claude", role: "executor" },
{ providerId: "gemini", role: "executor" },
{ providerId: "grok", role: "reviewer" }
]
});
const result = await promise;
assert.equal(result.status, "SUCCESS");
assert.equal(result.ok, true);
assert.equal(result.strategy, "parallel");
assert.equal(result.scope.id, "research-scope");
assert.equal(result.maxConcurrency, 2);
assert.equal(result.agents.length, 4);
assert.ok(result.agents.every((agent) => agent.status === "SUCCESS"));
assert.equal(result.responses.length, 4);
assert.equal("prompt" in result, false);
assert.equal(maxActive, 2);
assert.equal(calls.length, 4);
assert.ok(calls.every(({ prompt }) => prompt.includes("Compare the current approaches")));
assert.ok(calls.every(({ context: adapterContext }) => adapterContext.executionId === "research-task"));
assert.ok(calls.every(({ context: adapterContext }) => adapterContext.scopeId === "research-scope"));
assert.deepEqual(
[...runtime.listTasks().map((task) => task.operation)].sort(),
["agent-executor", "agent-executor", "agent-planner", "agent-reviewer"]
);
assert.deepEqual(events.slice(0, 3), ["IDLE", "QUEUED", "RUNNING"]);
assert.equal(events.at(-1), "SUCCESS");
});
test("one agent failure is isolated and produces a partial aggregate", async () => {
const context = loadController();
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime({
defaultTimeoutMs: 1000,
idFactory: (() => {
let sequence = 0;
return () => `provider-task-${++sequence}`;
})()
});
const calls = [];
const adapters = {
chatgpt: createAdapter("chatgpt", () => Promise.resolve({
ok: true,
response: { content: "planned" }
})),
claude: createAdapter("claude", () => {
calls.push("failed");
return Promise.resolve({ ok: false, error: "provider unavailable", code: "UNAVAILABLE" });
}),
gemini: createAdapter("gemini", () => {
calls.push("continued");
return Promise.resolve({ ok: true, response: { content: "reviewed" } });
})
};
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
taskRuntime: runtime,
adapters,
maxConcurrency: 1,
idFactory: () => "execution-2"
});
const result = await controller.run({
prompt: "Run each bounded role independently.",
agents: [
{ providerId: "chatgpt", role: "planner" },
{ providerId: "claude", role: "executor" },
{ providerId: "gemini", role: "reviewer" }
]
});
assert.equal(result.status, "PARTIAL");
assert.equal(result.agents.filter((agent) => agent.status === "SUCCESS").length, 2);
assert.equal(result.agents.find((agent) => agent.providerId === "claude").status, "FAILED");
assert.equal(result.agents.find((agent) => agent.providerId === "claude").error.message, "provider unavailable");
assert.equal(result.responses.length, 2);
assert.equal("prompt" in result, false);
assert.deepEqual(calls, ["failed", "continued"]);
assert.equal(runtime.listTasks({ includeFinished: false }).length, 0);
});
test("cancelling an execution cancels active and queued agents", async () => {
const context = loadController();
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime({
defaultTimeoutMs: 1000,
idFactory: () => "provider-task-cancel"
});
let providerStarted;
const providerStartedPromise = new Promise((resolve) => { providerStarted = resolve; });
const adapters = {
chatgpt: createAdapter("chatgpt", (_prompt, { signal }) => new Promise((resolve) => {
providerStarted();
signal.addEventListener("abort", () => resolve({
ok: false,
error: "cancelled",
code: "TASK_CANCELLED"
}), { once: true });
})),
claude: createAdapter("claude", () => Promise.resolve({ ok: true }))
};
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
taskRuntime: runtime,
adapters,
maxConcurrency: 1,
idFactory: () => "execution-3"
});
const promise = controller.run({
prompt: "This execution should be cancelled.",
agents: [
{ providerId: "chatgpt", role: "planner" },
{ providerId: "claude", role: "reviewer" }
]
});
await providerStartedPromise;
assert.equal(promise.cancel("user stopped the run"), true);
const result = await promise;
assert.equal(result.status, "CANCELLED");
assert.equal(result.error, "user stopped the run");
assert.ok(result.agents.every((agent) => agent.status === "CANCELLED"));
assert.equal(controller.cancel("execution-3"), false);
await delay(0);
});
test("agent scope validation enforces bounded roles and agent count", () => {
const context = loadController();
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime();
const adapter = createAdapter("chatgpt", () => Promise.resolve({ ok: true }));
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
taskRuntime: runtime,
adapters: { chatgpt: adapter },
maxAgents: 2
});
assert.throws(() => controller.createExecution({
prompt: "x",
agents: [{ providerId: "chatgpt", role: "scheduler" }]
}), /planner, executor, or reviewer/);
assert.throws(() => controller.createExecution({
prompt: "x",
agents: [
{ providerId: "chatgpt", role: "planner" },
{ providerId: "chatgpt", role: "executor" },
{ providerId: "chatgpt", role: "reviewer" }
]
}), /limited to 2 agents/);
assert.throws(() => controller.createExecution({
strategy: "sequential",
prompt: "x",
agents: [{ providerId: "chatgpt", role: "planner" }]
}), /parallel/);
});
-448
View File
@@ -1,448 +0,0 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension");
const diagnosticsDir = path.join(process.cwd(), "test-results", "authenticated-browser-smoke");
const diagnosticsPath = path.join(diagnosticsDir, "diagnostics.json");
const DEFAULT_PROVIDER_IDS = ["chatgpt", "grok"];
const MAX_PROVIDER_IDS = 8;
const AUTH_TIMEOUT_MS = 45000;
const RESPONSE_TIMEOUT_MS = 120000;
const SMOKE_PROMPT = "AI Parallel authenticated smoke: respond with a short health check.";
const GROK_HOSTS = new Set(["grok.com", "www.grok.com"]);
const COMMON_LOGIN_SELECTORS = [
"a[href*='/login']",
"a[href*='/sign-in']",
"button:has-text('Log in')",
"button:has-text('Sign in')",
"a:has-text('Log in')",
"a:has-text('Sign in')",
"button:has-text('登录')",
"a:has-text('登录')"
];
const COMMON_USERNAME_SELECTORS = [
"input[type='email']",
"input[name='email']",
"input[name='username']",
"input[autocomplete='username']",
"input[autocomplete='email']"
];
const COMMON_PASSWORD_SELECTORS = [
"input[type='password']",
"input[name='password']",
"input[autocomplete='current-password']"
];
const COMMON_SUBMIT_SELECTORS = [
"button[type='submit']",
"button:has-text('Continue')",
"button:has-text('Next')",
"button:has-text('Log in')",
"button:has-text('Sign in')",
"button:has-text('登录')",
"input[type='submit']"
];
const PROVIDERS = Object.freeze({
chatgpt: {
mode: "iframe",
hosts: ["chatgpt.com", "chat.openai.com"],
url: "https://chatgpt.com/",
editorSelectors: [
"#prompt-textarea",
"textarea[data-testid='prompt-textarea']",
"div[contenteditable='true'][data-testid='prompt-textarea']",
"form textarea",
"main textarea"
],
responseSelectors: ["[data-message-author-role='assistant']"]
},
deepseek: {
mode: "iframe",
hosts: ["chat.deepseek.com"],
url: "https://chat.deepseek.com/",
editorSelectors: ["textarea", "div[contenteditable='true']"],
responseSelectors: ["[class*='ds-markdown']", "[class*='message-content']"]
},
zhipu: {
mode: "iframe",
hosts: ["chatglm.cn"],
url: "https://chatglm.cn/",
editorSelectors: ["textarea", "div[contenteditable='true']"],
responseSelectors: ["[class*='markdown']", "[class*='message-content']"]
},
qwen: {
mode: "iframe",
hosts: ["chat.qwen.ai"],
url: "https://chat.qwen.ai/",
editorSelectors: ["textarea", "div[contenteditable='true']"],
responseSelectors: ["[class*='markdown']", "[class*='message-content']"]
},
kimi: {
mode: "iframe",
hosts: ["www.kimi.com", "kimi.com"],
url: "https://www.kimi.com/",
editorSelectors: ["textarea", "div[contenteditable='true']"],
responseSelectors: ["[class*='markdown']", "[class*='message-content']"]
},
claude: {
mode: "iframe",
hosts: ["claude.ai"],
url: "https://claude.ai/new",
editorSelectors: ["div.ProseMirror[contenteditable='true']", "div[contenteditable='true']", "textarea"],
responseSelectors: ["[data-testid='assistant-message']", "[class*='font-claude-response']"]
},
gemini: {
mode: "iframe",
hosts: ["gemini.google.com"],
url: "https://gemini.google.com/app",
editorSelectors: ["rich-textarea div[contenteditable='true']", "div[contenteditable='true']", "textarea"],
responseSelectors: ["message-content", ".markdown-main-panel", "[class*='markdown']"]
},
grok: {
mode: "tab",
hosts: ["grok.com"],
url: "https://grok.com/",
editorSelectors: [
"textarea[aria-label='Ask Grok anything']",
"textarea[placeholder*='Ask']",
"textarea",
"div.ProseMirror[contenteditable='true']",
"div[contenteditable='true'][role='textbox']",
"div[contenteditable='true']"
],
responseSelectors: [
"[data-testid='assistant-message']",
"[data-message-author-role='assistant']",
"[class*='markdown']"
]
}
});
class SmokeFailure extends Error {
constructor(code) {
super(code);
this.name = "SmokeFailure";
this.code = code;
}
}
function parseProviderIds(value = process.env.AI_PARALLEL_SMOKE_PROVIDER_IDS) {
const rawIds = typeof value === "string" && value.trim()
? value.split(",").map((id) => id.trim().toLowerCase()).filter(Boolean)
: DEFAULT_PROVIDER_IDS;
const ids = [...new Set(rawIds)];
if (!ids.length || ids.length > MAX_PROVIDER_IDS || ids.some((id) => !PROVIDERS[id])) {
throw new SmokeFailure("INVALID_PROVIDER_ALLOWLIST");
}
return ids;
}
function credentialsFor(providerId) {
const prefix = `AI_PARALLEL_SMOKE_${providerId.toUpperCase()}`;
const username = process.env[`${prefix}_USERNAME`];
const password = process.env[`${prefix}_PASSWORD`];
if (typeof username !== "string" || !username.trim() || typeof password !== "string" || !password) {
throw new SmokeFailure(`MISSING_${providerId.toUpperCase()}_CREDENTIALS`);
}
return { username, password };
}
function writeDiagnostics(entries) {
fs.mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(diagnosticsPath, JSON.stringify({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
entries
}, null, 2), { encoding: "utf8", mode: 0o600 });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function positiveInteger(value, fallback, maximum) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return fallback;
return Math.min(Math.max(1, Math.floor(number)), maximum);
}
async function waitForVisible(pageOrFrame, selectors, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const selector of selectors) {
try {
const locator = pageOrFrame.locator(selector).first();
if (await locator.isVisible()) return locator;
} catch {
// A provider may replace the document while the login flow is changing pages.
}
}
await sleep(250);
}
return null;
}
async function clickVisible(page, selectors, timeoutMs = 10000) {
const locator = await waitForVisible(page, selectors, timeoutMs);
if (!locator) return false;
await locator.click();
return true;
}
async function pageHasEditor(page, provider) {
return Boolean(await waitForVisible(page, provider.editorSelectors, 5000));
}
async function authenticateProvider(page, provider, credentials) {
try {
await page.goto(provider.url, { waitUntil: "domcontentloaded", timeout: AUTH_TIMEOUT_MS });
if (await pageHasEditor(page, provider)) return;
let usernameInput = await waitForVisible(page, COMMON_USERNAME_SELECTORS, 5000);
let passwordInput = await waitForVisible(page, COMMON_PASSWORD_SELECTORS, 1000);
if (!usernameInput && !passwordInput) {
const clicked = await clickVisible(page, COMMON_LOGIN_SELECTORS, 10000);
if (!clicked) throw new SmokeFailure("AUTH_FORM_NOT_FOUND");
usernameInput = await waitForVisible(page, COMMON_USERNAME_SELECTORS, 20000);
passwordInput = await waitForVisible(page, COMMON_PASSWORD_SELECTORS, 3000);
}
if (!usernameInput) throw new SmokeFailure("AUTH_USERNAME_FIELD_NOT_FOUND");
await usernameInput.fill(credentials.username);
if (!passwordInput) {
await clickVisible(page, COMMON_SUBMIT_SELECTORS, 5000);
passwordInput = await waitForVisible(page, COMMON_PASSWORD_SELECTORS, 20000);
}
if (!passwordInput) throw new SmokeFailure("AUTH_PASSWORD_FIELD_NOT_FOUND");
await passwordInput.fill(credentials.password);
const submitted = await clickVisible(page, COMMON_SUBMIT_SELECTORS, 5000);
if (!submitted) await passwordInput.press("Enter");
await page.waitForLoadState("domcontentloaded", { timeout: AUTH_TIMEOUT_MS }).catch(() => {});
if (!await pageHasEditor(page, provider)) throw new SmokeFailure("AUTH_INCOMPLETE");
} catch (error) {
if (error instanceof SmokeFailure) throw error;
throw new SmokeFailure("AUTH_PROVIDER_ERROR");
} finally {
credentials.username = "";
credentials.password = "";
}
}
function hostMatches(value, hosts) {
try {
return hosts.includes(new URL(value).hostname);
} catch {
return false;
}
}
async function waitForProviderResponse(context, provider, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const page of context.pages()) {
for (const frame of page.frames()) {
if (!hostMatches(frame.url(), provider.hosts)) continue;
for (const selector of provider.responseSelectors) {
try {
const locator = frame.locator(selector).last();
if (!await locator.isVisible()) continue;
if ((await locator.innerText()).trim()) return true;
} catch {
// The response DOM can be replaced while a provider is streaming.
}
}
}
}
await sleep(500);
}
return false;
}
async function configureWorkspace(page, providerIds) {
await page.waitForFunction(() => document.querySelectorAll("#providerBar .provider-chip").length === 8, {
timeout: 20000
});
const selected = new Set(providerIds);
const providerOrder = Object.keys(PROVIDERS);
for (let index = 0; index < providerOrder.length; index += 1) {
const button = page.locator("#providerBar .provider-chip").nth(index);
const expected = selected.has(providerOrder[index]);
if ((await button.getAttribute("data-selected")) === String(expected)) continue;
await button.click();
await page.waitForFunction(({ index: buttonIndex, value }) => (
document.querySelectorAll("#providerBar .provider-chip")[buttonIndex]?.dataset.selected === String(value)
), { index, value: expected }, { timeout: 5000 });
}
await page.waitForFunction((ids) => ids.every((id) => (
document.querySelector(`[data-provider-id="${id}"]`)
)), providerIds, { timeout: 20000 });
}
async function waitForWorkspaceProviders(page, providerIds) {
await page.waitForFunction((ids) => ids.every((id) => {
const panel = document.querySelector(`[data-provider-id="${id}"]`);
return panel?.dataset.ready === "true";
}), providerIds, { timeout: 60000 });
}
async function sendAndCollect(page, context, providerIds) {
await page.locator("#promptInput").fill(SMOKE_PROMPT);
await page.locator("#sendBtn").click();
await page.waitForFunction(() => document.querySelector("#dispatchStatus")?.textContent.includes("已发送"), {
timeout: 60000
});
const dispatchStatus = await page.locator("#dispatchStatus").innerText();
if (dispatchStatus.includes("需要处理")) throw new SmokeFailure("PROMPT_SEND_FAILED");
const responseTimeout = positiveInteger(
process.env.AI_PARALLEL_SMOKE_RESPONSE_TIMEOUT_MS,
RESPONSE_TIMEOUT_MS,
RESPONSE_TIMEOUT_MS
);
for (const providerId of providerIds) {
const provider = PROVIDERS[providerId];
if (!await waitForProviderResponse(context, provider, responseTimeout)) {
throw new SmokeFailure(`RESPONSE_NOT_VISIBLE_${providerId.toUpperCase()}`);
}
}
await page.locator("#compareBtn").click();
await page.waitForFunction((count) => (
document.querySelectorAll("#responseList .response-card").length === count
), providerIds.length, { timeout: 30000 });
const contentCount = await page.locator("#responseList .response-card-content").count();
if (contentCount !== providerIds.length) throw new SmokeFailure("RESPONSE_COLLECTION_INCOMPLETE");
}
async function run() {
const diagnostics = [];
let context;
let userDataDir;
try {
const providerIds = parseProviderIds();
writeDiagnostics(diagnostics);
const recordStage = async (providerId, stage, action) => {
const startedAt = new Date().toISOString();
try {
const result = await action();
diagnostics.push({ providerId, stage, status: "passed", code: null, startedAt, finishedAt: new Date().toISOString() });
writeDiagnostics(diagnostics);
console.log(`[authenticated-smoke] ${providerId} ${stage} passed`);
return result;
} catch (error) {
const code = error instanceof SmokeFailure ? error.code : "UNEXPECTED_FAILURE";
diagnostics.push({ providerId, stage, status: "failed", code, startedAt, finishedAt: new Date().toISOString() });
writeDiagnostics(diagnostics);
console.log(`[authenticated-smoke] ${providerId} ${stage} failed (${code})`);
throw error instanceof SmokeFailure ? error : new SmokeFailure(code);
}
};
userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-parallel-auth-smoke-"));
context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
`--disable-extensions-except=${extensionRoot}`,
`--load-extension=${extensionRoot}`
]
});
let grokWebSocketCount = 0;
const observePage = (page) => {
page.on("websocket", (webSocket) => {
try {
const hostname = new URL(webSocket.url()).hostname;
if (hostname === "grok.com" || hostname.endsWith(".grok.com") || hostname.endsWith(".x.ai")) {
grokWebSocketCount += 1;
}
} catch {
// WebSocket URLs are never included in diagnostics.
}
});
};
context.pages().forEach(observePage);
context.on("page", observePage);
let serviceWorker = context.serviceWorkers()[0];
if (!serviceWorker) serviceWorker = await context.waitForEvent("serviceworker", { timeout: 20000 });
const extensionId = new URL(serviceWorker.url()).hostname;
for (const providerId of providerIds) {
const provider = PROVIDERS[providerId];
let credentials;
try {
credentials = await recordStage(providerId, "credentials", () => credentialsFor(providerId));
const providerPage = await context.newPage();
try {
await recordStage(providerId, "authentication", () => authenticateProvider(providerPage, provider, credentials));
} finally {
if (providerId !== "grok") await providerPage.close().catch(() => {});
}
} finally {
if (credentials) {
credentials.username = "";
credentials.password = "";
}
}
}
const workspace = await context.newPage();
await recordStage("workspace", "extension-load", async () => {
await workspace.goto(`chrome-extension://${extensionId}/workspace/index.html`, {
waitUntil: "domcontentloaded",
timeout: 30000
});
await workspace.waitForFunction(() => document.querySelectorAll("#providerBar .provider-chip").length === 8, {
timeout: 20000
});
});
await recordStage("workspace", "provider-selection", () => configureWorkspace(workspace, providerIds));
await recordStage("workspace", "provider-ready", () => waitForWorkspaceProviders(workspace, providerIds));
await recordStage("workspace", "prompt-send-and-response-collect", () => sendAndCollect(workspace, context, providerIds));
if (providerIds.includes("grok")) {
await recordStage("grok", "top-level-tab", async () => {
const page = context.pages().find((candidate) => {
try { return GROK_HOSTS.has(new URL(candidate.url()).hostname); } catch { return false; }
});
if (!page) throw new SmokeFailure("GROK_TAB_NOT_OPEN");
await page.bringToFront();
});
await recordStage("grok", "websocket-observed", async () => {
if (!grokWebSocketCount) throw new SmokeFailure("GROK_WEBSOCKET_NOT_OBSERVED");
});
}
console.log(`[authenticated-smoke] passed for ${providerIds.join(",")}`);
} catch (error) {
if (!diagnostics.some((entry) => entry.status === "failed")) {
diagnostics.push({
providerId: "run",
stage: "setup",
status: "failed",
code: error instanceof SmokeFailure ? error.code : "UNEXPECTED_FAILURE",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString()
});
writeDiagnostics(diagnostics);
}
console.error("[authenticated-smoke] failed; inspect sanitized diagnostics artifact");
process.exitCode = 1;
} finally {
if (context) await context.close().catch(() => {});
if (userDataDir) fs.rmSync(userDataDir, { recursive: true, force: true });
writeDiagnostics(diagnostics);
}
}
if (require.main === module) run();
module.exports = Object.freeze({
PROVIDERS,
parseProviderIds,
credentialsFor
});
-81
View File
@@ -1,81 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension");
const diagnosticsDir = path.join(process.cwd(), "test-results", "browser-smoke");
async function run() {
fs.mkdirSync(diagnosticsDir, { recursive: true });
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-parallel-browser-smoke-"));
let context;
let page;
try {
context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
`--disable-extensions-except=${extensionRoot}`,
`--load-extension=${extensionRoot}`
]
});
await context.tracing.start({ screenshots: true, snapshots: true });
// Keep the smoke test deterministic and credential-free. Provider pages are
// intentionally not exercised here; live authenticated checks belong in a
// separately isolated environment.
await context.route("https://**/*", (route) => route.abort());
let serviceWorker = context.serviceWorkers()[0];
if (!serviceWorker) {
serviceWorker = await context.waitForEvent("serviceworker", { timeout: 15000 });
}
const extensionId = new URL(serviceWorker.url()).hostname;
page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/workspace/index.html`, {
waitUntil: "domcontentloaded"
});
await page.waitForFunction(() => document.querySelectorAll("#providerBar .provider-chip").length === 8);
assert.equal(await page.locator("#providerBar .provider-chip").count(), 8);
assert.equal(await page.locator("#sendBtn").isDisabled(), true);
await page.locator("#promptInput").fill("CI browser smoke prompt");
assert.equal(await page.locator("#sendBtn").isDisabled(), false);
await page.locator("#sessionBtn").click();
await page.locator("#sessionTitleInput").fill("CI smoke session");
await page.locator("#saveSessionBtn").click();
await page.locator("#sessionList .prompt-card").waitFor({ state: "visible" });
assert.match(await page.locator("#sessionList").innerText(), /CI smoke session/);
await page.locator("#sessionList .prompt-card").first().locator("button").first().click();
assert.equal(await page.locator("#sessionDrawer").getAttribute("aria-hidden"), "true");
assert.equal(await page.locator("#promptInput").inputValue(), "CI browser smoke prompt");
await page.locator("#sessionBtn").click();
await page.locator("#sessionList .prompt-card").first().locator("button").nth(1).click();
await page.locator("#sessionList .prompt-empty").waitFor({ state: "visible" });
await page.locator("#compareBtn").click();
assert.equal(await page.locator("#compareDrawer").getAttribute("aria-hidden"), "false");
await page.locator("#sendAgentBtn").click();
await page.locator("#compareStatus").waitFor({ state: "visible" });
assert.match(await page.locator("#compareStatus").innerText(), /请先点击 Compare 收集至少一个回答/);
} finally {
if (context) {
try {
await context.tracing.stop({ path: path.join(diagnosticsDir, "trace.zip") });
} catch {
// Preserve the original browser/assertion failure.
}
await context.close();
}
}
}
run().catch(async (error) => {
console.error(error);
process.exitCode = 1;
});
-42
View File
@@ -1,42 +0,0 @@
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 file = path.join(__dirname, "..", "apps", "browser-extension", "workspace", "context-utils.js");
function loadUtils() {
const context = {};
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(file, "utf8"), context, { filename: file });
return context.AIParallelWorkspaceUtils;
}
test("context utilities produce the documented export formats", () => {
const utils = loadUtils();
const providers = [
{ id: "chatgpt", name: "ChatGPT" },
{ id: "deepseek", name: "DeepSeek" }
];
const responses = new Map([
["chatgpt", { ok: true, response: { provider: "chatgpt", content: "Answer A", markdown: "**Answer A**", timestamp: "2026-09-14T00:00:00.000Z" } }],
["deepseek", { ok: false, error: "未找到回答" }]
]);
const markdown = utils.buildComparisonMarkdown("What?", providers, responses);
assert.match(markdown, /^# AI Parallel Context/m);
assert.match(markdown, /## ChatGPT/);
assert.match(markdown, /\*\*Answer A\*\*/);
assert.match(markdown, /## DeepSeek/);
const json = JSON.parse(utils.buildComparisonJson("What?", providers, responses));
assert.equal(json.question, "What?");
assert.equal(json.responses[0].provider, "chatgpt");
assert.equal(json.responses[1].error, "未找到回答");
const handoff = utils.buildHandoffPrompt("What?", providers, responses);
assert.match(handoff, /target agent/);
assert.match(handoff, /Answer A/);
});
-65
View File
@@ -1,65 +0,0 @@
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");
function loadAdapters() {
const context = { console, setTimeout, clearTimeout, URL };
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(extensionRoot, "shared/provider-catalog.js"), "utf8"), context, {
filename: "shared/provider-catalog.js"
});
for (const file of [
"content/providers/core.js",
"content/providers/chatgpt.js",
"content/providers/deepseek.js",
"content/providers/qwen.js",
"content/providers/kimi.js",
"content/providers/zhipu.js",
"content/providers/claude.js",
"content/providers/gemini.js",
"content/providers/grok.js"
]) {
vm.runInContext(fs.readFileSync(path.join(extensionRoot, file), "utf8"), context, { filename: file });
}
return {
adapters: context.AIParallelProviderAdapters,
catalog: context.AIParallelProviderCatalog
};
}
test("all supported providers expose the adapter contract", () => {
const { adapters } = loadAdapters();
const ids = ["chatgpt", "deepseek", "qwen", "kimi", "zhipu", "claude", "gemini", "grok"];
assert.deepEqual(Object.keys(adapters).sort(), ids.slice().sort());
for (const id of ids) {
assert.equal(adapters[id].id, id);
assert.ok(adapters[id].hosts.length);
assert.ok(adapters[id].editorSelectors.length);
assert.ok(adapters[id].sendSelectors.length);
assert.equal(typeof adapters[id].sendPrompt, "function");
assert.equal(typeof adapters[id].collectResponse, "function");
assert.equal(typeof adapters[id].newChat, "function");
assert.equal(typeof adapters[id].healthCheck, "function");
}
});
test("provider adapters use the shared provider host catalog", () => {
const { adapters, catalog } = loadAdapters();
for (const provider of catalog) {
assert.deepEqual(adapters[provider.id].hosts, provider.hosts, provider.id);
}
});
test("Grok recognizes iframe-only authentication routes", () => {
const grok = loadAdapters().adapters.grok;
assert.equal(grok.isExternalAuthUrl("https://accounts.x.ai/check-login?redirect=grok-com"), true);
assert.equal(grok.isExternalAuthUrl("https://grok.com/sign-in?return_to=%2F"), true);
assert.equal(grok.isExternalAuthUrl("/login"), true);
assert.equal(grok.isExternalAuthUrl("https://grok.com/"), false);
assert.equal(grok.isExternalAuthUrl("https://example.com/sign-in"), false);
});
-94
View File
@@ -1,94 +0,0 @@
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");
function loadContract() {
const context = {};
context.globalThis = context;
vm.createContext(context);
vm.runInContext(
fs.readFileSync(path.join(extensionRoot, "shared", "provider-adapter-contract.js"), "utf8"),
context,
{ filename: "shared/provider-adapter-contract.js" }
);
vm.runInContext(
fs.readFileSync(path.join(extensionRoot, "shared", "provider-catalog.js"), "utf8"),
context,
{ filename: "shared/provider-catalog.js" }
);
return {
contract: context.AIParallelProviderAdapterContract,
catalog: context.AIParallelProviderCatalog
};
}
test("provider adapter contract exposes the required capability methods", () => {
const { contract } = loadContract();
assert.equal(contract.CONTRACT_VERSION, "provider-adapter-v1");
assert.deepEqual([...contract.REQUIRED_METHODS], [
"sendPrompt",
"collectResponse",
"newChat",
"healthCheck"
]);
});
test("catalog adapters route every operation through the shared transport", async () => {
const { contract, catalog } = loadContract();
const calls = [];
const transport = {
request(provider, operation, payload, context) {
calls.push({ providerId: provider.id, operation, payload, context });
return Promise.resolve({ ok: true, operation });
},
healthCheck(provider, context) {
calls.push({ providerId: provider.id, operation: "healthCheck", context });
return Promise.resolve({ ok: true, providerId: provider.id });
}
};
for (const provider of catalog) {
const adapter = contract.createProviderAdapter({ provider, transport });
assert.equal(adapter.id, provider.adapter);
assert.equal(adapter.providerId, provider.id);
assert.equal(contract.validateProviderAdapter(adapter), true);
await adapter.sendPrompt(" hello ", { attempt: 1 });
await adapter.collectResponse({ attempt: 1 });
await adapter.newChat({ attempt: 1 });
await adapter.healthCheck({ attempt: 1 });
}
assert.equal(calls.length, catalog.length * 4);
assert.equal(calls.filter(({ operation }) => operation === "AI_PARALLEL_SEND").length, catalog.length);
assert.equal(calls.filter(({ operation }) => operation === "AI_PARALLEL_COLLECT_RESPONSE").length, catalog.length);
assert.equal(calls.filter(({ operation }) => operation === "AI_PARALLEL_NEW_CHAT").length, catalog.length);
assert.equal(calls.filter(({ operation }) => operation === "healthCheck").length, catalog.length);
assert.equal(calls[0].payload.prompt, "hello");
});
test("provider adapter rejects an empty prompt without invoking transport", async () => {
const { contract, catalog } = loadContract();
let called = false;
const adapter = contract.createProviderAdapter({
provider: catalog[0],
transport: {
request() {
called = true;
return Promise.resolve({ ok: true });
},
healthCheck() {
return Promise.resolve({ ok: true });
}
}
});
const result = await adapter.sendPrompt(" \n ");
assert.equal(result.ok, false);
assert.equal(result.code, "INVALID_PROMPT");
assert.equal(called, false);
});
-75
View File
@@ -1,75 +0,0 @@
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");
function loadCatalog() {
const context = { URL };
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(extensionRoot, "shared", "provider-catalog.js"), "utf8"), context, {
filename: "shared/provider-catalog.js"
});
return context.AIParallelProviderCatalog;
}
test("shared provider catalog matches manifest host coverage", () => {
const catalog = loadCatalog();
const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "manifest.json"), "utf8"));
const permissionHosts = new Set(manifest.host_permissions);
const contentScriptMatches = new Set(manifest.content_scripts[0].matches);
assert.deepEqual(Array.from(catalog, (provider) => provider.id), [
"chatgpt",
"deepseek",
"zhipu",
"qwen",
"kimi",
"claude",
"gemini",
"grok"
]);
for (const provider of catalog) {
for (const host of provider.hosts) {
const match = `https://${host}/*`;
assert.ok(permissionHosts.has(match), `${provider.id} missing host permission ${match}`);
assert.ok(contentScriptMatches.has(match), `${provider.id} missing content-script match ${match}`);
}
assert.equal(new URL(provider.url).origin, provider.origins[0]);
assert.equal(provider.adapter, provider.id);
assert.equal(provider.adapterType, "dom");
assert.equal(provider.adapterContract, "provider-adapter-v1");
assert.equal(provider.capabilities.send, true);
assert.equal(provider.capabilities.collect, true);
assert.equal(provider.capabilities.newChat, true);
assert.equal(provider.capabilities.retry, true);
assert.equal(provider.capabilities.streaming, false);
assert.equal(provider.capabilities.timeout, true);
assert.equal(provider.capabilities.cancel, true);
}
});
test("shared provider catalog protects metadata arrays from mutation", () => {
const catalog = loadCatalog();
assert.equal(Object.isFrozen(catalog), true);
assert.equal(Object.isFrozen(catalog[0]), true);
assert.equal(Object.isFrozen(catalog[0].hosts), true);
assert.equal(Object.isFrozen(catalog[0].origins), true);
assert.equal(Object.isFrozen(catalog[0].capabilities), true);
});
test("extension entry points load the catalog before consuming it", () => {
const popupHtml = fs.readFileSync(path.join(extensionRoot, "popup.html"), "utf8");
const workspaceHtml = fs.readFileSync(path.join(extensionRoot, "workspace", "index.html"), "utf8");
const serviceWorker = fs.readFileSync(path.join(extensionRoot, "service-worker.js"), "utf8");
assert.match(popupHtml, /shared\/provider-catalog\.js[\s\S]*popup\.js/);
assert.match(workspaceHtml, /\.\.\/shared\/provider-catalog\.js[\s\S]*workspace\.js/);
assert.match(workspaceHtml, /\.\.\/shared\/provider-adapter-contract\.js[\s\S]*workspace\.js/);
assert.match(workspaceHtml, /\.\.\/shared\/provider-task-runtime\.js[\s\S]*workspace\.js/);
assert.match(serviceWorker, /^importScripts\("shared\/provider-catalog\.js"\);/);
});
-204
View File
@@ -1,204 +0,0 @@
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 corePath = path.join(__dirname, "..", "apps", "browser-extension", "content", "providers", "core.js");
class FakeElement {
constructor({ parent = null, onClick = null } = {}) {
this.parentElement = parent;
this.onClick = onClick;
this.disabled = false;
this.readOnly = false;
this.attributes = {};
this.style = {};
this.connected = true;
this.clickCount = 0;
this.events = [];
this.queryMap = new Map();
}
get isConnected() {
return this.connected;
}
get textContent() {
return this._textContent || "";
}
set textContent(value) {
this._textContent = String(value);
}
getAttribute(name) {
return this.attributes[name] ?? null;
}
closest(selector) {
if (selector === "form") return this.form || null;
return null;
}
querySelectorAll(selector) {
return this.queryMap.get(selector) || [];
}
getBoundingClientRect() {
return { width: 100, height: 20 };
}
compareDocumentPosition() {
return 0;
}
focus() {}
click() {
this.clickCount += 1;
this.onClick?.();
}
dispatchEvent(event) {
this.events.push(event.type);
return true;
}
}
class FakeTextArea extends FakeElement {
constructor(options) {
super(options);
this._value = "";
}
get value() {
return this._value;
}
set value(value) {
this._value = String(value);
}
}
function loadCore({ withButton = true, confirmOn = "button" } = {}) {
const body = new FakeElement();
const form = new FakeElement();
const editor = new FakeTextArea({ parent: form });
editor.form = form;
form.form = form;
const button = withButton ? new FakeElement({ parent: form }) : null;
if (button) {
button.attributes.type = "submit";
button.onClick = () => {
if (confirmOn === "button") {
editor.value = "";
button.disabled = true;
}
};
}
let requestSubmitCount = 0;
form.requestSubmit = () => {
requestSubmitCount += 1;
if (confirmOn === "form") {
editor.value = "";
if (button) button.disabled = true;
}
};
form.queryMap.set("button", button ? [button] : []);
form.queryMap.set("button[type='submit']", button ? [button] : []);
body.queryMap.set("button[type='submit']", [new FakeElement({ parent: body })]);
const document = {
body,
querySelectorAll(selector) {
if (selector === "textarea") return [editor];
if (selector === "button[type='submit']") return body.querySelectorAll(selector);
return [];
},
createRange() {
return { selectNodeContents() {} };
}
};
const context = {
console,
document,
Element: FakeElement,
HTMLTextAreaElement: FakeTextArea,
HTMLInputElement: class extends FakeElement {},
InputEvent: class { constructor(type) { this.type = type; } },
Event: class { constructor(type) { this.type = type; } },
KeyboardEvent: class { constructor(type) { this.type = type; } },
getComputedStyle: () => ({ display: "block", visibility: "visible", opacity: "1" }),
setTimeout,
clearTimeout
};
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(corePath, "utf8"), context, { filename: corePath });
return {
core: context.AIParallelProviderCore,
editor,
button,
form,
get requestSubmitCount() { return requestSubmitCount; }
};
}
test("provider submission scopes the send button and confirms the UI transition", async () => {
const fixture = loadCore({ withButton: true, confirmOn: "button" });
const adapter = fixture.core.createProviderAdapter({
id: "fixture",
hosts: ["fixture.test"],
editorSelectors: ["textarea"],
sendSelectors: ["button[type='submit']"],
responseSelectors: [],
submitConfirmationTimeoutMs: 50
});
const result = await adapter.sendPrompt("hello provider");
assert.equal(result, true);
assert.equal(fixture.button.clickCount, 1);
assert.equal(fixture.form.querySelectorAll("button[type='submit']").length, 1);
assert.equal(fixture.editor.value, "");
});
test("provider submission uses native form submission before synthetic Enter", async () => {
const fixture = loadCore({ withButton: false, confirmOn: "form" });
const adapter = fixture.core.createProviderAdapter({
id: "fixture",
hosts: ["fixture.test"],
editorSelectors: ["textarea"],
sendSelectors: [],
responseSelectors: [],
submitConfirmationTimeoutMs: 50
});
await adapter.sendPrompt("submit through form");
assert.equal(fixture.requestSubmitCount, 1);
assert.equal(fixture.editor.value, "");
});
test("provider submission reports deterministic failure when no UI confirmation occurs", async () => {
const fixture = loadCore({ withButton: true, confirmOn: "never" });
const adapter = fixture.core.createProviderAdapter({
id: "fixture",
hosts: ["fixture.test"],
editorSelectors: ["textarea"],
sendSelectors: ["button[type='submit']"],
responseSelectors: [],
sendReadyTimeoutMs: 50,
submitConfirmationTimeoutMs: 20
});
await assert.rejects(
adapter.sendPrompt("never submitted"),
/无法确认 Prompt 已发送/
);
assert.equal(fixture.button.clickCount, 1);
assert.equal(fixture.requestSubmitCount, 1);
assert.equal(fixture.editor.value, "never submitted");
});
-165
View File
@@ -1,165 +0,0 @@
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 file = path.join(
__dirname,
"..",
"apps",
"browser-extension",
"shared",
"provider-task-runtime.js"
);
function loadRuntime() {
const context = { console, setTimeout, clearTimeout, AbortController, Date };
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(file, "utf8"), context, { filename: file });
return context.AIParallelProviderTaskRuntime;
}
function createRuntime(api, options = {}) {
let sequence = 0;
return new api.ProviderTaskRuntime({
idFactory: () => `task-${++sequence}`,
...options
});
}
test("provider tasks expose the complete lifecycle and response", async () => {
const api = loadRuntime();
const runtime = createRuntime(api);
const states = [];
runtime.subscribe((task) => states.push(task.status));
const task = runtime.createTask({
providerId: "chatgpt",
operation: "AI_PARALLEL_SEND",
execute: async () => ({ ok: true, response: "sent" })
});
assert.equal(task.status, api.STATUS.IDLE);
const result = await task.run();
assert.deepEqual(result, { ok: true, response: "sent" });
assert.deepEqual(states, ["IDLE", "QUEUED", "RUNNING", "SUCCESS"]);
assert.equal(task.status, api.STATUS.SUCCESS);
assert.equal(task.attempt, 1);
assert.equal(task.error, null);
assert.equal(runtime.getTask(task.id).response.response, "sent");
});
test("retry is bounded and records the retry reason", async () => {
const api = loadRuntime();
const runtime = createRuntime(api);
let attempts = 0;
const task = runtime.createTask({
providerId: "deepseek",
operation: "AI_PARALLEL_COLLECT_RESPONSE",
maxAttempts: 2,
retryOn: (error) => error.retryable === true,
execute: async () => {
attempts += 1;
if (attempts === 1) {
const error = new Error("temporary transport failure");
error.retryable = true;
error.code = "TRANSPORT_ERROR";
throw error;
}
return { ok: true, response: "collected" };
}
});
const result = await task.run();
assert.equal(attempts, 2);
assert.equal(result.response, "collected");
assert.equal(task.status, api.STATUS.SUCCESS);
assert.equal(task.attempt, 2);
assert.equal(task.retryReasons.length, 1);
assert.equal(task.retryReasons[0].attempt, 1);
assert.equal(task.retryReasons[0].reason, "temporary transport failure");
assert.equal(task.retryReasons[0].code, "TRANSPORT_ERROR");
assert.equal(typeof task.retryReasons[0].timestamp, "number");
});
test("deterministic provider failures do not retry or hide the failure", async () => {
const api = loadRuntime();
const runtime = createRuntime(api);
let attempts = 0;
const result = await runtime.run({
providerId: "qwen",
operation: "AI_PARALLEL_SEND",
maxAttempts: 3,
execute: async () => {
attempts += 1;
return { ok: false, error: "未找到输入框", retryable: false };
}
});
assert.equal(attempts, 1);
assert.equal(result.ok, false);
assert.equal(result.status, api.STATUS.FAILED);
assert.equal(result.error, "未找到输入框");
assert.equal(result.attempt, 1);
assert.equal(result.retryReasons.length, 0);
});
test("timeout is observable and aborts the attempt", async () => {
const api = loadRuntime();
const runtime = createRuntime(api);
let aborted = false;
const task = runtime.createTask({
providerId: "grok",
operation: "AI_PARALLEL_COLLECT_RESPONSE",
timeoutMs: 10,
execute: ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
})
});
const result = await task.run();
assert.equal(aborted, true);
assert.equal(task.status, api.STATUS.TIMEOUT);
assert.equal(runtime.getTask(task.id).status, api.STATUS.TIMEOUT);
assert.equal(result.status, api.STATUS.TIMEOUT);
});
test("cancellation settles only the cancelled task", async () => {
const api = loadRuntime();
const runtime = createRuntime(api);
let cancelledSignal;
const cancelledTask = runtime.createTask({
providerId: "kimi",
execute: ({ signal }) => new Promise((_resolve, reject) => {
cancelledSignal = signal;
signal.addEventListener("abort", () => reject(new Error("aborted")));
})
});
const successfulTask = runtime.createTask({
providerId: "claude",
execute: async () => "independent result"
});
const cancelledPromise = cancelledTask.run();
const successfulPromise = successfulTask.run();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(cancelledTask.cancel("user stopped"), true);
const [cancelledResult, successfulResult] = await Promise.all([cancelledPromise, successfulPromise]);
assert.equal(cancelledSignal.aborted, true);
assert.equal(cancelledResult.status, api.STATUS.CANCELLED);
assert.equal(cancelledTask.error, "user stopped");
assert.equal(successfulResult, "independent result");
assert.equal(successfulTask.status, api.STATUS.SUCCESS);
});
-106
View File
@@ -1,106 +0,0 @@
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");
function loadServiceWorker() {
let messageListener;
const openedUrls = [];
const sentMessages = [];
const context = {
console,
setTimeout,
URL,
chrome: {
action: { onClicked: { addListener() {} } },
runtime: {
getURL: (value) => `chrome-extension://test/${value}`,
onMessage: { addListener(listener) { messageListener = listener; } }
},
storage: { local: { async get() { return {}; } } },
tabs: {
async create({ url }) {
openedUrls.push(url);
return { id: openedUrls.length, url };
},
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() {} }
}
};
context.importScripts = (...files) => {
for (const file of files) {
vm.runInContext(
fs.readFileSync(path.join(__dirname, "..", "apps", "browser-extension", file), "utf8"),
context,
{ filename: file }
);
}
};
vm.createContext(context);
const source = fs.readFileSync(
path.join(__dirname, "..", "apps", "browser-extension", "service-worker.js"),
"utf8"
);
vm.runInContext(source, context, { filename: "service-worker.js" });
return { messageListener, openedUrls, sentMessages };
}
function sendMessage(listener, message) {
return new Promise((resolve) => {
assert.equal(listener(message, {}, resolve), true);
});
}
test("Grok authentication only opens allowlisted HTTPS URLs", async () => {
const { messageListener, openedUrls } = loadServiceWorker();
const allowed = await sendMessage(messageListener, {
type: "OPEN_PROVIDER_AUTH",
providerId: "grok",
url: "https://accounts.x.ai/sign-in?returnTo=https%3A%2F%2Fgrok.com%2F"
});
assert.equal(allowed.ok, true);
assert.deepEqual(openedUrls, ["https://accounts.x.ai/sign-in?returnTo=https%3A%2F%2Fgrok.com%2F"]);
const wrongHost = await sendMessage(messageListener, {
type: "OPEN_PROVIDER_AUTH",
providerId: "grok",
url: "https://example.com/sign-in"
});
assert.equal(wrongHost.ok, false);
const insecure = await sendMessage(messageListener, {
type: "OPEN_PROVIDER_AUTH",
providerId: "grok",
url: "http://accounts.x.ai/sign-in"
});
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");
});
-52
View File
@@ -1,52 +0,0 @@
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);
});