fix(antigravity): implement configurable timeout and auto-reconnect for serve (#859)

* fix(antigravity): implement configurable timeout and auto-reconnect for serve

* fix(antigravity): avoid private runtime import

* docs(antigravity): document serve timeout options

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
deepziyu
2026-04-17 10:04:08 +08:00
committed by GitHub
parent ab44d9f542
commit 8a8f4a1778
7 changed files with 111 additions and 34 deletions
+1 -1
View File
@@ -201,7 +201,7 @@ npm link
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
| **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
+71 -25
View File
@@ -54,6 +54,20 @@ function jsonResponse(res, status, data) {
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function parseTimeoutValue(val, label, fallback) {
if (val === undefined) {
return fallback;
}
const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error(`[serve] Invalid ${label}="${val}", using default ${fallback}s`);
return fallback;
}
return parsed;
}
function parseEnvTimeout(envVar, fallback) {
return parseTimeoutValue(process.env[envVar], envVar, fallback);
}
// ─── DOM helpers ─────────────────────────────────────────────────────
/**
* Click the 'New Conversation' button to reset context.
@@ -267,41 +281,65 @@ async function waitForReply(page, beforeText, opts = {}) {
let lastText = beforeText;
let stableCount = 0;
const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback
let reconnectCount = 0;
while (Date.now() < deadline) {
const generating = await isGenerating(page);
const currentText = await getConversationText(page);
const textChanged = currentText !== beforeText && currentText.length > 0;
if (generating) {
hasStartedGenerating = true;
stableCount = 0; // Reset stability while generating
}
else {
if (hasStartedGenerating) {
// It actively generated and now it stopped -> DONE
// Provide a small buffer to let React render the final message fully
await sleep(500);
return;
try {
const generating = await isGenerating(page);
const currentText = await getConversationText(page);
const textChanged = currentText !== beforeText && currentText.length > 0;
if (generating) {
hasStartedGenerating = true;
stableCount = 0; // Reset stability while generating
}
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
if (textChanged) {
if (currentText === lastText) {
stableCount++;
if (stableCount >= stableThreshold) {
return; // Text has been stable for 2 seconds -> DONE
else {
if (hasStartedGenerating) {
// It actively generated and now it stopped -> DONE
// Provide a small buffer to let React render the final message fully
await sleep(500);
return page;
}
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
if (textChanged) {
if (currentText === lastText) {
stableCount++;
if (stableCount >= stableThreshold) {
return page; // Text has been stable for 2 seconds -> DONE
}
}
else {
stableCount = 0;
lastText = currentText;
}
}
else {
}
}
catch (err) {
const msg = err.message || String(err);
const isSessionLoss = /closed|lost|not open|websocket/i.test(msg);
if (opts.reconnect && isSessionLoss && reconnectCount < 2) {
reconnectCount++;
console.error(`[serve] CDP session loss detected (${msg}), attempting to reconnect (${reconnectCount}/2)...`);
try {
page = await opts.reconnect();
// Reset stability tracking after reconnect
stableCount = 0;
lastText = currentText;
lastText = beforeText;
continue;
}
catch (reconnectErr) {
console.error(`[serve] Reconnection failed: ${reconnectErr.message}`);
throw err; // Throw original error if reconnection itself fails
}
}
throw err;
}
await sleep(pollInterval);
}
throw new Error('Timeout waiting for Antigravity reply');
throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`);
}
// ─── Request Handlers ────────────────────────────────────────────────
async function handleMessages(body, page, bridge) {
async function handleMessages(body, page, opts = {}) {
const { bridge, timeout, reconnect } = opts;
// Extract the last user message
const userMessages = body.messages.filter(m => m.role === 'user');
if (userMessages.length === 0) {
@@ -328,7 +366,7 @@ async function handleMessages(body, page, bridge) {
await sendMessage(page, userText, bridge);
// Poll for reply (change detection)
console.error('[serve] Waiting for reply...');
await waitForReply(page, beforeText);
page = await waitForReply(page, beforeText, { timeout, reconnect });
// Extract the actual reply text precisely from the DOM
const replyText = await getLastAssistantReply(page, userText);
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
@@ -349,6 +387,10 @@ async function handleMessages(body, page, bridge) {
// ─── Server ──────────────────────────────────────────────────────────
export async function startServe(opts = {}) {
const port = opts.port ?? 8082;
const envTimeoutSeconds = parseEnvTimeout('OPENCLI_ANTIGRAVITY_TIMEOUT', 120);
const effectiveTimeoutSeconds = parseTimeoutValue(opts.timeout, '--timeout', envTimeoutSeconds);
const effectiveTimeout = effectiveTimeoutSeconds * 1000;
console.error(`[serve] Starting Antigravity API proxy on port ${port} (timeout: ${effectiveTimeout / 1000}s)`);
// Lazy CDP connection — connect when first request comes in
let cdp = null;
let page = null;
@@ -462,7 +504,11 @@ export async function startServe(opts = {}) {
}
// Lazy connect on first request
const activePage = await ensureConnected();
const response = await handleMessages(body, activePage, cdp ?? undefined);
const response = await handleMessages(body, activePage, {
bridge: cdp,
timeout: effectiveTimeout,
reconnect: ensureConnected,
});
jsonResponse(res, 200, response);
}
finally {
+18
View File
@@ -47,3 +47,21 @@ Quickly target and switch the active LLM engine. Example: `opencli antigravity m
### `opencli antigravity watch`
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
### `opencli antigravity serve`
Start an Anthropic-compatible `/v1/messages` proxy server backed by the local Antigravity desktop app.
```bash
opencli antigravity serve --port 8082
opencli antigravity serve --timeout 300
OPENCLI_ANTIGRAVITY_TIMEOUT=300 opencli antigravity serve
```
- `--port <port>`: HTTP listen port, default `8082`
- `--timeout <seconds>`: maximum time to wait for one reply before returning a timeout error, default `120`
- `OPENCLI_ANTIGRAVITY_TIMEOUT`: default timeout in seconds when `--timeout` is not provided
Runtime notes:
- reply polling only reconnects on session-loss style CDP errors such as closed/lost websocket connections
- reconnect attempts are bounded; DOM/logic errors are surfaced directly instead of being retried as reconnects
+1 -1
View File
@@ -142,7 +142,7 @@ Type legend: 🌐 = Browser (needs Chrome login) · ✅ = Public API (no browser
| App | Commands |
|-----|----------|
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` |
+3 -1
View File
@@ -92,7 +92,7 @@ opencli doubao-app send "message" # 发送消息
```bash
opencli antigravity status # 检查 CDP 连接状态
opencli antigravity serve # 启动 Anthropic 兼容 API 代理
opencli antigravity serve --timeout 300 # 启动 Anthropic 兼容 API 代理,等待回复最多 300s
opencli antigravity dump # 导出 DOM 调试信息
opencli antigravity extract-code # 提取对话中的代码块
opencli antigravity model <name> # 切换底层模型
@@ -101,3 +101,5 @@ opencli antigravity read # 读取聊天记录
opencli antigravity send "hello" # 发送文本到当前聊天框
opencli antigravity watch # 流式监听增量消息
```
也可以通过 `OPENCLI_ANTIGRAVITY_TIMEOUT=300` 设置 `serve` 的默认等待时长(单位:秒)。
+6 -1
View File
@@ -1128,10 +1128,15 @@ cli({
.command('serve')
.description('Start Anthropic-compatible API proxy for Antigravity')
.option('--port <port>', 'Server port (default: 8082)', '8082')
.option('--timeout <seconds>', 'Maximum time to wait for a reply (default: 120s)')
.action(async (opts) => {
// @ts-expect-error JS adapter — no type declarations
const { startServe } = await import('../clis/antigravity/serve.js');
await startServe({ port: parseInt(opts.port) });
const { parseTimeoutValue } = await import('./runtime.js');
await startServe({
port: parseInt(opts.port, 10),
timeout: opts.timeout ? parseTimeoutValue(opts.timeout, '--timeout', 120) : undefined,
});
});
// ── Dynamic adapter commands ──────────────────────────────────────────────
+11 -5
View File
@@ -13,17 +13,23 @@ export function getBrowserFactory(site?: string): new () => IBrowserFactory {
return BrowserBridge;
}
function parseEnvTimeout(envVar: string, fallback: number): number {
const raw = process.env[envVar];
if (raw === undefined) return fallback;
const parsed = parseInt(raw, 10);
/**
* Validates and parses a timeout value (seconds).
*/
export function parseTimeoutValue(val: string | number | undefined, label: string, fallback: number): number {
if (val === undefined) return fallback;
const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
if (Number.isNaN(parsed) || parsed <= 0) {
log.warn(`[runtime] Invalid ${envVar}="${raw}", using default ${fallback}s`);
console.error(`[runtime] Invalid ${label}="${val}", using default ${fallback}s`);
return fallback;
}
return parsed;
}
export function parseEnvTimeout(envVar: string, fallback: number): number {
return parseTimeoutValue(process.env[envVar], envVar, fallback);
}
export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_CONNECT_TIMEOUT', 30);
export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_COMMAND_TIMEOUT', 60);
export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_EXPLORE_TIMEOUT', 120);