Files
UHD_Media_Tool/packages/contracts/src/browser.ts
T
root f41cd96fee Merge branch 'hh2'
# Conflicts:
#	apps/server/src/modules/browser/service.ts
#	apps/web/app/components/browser/desktop-view.tsx
#	apps/web/app/components/browser/site-origin-view.tsx
#	packages/contracts/src/browser.ts
2026-09-29 17:43:00 +08:00

535 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { ManualAcquisitionResult } from "./execution.ts";
import { InstanceHealth } from "./instances.ts";
// 浏览器能力合同(REQ §4.4、§4.7.2–4.7.4、§12 浏览器实体)。
// 这是本项目自有接口,不等于上游同名接口:上游对接由 @umt/adapters 的 BrowserProvider 实现
// (首个实现为用户已有的 camofox-browser;Playwright 只用于前端截图测试,不是产品浏览器能力)。
// v0.1 只落合同与骨架,会话/接管/下载捕获在 M2 原型验证后启用(docs/design/browser-provider.md)。
/** 浏览器服务实现种类;none = 未配置(默认),所有浏览器动作返回不可用 */
export const BrowserProviderKind = z.enum(["camofox", "none"]);
export type BrowserProviderKind = z.infer<typeof BrowserProviderKind>;
/**
* 单项能力声明。supported = 实现/上游声明具备;verified = 已对用户真实实例做过原型验证(REQ §4.7.4)。
* 界面只把 supported && verified 的能力当作可用,未验证的明确标注"未验证",不得用其他浏览器替代后宣称可用。
*/
export const BrowserCapabilityFlag = z.object({
supported: z.boolean(),
verified: z.boolean(),
/** static = 适配器按文档静态声明;probed = 从服务端 OpenAPI/健康端点探测到 */
source: z.enum(["static", "probed"]),
note: z.string().optional(),
});
export type BrowserCapabilityFlag = z.infer<typeof BrowserCapabilityFlag>;
export const BROWSER_CAPABILITY_KEYS = [
"sessions",
"tabs",
"navigate",
"snapshot",
"screenshot",
"interact",
"extract",
"downloadCapture",
"takeover",
"persistentProfile",
] as const;
export type BrowserCapabilityKey = (typeof BROWSER_CAPABILITY_KEYS)[number];
export const BrowserCapabilities = z.object({
/** 会话(站点/账号隔离的浏览器上下文) */
sessions: BrowserCapabilityFlag,
/** 标签页列举/新建/关闭 */
tabs: BrowserCapabilityFlag,
navigate: BrowserCapabilityFlag,
/** 结构化快照(可访问性树 + 元素引用),非原始 HTML */
snapshot: BrowserCapabilityFlag,
screenshot: BrowserCapabilityFlag,
/** click / type / select */
interact: BrowserCapabilityFlag,
/** 结构化提取(按 schema 取字段) */
extract: BrowserCapabilityFlag,
/** 捕获浏览器下载产物(.torrent 等)交后端暂存核验 */
downloadCapture: BrowserCapabilityFlag,
/** 人工接管:同一会话的画面与输入通道 */
takeover: BrowserCapabilityFlag,
/** 登录状态随 profile 持久化,重启后恢复 */
persistentProfile: BrowserCapabilityFlag,
/** 上游版本(来自 OpenAPI info.version 等);未取得为 null */
upstreamVersion: z.string().nullable(),
probedAt: Timestamp.nullable(),
});
export type BrowserCapabilities = z.infer<typeof BrowserCapabilities>;
/** 会话控制权:自动化 / 人工接管 / 等待处理(验证码、2FA 等)/ 已关闭(REQ §4.7.3) */
export const BrowserController = z.enum(["automation", "human", "waiting", "closed"]);
export type BrowserController = z.infer<typeof BrowserController>;
export const BrowserProviderRow = EntityBase.extend({
name: z.string().min(1),
kind: BrowserProviderKind,
baseUrl: z.string().nullable(),
/** 访问凭据(如 camofox CAMOFOX_ACCESS_KEY)只以 secret 引用保存 */
secretRef: Id.nullable(),
enabled: z.boolean(),
/** 最近一次声明/探测到的 BrowserCapabilities 快照 */
capabilities: JsonObject,
/** 最近一次对真实实例完成原型验证的时间;null = 从未验证 */
verifiedAt: Timestamp.nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
config: JsonObject,
});
export type BrowserProviderRow = z.infer<typeof BrowserProviderRow>;
/** 浏览器会话:按站点/账号绑定;同一 profileKey 同时至多一个未关闭会话 */
export const BrowserSession = EntityBase.extend({
providerId: Id,
siteId: Id,
accountId: Id.nullable(),
/** 站点/账号维度的 profile 键,决定上游登录状态隔离与串行锁 */
profileKey: z.string().min(1),
controller: BrowserController,
currentUrl: z.string().nullable(),
lastActivityAt: Timestamp,
/** 上游会话标识(camofox: userId/sessionKey);不含凭据 */
upstreamRef: JsonObject,
/** 接管/断线/重启时的可恢复状态;恢复后必须重新读取页面,不重放旧元素引用 */
recoveryState: JsonObject,
closedAt: Timestamp.nullable(),
closeReason: z.string().nullable(),
});
export type BrowserSession = z.infer<typeof BrowserSession>;
export const BrowserTabStatus = z.enum(["open", "closed", "lost"]);
export type BrowserTabStatus = z.infer<typeof BrowserTabStatus>;
export const BrowserTab = EntityBase.extend({
sessionId: Id,
/** 上游标签页 ID(camofox tabId) */
upstreamTabId: z.string().min(1),
url: z.string().nullable(),
title: z.string().nullable(),
status: BrowserTabStatus,
lastSnapshotAt: Timestamp.nullable(),
closedAt: Timestamp.nullable(),
});
export type BrowserTab = z.infer<typeof BrowserTab>;
export const BrowserLeaseHolderKind = z.enum(["automation", "human"]);
export type BrowserLeaseHolderKind = z.infer<typeof BrowserLeaseHolderKind>;
export const BrowserLeaseStatus = z.enum(["active", "released", "expired", "revoked"]);
export type BrowserLeaseStatus = z.infer<typeof BrowserLeaseStatus>;
/** 会话控制权租约:每个会话同时至多一个 active(DB 部分唯一索引保证) */
export const BrowserControlLease = EntityBase.extend({
sessionId: Id,
holderKind: BrowserLeaseHolderKind,
/** 持有者标识:agent_run id / admin 用户名等 */
holder: z.string().min(1),
reason: z.string().min(1),
status: BrowserLeaseStatus,
acquiredAt: Timestamp,
expiresAt: Timestamp,
releasedAt: Timestamp.nullable(),
releaseReason: z.string().nullable(),
});
export type BrowserControlLease = z.infer<typeof BrowserControlLease>;
/** 导航预算(REQ §4.4):域名范围、超时、重试与资源上限;空白名单 = 拒绝全部导航 */
export const NavigationBudget = z.object({
/** 允许的主机名;"example.org" 同时放行其子域 */
domainAllowlist: z.array(z.string().min(1)),
timeoutMs: z.number().int().min(1),
maxRetries: z.number().int().min(0).max(10),
/** 单次响应(快照文本/截图)字节上限 */
maxBytes: z.number().int().min(1),
});
export type NavigationBudget = z.infer<typeof NavigationBudget>;
export const SnapshotRedactionKind = z.enum(["password_field", "cookie", "credential_url", "secret_value"]);
export type SnapshotRedactionKind = z.infer<typeof SnapshotRedactionKind>;
/**
* 可交给模型的页面快照:只含经 redactSnapshot 脱敏后的结构化文本与元素引用,
* 密码框值、Cookie 值、含 passkey 等凭据的 URL 已遮蔽;不含原始 HTML/脚本。
*/
export const BrowserSnapshot = z.object({
sessionId: Id,
tabId: z.string().min(1),
/** 已脱敏的当前地址 */
url: z.string(),
format: z.enum(["a11y_text"]),
/** 已脱敏的可访问性快照文本,元素以 eN 引用 */
text: z.string(),
/** 表单/表格摘要(已脱敏) */
structure: JsonObject.nullable(),
refsCount: NonNegInt,
truncated: z.boolean(),
nextOffset: NonNegInt.nullable(),
redactions: z.array(z.object({ kind: SnapshotRedactionKind, count: NonNegInt })),
capturedAt: Timestamp,
});
export type BrowserSnapshot = z.infer<typeof BrowserSnapshot>;
/** 二进制内容的引用:blob 暂存路径或 secret(下载产物可能含 passkey,不内联进合同/日志) */
export const BlobRef = z.object({
kind: z.enum(["blob", "secret"]),
ref: z.string().min(1),
});
export type BlobRef = z.infer<typeof BlobRef>;
/** 浏览器捕获的下载产物:交后端受控暂存与核验后再走原有提交链路(REQ §4.7.1) */
export const DownloadArtifact = z.object({
id: Id,
sessionId: Id,
filename: z.string().min(1),
mimeType: z.string().nullable(),
sizeBytes: NonNegInt,
bytes: BlobRef,
/** 十六进制 sha256 */
sha256: z.string().min(1),
/** 已脱敏的下载来源地址(上游提供时) */
sourceUrl: z.string().nullable().optional(),
capturedAt: Timestamp,
});
export type DownloadArtifact = z.infer<typeof DownloadArtifact>;
export const BrowserHealth = z.object({
status: z.enum(["ok", "degraded", "down", "disabled"]),
kind: BrowserProviderKind,
detail: JsonObject,
checkedAt: Timestamp,
});
export type BrowserHealth = z.infer<typeof BrowserHealth>;
export const BrowserErrorKind = z.enum([
"not_implemented",
"unavailable",
"auth",
"timeout",
"network",
"not_found",
"rate_limited",
"bad_request",
"upstream",
"protocol",
"domain_blocked",
"budget_exceeded",
"control_conflict",
"lock_timeout",
/** 元素引用来自导航/交接之前的快照,已失效:先重新读取页面 */
"stale_ref",
]);
export type BrowserErrorKind = z.infer<typeof BrowserErrorKind>;
// ── C1-E:浏览器 API 与 Agent 能力的请求/响应(docs/runbook/browser.md) ──
/** 查询串布尔 */
const QueryBool = z.enum(["true", "false"]).optional();
/** 浏览器服务(GET /browser/providers);凭证只给引用是否存在,不给明文 */
export const BrowserProviderView = z.object({
id: Id,
name: z.string(),
kind: BrowserProviderKind,
baseUrl: z.string().nullable(),
/** 访问凭据的 secret 引用(明文永不出 API) */
secretRef: Id.nullable(),
/** env = 由 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 登记(地址与凭据只能改部署配置);null = 设置页登记 */
managedBy: z.enum(["env"]).nullable(),
/** 是否配置了访问凭据(secret 或环境变量) */
hasAccessKey: z.boolean(),
enabled: z.boolean(),
/** 最近一次声明/探测到的能力;verified 只由人工核验写入(config.verified 或适配器内置的已核验版本表) */
capabilities: BrowserCapabilities.nullable(),
verifiedAt: Timestamp.nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
/** probe=true 时的即时健康结果 */
health: BrowserHealth.nullable(),
});
export type BrowserProviderView = z.infer<typeof BrowserProviderView>;
const HttpUrl = z
.string()
.url()
.refine((u) => /^https?:\/\//i.test(u), "只支持 http(s) 地址");
/** 设置页登记浏览器服务(camofox);accessKey 明文只在请求里出现一次,落库为 secret */
export const BrowserProviderCreate = z.object({
name: z.string().min(1).max(64),
baseUrl: HttpUrl,
accessKey: z.string().min(1).max(512).optional(),
enabled: z.boolean().optional(),
});
export type BrowserProviderCreate = z.infer<typeof BrowserProviderCreate>;
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据 */
export const BrowserProviderUpdate = z.object({
baseUrl: HttpUrl.optional(),
accessKey: z.string().min(1).max(512).nullable().optional(),
enabled: z.boolean().optional(),
});
export type BrowserProviderUpdate = z.infer<typeof BrowserProviderUpdate>;
export const BrowserProviderParams = z.object({ id: Id });
export const BrowserProvidersQuery = z.object({
/** true = 现场探测 /health 与 /openapi.json 并写回 */
probe: QueryBool,
});
export type BrowserProvidersQuery = z.infer<typeof BrowserProvidersQuery>;
/** 会话视图:会话 + 标签页 + 当前有效租约 + 界面可用能力 */
// ── BR-2 凭据统一:站点账号 Cookie ⇄ 浏览器 profile ──
/** 浏览器 Cookie(Playwright 形状);value 是凭据,只在服务端与浏览器服务之间传,不进日志、模型与 API 响应 */
export const BrowserCookie = z.object({
name: z.string().min(1),
value: z.string(),
/** 主机名(host-only)或 .根域 */
domain: z.string().min(1),
path: z.string().default("/"),
/** Unix 秒;-1 = 会话 Cookie */
expires: z.number().default(-1),
httpOnly: z.boolean().default(false),
secure: z.boolean().default(false),
sameSite: z.enum(["Strict", "Lax", "None"]).optional(),
});
export type BrowserCookie = z.infer<typeof BrowserCookie>;
/** 站点账号登录态(Cookie)来源:手填 / 账密自动登录 / 浏览器回写 */
export const SiteCookieSource = z.enum(["manual", "login", "browser"]);
export type SiteCookieSource = z.infer<typeof SiteCookieSource>;
/**
* 会话打开时的凭据注入结果(存 recovery_state.credential):
* injected = 已把账号 Cookie 注入浏览器 profile;none = 账号没有可注入的 Cookie;
* not_applicable = 匿名会话或 API Key 类站点(网页登录态与 API Key 无关,不注入);
* unsupported = 浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件且上游导入口令不同);
* failed = 注入请求失败;expired = 注入后首页仍是登录页(Cookie 过期),会话转"需要人工登录"。
*/
export const BrowserCredentialState = z.object({
status: z.enum(["injected", "none", "not_applicable", "unsupported", "failed", "expired"]),
source: SiteCookieSource.nullable(),
/** 该来源最近一次写入凭据的时间 */
sourceAt: Timestamp.nullable(),
/** 注入的 Cookie 个数(不含值) */
count: NonNegInt,
message: z.string().nullable(),
/** 最近一次从浏览器读回并比对的时间 */
syncedAt: Timestamp.nullable(),
});
export type BrowserCredentialState = z.infer<typeof BrowserCredentialState>;
/**
* 浏览器服务重启 / 回收后的自动重连(BR-3,存 recovery_state.reconnect):
* trigger = tab_gone(操作时发现标签页不在)/ provider_restarted(实例标识变了)/ manual(原站视图调用 reconnect);
* humanNotice = 重连时会话正由人工接管(页面已重新加载,请核对)。
*/
export const BrowserReconnectState = z.object({
status: z.enum(["ok", "failed"]),
trigger: z.enum(["tab_gone", "provider_restarted", "manual"]),
at: Timestamp,
/** 失败原因(不含凭据) */
reason: z.string().nullable(),
humanNotice: z.boolean(),
});
export type BrowserReconnectState = z.infer<typeof BrowserReconnectState>;
export const BrowserSessionView = BrowserSession.extend({
tabs: z.array(BrowserTab),
currentTabId: z.string().nullable(),
lease: BrowserControlLease.nullable(),
/** 控制权交还后到重新读取页面之前为 true:旧元素引用一律失效 */
needsSnapshot: z.boolean(),
/** 等待处理/接管原因(login_required / captcha / human_takeover / lease_expired …) */
waitingReason: z.string().nullable(),
/** 打开会话时的凭据注入与回写状态(BR-2);旧会话没有 */
credential: BrowserCredentialState.nullable().optional(),
/** 浏览器服务重启过、上游上下文已不在:下一次操作(或 POST .../reconnect)自动重建(BR-3) */
needsReconnect: z.boolean().optional(),
/** 最近一次自动重连的结果 */
reconnect: BrowserReconnectState.nullable().optional(),
/** 最近一次自动化(Agent 能力)动作成功的时间;从未有过为 null。原站视图据此显示「Agent」或「自动化」 */
lastAutomationAt: Timestamp.nullable().optional(),
});
export type BrowserSessionView = z.infer<typeof BrowserSessionView>;
export const BrowserSessionOpen = z.object({
siteId: Id,
accountId: Id.nullable().optional(),
/** 缺省取第一个启用的浏览器服务 */
providerId: Id.optional(),
/** 缺省打开站点首页;必须在站点域名内 */
url: z.url().optional(),
});
export type BrowserSessionOpen = z.infer<typeof BrowserSessionOpen>;
export const BrowserSessionsQuery = z.object({
siteId: Id.optional(),
includeClosed: QueryBool,
});
export type BrowserSessionsQuery = z.infer<typeof BrowserSessionsQuery>;
export const BrowserSessionParams = z.object({ id: Id });
export const BrowserTabParams = z.object({ id: Id, tabId: z.string().min(1).max(200) });
export const BrowserTabOpen = z.object({ url: z.url().optional() });
/** 导航:地址栏(url)或导航按钮(action),二选一(url 优先) */
export const BrowserNavigate = z.object({
tabId: z.string().min(1).optional(),
url: z.url().max(4000).optional(),
action: z.enum(["back", "forward", "reload"]).optional(),
});
export type BrowserNavigate = z.infer<typeof BrowserNavigate>;
export const BrowserNavigation = z.object({
/** 已脱敏的最终地址 */
url: z.string(),
httpStatus: z.number().int().nullable(),
ok: z.boolean(),
attempts: z.number().int().min(0),
/** 落在登录页(登录失效) */
loginRequired: z.boolean(),
session: BrowserSessionView,
});
export type BrowserNavigation = z.infer<typeof BrowserNavigation>;
export const BrowserSnapshotQuery = z.object({
tabId: z.string().min(1).optional(),
offset: z.coerce.number().int().min(0).optional(),
});
export const BrowserScreenshotQuery = z.object({ tabId: z.string().min(1).optional() });
/** 截图:base64 直接给原站视图;不落盘、不进模型(modelSafe 标注) */
export const BrowserScreenshotView = z.object({
mimeType: z.string(),
dataBase64: z.string(),
/** 图片像素尺寸(PNG/JPEG 头解析;解析不出为 null) */
width: z.number().int().nullable(),
height: z.number().int().nullable(),
url: z.string().nullable(),
modelSafe: z.boolean(),
capturedAt: Timestamp,
controller: BrowserController,
});
export type BrowserScreenshotView = z.infer<typeof BrowserScreenshotView>;
/** 元素定位:快照引用 / CSS 选择器 / 截图像素坐标(原站视图点击) */
export const BrowserTarget = z.union([
/** 快照元素引用,形如 e12 */
z.object({ ref: z.string().min(1).max(40) }),
z.object({ selector: z.string().min(1).max(500) }),
z.object({ x: z.number().min(0), y: z.number().min(0) }),
]);
export type BrowserTarget = z.infer<typeof BrowserTarget>;
export const BrowserClick = z.object({ tabId: z.string().min(1).optional(), target: BrowserTarget });
export type BrowserClick = z.infer<typeof BrowserClick>;
/** 输入:按键(key)> 站点账号凭证字段(credential,服务端解析后直接下发)> 明文(text);target 缺省 = 当前焦点 */
export const BrowserType = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget.optional(),
text: z.string().max(4000).optional(),
credential: z.enum(["username", "password", "totp"]).optional(),
key: z.string().min(1).max(40).optional(),
submit: z.boolean().optional(),
clear: z.boolean().optional(),
});
export type BrowserType = z.infer<typeof BrowserType>;
export const BrowserScroll = z.object({
tabId: z.string().min(1).optional(),
direction: z.enum(["up", "down", "left", "right"]),
amount: z.number().int().min(1).max(10_000).default(600),
});
export type BrowserScroll = z.infer<typeof BrowserScroll>;
export const BrowserSelect = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget,
value: z.string().max(500),
});
export type BrowserSelect = z.infer<typeof BrowserSelect>;
export const BrowserActionResult = z.object({ ok: z.literal(true), session: BrowserSessionView });
export type BrowserActionResult = z.infer<typeof BrowserActionResult>;
export const BrowserExtract = z.object({
tabId: z.string().min(1).optional(),
/** JSON Schema;属性用 x-ref 指向快照元素(camofox /extract) */
schema: JsonObject,
});
export const BrowserExtractResult = z.object({ data: JsonObject });
/** 捕获下载:可先点击 target 触发;submit 给出时经 POST /acquisition-plans 同一流程提交 */
export const BrowserDownload = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget.optional(),
timeoutMs: z.number().int().min(500).max(120_000).default(15_000),
submit: z
.object({ slotId: Id.optional(), entityId: Id.optional(), note: z.string().max(2000).optional() })
.optional(),
});
export type BrowserDownload = z.infer<typeof BrowserDownload>;
/** 暂存核验结果:种子解析出的 infohash、与本地资源的匹配 */
export const BrowserDownloadVerification = z.object({
valid: z.boolean(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
name: z.string().nullable(),
totalSize: NonNegInt.nullable(),
private: z.boolean().nullable(),
error: z.string().nullable(),
});
export type BrowserDownloadVerification = z.infer<typeof BrowserDownloadVerification>;
export const BrowserDownloadResult = z.object({
artifact: DownloadArtifact,
verification: BrowserDownloadVerification,
/** 按 infohash / 站点资源 ID 匹配到的本地资源;null = 不在本地索引 */
sourceReleaseId: Id.nullable(),
/** 同一会话已捕获过同一文件(sha256 相同):不重复提交 */
duplicate: z.boolean(),
/** submit 给出且匹配到资源时的提交结果(与 POST /acquisition-plans 相同) */
submission: ManualAcquisitionResult.nullable(),
note: z.string().nullable(),
});
export type BrowserDownloadResult = z.infer<typeof BrowserDownloadResult>;
export const BrowserControlAcquire = z.object({
reason: z.string().min(1).max(200).default("人工接管"),
/** 租约时长;到期未交还 → 会话转"等待处理",不自动恢复自动化 */
ttlMs: z
.number()
.int()
.min(60_000)
.max(4 * 3_600_000)
.default(30 * 60_000),
});
export type BrowserControlAcquire = z.infer<typeof BrowserControlAcquire>;
export const BrowserControlRelease = z.object({
leaseId: Id.optional(),
reason: z.string().min(1).max(200).default("人工交还"),
/** 交还给自动化(缺省)或留在等待处理 */
to: z.enum(["automation", "waiting"]).default("automation"),
});
export type BrowserControlRelease = z.infer<typeof BrowserControlRelease>;
export const BrowserControlResult = z.object({
lease: BrowserControlLease.nullable(),
session: BrowserSessionView,
});
export type BrowserControlResult = z.infer<typeof BrowserControlResult>;