mocode-ai 1.1.7 → 1.1.9
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 +45 -40
- package/README.zh-CN.md +51 -41
- package/dist/agent/core.js +137 -447
- package/dist/agent/index.js +5 -24
- package/dist/agent/spawn.js +5 -5
- package/dist/agent/work-discipline.js +16 -70
- package/dist/config/index.js +87 -33
- package/dist/context/age-aware.js +18 -48
- package/dist/context/artifacts.js +19 -17
- package/dist/context/budget.js +27 -28
- package/dist/context/classifier.js +0 -1
- package/dist/context/encoders/index.js +4 -11
- package/dist/context/index.js +4 -7
- package/dist/context/lifecycle.js +115 -483
- package/dist/context/pipeline.js +8 -15
- package/dist/context/relevance.js +77 -55
- package/dist/host/stdio.js +0 -6
- package/dist/i18n/index.js +20 -6
- package/dist/index.js +11 -1
- package/dist/llm/index.js +82 -9
- package/dist/mcp/index.js +0 -1
- package/dist/repl/index.js +69 -15
- package/dist/runtime/browser-manager.js +299 -0
- package/dist/runtime/dev-server-manager.js +354 -0
- package/dist/runtime/shutdown.js +26 -0
- package/dist/session/compact.js +86 -102
- package/dist/session/index.js +0 -1
- package/dist/session/notes.js +107 -0
- package/dist/session/scheduler.js +88 -92
- package/dist/session/trace-metrics.js +5 -92
- package/dist/session/trace.js +1 -10
- package/dist/tools/builtins/browser.js +199 -0
- package/dist/tools/builtins/dev-server.js +99 -0
- package/dist/tools/builtins/index.js +33 -19
- package/dist/tools/builtins/plan-update.js +144 -0
- package/dist/tools/builtins/screenshot.js +173 -0
- package/dist/tools/builtins/view-image.js +49 -0
- package/dist/tools/constants.js +38 -8
- package/dist/tools/registry.js +5 -29
- package/dist/ui/batch.js +3 -0
- package/dist/ui/content.js +19 -16
- package/dist/ui/layout.js +120 -46
- package/dist/ui/render.js +11 -0
- package/package.json +2 -2
- package/dist/agent/middleware/checklist.js +0 -59
- package/dist/session/drop.d.ts +0 -19
- package/dist/session/drop.js +0 -93
- package/dist/tools/builtins/drop-context.d.ts +0 -18
- package/dist/tools/builtins/drop-context.js +0 -68
- package/dist/verification/diagnostics.js +0 -108
- package/dist/verification/fingerprint.js +0 -54
- package/dist/verification/index.js +0 -333
- package/dist/verification/postconditions.js +0 -98
- package/dist/verification/targeted-tests.js +0 -96
- package/dist/verification/types.js +0 -1
package/dist/repl/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import readline from 'node:readline/promises';
|
|
2
2
|
import { emitKeypressEvents } from 'node:readline';
|
|
3
3
|
import { stdin, stdout } from 'node:process';
|
|
4
|
-
import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, } from '../config/index.js';
|
|
4
|
+
import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, reinjectActivePlanIntoSystem, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
|
|
5
5
|
import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
|
|
6
|
+
import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
|
|
6
7
|
import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
|
|
7
8
|
import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
|
|
8
9
|
import { runAgent } from '../agent/index.js';
|
|
@@ -71,6 +72,13 @@ function buildSlashCommands() {
|
|
|
71
72
|
{ name: 'status', value: '/subagent status', desc: d('commands.subagentStatus') },
|
|
72
73
|
],
|
|
73
74
|
},
|
|
75
|
+
{
|
|
76
|
+
name: '/fe', desc: d('commands.fe'), children: [
|
|
77
|
+
{ name: 'on', value: '/fe on', desc: d('commands.feOn') },
|
|
78
|
+
{ name: 'off', value: '/fe off', desc: d('commands.feOff') },
|
|
79
|
+
{ name: 'status', value: '/fe status', desc: d('commands.feStatus') },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
74
82
|
{ name: '/theme', desc: d('commands.theme') },
|
|
75
83
|
{
|
|
76
84
|
name: '/model', desc: d('commands.model'), children: [
|
|
@@ -142,15 +150,15 @@ function themeDescription(name) {
|
|
|
142
150
|
}
|
|
143
151
|
/** /model 预设后端:选一个预填 baseURL,仍可逐项改。base_url 取自 README 常见表。 */
|
|
144
152
|
const MODEL_PRESETS = [
|
|
145
|
-
{ label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window:
|
|
146
|
-
{ label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window:
|
|
147
|
-
{ label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window:
|
|
153
|
+
{ label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
|
|
154
|
+
{ label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
|
|
155
|
+
{ label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
|
|
148
156
|
// MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
|
|
149
157
|
// MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
|
|
150
|
-
{ label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window:
|
|
151
|
-
{ label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window:
|
|
152
|
-
{ label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window:
|
|
153
|
-
{ label: '自定义 base_url', baseURL: '', model: '', window:
|
|
158
|
+
{ label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
|
|
159
|
+
{ label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
|
|
160
|
+
{ label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
|
|
161
|
+
{ label: '自定义 base_url', baseURL: '', model: '', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
|
|
154
162
|
];
|
|
155
163
|
/** apiKey 脱敏:只露末 4 位,前面打星号(显示用,绝不把明文 key 写进内容区)。 */
|
|
156
164
|
function maskKey(k) {
|
|
@@ -217,7 +225,7 @@ function renderContextBar(history) {
|
|
|
217
225
|
const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
|
|
218
226
|
const src = contextState.lastUsage ? t('status.measured') : t('status.estimated');
|
|
219
227
|
const k = (n) => `${Math.round(n / 1000)}k`;
|
|
220
|
-
const pctCol = pct >=
|
|
228
|
+
const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
|
|
221
229
|
const lifecycle = contextState.lifecycleStats;
|
|
222
230
|
const archived = computePruneStats(history);
|
|
223
231
|
const artifactStats = contextState.artifactStats;
|
|
@@ -242,7 +250,7 @@ function renderContextBarInline(history) {
|
|
|
242
250
|
const filled = Math.round(pct * W);
|
|
243
251
|
const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
|
|
244
252
|
const k = (n) => `${Math.round(n / 1000)}k`;
|
|
245
|
-
const pctCol = pct >=
|
|
253
|
+
const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
|
|
246
254
|
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
|
|
247
255
|
}
|
|
248
256
|
// 宿主侧记录已结束轮次最后看到的 plan。notes.md 仍完整保留,只抑制未变化的旧 plan 状态栏,
|
|
@@ -1031,10 +1039,14 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1031
1039
|
// 直接给原文对中文用户不友好。这里翻译成中文 + 提示 /model 换视觉模型。
|
|
1032
1040
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1033
1041
|
const lower = msg.toLowerCase();
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1042
|
+
// 只有明确的「能力不支持」才显示换模型提示。不能仅因错误里出现 image/vision
|
|
1043
|
+
// 就下结论:例如 MiniMax 会因 `image_url.detail=auto` 返 400 invalid params,
|
|
1044
|
+
// 模型本身仍支持视觉。参数/格式错误应保留原始 provider 诊断,方便准确修复。
|
|
1045
|
+
const isImageParameterError = /\b(?:invalid|unsupported)\s+(?:image\s+)?(?:detail|parameter|param|format|url)\b/i.test(lower);
|
|
1046
|
+
const isVisionUnsupportedError = !isImageParameterError && (/\b(?:does not support|doesn't support|not supported|unsupported)\b.{0,48}\b(?:image|vision|multimodal|media)\b/i.test(lower) ||
|
|
1047
|
+
/\b(?:image|vision|multimodal|media)\b.{0,48}\b(?:is not supported|not supported|unsupported)\b/i.test(lower) ||
|
|
1048
|
+
/(?:不支持|不具备).{0,12}(?:视觉|图片|图像|多模态)|(?:视觉|图片|图像|多模态).{0,12}(?:不支持|不可用)/.test(msg));
|
|
1049
|
+
if (isVisionUnsupportedError) {
|
|
1038
1050
|
layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t('repl.visionUnsupported', { model: `${ui.accent}${config.model}${ui.reset}` })}${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
|
|
1039
1051
|
layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
|
|
1040
1052
|
}
|
|
@@ -1049,6 +1061,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1049
1061
|
const waitingForPlanApproval = ok && planMode && getAgentMode() === 'plan';
|
|
1050
1062
|
if (!waitingForPlanApproval)
|
|
1051
1063
|
settlePlanStatus();
|
|
1064
|
+
layout.setLiveUsage(undefined); // 轮末清实时 chip,回 INPUT 态不再显示
|
|
1052
1065
|
refreshStatusBase(history, lastTurnUsage);
|
|
1053
1066
|
layout.drawStatusBar();
|
|
1054
1067
|
}
|
|
@@ -1463,6 +1476,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1463
1476
|
// focus 透传到 compact_history action 的 LLM 摘要 prompt。
|
|
1464
1477
|
// 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
|
|
1465
1478
|
const log = await manualCompact(history, focus, { force });
|
|
1479
|
+
// ② compact 后把活跃 plan 重注入系统提示(history[0]),避免 agent 因上下文压缩丢失执行计划。
|
|
1480
|
+
if (log.compactHistoryCalled)
|
|
1481
|
+
reinjectActivePlanIntoSystem(history);
|
|
1466
1482
|
const d = log.compactDetail;
|
|
1467
1483
|
appendCurrentSessionRuntimeEvent('compact', {
|
|
1468
1484
|
source: 'manual',
|
|
@@ -1915,7 +1931,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1915
1931
|
const res = await promptIntervention({
|
|
1916
1932
|
type: 'input',
|
|
1917
1933
|
title: 'CONTEXT_WINDOW_TOKENS',
|
|
1918
|
-
detail: '
|
|
1934
|
+
detail: '模型上下文窗口,全局默认 256k;如需不同窗口可手动覆盖。回车采纳预填值。',
|
|
1919
1935
|
seed: String(preset.window || config.contextWindowTokens),
|
|
1920
1936
|
});
|
|
1921
1937
|
if (res.action === 'cancelled') {
|
|
@@ -2006,6 +2022,44 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
2006
2022
|
layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'subagent.changedOn' : 'subagent.changedOff')}${ui.reset}\n`);
|
|
2007
2023
|
continue;
|
|
2008
2024
|
}
|
|
2025
|
+
if (line === '/fe' ||
|
|
2026
|
+
line.startsWith('/fe ') ||
|
|
2027
|
+
line === '/frontend' ||
|
|
2028
|
+
line.startsWith('/frontend ')) {
|
|
2029
|
+
// /fe — 前端工具簇总开关(browser / dev_server / screenshot / view_image)。
|
|
2030
|
+
// 无参切换 on/off;有参 on|off 显式;status 只读。设计同 /subagent:单一来源
|
|
2031
|
+
// isFrontendToolsEnabled();关闭时 4 个工具不进模型 schema(refreshChatTools 过滤)、
|
|
2032
|
+
// 运行时 getRuntimeDisabledTools 兜底拦截、plan 模式 getPlanDisabledTools 也剔除。默认 false。
|
|
2033
|
+
const raw = line.startsWith('/fe ')
|
|
2034
|
+
? line.slice('/fe '.length)
|
|
2035
|
+
: line.startsWith('/frontend ')
|
|
2036
|
+
? line.slice('/frontend '.length)
|
|
2037
|
+
: line === '/frontend'
|
|
2038
|
+
? ''
|
|
2039
|
+
: line.slice('/fe'.length);
|
|
2040
|
+
const arg = raw.trim().toLowerCase();
|
|
2041
|
+
if (arg === '' || arg === 'status') {
|
|
2042
|
+
const enabled = isFrontendToolsEnabled();
|
|
2043
|
+
const state = t(enabled ? 'fe.stateOn' : 'fe.stateOff');
|
|
2044
|
+
layout.contentWrite(`${ui.accent}${t('fe.status', { state })}${ui.reset}\n` +
|
|
2045
|
+
`${ui.dim}MOCODE_FRONTEND_TOOLS_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n`);
|
|
2046
|
+
continue;
|
|
2047
|
+
}
|
|
2048
|
+
if (arg !== 'on' && arg !== 'off') {
|
|
2049
|
+
layout.contentWrite(`${ui.yellow}${t('fe.usage')}${ui.reset}\n`);
|
|
2050
|
+
continue;
|
|
2051
|
+
}
|
|
2052
|
+
const enabled = arg === 'on';
|
|
2053
|
+
if (enabled !== isFrontendToolsEnabled()) {
|
|
2054
|
+
updateFrontendToolsConfig(enabled);
|
|
2055
|
+
updateConfigKey('MOCODE_FRONTEND_TOOLS_ENABLED', enabled ? 'true' : 'false');
|
|
2056
|
+
refreshChatTools();
|
|
2057
|
+
history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
|
|
2058
|
+
layout.rewriteBanner(bannerLines(banner()));
|
|
2059
|
+
}
|
|
2060
|
+
layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'fe.changedOn' : 'fe.changedOff')}${ui.reset}\n`);
|
|
2061
|
+
continue;
|
|
2062
|
+
}
|
|
2009
2063
|
if (line === '/memory_switch' ||
|
|
2010
2064
|
line.startsWith('/memory_switch ') ||
|
|
2011
2065
|
line === '/memory_status' ||
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Playwright 浏览器会话管理器。
|
|
2
|
+
//
|
|
3
|
+
// 设计要点:
|
|
4
|
+
// - 会话跨工具调用存活(Browser 单例 + 每会话独立 BrowserContext/Page),这样 agent 可以
|
|
5
|
+
// navigate → click → screenshot 连续操作同一个页面,而不是每次重开浏览器丢状态。
|
|
6
|
+
// - playwright 用动态 import:启动 mocode 不该为一个可选能力付加载成本,缺浏览器二进制时
|
|
7
|
+
// 也能给出明确的 `npx playwright install chromium` 指引而不是崩在模块加载期。
|
|
8
|
+
// - 默认只允许回环地址。agent 能驱动真浏览器意味着它能发任意请求,若放开就等于给了
|
|
9
|
+
// SSRF / 内网扫描能力。放开需用户显式设 MOCODE_BROWSER_ALLOW_REMOTE=true。
|
|
10
|
+
// - 采集 console / pageerror / requestfailed 到有界 ring buffer:前端问题往往只在控制台可见,
|
|
11
|
+
// 截图看不出来。
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
export class BrowserManagerError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'BrowserManagerError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const sessions = new Map();
|
|
22
|
+
const DIAGNOSTIC_LIMIT = 200;
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
24
|
+
const MAX_TIMEOUT_MS = 60_000;
|
|
25
|
+
const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
26
|
+
let browserPromise = null;
|
|
27
|
+
let launchedHeaded = false;
|
|
28
|
+
function errorMessage(error) {
|
|
29
|
+
return error instanceof Error ? error.message : String(error);
|
|
30
|
+
}
|
|
31
|
+
export function resolveTimeout(input) {
|
|
32
|
+
const value = Number(input ?? DEFAULT_TIMEOUT_MS);
|
|
33
|
+
if (!Number.isFinite(value))
|
|
34
|
+
return DEFAULT_TIMEOUT_MS;
|
|
35
|
+
return Math.min(Math.max(value, 500), MAX_TIMEOUT_MS);
|
|
36
|
+
}
|
|
37
|
+
function remoteAllowed() {
|
|
38
|
+
return process.env.MOCODE_BROWSER_ALLOW_REMOTE === 'true';
|
|
39
|
+
}
|
|
40
|
+
/** 默认只放行 http/https 回环地址;拒绝 file:/data:/javascript: 等本地读取与脚本注入面。 */
|
|
41
|
+
export function validateNavigationUrl(input) {
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(input);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new BrowserManagerError('INVALID_ARGUMENTS', `Invalid url: ${input}`);
|
|
48
|
+
}
|
|
49
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
50
|
+
throw new BrowserManagerError('SANDBOX_DENIED', `Only http and https URLs are allowed (received ${url.protocol}).`);
|
|
51
|
+
}
|
|
52
|
+
if (url.username || url.password) {
|
|
53
|
+
throw new BrowserManagerError('SANDBOX_DENIED', 'URLs with embedded credentials are not allowed.');
|
|
54
|
+
}
|
|
55
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
56
|
+
const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
57
|
+
if (!loopback && !remoteAllowed()) {
|
|
58
|
+
throw new BrowserManagerError('SANDBOX_DENIED', `Only localhost, 127.0.0.1, and ::1 are allowed by default (received ${url.hostname}). ` +
|
|
59
|
+
'Set MOCODE_BROWSER_ALLOW_REMOTE=true to permit remote navigation.');
|
|
60
|
+
}
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
async function launchBrowser(headed) {
|
|
64
|
+
let playwright;
|
|
65
|
+
try {
|
|
66
|
+
playwright = await import('playwright');
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new BrowserManagerError('EXECUTION_ERROR', `Playwright is not available: ${errorMessage(error)}. Run "npm install" first.`);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return await playwright.chromium.launch({ headless: !headed });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new BrowserManagerError('EXECUTION_ERROR', `Unable to launch Chromium: ${errorMessage(error)}. If the browser binary is missing, run "npx playwright install chromium".`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function getBrowser(headed) {
|
|
79
|
+
const existing = browserPromise ? await browserPromise.catch(() => null) : null;
|
|
80
|
+
// headless 与 headed 是不同的进程模式;请求切换时重开,避免默默给出与请求不符的模式。
|
|
81
|
+
if (existing?.isConnected() && launchedHeaded === headed)
|
|
82
|
+
return existing;
|
|
83
|
+
if (existing?.isConnected())
|
|
84
|
+
await existing.close().catch(() => { });
|
|
85
|
+
launchedHeaded = headed;
|
|
86
|
+
browserPromise = launchBrowser(headed);
|
|
87
|
+
try {
|
|
88
|
+
return await browserPromise;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
browserPromise = null;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function record(session, bucket, line) {
|
|
96
|
+
const target = session[bucket];
|
|
97
|
+
target.push(line);
|
|
98
|
+
if (target.length > DIAGNOSTIC_LIMIT)
|
|
99
|
+
target.splice(0, target.length - DIAGNOSTIC_LIMIT);
|
|
100
|
+
}
|
|
101
|
+
export async function openSession(opts = {}) {
|
|
102
|
+
const headed = opts.headed === true;
|
|
103
|
+
const browser = await getBrowser(headed);
|
|
104
|
+
const context = await browser.newContext({ viewport: DEFAULT_VIEWPORT });
|
|
105
|
+
const page = await context.newPage();
|
|
106
|
+
page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
|
|
107
|
+
const session = {
|
|
108
|
+
sessionId: `page-${randomBytes(3).toString('hex')}`,
|
|
109
|
+
context,
|
|
110
|
+
page,
|
|
111
|
+
headed,
|
|
112
|
+
console: [],
|
|
113
|
+
pageErrors: [],
|
|
114
|
+
failedRequests: [],
|
|
115
|
+
};
|
|
116
|
+
page.on('console', (message) => {
|
|
117
|
+
record(session, 'console', `[${message.type()}] ${message.text()}`);
|
|
118
|
+
});
|
|
119
|
+
page.on('pageerror', (error) => record(session, 'pageErrors', errorMessage(error)));
|
|
120
|
+
page.on('requestfailed', (request) => {
|
|
121
|
+
record(session, 'failedRequests', `${request.method()} ${request.url()} — ${request.failure()?.errorText ?? 'failed'}`);
|
|
122
|
+
});
|
|
123
|
+
sessions.set(session.sessionId, session);
|
|
124
|
+
return describe(session);
|
|
125
|
+
}
|
|
126
|
+
function requireSession(sessionId) {
|
|
127
|
+
if (sessionId) {
|
|
128
|
+
const session = sessions.get(sessionId);
|
|
129
|
+
if (!session) {
|
|
130
|
+
const known = [...sessions.keys()];
|
|
131
|
+
throw new BrowserManagerError('INVALID_ARGUMENTS', known.length
|
|
132
|
+
? `Unknown browser sessionId "${sessionId}". Known sessions: ${known.join(', ')}.`
|
|
133
|
+
: `Unknown browser sessionId "${sessionId}". Call action=open first.`);
|
|
134
|
+
}
|
|
135
|
+
return session;
|
|
136
|
+
}
|
|
137
|
+
// 只有一个会话时允许省略 sessionId,减少模型出错面;多会话必须显式指定。
|
|
138
|
+
const all = [...sessions.values()];
|
|
139
|
+
if (all.length === 1)
|
|
140
|
+
return all[0];
|
|
141
|
+
if (all.length === 0) {
|
|
142
|
+
throw new BrowserManagerError('INVALID_ARGUMENTS', 'No browser session is open. Call action=open first.');
|
|
143
|
+
}
|
|
144
|
+
throw new BrowserManagerError('INVALID_ARGUMENTS', `Multiple browser sessions are open; specify sessionId. Known sessions: ${all.map((s) => s.sessionId).join(', ')}.`);
|
|
145
|
+
}
|
|
146
|
+
function describe(session) {
|
|
147
|
+
const viewport = session.page.viewportSize() ?? DEFAULT_VIEWPORT;
|
|
148
|
+
return {
|
|
149
|
+
sessionId: session.sessionId,
|
|
150
|
+
url: session.page.url(),
|
|
151
|
+
title: '',
|
|
152
|
+
headed: session.headed,
|
|
153
|
+
viewport,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async function describeAsync(session) {
|
|
157
|
+
const base = describe(session);
|
|
158
|
+
return { ...base, title: await session.page.title().catch(() => '') };
|
|
159
|
+
}
|
|
160
|
+
/** Playwright 的超时错误统一映射成结构化 TIMEOUT,便于 agent 区分「选择器没出现」与真故障。 */
|
|
161
|
+
function wrapActionError(error) {
|
|
162
|
+
if (error instanceof BrowserManagerError)
|
|
163
|
+
throw error;
|
|
164
|
+
const message = errorMessage(error);
|
|
165
|
+
if (/Timeout .* exceeded|waiting for/i.test(message)) {
|
|
166
|
+
throw new BrowserManagerError('TIMEOUT', message);
|
|
167
|
+
}
|
|
168
|
+
throw new BrowserManagerError('EXECUTION_ERROR', message);
|
|
169
|
+
}
|
|
170
|
+
function diagnostics(session) {
|
|
171
|
+
return {
|
|
172
|
+
console: session.console.slice(-20),
|
|
173
|
+
pageErrors: session.pageErrors.slice(-20),
|
|
174
|
+
failedRequests: session.failedRequests.slice(-20),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
async function finish(session, action) {
|
|
178
|
+
return { action, ...(await describeAsync(session)), diagnostics: diagnostics(session) };
|
|
179
|
+
}
|
|
180
|
+
export async function navigate(opts) {
|
|
181
|
+
const session = requireSession(opts.sessionId);
|
|
182
|
+
const url = validateNavigationUrl(opts.url);
|
|
183
|
+
try {
|
|
184
|
+
const response = await session.page.goto(url.toString(), {
|
|
185
|
+
timeout: resolveTimeout(opts.timeoutMs),
|
|
186
|
+
waitUntil: 'domcontentloaded',
|
|
187
|
+
});
|
|
188
|
+
if (response && !response.ok()) {
|
|
189
|
+
record(session, 'failedRequests', `GET ${url.toString()} — HTTP ${response.status()}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
wrapActionError(error);
|
|
194
|
+
}
|
|
195
|
+
return finish(session, 'navigate');
|
|
196
|
+
}
|
|
197
|
+
export async function click(opts) {
|
|
198
|
+
const session = requireSession(opts.sessionId);
|
|
199
|
+
try {
|
|
200
|
+
await session.page.click(opts.selector, { timeout: resolveTimeout(opts.timeoutMs) });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
wrapActionError(error);
|
|
204
|
+
}
|
|
205
|
+
return finish(session, 'click');
|
|
206
|
+
}
|
|
207
|
+
export async function fill(opts) {
|
|
208
|
+
const session = requireSession(opts.sessionId);
|
|
209
|
+
try {
|
|
210
|
+
await session.page.fill(opts.selector, opts.value, { timeout: resolveTimeout(opts.timeoutMs) });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
wrapActionError(error);
|
|
214
|
+
}
|
|
215
|
+
return finish(session, 'fill');
|
|
216
|
+
}
|
|
217
|
+
export async function press(opts) {
|
|
218
|
+
const session = requireSession(opts.sessionId);
|
|
219
|
+
const timeout = resolveTimeout(opts.timeoutMs);
|
|
220
|
+
try {
|
|
221
|
+
if (opts.selector)
|
|
222
|
+
await session.page.press(opts.selector, opts.key, { timeout });
|
|
223
|
+
else
|
|
224
|
+
await session.page.keyboard.press(opts.key);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
wrapActionError(error);
|
|
228
|
+
}
|
|
229
|
+
return finish(session, 'press');
|
|
230
|
+
}
|
|
231
|
+
export async function waitFor(opts) {
|
|
232
|
+
const session = requireSession(opts.sessionId);
|
|
233
|
+
try {
|
|
234
|
+
await session.page.waitForSelector(opts.selector, { timeout: resolveTimeout(opts.timeoutMs) });
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
wrapActionError(error);
|
|
238
|
+
}
|
|
239
|
+
return finish(session, 'wait_for');
|
|
240
|
+
}
|
|
241
|
+
export async function readText(opts) {
|
|
242
|
+
const session = requireSession(opts.sessionId);
|
|
243
|
+
const limit = Math.min(Math.max(Number(opts.limit ?? 4_000), 100), 32_000);
|
|
244
|
+
let text;
|
|
245
|
+
try {
|
|
246
|
+
text = opts.selector
|
|
247
|
+
? (await session.page.textContent(opts.selector, { timeout: DEFAULT_TIMEOUT_MS })) ?? ''
|
|
248
|
+
: await session.page.innerText('body');
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
wrapActionError(error);
|
|
252
|
+
}
|
|
253
|
+
const normalized = text.replace(/\n{3,}/g, '\n\n').trim();
|
|
254
|
+
return {
|
|
255
|
+
...(await finish(session, 'text')),
|
|
256
|
+
text: normalized.length > limit ? `${normalized.slice(0, limit)}\n… (truncated)` : normalized,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
export async function screenshot(opts) {
|
|
260
|
+
const session = requireSession(opts.sessionId);
|
|
261
|
+
const timeout = resolveTimeout(opts.timeoutMs);
|
|
262
|
+
let buffer;
|
|
263
|
+
try {
|
|
264
|
+
buffer = opts.selector
|
|
265
|
+
? await session.page.locator(opts.selector).screenshot({ timeout, type: 'png' })
|
|
266
|
+
: await session.page.screenshot({ timeout, type: 'png', fullPage: opts.fullPage === true });
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
wrapActionError(error);
|
|
270
|
+
}
|
|
271
|
+
return { ...(await finish(session, 'screenshot')), buffer };
|
|
272
|
+
}
|
|
273
|
+
export function listSessions() {
|
|
274
|
+
return [...sessions.values()].map(describe);
|
|
275
|
+
}
|
|
276
|
+
export async function closeSession(sessionId) {
|
|
277
|
+
const session = requireSession(sessionId);
|
|
278
|
+
sessions.delete(session.sessionId);
|
|
279
|
+
await session.context.close().catch(() => { });
|
|
280
|
+
if (sessions.size === 0 && browserPromise) {
|
|
281
|
+
const browser = await browserPromise.catch(() => null);
|
|
282
|
+
browserPromise = null;
|
|
283
|
+
await browser?.close().catch(() => { });
|
|
284
|
+
}
|
|
285
|
+
return { sessionId: session.sessionId, closed: true };
|
|
286
|
+
}
|
|
287
|
+
/** 全局退出清理:关闭所有页面与浏览器进程。幂等。 */
|
|
288
|
+
export async function closeAllBrowsers() {
|
|
289
|
+
for (const session of [...sessions.values()]) {
|
|
290
|
+
sessions.delete(session.sessionId);
|
|
291
|
+
await session.context.close().catch(() => { });
|
|
292
|
+
}
|
|
293
|
+
const pending = browserPromise;
|
|
294
|
+
browserPromise = null;
|
|
295
|
+
if (!pending)
|
|
296
|
+
return;
|
|
297
|
+
const browser = await pending.catch(() => null);
|
|
298
|
+
await browser?.close().catch(() => { });
|
|
299
|
+
}
|