Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf54805d60 | ||
|
|
869efc4a04 | ||
|
|
eaec144ee7 | ||
|
|
ec998005ac | ||
|
|
05493ead7d | ||
|
|
01d656e585 | ||
|
|
3c3b7200cf | ||
|
|
c6194cc311 | ||
|
|
909e7dcf92 | ||
|
|
51a3112deb | ||
|
|
130ae98c80 | ||
|
|
501d9467c3 | ||
|
|
4510833ca8 | ||
|
|
a8bba86b33 | ||
|
|
536e657506 | ||
|
|
bf7a1e8d25 | ||
|
|
067ad77b28 | ||
|
|
19854a03b2 | ||
|
|
c16d800338 | ||
|
|
fb744176d2 | ||
|
|
b7ec7ef794 |
@@ -0,0 +1,71 @@
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
@@ -21,3 +21,11 @@ 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
|
||||
|
||||
@@ -13,3 +13,4 @@ coverage/
|
||||
|
||||
# local scratch/artifacts
|
||||
*.zip
|
||||
test-results/
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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
|
||||
@@ -1,83 +1,157 @@
|
||||
# AI Parallel
|
||||
|
||||
AI Parallel is a monorepo for comparing and orchestrating multiple AI web experiences from one workspace.
|
||||
AI Parallel 是一个 Chrome / Edge Manifest V3 扩展:在一个工作区中打开多个 AI 网页,一次输入,并行发送,并按需收集、比较和导出回答。
|
||||
|
||||
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.
|
||||
> No API keys. No proxy server. AI Parallel reuses your existing provider web sessions locally in the browser.
|
||||
|
||||
## Current app
|
||||
## 功能
|
||||
|
||||
```text
|
||||
apps/browser-extension
|
||||
- 多模型网页工作区: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
|
||||
```
|
||||
|
||||
Workspace v1 supports:
|
||||
## 首次使用
|
||||
|
||||
- ChatGPT
|
||||
- DeepSeek
|
||||
- 智谱清言
|
||||
- Qwen
|
||||
- Kimi
|
||||
- Claude (best-effort response adapter)
|
||||
- Gemini (best-effort response adapter)
|
||||
1. 先在各 Provider 官网完成登录。
|
||||
2. 点击 AI Parallel 图标打开工作区。
|
||||
3. 在顶部选择 Provider。
|
||||
4. 在底部输入 Prompt,按 `Ctrl+Enter` / `Cmd+Enter` 或点击“发送”。
|
||||
5. 等待原站生成回答,点击 `Compare` 收集结果。
|
||||
|
||||
The default comparison set is ChatGPT, DeepSeek, 智谱清言, Qwen and Kimi.
|
||||
Grok 第一次参与发送时会打开或复用官网标签页。扩展升级后,如果旧 Grok 标签页没有新版桥接脚本,AI Parallel 会自动刷新该标签页一次。
|
||||
|
||||
## Workspace architecture
|
||||
## 更新
|
||||
|
||||
### 手动更新
|
||||
|
||||
下载新 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
|
||||
```
|
||||
|
||||
首次运行会把扩展同步到:
|
||||
|
||||
```text
|
||||
~/.local/share/ai-parallel/browser-extension
|
||||
```
|
||||
|
||||
第一次在浏览器中加载这个固定目录。以后只需运行 `ai-parallel-sync`,再到扩展管理页点击“重新加载”。命令默认同步最新 `v*` Release 标签,需要 `git`、`node`、`npm` 和 `rsync`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `accounts.x.ai refused to connect`
|
||||
|
||||
不要在 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/`。
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
AI Parallel Workspace
|
||||
│
|
||||
│ 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
|
||||
├─ iframe Providers ─ postMessage ─ Provider Adapter
|
||||
└─ Grok top-level tab ─ service worker ─ tabs.sendMessage ─ Grok Adapter
|
||||
```
|
||||
|
||||
Prompts are not transported in destination URLs. Worker pages receive one-time jobs through extension messaging/session storage.
|
||||
Provider Adapter 统一提供 `sendPrompt()`、`collectResponse()` 和 `newChat()`。详细设计见 [docs/architecture.md](docs/architecture.md)。第三方架构参考和署名见 [ATTRIBUTION.md](ATTRIBUTION.md)。
|
||||
|
||||
## Repository structure
|
||||
## 项目状态
|
||||
|
||||
```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.
|
||||
AI Web UI 不是稳定 API,Provider DOM 或安全策略变化可能导致 Adapter 暂时失效。欢迎通过 GitHub Issues 提交复现步骤、页面截图和控制台错误。
|
||||
|
||||
@@ -6,28 +6,59 @@ 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
|
||||
├─ Gemini iframe
|
||||
└─ Grok controlled top-level tab
|
||||
│
|
||||
└─ content/frame-bridge.js handles prompt injection + submit
|
||||
├─ content/providers/core.js shared DOM operations
|
||||
├─ content/providers/<provider>.js provider selectors
|
||||
└─ content/frame-bridge.js message boundary + adapter delegation
|
||||
```
|
||||
|
||||
`rules/bypass-headers.json` removes `X-Frame-Options` and framing CSP headers only for matching `sub_frame` responses so the original provider pages can render inside the extension workspace.
|
||||
`rules/bypass-headers.json` removes `X-Frame-Options` and framing CSP headers only for matching `sub_frame` responses so compatible provider pages can render inside the extension workspace. Grok runs in a controlled top-level tab because its authenticated WebSocket channel does not work reliably inside an extension iframe.
|
||||
|
||||
Model output is not scraped or re-rendered. You see the original provider page directly, so there is no response-mirroring latency.
|
||||
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.
|
||||
|
||||
## Install
|
||||
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.
|
||||
|
||||
Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select this directory.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -1,72 +1,75 @@
|
||||
(() => {
|
||||
if (window.top === window) return;
|
||||
try {
|
||||
if (window.parent !== window.top) return;
|
||||
} catch {
|
||||
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;
|
||||
});
|
||||
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(
|
||||
@@ -79,181 +82,6 @@
|
||||
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;
|
||||
@@ -264,24 +92,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
}));
|
||||
if (!["AI_PARALLEL_SEND", "AI_PARALLEL_COLLECT_RESPONSE", "AI_PARALLEL_NEW_CHAT"].includes(event.data.type)) return;
|
||||
executeCommand(event.data).then(postParent);
|
||||
});
|
||||
|
||||
announceReady();
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,19 +1,32 @@
|
||||
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']"
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
(() => {
|
||||
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"
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
(() => {
|
||||
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*='新对话']"]
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,328 @@
|
||||
(() => {
|
||||
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,15 +1,11 @@
|
||||
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'
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
(() => {
|
||||
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*='新对话']"]
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
(() => {
|
||||
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*='新对话']"]
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,79 @@
|
||||
(() => {
|
||||
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);
|
||||
})();
|
||||
@@ -0,0 +1,11 @@
|
||||
(() => {
|
||||
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,15 +1,11 @@
|
||||
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'
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
(() => {
|
||||
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*='新对话']"]
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ProviderAdapter } from './base.js';
|
||||
|
||||
export const providerRegistry = {
|
||||
create(config) {
|
||||
return new ProviderAdapter(config);
|
||||
}
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
(() => {
|
||||
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*='新对话']"]
|
||||
});
|
||||
})();
|
||||
@@ -1,15 +0,0 @@
|
||||
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()
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
(() => {
|
||||
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;
|
||||
})();
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AI Parallel",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.4",
|
||||
"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",
|
||||
@@ -19,7 +20,8 @@
|
||||
"https://www.kimi.com/*",
|
||||
"https://kimi.com/*",
|
||||
"https://claude.ai/*",
|
||||
"https://gemini.google.com/*"
|
||||
"https://gemini.google.com/*",
|
||||
"https://grok.com/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js"
|
||||
@@ -50,9 +52,21 @@
|
||||
"https://www.kimi.com/*",
|
||||
"https://kimi.com/*",
|
||||
"https://claude.ai/*",
|
||||
"https://gemini.google.com/*"
|
||||
"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"
|
||||
],
|
||||
"js": ["content/frame-bridge.js"],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": true
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@ai-parallel/browser-extension",
|
||||
"private": true,
|
||||
"version": "2.0.1",
|
||||
"description": "Manifest V3 iframe workspace for comparing AI web chats in parallel.",
|
||||
"version": "2.1.4",
|
||||
"description": "Manifest V3 workspace for comparing AI web chats in parallel.",
|
||||
"scripts": {
|
||||
"check": "node --check service-worker.js && node --check popup.js && node --check content/frame-bridge.js && node --check workspace/workspace.js"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +40,15 @@
|
||||
</button>
|
||||
|
||||
<section class="status-panel">
|
||||
<div class="section-head">
|
||||
<span>最近一次</span>
|
||||
<button id="refreshBtn" class="text-button" type="button">刷新</button>
|
||||
</div>
|
||||
<div id="statusList" class="status-list empty">暂无运行记录</div>
|
||||
<div class="section-head"><span>启动方式</span></div>
|
||||
<div class="status-list empty">点击发送后打开 Workspace,并自动发送到已选模型。</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Prompt 仅保存在浏览器扩展会话存储中,不写入 URL。
|
||||
Prompt 仅保存在浏览器扩展本地存储中,不写入 URL。
|
||||
</footer>
|
||||
</main>
|
||||
<script src="shared/provider-catalog.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
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 PROVIDERS = globalThis.AIParallelProviderCatalog;
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const providerGrid = $("#providerGrid");
|
||||
@@ -15,7 +7,6 @@ const sendBtn = $("#sendBtn");
|
||||
const selectedCount = $("#selectedCount");
|
||||
const charCount = $("#charCount");
|
||||
const errorBox = $("#errorBox");
|
||||
const statusList = $("#statusList");
|
||||
|
||||
let selected = new Set();
|
||||
|
||||
@@ -63,48 +54,6 @@ 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -115,14 +64,19 @@ async function launch() {
|
||||
sendBtn.querySelector("span").textContent = "正在打开…";
|
||||
|
||||
try {
|
||||
await chrome.storage.local.set({ draftPrompt: promptInput.value });
|
||||
await chrome.storage.local.set({
|
||||
draftPrompt: promptInput.value,
|
||||
selectedProviders: [...selected],
|
||||
pendingLaunch: {
|
||||
prompt,
|
||||
providerIds: [...selected],
|
||||
queuedAt: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: "LAUNCH_PARALLEL",
|
||||
prompt,
|
||||
providerIds: [...selected]
|
||||
type: "OPEN_WORKSPACE"
|
||||
});
|
||||
if (!response?.ok) throw new Error(response?.error || "启动失败");
|
||||
await refreshStatus();
|
||||
window.close();
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : String(error));
|
||||
@@ -161,7 +115,6 @@ $("#toggleAllBtn").addEventListener("click", async () => {
|
||||
updateCounters();
|
||||
});
|
||||
|
||||
$("#refreshBtn").addEventListener("click", refreshStatus);
|
||||
sendBtn.addEventListener("click", launch);
|
||||
|
||||
loadPreferences().then(refreshStatus);
|
||||
loadPreferences();
|
||||
|
||||
@@ -1,16 +1,78 @@
|
||||
importScripts("shared/provider-catalog.js");
|
||||
|
||||
const PROVIDERS = {
|
||||
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" }
|
||||
...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"])
|
||||
};
|
||||
|
||||
const WORKSPACE_PATH = "workspace/index.html";
|
||||
const FRAME_REGISTRY_KEY = "iframeRegistryByTab";
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const PROVIDER_COMMANDS = new Set([
|
||||
"AI_PARALLEL_SEND",
|
||||
"AI_PARALLEL_COLLECT_RESPONSE",
|
||||
"AI_PARALLEL_NEW_CHAT"
|
||||
]);
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function matchesProviderTab(tab, provider) {
|
||||
if (!tab?.id || typeof tab.url !== "string") return false;
|
||||
try {
|
||||
return provider.hosts?.includes(new URL(tab.url).hostname) || false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProviderTab(providerId, { active = false } = {}) {
|
||||
const provider = PROVIDERS[providerId];
|
||||
if (!provider) throw new Error("Unknown provider");
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const existing = tabs.find((tab) => matchesProviderTab(tab, provider));
|
||||
if (existing) {
|
||||
if (active) {
|
||||
await chrome.tabs.update(existing.id, { active: true });
|
||||
if (existing.windowId) await chrome.windows.update(existing.windowId, { focused: true });
|
||||
}
|
||||
return { tab: existing, created: false };
|
||||
}
|
||||
const tab = await chrome.tabs.create({ url: provider.url, active: true });
|
||||
return { tab, created: true };
|
||||
}
|
||||
|
||||
async function deliverProviderCommand(tabId, message, attempts) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
return await chrome.tabs.sendMessage(tabId, message);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error("Provider tab is not ready");
|
||||
}
|
||||
|
||||
async function sendProviderTabCommand(providerId, command) {
|
||||
const provider = PROVIDERS[providerId];
|
||||
if (!provider?.tabMode) throw new Error("Provider does not support tab mode");
|
||||
if (!PROVIDER_COMMANDS.has(command?.type)) throw new Error("Unknown provider command");
|
||||
|
||||
const { tab, created } = await ensureProviderTab(providerId);
|
||||
const message = { type: "AI_PARALLEL_TAB_COMMAND", providerId, command };
|
||||
try {
|
||||
return await deliverProviderCommand(tab.id, message, created ? 40 : 4);
|
||||
} catch (error) {
|
||||
if (created) throw error;
|
||||
await chrome.tabs.reload(tab.id);
|
||||
return deliverProviderCommand(tab.id, message, 40);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorkspace() {
|
||||
const workspaceUrl = chrome.runtime.getURL(WORKSPACE_PATH);
|
||||
@@ -24,83 +86,21 @@ async function ensureWorkspace() {
|
||||
return chrome.tabs.create({ url: workspaceUrl, active: true });
|
||||
}
|
||||
|
||||
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 尚未就绪"
|
||||
};
|
||||
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.
|
||||
});
|
||||
}
|
||||
|
||||
chrome.action.onClicked.addListener(() => {
|
||||
ensureWorkspace().catch((error) => console.warn("[AI Parallel] Cannot open workspace", error));
|
||||
});
|
||||
|
||||
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) => {
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
(async () => {
|
||||
if (!message || typeof message.type !== "string") {
|
||||
sendResponse({ ok: false, error: "Invalid message" });
|
||||
@@ -109,55 +109,45 @@ 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) return sendResponse({ ok: false, error: "Unknown provider" });
|
||||
const tab = await chrome.tabs.create({ url: provider.url, active: true });
|
||||
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 });
|
||||
sendResponse({ ok: true, tabId: tab.id });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
(() => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
(() => {
|
||||
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
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,115 @@
|
||||
(() => {
|
||||
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 })
|
||||
})));
|
||||
})();
|
||||
@@ -0,0 +1,369 @@
|
||||
(() => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
(() => {
|
||||
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
|
||||
});
|
||||
})();
|
||||
@@ -15,18 +15,23 @@
|
||||
<span class="version">Live Web</span>
|
||||
</div>
|
||||
<div class="toolbar-center" id="providerBar"></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 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>
|
||||
</header>
|
||||
|
||||
<main id="panelGrid" class="panel-grid" data-layout="auto" data-count="0"></main>
|
||||
|
||||
<section class="composer-shell">
|
||||
<div id="dispatchStatus" class="dispatch-status">原站实时显示 · 不再抓取回答</div>
|
||||
<div id="dispatchStatus" class="dispatch-status">原站实时显示 · Compare 可按需收集回答</div>
|
||||
<div class="composer">
|
||||
<textarea id="promptInput" rows="1" placeholder="Ask all selected models…"></textarea>
|
||||
<button id="sendBtn" type="button">
|
||||
@@ -42,6 +47,63 @@
|
||||
</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">
|
||||
@@ -51,6 +113,7 @@
|
||||
<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>
|
||||
@@ -58,10 +121,21 @@
|
||||
<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,6 +28,9 @@ 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); }
|
||||
@@ -50,12 +53,21 @@ 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); }
|
||||
@@ -65,6 +77,46 @@ 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; }
|
||||
@@ -75,6 +127,7 @@ 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
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
+72
-18
@@ -8,21 +8,37 @@ 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
|
||||
├─ Gemini iframe
|
||||
└─ Grok controlled top-level tab
|
||||
│
|
||||
└─ content/frame-bridge.js
|
||||
├─ locates provider editor
|
||||
├─ injects the shared prompt
|
||||
└─ submits through the provider UI
|
||||
├─ 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
|
||||
```
|
||||
|
||||
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.
|
||||
The original provider page remains the primary renderer. Iframe-compatible
|
||||
providers render inside the workspace panel. Grok remains in a normal top-level
|
||||
tab so its login state and WebSocket channel retain first-party browser behavior.
|
||||
When the user opens Compare, the selected provider adapter can collect the latest
|
||||
visible response on demand; the drawer renders safe text and never injects provider HTML.
|
||||
|
||||
## Framing model
|
||||
|
||||
@@ -30,31 +46,69 @@ Most AI web applications send `X-Frame-Options` and/or CSP `frame-ancestors` hea
|
||||
|
||||
Rules live in `apps/browser-extension/rules/bypass-headers.json`.
|
||||
|
||||
The extension does not rewrite provider HTML or JavaScript. It does not request cookie permission; the provider iframe uses the browser's normal authenticated session behavior.
|
||||
The extension does not rewrite provider HTML or JavaScript. It does not request
|
||||
cookie permission. Grok is excluded from framing-header bypass rules because its
|
||||
authenticated real-time channel is not reliable in an extension iframe.
|
||||
|
||||
## Message flow
|
||||
|
||||
```text
|
||||
workspace/workspace.js
|
||||
│ DISPATCH_PROMPT
|
||||
▼
|
||||
service-worker.js
|
||||
│ frame registry: workspace tab + provider -> frameId
|
||||
│ chrome.tabs.sendMessage(..., { frameId })
|
||||
▼
|
||||
content/frame-bridge.js
|
||||
│
|
||||
└─ Provider Task Runtime
|
||||
└─ Provider Adapter
|
||||
├─ postMessage ─ provider iframe
|
||||
└─ runtime message ─ service worker ─ tabs.sendMessage ─ Grok tab
|
||||
│
|
||||
▼
|
||||
content/frame-bridge.js
|
||||
│ adapter.sendPrompt(prompt)
|
||||
├─ fill editor
|
||||
└─ submit
|
||||
│ postMessage(AI_PARALLEL_SEND_RESULT)
|
||||
▼
|
||||
workspace/workspace.js
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Provider boundaries
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
As provider handling grows, split `frame-bridge.js` into provider adapters without changing the workspace protocol.
|
||||
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.
|
||||
|
||||
## Security model
|
||||
|
||||
@@ -62,7 +116,7 @@ As provider handling grows, split `frame-bridge.js` into provider adapters witho
|
||||
- Prompt contents are not put into destination URLs.
|
||||
- Response contents are not copied to extension storage.
|
||||
- No cookie/history/webRequest permission is requested.
|
||||
- Opening a provider in a normal top-level tab does not activate the iframe bridge workflow.
|
||||
- Tab-mode commands are restricted to providers explicitly marked for tab mode and to allowlisted command types.
|
||||
|
||||
## Inspiration
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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/` 后用同名命令同步后续版本。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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 6–7 — 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
@@ -0,0 +1,184 @@
|
||||
# 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>
|
||||
```
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "ai-parallel",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "2.1.4",
|
||||
"description": "Monorepo for parallel AI workflows across browser, web and desktop clients.",
|
||||
"scripts": {
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/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'
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,230 @@
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
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
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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"\);/);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension");
|
||||
|
||||
test("Grok top-level page exposes the provider command bridge", async () => {
|
||||
let messageListener;
|
||||
const context = {
|
||||
console,
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
URL,
|
||||
location: { hostname: "grok.com" },
|
||||
document: { querySelectorAll() { return []; } },
|
||||
chrome: {
|
||||
runtime: {
|
||||
getURL: (value) => `chrome-extension://test/${value}`,
|
||||
onMessage: { addListener(listener) { messageListener = listener; } }
|
||||
}
|
||||
}
|
||||
};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.top = context;
|
||||
context.parent = context;
|
||||
vm.createContext(context);
|
||||
|
||||
for (const file of [
|
||||
"content/providers/core.js",
|
||||
"content/providers/grok.js",
|
||||
"content/frame-bridge.js"
|
||||
]) {
|
||||
vm.runInContext(fs.readFileSync(path.join(extensionRoot, file), "utf8"), context, { filename: file });
|
||||
}
|
||||
|
||||
assert.equal(typeof messageListener, "function");
|
||||
const result = await new Promise((resolve) => {
|
||||
const asynchronous = messageListener({
|
||||
type: "AI_PARALLEL_TAB_COMMAND",
|
||||
providerId: "grok",
|
||||
command: { type: "AI_PARALLEL_COLLECT_RESPONSE", requestId: "request-1" }
|
||||
}, {}, resolve);
|
||||
assert.equal(asynchronous, true);
|
||||
});
|
||||
|
||||
assert.equal(result.type, "AI_PARALLEL_RESPONSE_RESULT");
|
||||
assert.equal(result.requestId, "request-1");
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
Reference in New Issue
Block a user