@turingflow/agent-kit 0.0.0-stage → 0.1.1
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 +592 -2
- package/dist/agent.d.ts +27 -0
- package/dist/agent.js +31 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +105 -0
- package/dist/handlers.d.ts +92 -0
- package/dist/handlers.js +211 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/sdk.d.ts +106 -0
- package/dist/sdk.js +309 -0
- package/dist/server/agent.d.ts +27 -0
- package/dist/server/agent.js +31 -0
- package/dist/server/artifact-hooks.d.ts +6 -0
- package/dist/server/artifact-hooks.js +37 -0
- package/dist/server/artifact-tool-tracker.d.ts +24 -0
- package/dist/server/artifact-tool-tracker.js +347 -0
- package/dist/server/artifacts.d.ts +227 -0
- package/dist/server/artifacts.js +1061 -0
- package/dist/server/auth/config.d.ts +34 -0
- package/dist/server/auth/config.js +82 -0
- package/dist/server/auth/db.d.ts +159 -0
- package/dist/server/auth/db.js +680 -0
- package/dist/server/auth/handlers.d.ts +27 -0
- package/dist/server/auth/handlers.js +297 -0
- package/dist/server/auth/index.d.ts +15 -0
- package/dist/server/auth/index.js +14 -0
- package/dist/server/auth/login-options.d.ts +14 -0
- package/dist/server/auth/login-options.js +50 -0
- package/dist/server/auth/providers/dingtalk.d.ts +16 -0
- package/dist/server/auth/providers/dingtalk.js +177 -0
- package/dist/server/auth/providers/feishu.d.ts +15 -0
- package/dist/server/auth/providers/feishu.js +100 -0
- package/dist/server/auth/providers/index.d.ts +13 -0
- package/dist/server/auth/providers/index.js +24 -0
- package/dist/server/auth/session.d.ts +48 -0
- package/dist/server/auth/session.js +108 -0
- package/dist/server/auth/types.d.ts +118 -0
- package/dist/server/auth/types.js +7 -0
- package/dist/server/browser-agent-mcp.d.ts +67 -0
- package/dist/server/browser-agent-mcp.js +608 -0
- package/dist/server/cli.d.ts +6 -0
- package/dist/server/cli.js +112 -0
- package/dist/server/debug-log.d.ts +25 -0
- package/dist/server/debug-log.js +86 -0
- package/dist/server/handlers.d.ts +141 -0
- package/dist/server/handlers.js +523 -0
- package/dist/server/index.d.ts +30 -0
- package/dist/server/index.js +28 -0
- package/dist/server/kit-api.d.ts +23 -0
- package/dist/server/kit-api.js +153 -0
- package/dist/server/model-sources.d.ts +32 -0
- package/dist/server/model-sources.js +58 -0
- package/dist/server/oa.d.ts +87 -0
- package/dist/server/oa.js +397 -0
- package/dist/server/providers/openrouter.d.ts +51 -0
- package/dist/server/providers/openrouter.js +283 -0
- package/dist/server/render-markdown.d.ts +2 -0
- package/dist/server/render-markdown.js +50 -0
- package/dist/server/sdk.d.ts +269 -0
- package/dist/server/sdk.js +2025 -0
- package/dist/server/session-access.d.ts +5 -0
- package/dist/server/session-access.js +68 -0
- package/dist/server/session-fork.d.ts +10 -0
- package/dist/server/session-fork.js +142 -0
- package/dist/server/session-store.d.ts +56 -0
- package/dist/server/session-store.js +148 -0
- package/dist/server/sessions.d.ts +24 -0
- package/dist/server/sessions.js +541 -0
- package/dist/server/share.d.ts +15 -0
- package/dist/server/share.js +433 -0
- package/dist/server/tool-activity.d.ts +2 -0
- package/dist/server/tool-activity.js +26 -0
- package/dist/server/transcription/xiaomi-mimo.d.ts +8 -0
- package/dist/server/transcription/xiaomi-mimo.js +86 -0
- package/dist/server/upload.d.ts +13 -0
- package/dist/server/upload.js +81 -0
- package/dist/server/user-files.d.ts +28 -0
- package/dist/server/user-files.js +311 -0
- package/dist/server/voice.d.ts +25 -0
- package/dist/server/voice.js +61 -0
- package/dist/server/web-fetch.d.ts +71 -0
- package/dist/server/web-fetch.js +330 -0
- package/dist/styles.css +3354 -0
- package/dist/transcription/xiaomi-mimo.d.ts +8 -0
- package/dist/transcription/xiaomi-mimo.js +86 -0
- package/dist/ui/index.d.ts +358 -0
- package/dist/ui/index.js +7730 -0
- package/dist/upload.d.ts +13 -0
- package/dist/upload.js +81 -0
- package/dist/voice.d.ts +25 -0
- package/dist/voice.js +61 -0
- package/package.json +74 -4
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-kit · browser-agent MCP 工具(computer use:AI 自主控制浏览器)
|
|
3
|
+
*
|
|
4
|
+
* 设计:把"浏览器操作智能体"封装成一个 MCP 工具 control_browser(task),由聊天模型(hy3)
|
|
5
|
+
* 在对话中**自主决定**何时调用——无需用户在 UI 上手动触发。
|
|
6
|
+
*
|
|
7
|
+
* 工具内部是一个纯视觉 computer-use 循环(与 web-fetch 同款 Playwright 持久上下文):
|
|
8
|
+
* 截图 → 把截图作为图片发给 hy3(allowedTools:[] 关掉工具、maxTurns:1 不自循环,仅做视觉
|
|
9
|
+
* 推理输出动作 JSON)→ 解析并执行(click/type/scroll/navigate/wait/done/ask)→ 循环。
|
|
10
|
+
*
|
|
11
|
+
* 关键能力:
|
|
12
|
+
* - 全程通过 SSE 事件(screenshot / thinking / action / done / ask / finished)外发,
|
|
13
|
+
* 聊天模型经 canUseTool 拦截时注册订阅者,过程事件同步转发进聊天 SSE,
|
|
14
|
+
* 前端 thinking 展开区实时展示浏览器截图与动作(headless 无独立窗口)。
|
|
15
|
+
* - 单例 BrowserAgentService 同时服务「手动入口(/api/browser/session)」与
|
|
16
|
+
* 「自主 control_browser 工具」,二者共用同一会话注册表,避免重复实现。
|
|
17
|
+
* - 容错解析:容忍 Markdown 代码块、尾随逗号;连续无效操作(疑似卡死)自动停止。
|
|
18
|
+
*
|
|
19
|
+
* 暴露:control_browser(task: string, max_steps?: number) → { summary, finalUrl, finalTitle, steps }
|
|
20
|
+
*/
|
|
21
|
+
import { chromium } from "playwright";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { query, createSdkMcpServer, tool, } from "@tencent-ai/agent-sdk";
|
|
24
|
+
import { mkdirSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { ReadableStream as NodeReadableStream } from "node:stream/web";
|
|
28
|
+
import { createServer as createNetServer } from "node:net";
|
|
29
|
+
/** 视口固定 1280x900:截图尺寸与 hy3 输出的坐标空间一致 */
|
|
30
|
+
const VIEWPORT = { width: 1280, height: 900 };
|
|
31
|
+
const DEFAULT_MAX_STEPS = 25;
|
|
32
|
+
/** 找一个空闲 TCP 端口给 Chromium 远程调试(多会话并发各起独立浏览器进程,端口不能固定) */
|
|
33
|
+
function findFreePort() {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const srv = createNetServer();
|
|
36
|
+
srv.on("error", reject);
|
|
37
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
38
|
+
const addr = srv.address();
|
|
39
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
40
|
+
srv.close(() => resolve(port));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function defaultProfileDir() {
|
|
45
|
+
if (process.env.BROWSER_AGENT_PROFILE_DIR?.trim()) {
|
|
46
|
+
return path.resolve(process.env.BROWSER_AGENT_PROFILE_DIR.trim());
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
51
|
+
return path.join(repoRoot, ".browser-agent-profile");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return path.join(process.cwd(), ".browser-agent-profile");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** 允许访问的域名白名单(空数组 = 不限制;建议按需收紧) */
|
|
58
|
+
function allowedHosts() {
|
|
59
|
+
return (process.env.BROWSER_AGENT_ALLOWED_HOSTS || "")
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
const BROWSER_SYSTEM_PROMPT = `你是一个浏览器操作智能体,运行在 ${VIEWPORT.width}x${VIEWPORT.height} 的视口中。
|
|
65
|
+
你的任务:根据当前页面截图,决定下一步浏览器操作,并**只输出一个 JSON 对象**,不要输出任何多余文字、不要调用任何工具。
|
|
66
|
+
|
|
67
|
+
坐标系统:截图左上角为 (0,0),x 向右、y 向下,范围 x∈[0,${VIEWPORT.width}]、y∈[0,${VIEWPORT.height}]。
|
|
68
|
+
动作 JSON 规范(必须严格遵循其一,一次只做一个动作):
|
|
69
|
+
{"action":"click","x":<number>,"y":<number>} 点击屏幕坐标
|
|
70
|
+
{"action":"type","text":"<string>"} 在当前焦点输入框输入文字
|
|
71
|
+
{"action":"scroll","direction":"up"|"down","amount":<px>} 滚动页面(amount 默认 600)
|
|
72
|
+
{"action":"navigate","url":"<https://...>"} 打开/跳转到 URL
|
|
73
|
+
{"action":"wait","ms":<number>} 等待(默认 1500)
|
|
74
|
+
{"action":"ask","question":"<string>"} 需要人类接管时提问(如遇到图形验证码)
|
|
75
|
+
{"action":"done","summary":"<string>"} 任务完成,简述结果
|
|
76
|
+
|
|
77
|
+
严格格式要求:
|
|
78
|
+
1. 只输出单个 JSON 对象,禁止用 Markdown 代码块包裹,禁止任何解释文字;
|
|
79
|
+
2. 禁止 JSON 尾随逗号;字符串必须用双引号;
|
|
80
|
+
3. 点击前先确认目标元素在视口内,必要时先 scroll 再 click;
|
|
81
|
+
4. 输入前必须先 click 对应输入框使其获得焦点,再输出 type;
|
|
82
|
+
5. 任务完成后务必输出 done,并在 summary 中给出用户在原问题中要的答案;
|
|
83
|
+
6. 遇到图形验证码、短信/邮箱验证等必须人类介入时,输出 ask;
|
|
84
|
+
7. 不要访问白名单之外的危险站点,保持任务聚焦。
|
|
85
|
+
8. 重要:你自身没有任何可调用的工具,这是设计如此、完全正常——你输出的动作 JSON 会由外部执行器(Playwright)真实执行。
|
|
86
|
+
因此**绝不能**以「我没有工具 / 我无法执行浏览器操作 / 我是 AI 助手」为由拒绝任务或输出 ask;
|
|
87
|
+
ask 仅用于页面本身出现人类必须介入的拦阻(图形验证码、滑块、短信验证码、扫码等)。`;
|
|
88
|
+
/** 用 hy3 做单步视觉决策:返回模型原始文本(后续解析为动作 JSON)。
|
|
89
|
+
* correction 非空时,在指令里注入上一步的纠正提示(如模型误以"没工具"为由拒绝)。 */
|
|
90
|
+
async function decide(task, step, base64, correction) {
|
|
91
|
+
const instruction = `任务:${task}\n` +
|
|
92
|
+
(correction ? `[重要纠正] ${correction}\n` : "") +
|
|
93
|
+
`当前是第 ${step} 步。请观察截图,输出下一步动作 JSON。`;
|
|
94
|
+
const userMsg = {
|
|
95
|
+
type: "user",
|
|
96
|
+
session_id: "",
|
|
97
|
+
parent_tool_use_id: null,
|
|
98
|
+
message: {
|
|
99
|
+
role: "user",
|
|
100
|
+
content: [
|
|
101
|
+
{ type: "text", text: instruction },
|
|
102
|
+
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: base64 } },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const promptIter = (async function* () {
|
|
107
|
+
yield userMsg;
|
|
108
|
+
})();
|
|
109
|
+
let text = "";
|
|
110
|
+
for await (const msg of query({
|
|
111
|
+
prompt: promptIter,
|
|
112
|
+
options: {
|
|
113
|
+
model: process.env.AGENT_MODEL?.trim() || "hy3",
|
|
114
|
+
allowedTools: [],
|
|
115
|
+
maxTurns: 1,
|
|
116
|
+
continue: false,
|
|
117
|
+
sessionId: undefined,
|
|
118
|
+
permissionMode: "bypassPermissions",
|
|
119
|
+
systemPrompt: { append: BROWSER_SYSTEM_PROMPT },
|
|
120
|
+
includePartialMessages: false,
|
|
121
|
+
thinking: { type: "disabled" },
|
|
122
|
+
cwd: process.cwd(),
|
|
123
|
+
},
|
|
124
|
+
})) {
|
|
125
|
+
if (msg.type === "assistant") {
|
|
126
|
+
for (const block of msg.message.content) {
|
|
127
|
+
if (block.type === "text")
|
|
128
|
+
text += block.text;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
/** 容错 JSON 解析:先试原串,再修尾随逗号;失败回 null */
|
|
135
|
+
function tryJson(s) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(s);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const fixed = s.replace(/,(\s*[}\]])/g, "$1");
|
|
144
|
+
return JSON.parse(fixed);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** 从文本提取第一个 {...} 平衡块 */
|
|
151
|
+
function extractBraces(raw) {
|
|
152
|
+
const start = raw.indexOf("{");
|
|
153
|
+
if (start < 0)
|
|
154
|
+
return "";
|
|
155
|
+
let depth = 0;
|
|
156
|
+
for (let i = start; i < raw.length; i++) {
|
|
157
|
+
const c = raw[i];
|
|
158
|
+
if (c === "{")
|
|
159
|
+
depth++;
|
|
160
|
+
else if (c === "}") {
|
|
161
|
+
depth--;
|
|
162
|
+
if (depth === 0)
|
|
163
|
+
return raw.slice(start, i + 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return raw.slice(start);
|
|
167
|
+
}
|
|
168
|
+
/** 正则兜底:当 JSON 完全无法解析时,直接从文本抽取字段 */
|
|
169
|
+
function regexExtract(raw) {
|
|
170
|
+
const am = raw.match(/"action"\s*:\s*"(click|type|scroll|navigate|wait|ask|done)"/i);
|
|
171
|
+
if (!am)
|
|
172
|
+
return null;
|
|
173
|
+
const action = am[1].toLowerCase();
|
|
174
|
+
const obj = { action };
|
|
175
|
+
const pick = (key, re) => {
|
|
176
|
+
const m = raw.match(re);
|
|
177
|
+
if (m)
|
|
178
|
+
obj[key] = m[1];
|
|
179
|
+
};
|
|
180
|
+
pick("x", /"x"\s*:\s*([\d.]+)/);
|
|
181
|
+
pick("y", /"y"\s*:\s*([\d.]+)/);
|
|
182
|
+
pick("text", /"text"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
183
|
+
pick("url", /"url"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
184
|
+
pick("direction", /"direction"\s*:\s*"(up|down)"/i);
|
|
185
|
+
pick("amount", /"amount"\s*:\s*([\d.]+)/);
|
|
186
|
+
pick("ms", /"ms"\s*:\s*([\d.]+)/);
|
|
187
|
+
pick("question", /"question"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
188
|
+
pick("summary", /"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
189
|
+
return obj;
|
|
190
|
+
}
|
|
191
|
+
function buildAction(obj) {
|
|
192
|
+
const action = String(obj.action || "");
|
|
193
|
+
switch (action) {
|
|
194
|
+
case "click":
|
|
195
|
+
return { action: "click", x: Number(obj.x) || 0, y: Number(obj.y) || 0 };
|
|
196
|
+
case "type":
|
|
197
|
+
return { action: "type", text: String(obj.text ?? "") };
|
|
198
|
+
case "scroll":
|
|
199
|
+
return { action: "scroll", direction: obj.direction === "up" ? "up" : "down", amount: Number(obj.amount) || 600 };
|
|
200
|
+
case "navigate":
|
|
201
|
+
return { action: "navigate", url: String(obj.url ?? "") };
|
|
202
|
+
case "wait":
|
|
203
|
+
return { action: "wait", ms: Number(obj.ms) || 1500 };
|
|
204
|
+
case "ask":
|
|
205
|
+
return { action: "ask", question: String(obj.question ?? "需要人工接管") };
|
|
206
|
+
case "done":
|
|
207
|
+
return { action: "done", summary: String(obj.summary ?? "") };
|
|
208
|
+
default:
|
|
209
|
+
return { action: "wait", ms: 1500 };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** 解析模型输出为动作;valid=false 表示进入兜底(用于卡死检测) */
|
|
213
|
+
function parseAction(raw) {
|
|
214
|
+
const cleaned = raw.replace(/```json|```/gi, "").trim();
|
|
215
|
+
const slice = extractBraces(cleaned);
|
|
216
|
+
const obj = (slice && tryJson(slice)) || regexExtract(cleaned);
|
|
217
|
+
if (!obj || !obj.action)
|
|
218
|
+
return { action: { action: "wait", ms: 1500 }, valid: false };
|
|
219
|
+
return { action: buildAction(obj), valid: true };
|
|
220
|
+
}
|
|
221
|
+
function clamp(v, max) {
|
|
222
|
+
if (!Number.isFinite(v))
|
|
223
|
+
return 0;
|
|
224
|
+
return Math.max(0, Math.min(max, Math.round(v)));
|
|
225
|
+
}
|
|
226
|
+
function hostAllowed(url, hosts) {
|
|
227
|
+
if (hosts.length === 0)
|
|
228
|
+
return true;
|
|
229
|
+
try {
|
|
230
|
+
const h = new URL(url).hostname;
|
|
231
|
+
return hosts.some((a) => h === a || h.endsWith(`.${a}`));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function delay(ms) {
|
|
238
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
239
|
+
}
|
|
240
|
+
/** 判定 ask 是否属于"模型误以为自己没工具"的无效接管(而非页面本身的验证拦阻)。
|
|
241
|
+
* 这类拒绝不该转给人工,应作为无效步纠正后重试。 */
|
|
242
|
+
function isCapabilityRefusal(question) {
|
|
243
|
+
return /没有[^。]*(工具|自动化)|无法[^。]*(执行|操作|驱动|点击|输入)|作为(一?个)?(AI|人工智能|助手|语言模型)|(不能|无法)[^。]*(真正|实际)[^。]*(执行|操作)/i.test(question);
|
|
244
|
+
}
|
|
245
|
+
function sseFrame(event, data) {
|
|
246
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
247
|
+
}
|
|
248
|
+
const enc = new TextEncoder();
|
|
249
|
+
/**
|
|
250
|
+
* BrowserAgentService:浏览器 computer-use 单例。
|
|
251
|
+
* 同时服务「手动入口(/api/browser/session)」与「自主 control_browser 工具」。
|
|
252
|
+
* 同一时刻串行执行一个任务(共享 profile 目录)。
|
|
253
|
+
*/
|
|
254
|
+
export class BrowserAgentService {
|
|
255
|
+
static instance = null;
|
|
256
|
+
queue = Promise.resolve();
|
|
257
|
+
profileDir;
|
|
258
|
+
sessions = new Map();
|
|
259
|
+
/** 下一个任务的聊天流订阅者:control_browser 工具经 canUseTool 拦截时设置,
|
|
260
|
+
* run() 创建会话后自动挂到该会话,浏览器过程事件同步转发进聊天 SSE。 */
|
|
261
|
+
pendingSubscriber = null;
|
|
262
|
+
constructor() {
|
|
263
|
+
this.profileDir = defaultProfileDir();
|
|
264
|
+
try {
|
|
265
|
+
mkdirSync(this.profileDir, { recursive: true });
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* ignore */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
static get() {
|
|
272
|
+
if (!BrowserAgentService.instance)
|
|
273
|
+
BrowserAgentService.instance = new BrowserAgentService();
|
|
274
|
+
return BrowserAgentService.instance;
|
|
275
|
+
}
|
|
276
|
+
/** 注册下一个任务的聊天流订阅者(canUseTool 拦截 control_browser 时调用) */
|
|
277
|
+
setPendingSubscriber(cb) {
|
|
278
|
+
this.pendingSubscriber = cb;
|
|
279
|
+
}
|
|
280
|
+
/** 启动一个浏览器任务(异步执行),立即返回会话 id 与完成 Promise。
|
|
281
|
+
* headless 模式无可见窗口;过程事件经 subscriber 转发进聊天 thinking 展示。 */
|
|
282
|
+
run(task, maxSteps, notify = true) {
|
|
283
|
+
const id = `ba-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
284
|
+
const session = {
|
|
285
|
+
id,
|
|
286
|
+
controller: null,
|
|
287
|
+
buffer: [],
|
|
288
|
+
stopped: false,
|
|
289
|
+
context: null,
|
|
290
|
+
resolveAsk: null,
|
|
291
|
+
askReject: null,
|
|
292
|
+
subscriber: this.pendingSubscriber,
|
|
293
|
+
};
|
|
294
|
+
this.pendingSubscriber = null; // 只绑定一次
|
|
295
|
+
this.sessions.set(id, session);
|
|
296
|
+
const done = this.queue.then(() => this.doRun(task, maxSteps, session));
|
|
297
|
+
this.queue = done.then(() => undefined, () => undefined);
|
|
298
|
+
if (notify) {
|
|
299
|
+
try {
|
|
300
|
+
process.stdout.write(`BROWSER_SESSION_START ${id}\n`);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* ignore */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { id, done };
|
|
307
|
+
}
|
|
308
|
+
/** 自主工具调用入口:返回最终结论(headless 无弹窗;过程事件进聊天 thinking) */
|
|
309
|
+
runTask(task, maxSteps) {
|
|
310
|
+
return this.run(task, maxSteps, true).done;
|
|
311
|
+
}
|
|
312
|
+
/** 把已存在的会话 SSE 流挂到某个控制面板(手动入口或自主模式自动弹窗都走这里) */
|
|
313
|
+
attach(id) {
|
|
314
|
+
const session = this.sessions.get(id);
|
|
315
|
+
// NodeReadableStream(node:stream/web)与全局 ReadableStream(undici-types)类型存在
|
|
316
|
+
// NonSharedArrayBufferView 细节差异;两者运行时等价,这里做类型收窄。
|
|
317
|
+
return new NodeReadableStream({
|
|
318
|
+
start: (controller) => {
|
|
319
|
+
if (!session) {
|
|
320
|
+
controller.enqueue(enc.encode(sseFrame("error", { text: "session 不存在或已结束" })));
|
|
321
|
+
controller.close();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (session.controller) {
|
|
325
|
+
controller.enqueue(enc.encode(sseFrame("error", { text: "该 session 已被其他面板连接" })));
|
|
326
|
+
controller.close();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
session.controller = controller;
|
|
330
|
+
// 回放已发生的事件(自主模式下面板可能晚于任务启动打开)
|
|
331
|
+
let terminal = false;
|
|
332
|
+
for (const ev of session.buffer) {
|
|
333
|
+
if (ev.event === "finished" || ev.event === "stopped" || ev.event === "error")
|
|
334
|
+
terminal = true;
|
|
335
|
+
try {
|
|
336
|
+
controller.enqueue(enc.encode(sseFrame(ev.event, ev.data)));
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* ignore */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (terminal) {
|
|
343
|
+
try {
|
|
344
|
+
controller.close();
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
/* ignore */
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
input(id, text) {
|
|
354
|
+
const s = this.sessions.get(id);
|
|
355
|
+
if (!s || !s.resolveAsk)
|
|
356
|
+
return false;
|
|
357
|
+
const resolve = s.resolveAsk;
|
|
358
|
+
s.resolveAsk = null;
|
|
359
|
+
s.askReject = null;
|
|
360
|
+
resolve(text || "");
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
stop(id) {
|
|
364
|
+
const s = this.sessions.get(id);
|
|
365
|
+
if (!s)
|
|
366
|
+
return false;
|
|
367
|
+
s.stopped = true;
|
|
368
|
+
if (s.askReject) {
|
|
369
|
+
s.askReject(new Error("已急停"));
|
|
370
|
+
s.askReject = null;
|
|
371
|
+
s.resolveAsk = null;
|
|
372
|
+
}
|
|
373
|
+
void s.context?.close().catch(() => undefined);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
async doRun(task, maxSteps, session) {
|
|
377
|
+
const hosts = allowedHosts();
|
|
378
|
+
const emit = (event, data) => {
|
|
379
|
+
// 聊天流订阅者(control_browser 经 canUseTool 挂上的):事件带会话 id,前端据此
|
|
380
|
+
// 定位 thinking 中的浏览器步骤、渲染截图/动作,并在 ask 时回传人工答案。
|
|
381
|
+
if (session.subscriber) {
|
|
382
|
+
try {
|
|
383
|
+
session.subscriber(event, { id: session.id, ...data });
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* 订阅者异常不影响浏览器任务 */
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
session.buffer.push({ event, data });
|
|
390
|
+
if (session.buffer.length > 120)
|
|
391
|
+
session.buffer.shift();
|
|
392
|
+
if (session.controller) {
|
|
393
|
+
try {
|
|
394
|
+
session.controller.enqueue(enc.encode(sseFrame(event, data)));
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* 客户端断开,忽略 */
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (event === "thinking" || event === "action" || event === "done" || event === "error") {
|
|
401
|
+
console.log(`[browser-agent ${session.id} ${event}] ${JSON.stringify(data).slice(0, 200)}`);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const cdpPort = await findFreePort();
|
|
405
|
+
const context = await chromium.launchPersistentContext(this.profileDir, {
|
|
406
|
+
// 无头模式:不弹独立浏览器窗口;过程截图经 subscriber 转发进聊天 thinking 展示
|
|
407
|
+
headless: true,
|
|
408
|
+
viewport: { ...VIEWPORT },
|
|
409
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
410
|
+
// 远程调试端口:暴露 CDP,前端右侧边栏借此把"用户点击截图"转发进同一个浏览器,
|
|
411
|
+
// 实现内嵌预览可点击、且与 AI 操控同一实例(截图=视口 1:1,坐标可直接映射)。
|
|
412
|
+
args: ["--no-sandbox", "--disable-dev-shm-usage", `--remote-debugging-port=${cdpPort}`],
|
|
413
|
+
});
|
|
414
|
+
session.context = context;
|
|
415
|
+
// 告知前端本会话浏览器的 CDP 端口(browser_cdp 帧 → 右侧预览点击连接用)
|
|
416
|
+
emit("cdp", { port: cdpPort, sessionId: session.id });
|
|
417
|
+
// 关键修复:持久上下文(launchPersistentContext)会恢复上次会话的标签页,
|
|
418
|
+
// 全新 profile 时也会默认开一个起始空白 tab;而 newPage() 又会再开一个,
|
|
419
|
+
// 结果就是“启动即 2 个 tab,我们在第二个工作、第一个一直停在别处”。
|
|
420
|
+
// 这里先新建唯一的工作页,再关闭所有启动时自带的残留标签,保证全程只有 1 个 tab。
|
|
421
|
+
const page = await context.newPage();
|
|
422
|
+
for (const stale of context.pages()) {
|
|
423
|
+
if (stale === page)
|
|
424
|
+
continue;
|
|
425
|
+
await stale.close().catch(() => undefined);
|
|
426
|
+
}
|
|
427
|
+
const steps = [];
|
|
428
|
+
let stopped = false;
|
|
429
|
+
let summary = "";
|
|
430
|
+
let idleStreak = 0;
|
|
431
|
+
let correction;
|
|
432
|
+
const limit = Math.min(Math.max(1, maxSteps ?? DEFAULT_MAX_STEPS), 40);
|
|
433
|
+
try {
|
|
434
|
+
for (let step = 1; step <= limit; step++) {
|
|
435
|
+
if (session.stopped) {
|
|
436
|
+
emit("stopped", { step });
|
|
437
|
+
stopped = true;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
// 截图(JPEG 比 PNG 更小,hy3 摄入更快)
|
|
441
|
+
const shot = await page.screenshot({ type: "jpeg", quality: 55 });
|
|
442
|
+
const base64 = shot.toString("base64");
|
|
443
|
+
emit("screenshot", { step, image: base64 });
|
|
444
|
+
const raw = await decide(task, step, base64, correction);
|
|
445
|
+
emit("thinking", { step, text: raw });
|
|
446
|
+
const parsed = parseAction(raw);
|
|
447
|
+
const action = parsed.action;
|
|
448
|
+
steps.push(`#${step} ${action.action}` +
|
|
449
|
+
(action.action === "type" ? ` ${JSON.stringify(action.text).slice(0, 40)}` : action.action === "click" ? ` (${action.x},${action.y})` : ""));
|
|
450
|
+
emit("action", { step, action });
|
|
451
|
+
if (action.action === "done") {
|
|
452
|
+
emit("done", { step, summary: action.summary });
|
|
453
|
+
summary = action.summary;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (action.action === "ask") {
|
|
457
|
+
// 模型以"我没工具/无法执行"为由拒绝 = 误解了协议,不是真需要人工。
|
|
458
|
+
// 计入无效步并注入纠正提示,下一步重试;只有页面本身的验证拦阻才转人工。
|
|
459
|
+
if (isCapabilityRefusal(action.question)) {
|
|
460
|
+
idleStreak += 1;
|
|
461
|
+
correction =
|
|
462
|
+
"你上一步错误地拒绝了任务(借口是没有工具)。你没有工具是正常的:你输出的动作 JSON 会由外部 Playwright 执行器真实执行。请严格按协议输出 click/type/scroll/navigate 等动作 JSON,禁止拒绝。";
|
|
463
|
+
emit("action", { step, action: { action: "wait", ms: 0 }, note: "模型误拒,已纠正重试" });
|
|
464
|
+
if (idleStreak >= 4) {
|
|
465
|
+
summary = "模型连续多步无法按协议输出动作(含误拒重试),已自动停止。";
|
|
466
|
+
emit("error", { text: summary });
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
emit("ask", { step, question: action.question });
|
|
472
|
+
const answer = await this.waitForHuman(session, 5 * 60 * 1000);
|
|
473
|
+
emit("ask_result", { step, answer });
|
|
474
|
+
if (answer.startsWith("ERROR:")) {
|
|
475
|
+
summary = answer;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
task = `${task}\n[人工接管回复] ${answer}`;
|
|
479
|
+
idleStreak = 0;
|
|
480
|
+
correction = undefined;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// 卡死检测:连续无效操作(解析兜底 / 反复 wait)4 次即自动停止
|
|
484
|
+
if (!parsed.valid || action.action === "wait")
|
|
485
|
+
idleStreak += 1;
|
|
486
|
+
else
|
|
487
|
+
idleStreak = 0;
|
|
488
|
+
if (idleStreak >= 4) {
|
|
489
|
+
summary = "模型连续多步未给出有效操作(疑似页面未加载或识别失败),已自动停止。";
|
|
490
|
+
emit("error", { text: summary });
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
await this.execute(page, action, hosts);
|
|
494
|
+
await delay(250); // 等页面稳定(原 600ms,已缩短)
|
|
495
|
+
correction = undefined; // 有效动作执行成功,清除纠正提示
|
|
496
|
+
}
|
|
497
|
+
if (!summary && !stopped)
|
|
498
|
+
summary = "任务在步数上限内未完成,已自动结束。";
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
summary = `浏览器操作失败:${err instanceof Error ? err.message : String(err)}`;
|
|
502
|
+
emit("error", { text: summary });
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
const finalUrl = page.url() || "";
|
|
506
|
+
const finalTitle = await page.title().catch(() => "");
|
|
507
|
+
await context.close().catch(() => undefined);
|
|
508
|
+
this.sessions.delete(session.id);
|
|
509
|
+
emit("finished", { id: session.id });
|
|
510
|
+
return { summary, finalUrl, finalTitle, steps, stopped };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async waitForHuman(session, timeoutMs) {
|
|
514
|
+
return new Promise((resolve, reject) => {
|
|
515
|
+
session.resolveAsk = resolve;
|
|
516
|
+
session.askReject = reject;
|
|
517
|
+
const t = setTimeout(() => reject(new Error("人工接管超时(5 分钟)")), timeoutMs);
|
|
518
|
+
const orig = session.resolveAsk;
|
|
519
|
+
const wrapped = (v) => {
|
|
520
|
+
clearTimeout(t);
|
|
521
|
+
session.resolveAsk = null;
|
|
522
|
+
session.askReject = null;
|
|
523
|
+
orig?.(v);
|
|
524
|
+
};
|
|
525
|
+
session.resolveAsk = wrapped;
|
|
526
|
+
}).catch((err) => {
|
|
527
|
+
session.resolveAsk = null;
|
|
528
|
+
session.askReject = null;
|
|
529
|
+
return `ERROR:${err instanceof Error ? err.message : String(err)}`;
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
async execute(page, action, hosts) {
|
|
533
|
+
switch (action.action) {
|
|
534
|
+
case "click":
|
|
535
|
+
await page.mouse.click(clamp(action.x, VIEWPORT.width), clamp(action.y, VIEWPORT.height));
|
|
536
|
+
break;
|
|
537
|
+
case "type":
|
|
538
|
+
await page.keyboard.type(action.text);
|
|
539
|
+
break;
|
|
540
|
+
case "scroll": {
|
|
541
|
+
const dy = (action.direction === "up" ? -1 : 1) * (action.amount ?? 600);
|
|
542
|
+
await page.mouse.wheel(0, dy);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
case "navigate":
|
|
546
|
+
if (!hostAllowed(action.url, hosts)) {
|
|
547
|
+
throw new Error(`目标域名不在白名单:${action.url}`);
|
|
548
|
+
}
|
|
549
|
+
await page.goto(action.url, { waitUntil: "domcontentloaded", timeout: 25_000 });
|
|
550
|
+
break;
|
|
551
|
+
case "wait":
|
|
552
|
+
await delay(action.ms ?? 1500);
|
|
553
|
+
break;
|
|
554
|
+
default:
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/** 每次调用创建独立 McpServer 实例(绝不共享):agent-sdk 每个 query 的 transport 都会对
|
|
560
|
+
* 传入实例调用 server.connect(),而 MCP Protocol 实例只允许 connect 一次;共享实例在并发
|
|
561
|
+
* 或先后复用时抛 "Already connected to a transport"。薄壳开销可忽略(BrowserAgentService 为单例)。 */
|
|
562
|
+
export function createBrowserAgentMcpServer() {
|
|
563
|
+
const service = BrowserAgentService.get();
|
|
564
|
+
return createSdkMcpServer({
|
|
565
|
+
name: "browser-agent",
|
|
566
|
+
version: "1.0.0",
|
|
567
|
+
tools: [
|
|
568
|
+
tool("control_browser", "用 AI 自主控制浏览器去完成「必须在网页上真正交互操作」才能完成的任务:在页面上点击、输入文字、填写并提交表单、登录账号,或需要多步导航才能拿到的信息(例如:在搜索引擎搜某词并取结果、登录后查某信息、在网页表单填写并提交)。本工具会启动一个无头浏览器,模型截图并逐步自动操作(不弹出独立窗口),最终返回任务结果摘要、最终页面 URL/标题与操作步数,过程截图会实时展示在对话思考步骤中。重要边界:如果任务只是「读取 / 显示 / 展示某个网页或文章的内容(含图片)」,不要用本工具——那是 web-fetch 的 fetch_webpage 的职责(更快更稳、专为只读抓取设计)。control_browser 仅用于需要「动」网页(点击、输入、填表、登录等交互)才能达成的目标。", {
|
|
569
|
+
task: z.string().describe("要达成的浏览器操作目标,用自然语言描述,例如:打开 https://www.baidu.com ,搜索「北京天气」,返回第一条结果的标题"),
|
|
570
|
+
max_steps: z
|
|
571
|
+
.number()
|
|
572
|
+
.int()
|
|
573
|
+
.min(1)
|
|
574
|
+
.max(40)
|
|
575
|
+
.optional()
|
|
576
|
+
.describe("最大操作步数,默认 25;复杂任务可调大到 40"),
|
|
577
|
+
}, async ({ task, max_steps }) => {
|
|
578
|
+
try {
|
|
579
|
+
const result = await service.runTask(task, max_steps);
|
|
580
|
+
console.log(`[agent-kit browser-agent] 任务完成 summary=${result.summary.slice(0, 120)} 步数=${result.steps.length} finalUrl=${result.finalUrl}`);
|
|
581
|
+
return {
|
|
582
|
+
content: [
|
|
583
|
+
{
|
|
584
|
+
type: "text",
|
|
585
|
+
text: JSON.stringify({
|
|
586
|
+
summary: result.summary,
|
|
587
|
+
finalUrl: result.finalUrl,
|
|
588
|
+
finalTitle: result.finalTitle,
|
|
589
|
+
steps: result.steps,
|
|
590
|
+
}),
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
console.error(`[agent-kit browser-agent] 执行失败:`, err instanceof Error ? err.stack || err.message : err);
|
|
597
|
+
return {
|
|
598
|
+
content: [
|
|
599
|
+
{ type: "text", text: `浏览器操作失败:${err instanceof Error ? err.message : String(err)}` },
|
|
600
|
+
],
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
}),
|
|
604
|
+
],
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/** 工具在模型上下文中暴露的名称(server 名连字符转下划线) */
|
|
608
|
+
export const BROWSER_AGENT_TOOL_NAME = "mcp__browser_agent__control_browser";
|