yiyan-browser-agent 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/package.json +1 -1
- package/src/agent.js +25 -0
- package/src/browser.js +7 -0
- package/src/config.js +4 -0
- package/src/stability/crash-handler.js +261 -0
package/README.md
CHANGED
|
@@ -166,6 +166,26 @@ ya --debug "build a calculator"
|
|
|
166
166
|
|
|
167
167
|
When interactive mode (`-i`) is running, an HTTP server starts on port **9527**, allowing external services to send tasks.
|
|
168
168
|
|
|
169
|
+
### Task Queue (v1.5.2+)
|
|
170
|
+
|
|
171
|
+
**多客户端并发支持:** 所有请求进入队列串行处理,确保每个客户端收到正确的响应。
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
客户端A → 请求"北京天气" → 队列位置1 → 处理 → 返回"北京天气答案"
|
|
175
|
+
客户端B → 请求"上海天气" → 队列位置2 → 等待 → 处理 → 返回"上海天气答案"
|
|
176
|
+
客户端C → 请求"广州天气" → 队列位置3 → 等待 → 处理 → 返回"广州天气答案"
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### API Endpoints
|
|
180
|
+
|
|
181
|
+
| Endpoint | Method | Description |
|
|
182
|
+
|----------|--------|-------------|
|
|
183
|
+
| `/task` | POST | 提交任务 |
|
|
184
|
+
| `/status` | GET | 获取队列状态 |
|
|
185
|
+
| `/queue` | GET | 获取队列详情 |
|
|
186
|
+
| `/task/:id` | GET | 获取单个任务状态 |
|
|
187
|
+
| `/` | GET | 服务信息 |
|
|
188
|
+
|
|
169
189
|
### Process Communication
|
|
170
190
|
|
|
171
191
|
```bash
|
|
@@ -239,6 +259,67 @@ curl -X POST http://localhost:9527/task -H "Content-Type: application/json" -d '
|
|
|
239
259
|
- 第二次请求 (`newChat=false`):AI 回答"小明"
|
|
240
260
|
- 第三次请求 (`newChat=true`):AI 回答"不知道"或"你没有告诉我"
|
|
241
261
|
|
|
262
|
+
### GET /status - 队列状态查询
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
curl http://localhost:9527/status
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Response:**
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"queueLength": 2,
|
|
272
|
+
"isProcessing": true,
|
|
273
|
+
"currentTask": {
|
|
274
|
+
"id": "abc123",
|
|
275
|
+
"task": "北京天气",
|
|
276
|
+
"elapsed": 3000
|
|
277
|
+
},
|
|
278
|
+
"pendingTasks": [
|
|
279
|
+
{ "id": "def456", "task": "上海天气", "waitTime": 2000 },
|
|
280
|
+
{ "id": "ghi789", "task": "广州天气", "waitTime": 1000 }
|
|
281
|
+
],
|
|
282
|
+
"stats": {
|
|
283
|
+
"totalProcessed": 10,
|
|
284
|
+
"averageProcessTime": 5000
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### GET /queue - 队列详情
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
curl http://localhost:9527/queue
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### GET /task/:id - 单个任务状态
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
curl http://localhost:9527/task/abc123
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**Response (processing):**
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"id": "abc123",
|
|
305
|
+
"task": "北京天气",
|
|
306
|
+
"status": "processing",
|
|
307
|
+
"startedAt": 1735001234,
|
|
308
|
+
"elapsed": 3000
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Response (pending):**
|
|
313
|
+
```json
|
|
314
|
+
{
|
|
315
|
+
"id": "def456",
|
|
316
|
+
"task": "上海天气",
|
|
317
|
+
"status": "pending",
|
|
318
|
+
"queuePosition": 2,
|
|
319
|
+
"waitTime": 5000
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
242
323
|
### Windows CMD (curl)
|
|
243
324
|
|
|
244
325
|
```cmd
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -27,6 +27,31 @@ class YiyanAgent {
|
|
|
27
27
|
async run(task) {
|
|
28
28
|
const startTime = Date.now();
|
|
29
29
|
|
|
30
|
+
// 使用崩溃处理器包装整个执行流程
|
|
31
|
+
if (this.browser.crashHandler) {
|
|
32
|
+
return this.browser.crashHandler.wrap(
|
|
33
|
+
async () => {
|
|
34
|
+
logger.info('Sending task to Yiyan...');
|
|
35
|
+
await this.browser.sendMessage(task);
|
|
36
|
+
|
|
37
|
+
logger.info('Waiting for response...');
|
|
38
|
+
const answer = await this.browser.waitForResponse();
|
|
39
|
+
|
|
40
|
+
const duration = Date.now() - startTime;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
question: task,
|
|
44
|
+
answer: answer || 'No response',
|
|
45
|
+
duration,
|
|
46
|
+
status: answer ? 'success' : 'error'
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
'run_task',
|
|
50
|
+
config.RESPONSE_TIMEOUT
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 降级:无崩溃处理器时使用原有逻辑
|
|
30
55
|
logger.info('Sending task to Yiyan...');
|
|
31
56
|
await this.browser.sendMessage(task);
|
|
32
57
|
|
package/src/browser.js
CHANGED
|
@@ -6,6 +6,7 @@ const { chromium } = require('playwright');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const config = require('./config');
|
|
8
8
|
const logger = require('./logger');
|
|
9
|
+
const CrashHandler = require('./stability/crash-handler');
|
|
9
10
|
|
|
10
11
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
12
|
// Selector banks — ordered by likelihood, with fallbacks
|
|
@@ -85,6 +86,7 @@ class YiyanBrowser {
|
|
|
85
86
|
this.context = null;
|
|
86
87
|
this.page = null;
|
|
87
88
|
this._closed = false;
|
|
89
|
+
this.crashHandler = null; // 崩溃处理器
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
@@ -122,12 +124,17 @@ class YiyanBrowser {
|
|
|
122
124
|
await this._navigate(config.YIYAN_URL);
|
|
123
125
|
await this._ensureLoggedIn();
|
|
124
126
|
|
|
127
|
+
// 注册崩溃监听
|
|
128
|
+
this.crashHandler = new CrashHandler(this);
|
|
129
|
+
this.crashHandler.attach();
|
|
130
|
+
|
|
125
131
|
logger.success('Browser ready!');
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
async close() {
|
|
129
135
|
if (this._closed) return;
|
|
130
136
|
this._closed = true;
|
|
137
|
+
// 崩溃处理器不再需要监听
|
|
131
138
|
try { await this.context?.close(); } catch {}
|
|
132
139
|
}
|
|
133
140
|
|
package/src/config.js
CHANGED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// src/stability/crash-handler.js — Simplified crash detection and recovery
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const logger = require('../logger');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CrashHandler - 简化版崩溃处理器
|
|
9
|
+
*
|
|
10
|
+
* 功能:
|
|
11
|
+
* 1. 监听浏览器崩溃事件
|
|
12
|
+
* 2. 自动恢复(新建页面或重启浏览器)
|
|
13
|
+
* 3. 操作重试机制
|
|
14
|
+
* 4. 冷却期防止无限重启
|
|
15
|
+
*/
|
|
16
|
+
class CrashHandler {
|
|
17
|
+
constructor(browser) {
|
|
18
|
+
this.browser = browser;
|
|
19
|
+
this.crashCount = 0; // 连续崩溃计数
|
|
20
|
+
this.lastCrashTime = 0; // 上次崩溃时间
|
|
21
|
+
this.maxRetries = config.MAX_RETRIES || 3;
|
|
22
|
+
this.cooldownMs = config.COOLDOWN_MS || 10000;
|
|
23
|
+
this._recoveryPromise = null; // 正在进行的恢复
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── 注册崩溃监听 ──
|
|
27
|
+
attach() {
|
|
28
|
+
if (!this.browser.context || !this.browser.page) {
|
|
29
|
+
logger.warn('[CrashHandler] Browser not ready, cannot attach listeners');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 浏览器 context 关闭
|
|
34
|
+
this.browser.context.on('close', () => {
|
|
35
|
+
this._onCrash('context_closed');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 页面崩溃
|
|
39
|
+
this.browser.page.on('crash', () => {
|
|
40
|
+
this._onCrash('page_crash');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 页面断开
|
|
44
|
+
this.browser.page.on('disconnected', () => {
|
|
45
|
+
this._onCrash('disconnected');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
logger.dim('[CrashHandler] Listeners attached');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── 崩溃事件处理 ──
|
|
52
|
+
_onCrash(type) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
|
|
55
|
+
// 冷却期检查:短时间内重复崩溃不处理
|
|
56
|
+
if (now - this.lastCrashTime < this.cooldownMs) {
|
|
57
|
+
logger.warn(`[CrashHandler] ${type} ignored (cooldown active, ${Math.round((this.cooldownMs - (now - this.lastCrashTime)) / 1000)}s remaining)`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.crashCount++;
|
|
62
|
+
this.lastCrashTime = now;
|
|
63
|
+
|
|
64
|
+
logger.error(`[CrashHandler] Crash detected: ${type} (count: ${this.crashCount}/${this.maxRetries})`);
|
|
65
|
+
|
|
66
|
+
// 超过最大重试次数
|
|
67
|
+
if (this.crashCount > this.maxRetries) {
|
|
68
|
+
logger.error(`[CrashHandler] Max retries (${this.maxRetries}) exceeded, stopping auto-recovery`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 触发恢复(异步,不阻塞)
|
|
73
|
+
this._recover(type).catch(err => {
|
|
74
|
+
logger.error(`[CrashHandler] Recovery error: ${err.message}`);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── 恢复逻辑 ──
|
|
79
|
+
async _recover(type) {
|
|
80
|
+
// 防止并发恢复
|
|
81
|
+
if (this._recoveryPromise) {
|
|
82
|
+
logger.dim('[CrashHandler] Recovery already in progress, waiting...');
|
|
83
|
+
return this._recoveryPromise;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this._recoveryPromise = this._doRecovery(type);
|
|
87
|
+
try {
|
|
88
|
+
await this._recoveryPromise;
|
|
89
|
+
} finally {
|
|
90
|
+
this._recoveryPromise = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async _doRecovery(type) {
|
|
95
|
+
logger.info(`[CrashHandler] Starting recovery for: ${type}`);
|
|
96
|
+
const startTime = Date.now();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
if (type === 'page_crash') {
|
|
100
|
+
// 页面崩溃 → 新建页面
|
|
101
|
+
logger.dim('[CrashHandler] Strategy: create new page');
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await this.browser.page.close();
|
|
105
|
+
} catch {
|
|
106
|
+
// 页面可能已经关闭
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.browser.page = await this.browser.context.newPage();
|
|
110
|
+
await this.browser.page.goto(config.YIYAN_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
111
|
+
await this.browser.page.waitForTimeout(500);
|
|
112
|
+
|
|
113
|
+
} else {
|
|
114
|
+
// 其他崩溃 → 完全重启浏览器
|
|
115
|
+
logger.dim('[CrashHandler] Strategy: full browser restart');
|
|
116
|
+
|
|
117
|
+
// 先关闭
|
|
118
|
+
try {
|
|
119
|
+
await this.browser.close();
|
|
120
|
+
} catch {
|
|
121
|
+
// 可能已经关闭
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 重置状态
|
|
125
|
+
this.browser._closed = false;
|
|
126
|
+
this.browser.context = null;
|
|
127
|
+
this.browser.page = null;
|
|
128
|
+
|
|
129
|
+
// 等待一下再重启
|
|
130
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
131
|
+
|
|
132
|
+
// 重新启动
|
|
133
|
+
await this.browser.launch();
|
|
134
|
+
|
|
135
|
+
// 重新注册监听
|
|
136
|
+
this.attach();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 恢复成功,重置崩溃计数
|
|
140
|
+
this.crashCount = 0;
|
|
141
|
+
const duration = Date.now() - startTime;
|
|
142
|
+
logger.success(`[CrashHandler] Recovery successful (${duration}ms)`);
|
|
143
|
+
|
|
144
|
+
return { success: true, duration };
|
|
145
|
+
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger.error(`[CrashHandler] Recovery failed: ${err.message}`);
|
|
148
|
+
return { success: false, error: err.message };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── 操作包装器(带重试)──
|
|
153
|
+
async wrap(operation, operationName = 'operation', timeoutMs = null) {
|
|
154
|
+
const maxTimeout = timeoutMs || config.RESPONSE_TIMEOUT || 180000;
|
|
155
|
+
const startTime = Date.now();
|
|
156
|
+
let attempt = 0;
|
|
157
|
+
|
|
158
|
+
while (Date.now() - startTime < maxTimeout) {
|
|
159
|
+
attempt++;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// 检查浏览器状态
|
|
163
|
+
if (!this.browser.page || this.browser.page.isClosed()) {
|
|
164
|
+
logger.warn(`[CrashHandler] Browser disconnected before ${operationName}`);
|
|
165
|
+
await this._recover('disconnected');
|
|
166
|
+
|
|
167
|
+
if (!this.browser.page || this.browser.page.isClosed()) {
|
|
168
|
+
throw new Error('Browser recovery failed');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 执行操作
|
|
173
|
+
const result = await operation();
|
|
174
|
+
|
|
175
|
+
// 成功,重置崩溃计数
|
|
176
|
+
if (attempt > 1) {
|
|
177
|
+
this.crashCount = 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
|
|
182
|
+
} catch (err) {
|
|
183
|
+
const elapsed = Date.now() - startTime;
|
|
184
|
+
const msg = err.message?.toLowerCase() || '';
|
|
185
|
+
|
|
186
|
+
logger.warn(`[CrashHandler] ${operationName} failed (attempt ${attempt}): ${err.message}`);
|
|
187
|
+
|
|
188
|
+
// 判断是否值得重试
|
|
189
|
+
const shouldRetry =
|
|
190
|
+
elapsed < maxTimeout - 5000 && // 还有足够时间
|
|
191
|
+
this.crashCount <= this.maxRetries && // 未超过最大崩溃次数
|
|
192
|
+
(
|
|
193
|
+
msg.includes('disconnected') ||
|
|
194
|
+
msg.includes('crash') ||
|
|
195
|
+
msg.includes('closed') ||
|
|
196
|
+
msg.includes('timeout') ||
|
|
197
|
+
msg.includes('network') ||
|
|
198
|
+
msg.includes('socket') ||
|
|
199
|
+
msg.includes('navigation') ||
|
|
200
|
+
err.name === 'TimeoutError'
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (!shouldRetry) {
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 等待后重试
|
|
208
|
+
const retryDelay = Math.min(2000 * attempt, 10000); // 递增延迟,最多10秒
|
|
209
|
+
logger.dim(`[CrashHandler] Retrying ${operationName} in ${retryDelay}ms...`);
|
|
210
|
+
await new Promise(r => setTimeout(r, retryDelay));
|
|
211
|
+
|
|
212
|
+
// 尝试恢复浏览器
|
|
213
|
+
if (msg.includes('disconnected') || msg.includes('crash') || msg.includes('closed')) {
|
|
214
|
+
await this._recover('disconnected');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
throw new Error(`${operationName} timed out after ${Math.round((Date.now() - startTime) / 1000)}s`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── 健康检查 ──
|
|
223
|
+
async healthCheck() {
|
|
224
|
+
if (!this.browser.page) {
|
|
225
|
+
return { healthy: false, reason: 'no_page' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (this.browser.page.isClosed()) {
|
|
229
|
+
return { healthy: false, reason: 'page_closed' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
// 尝试执行简单 JS 检测页面是否响应
|
|
234
|
+
await this.browser.page.evaluate(() => Date.now());
|
|
235
|
+
return { healthy: true };
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return { healthy: false, reason: err.message };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 状态报告 ──
|
|
242
|
+
getStatus() {
|
|
243
|
+
return {
|
|
244
|
+
crashCount: this.crashCount,
|
|
245
|
+
lastCrashTime: this.lastCrashTime,
|
|
246
|
+
maxRetries: this.maxRetries,
|
|
247
|
+
cooldownMs: this.cooldownMs,
|
|
248
|
+
cooldownRemaining: Math.max(0, this.cooldownMs - (Date.now() - this.lastCrashTime)),
|
|
249
|
+
recovering: this._recoveryPromise !== null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── 重置崩溃计数(手动干预后调用)──
|
|
254
|
+
reset() {
|
|
255
|
+
this.crashCount = 0;
|
|
256
|
+
this.lastCrashTime = 0;
|
|
257
|
+
logger.info('[CrashHandler] Crash count reset');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = CrashHandler;
|