feat(server): browser module with DB-backed sessions, takeover lease and browser.* capabilities
/browser/providers, /browser/sessions (open/close/list), navigate/snapshot/screenshot/ click/type/scroll/select/extract/download and control acquire/release. Human input needs an active lease; expiry or captcha moves the session to waiting with a decision; release forces a fresh snapshot. Downloads are staged under DATA_DIR/tmp, verified by infohash and submitted through POST /acquisition-plans. Capability count 21 -> 25. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c17d4b90bd
commit
adffe36846
12 files changed
+2133
-5
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { isLoginUrl } from "@umt/adapters";
|
||||
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
||||
import { capabilitySpec } from "@umt/contracts";
|
||||
import type { Actor, BrowserService } from "./service.ts";
|
||||
import { BrowserServiceError } from "./service.ts";
|
||||
|
||||
// C1-E Agent 浏览器能力:browser.snapshot(read)/ browser.navigate / browser.interact / browser.download(execute)。
|
||||
// 一律以自动化身份执行:会话被人工接管或在等待处理时拒绝;元素引用只来自最近一次 snapshot;
|
||||
// 凭证用 credential 字段由服务端注入;下载只暂存核验并匹配本地资源,提交仍走 download.plan / download.submit。
|
||||
|
||||
const CODE = {
|
||||
not_found: "not_found",
|
||||
conflict: "conflict",
|
||||
precondition: "precondition",
|
||||
bad_request: "precondition",
|
||||
upstream: "precondition",
|
||||
rate_limited: "budget",
|
||||
} as const;
|
||||
|
||||
async function guard<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserServiceError) throw new CapabilityError(CODE[e.code], e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const agent = (actorId: string): Actor => ({ kind: "automation", holder: actorId });
|
||||
|
||||
export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: () => BrowserService): void {
|
||||
/** sessionId 优先;否则取站点的未关闭会话 */
|
||||
const resolveSession = async (input: { sessionId?: string | undefined; siteId?: string | undefined }) => {
|
||||
if (input.sessionId) return input.sessionId;
|
||||
const row = input.siteId ? await svc().liveSessionFor(input.siteId) : undefined;
|
||||
if (!row) throw new BrowserServiceError("not_found", "该站点没有浏览器会话,先用 browser.navigate 打开页面");
|
||||
return row.id;
|
||||
};
|
||||
|
||||
registry.register(capabilitySpec("browser.snapshot"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
const id = await resolveSession(input);
|
||||
const snap = await svc().run(
|
||||
id,
|
||||
agent(ctx.actorId),
|
||||
(p, ref) => p.snapshot(ref, input.offset !== undefined ? { offset: input.offset } : {}),
|
||||
{ write: false, patch: () => ({ needsSnapshot: false }), ...(input.tabId ? { tabId: input.tabId } : {}) },
|
||||
);
|
||||
const s = svc();
|
||||
const loginPage = isLoginUrl(snap.url || null);
|
||||
await s.afterAutomationSnapshot(id, snap.text, loginPage);
|
||||
const view = await s.view(id);
|
||||
return {
|
||||
snapshot: snap,
|
||||
controller: view.controller,
|
||||
waitingReason: view.waitingReason,
|
||||
loginRequired: loginPage,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
registry.register(capabilitySpec("browser.navigate"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
let id = input.sessionId;
|
||||
if (!id) {
|
||||
const live = await svc().liveSessionFor(input.siteId!, input.accountId);
|
||||
id =
|
||||
live?.id ??
|
||||
(await svc().open({
|
||||
siteId: input.siteId!,
|
||||
...(input.accountId ? { accountId: input.accountId } : {}),
|
||||
url: input.url,
|
||||
}));
|
||||
if (!live) {
|
||||
const v = await svc().view(id);
|
||||
const info = v.currentUrl ?? "";
|
||||
return {
|
||||
sessionId: id,
|
||||
url: info,
|
||||
httpStatus: null,
|
||||
ok: true,
|
||||
attempts: 1,
|
||||
loginRequired: isLoginUrl(info || null),
|
||||
controller: v.controller,
|
||||
};
|
||||
}
|
||||
}
|
||||
const sid = id;
|
||||
const nav = await svc().run(
|
||||
sid,
|
||||
agent(ctx.actorId),
|
||||
async (p, ref, x) => {
|
||||
await svc().limited(x.site);
|
||||
const r = await p.navigate(ref, input.url, x.budget);
|
||||
return { ...r, loginRequired: p.sessionInfo(sid)?.loginPage ?? false };
|
||||
},
|
||||
input.tabId ? { tabId: input.tabId } : {},
|
||||
);
|
||||
const v = await svc().view(sid);
|
||||
return { sessionId: sid, ...nav, controller: v.controller };
|
||||
}),
|
||||
);
|
||||
|
||||
registry.register(capabilitySpec("browser.interact"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
const s = svc();
|
||||
if (input.action === "request_human") {
|
||||
await s.requestHuman(input.sessionId, input.reason ?? "agent_request");
|
||||
const v = await s.view(input.sessionId);
|
||||
return { ok: true as const, controller: v.controller, needsSnapshot: true };
|
||||
}
|
||||
const needTarget = input.action === "click" || input.action === "select";
|
||||
if (needTarget && !input.target) throw new BrowserServiceError("bad_request", `${input.action} 需要 target`);
|
||||
await s.run(
|
||||
input.sessionId,
|
||||
agent(ctx.actorId),
|
||||
async (p, ref, x) => {
|
||||
switch (input.action) {
|
||||
case "click":
|
||||
return p.click(ref, input.target!);
|
||||
case "select":
|
||||
return p.select(ref, input.target!, input.value ?? "");
|
||||
case "scroll":
|
||||
return p.scroll(ref, { direction: input.direction ?? "down", amount: input.amount ?? 600 });
|
||||
case "press":
|
||||
return p.press(ref, input.key ?? "Enter");
|
||||
case "type": {
|
||||
const extra = input.submit !== undefined ? { submit: input.submit } : {};
|
||||
if (input.credential)
|
||||
return p.type(ref, input.target ?? null, {
|
||||
kind: "secret",
|
||||
siteId: x.row.site_id,
|
||||
accountId: x.row.account_id,
|
||||
field: input.credential,
|
||||
...extra,
|
||||
});
|
||||
if (input.text === undefined)
|
||||
throw new BrowserServiceError("bad_request", "type 需要 text 或 credential");
|
||||
return p.type(ref, input.target ?? null, { kind: "text", text: input.text, ...extra });
|
||||
}
|
||||
}
|
||||
},
|
||||
input.tabId ? { tabId: input.tabId } : {},
|
||||
);
|
||||
const info = (await s.view(input.sessionId)).controller;
|
||||
const changes = input.action === "click" || input.action === "press" || !!input.submit;
|
||||
return { ok: true as const, controller: info, needsSnapshot: changes };
|
||||
}),
|
||||
);
|
||||
|
||||
registry.register(capabilitySpec("browser.download"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
const s = svc();
|
||||
const artifact = await s.run(
|
||||
input.sessionId,
|
||||
agent(ctx.actorId),
|
||||
async (p, ref) => {
|
||||
if (input.target) await p.click(ref, input.target);
|
||||
return p.waitForDownload(ref, { timeoutMs: input.timeoutMs, maxBytes: 10 * 1024 * 1024 });
|
||||
},
|
||||
input.tabId ? { tabId: input.tabId } : {},
|
||||
);
|
||||
const v = await s.verifyDownload(input.sessionId, artifact);
|
||||
await s.recordDownload(input.sessionId, {
|
||||
sha256: artifact.sha256,
|
||||
artifactId: artifact.id,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
planId: v.duplicate?.planId ?? null,
|
||||
});
|
||||
return {
|
||||
artifactId: artifact.id,
|
||||
filename: artifact.filename,
|
||||
sizeBytes: artifact.sizeBytes,
|
||||
sha256: artifact.sha256,
|
||||
verification: v.verification,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
duplicate: !!v.duplicate,
|
||||
note: v.duplicate
|
||||
? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交"
|
||||
: !v.verification.valid
|
||||
? "不是有效的种子文件"
|
||||
: v.sourceReleaseId
|
||||
? "已暂存核验;用 download.plan 提交这个 sourceReleaseId"
|
||||
: "资源不在本地索引;先 release.search 该站点后再提交",
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user