Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

215 lines
6.7 KiB
TypeScript

export const browserElementSnapshotFormat = "tutti.browser-element.v1";
export const browserElementSnapshotMaxHtmlChars = 32_000;
export const browserElementSnapshotMaxTextChars = 4_000;
export interface BrowserElementSnapshot {
format: typeof browserElementSnapshotFormat;
capturedAt: string;
page: {
title: string;
url: string;
};
element: {
ariaLabel: string | null;
attributes: Record<string, string>;
bounds: { height: number; width: number; x: number; y: number };
classes: string[];
domPath: string;
html: string;
htmlTruncated: boolean;
id: string | null;
role: string | null;
selector: string;
styles: Record<string, string>;
tagName: string;
text: string;
textTruncated: boolean;
};
ancestors: Array<{
id: string | null;
role: string | null;
selector: string;
tagName: string;
}>;
viewport: {
devicePixelRatio: number;
height: number;
scrollX: number;
scrollY: number;
width: number;
};
}
export type BrowserElementSelectionResult =
| { status: "cancelled" }
| { snapshot: BrowserElementSnapshot; status: "selected" };
export function normalizeBrowserElementSelectionResult(
value: unknown
): BrowserElementSelectionResult | null {
if (!isRecord(value)) return null;
if (value.status === "cancelled") return { status: "cancelled" };
if (value.status !== "selected" || !isRecord(value.snapshot)) return null;
const snapshot = value.snapshot;
const page = isRecord(snapshot.page) ? snapshot.page : {};
const element = isRecord(snapshot.element) ? snapshot.element : {};
const bounds = isRecord(element.bounds) ? element.bounds : {};
const viewport = isRecord(snapshot.viewport) ? snapshot.viewport : {};
const normalized: BrowserElementSnapshot = {
format: browserElementSnapshotFormat,
capturedAt:
boundedString(snapshot.capturedAt, 64) || new Date().toISOString(),
page: {
title: boundedString(page.title, 500),
url: sanitizeSnapshotUrl(boundedString(page.url, 4_000))
},
element: {
ariaLabel: nullableBoundedString(element.ariaLabel, 1_000),
attributes: boundedStringRecord(element.attributes, 40, 200, 2_000),
bounds: {
height: finiteNumber(bounds.height),
width: finiteNumber(bounds.width),
x: finiteNumber(bounds.x),
y: finiteNumber(bounds.y)
},
classes: boundedStringArray(element.classes, 24, 200),
domPath:
boundedString(element.domPath, 4_000) ||
boundedString(element.selector, 2_000),
html: boundedString(element.html, browserElementSnapshotMaxHtmlChars),
htmlTruncated:
element.htmlTruncated === true ||
(typeof element.html === "string" &&
element.html.length > browserElementSnapshotMaxHtmlChars),
id: nullableBoundedString(element.id, 500),
role: nullableBoundedString(element.role, 200),
selector: boundedString(element.selector, 2_000),
styles: boundedStringRecord(element.styles, 32, 100, 500),
tagName: boundedString(element.tagName, 100).toLowerCase(),
text: boundedString(element.text, browserElementSnapshotMaxTextChars),
textTruncated:
element.textTruncated === true ||
(typeof element.text === "string" &&
element.text.length > browserElementSnapshotMaxTextChars)
},
ancestors: Array.isArray(snapshot.ancestors)
? snapshot.ancestors.slice(0, 8).flatMap((item) =>
isRecord(item)
? [
{
id: nullableBoundedString(item.id, 500),
role: nullableBoundedString(item.role, 200),
selector: boundedString(item.selector, 2_000),
tagName: boundedString(item.tagName, 100).toLowerCase()
}
]
: []
)
: [],
viewport: {
devicePixelRatio: finiteNumber(viewport.devicePixelRatio, 1),
height: finiteNumber(viewport.height),
scrollX: finiteNumber(viewport.scrollX),
scrollY: finiteNumber(viewport.scrollY),
width: finiteNumber(viewport.width)
}
};
if (!normalized.element.tagName || !normalized.element.domPath) return null;
return { snapshot: normalized, status: "selected" };
}
export function serializeBrowserElementSnapshot(
snapshot: BrowserElementSnapshot
): string {
const { bounds } = snapshot.element;
return [
`DOM Path: ${snapshot.element.domPath}`,
`Position: top=${formatPixel(bounds.y)}, left=${formatPixel(bounds.x)}, width=${formatPixel(bounds.width)}, height=${formatPixel(bounds.height)}`,
`HTML Element: ${compactHtmlFragment(snapshot.element.html)}`
].join("\n");
}
function formatPixel(value: number): string {
const rounded = Math.round(value * 100) / 100;
return `${Object.is(rounded, -0) ? 0 : rounded}px`;
}
function compactHtmlFragment(value: string): string {
return value
.replace(/\r?\n/gu, " ")
.replace(/\s{2,}/gu, " ")
.trim();
}
function sanitizeSnapshotUrl(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.hash = "";
for (const key of [...url.searchParams.keys()]) {
if (
/(?:token|auth|password|secret|session|cookie|key|signature)/iu.test(
key
)
) {
url.searchParams.set(key, "[redacted]");
}
}
return url.toString();
} catch {
return boundedString(value, 4_000);
}
}
function boundedString(value: unknown, maxChars: number): string {
return typeof value === "string" ? value.slice(0, maxChars) : "";
}
function nullableBoundedString(
value: unknown,
maxChars: number
): string | null {
const normalized = boundedString(value, maxChars).trim();
return normalized || null;
}
function boundedStringArray(
value: unknown,
maxItems: number,
maxChars: number
): string[] {
return Array.isArray(value)
? value
.slice(0, maxItems)
.filter((item): item is string => typeof item === "string")
.map((item) => item.slice(0, maxChars))
: [];
}
function boundedStringRecord(
value: unknown,
maxItems: number,
maxKeyChars: number,
maxValueChars: number
): Record<string, string> {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value)
.slice(0, maxItems)
.flatMap(([key, item]) =>
typeof item === "string"
? [[key.slice(0, maxKeyChars), item.slice(0, maxValueChars)]]
: []
)
);
}
function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}