@webskill/sdk 0.12.0 → 0.14.0
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/dist/agent.d.ts +3 -3
- package/dist/agent.js +3 -3
- package/dist/browser.d.ts +250 -9
- package/dist/browser.js +1257 -95
- package/dist/{catalogComponents-BgAJN0p8-C3K8klJd.js → catalogComponents-BgAJN0p8-Cr55ouOg.js} +348 -14
- package/dist/{dist-sdKFgERo.js → dist-Bfga1TmS.js} +368 -34
- package/dist/{dist-DU9KDAuR.js → dist-BnqAkSS6.js} +965 -49
- package/dist/{dist-DqcL6jKO.js → dist-CiaDIqkV.js} +2 -2
- package/dist/{dist-Bev6i6Ip.js → dist-D5YhlCdY.js} +59 -1
- package/dist/governance.d.ts +3 -3
- package/dist/governance.js +2 -2
- package/dist/{index-C9pzXLKy.d.ts → index-CWoKOFBP.d.ts} +86 -5
- package/dist/{index-DFhU1uks.d.ts → index-DQwGvHAI.d.ts} +447 -22
- package/dist/{index-3fCHc1mQ.d.ts → index-sd-gVKey.d.ts} +2 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -5
- package/dist/mcp.d.ts +77 -3
- package/dist/mcp.js +78 -4
- package/dist/{memoryArtifactStore-52Zn9npI-LbCQaqyx.js → memoryArtifactStore-52Zn9npI-BxtUkrgV.js} +1 -1
- package/dist/node.d.ts +3 -3
- package/dist/node.js +6 -6
- package/dist/{openUiLibrary-BKXW7Iwx-DaymVubt.js → openUiLibrary-BKXW7Iwx-DjvEnMlJ.js} +2 -2
- package/dist/{skillVersionStore-D-qHk9ZE-DheTIwAB.d.ts → skillVersionStore-D-qHk9ZE-C0OFDTQY.d.ts} +1 -1
- package/dist/{testing-WPTyXQYt.js → testing-Cm8MseLF.js} +2 -2
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +2 -2
- package/dist/{types-DLctJep_-B5G4uk2u.d.ts → types-C3V6lAeO-BgeOvc1Y.d.ts} +63 -4
- package/dist/ui-react.d.ts +2 -2
- package/dist/ui-react.js +13 -11
- package/dist/ui-vue.d.ts +1 -1
- package/dist/ui-vue.js +1 -1
- package/dist/ui.d.ts +3 -3
- package/dist/ui.js +2 -2
- package/dist/{webskillLitCatalog-D_zCqeQF-C9lrvvMr.js → webskillLitCatalog-D_zCqeQF-swXsWoAe.js} +1 -1
- package/package.json +2 -1
package/dist/browser.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { $ as bridgeError, A as ProgressiveRouter, Ct as normalizeToolError, E as GoogleGenAiClient, St as normalizeToolContent, _t as mergeCatalogEntries, b as FsMemoryStore, c as AnthropicClient, k as OpenAiCompatibleClient, nt as createWebSkillApi, s as AgentLoop, u as CapabilityApproval, wt as parseBridgeRequest, x as FsRunSnapshotStore, y as FsArtifactStore, yt as networkPolicyLibSource } from "./dist-
|
|
3
|
-
import {
|
|
4
|
-
import { n as MockLlmClient } from "./testing-
|
|
1
|
+
import { $ as unzipWithLimits, B as messageOf, E as buildManifest, G as readSkillSignature, H as parseSkillMarkdown, I as isAtomicTempPath, L as isValidSkillName, M as detectSkillArchiveShapeFromFs, P as exportSkills, S as assertRemoteUrlAllowed, T as buildCatalog, U as parseSkillPackManifest, W as readResponseWithLimit, Y as resolveInsideRoot, _ as SkillDiscovery, b as WebSkillError, c as MANIFEST_EXCLUDED_FILES, d as SKILLS_LOCKFILE, et as validateSkills, f as SKILL_MANIFEST_FILE, h as SKILL_PACK_FILE, nt as verifySkillSignature, tt as verifyManifest, w as atomicWriteText } from "./dist-D5YhlCdY.js";
|
|
2
|
+
import { $ as bridgeError, A as ProgressiveRouter, Ct as normalizeToolError, E as GoogleGenAiClient, St as normalizeToolContent, _t as mergeCatalogEntries, b as FsMemoryStore, c as AnthropicClient, k as OpenAiCompatibleClient, nt as createWebSkillApi, s as AgentLoop, u as CapabilityApproval, wt as parseBridgeRequest, x as FsRunSnapshotStore, y as FsArtifactStore, yt as networkPolicyLibSource } from "./dist-Bfga1TmS.js";
|
|
3
|
+
import { A as frameLabel, M as toActionFrameScopes, N as toFrameScopes, j as frameSteps } from "./dist-BnqAkSS6.js";
|
|
4
|
+
import { n as MockLlmClient } from "./testing-Cm8MseLF.js";
|
|
5
5
|
|
|
6
6
|
//#region ../browser/dist/index.js
|
|
7
7
|
/** 检测当前环境是否可用 OPFS(navigator.storage.getDirectory) */
|
|
@@ -905,12 +905,16 @@ var IframeWorkerLike = class {
|
|
|
905
905
|
#channelId;
|
|
906
906
|
#hostWindow;
|
|
907
907
|
#hostListener;
|
|
908
|
-
|
|
908
|
+
#readyTimer;
|
|
909
|
+
constructor(iframe, hostWindow, channelId, readyTimeout) {
|
|
909
910
|
this.#iframe = iframe;
|
|
910
911
|
this.#hostWindow = hostWindow;
|
|
911
912
|
this.#channelId = channelId;
|
|
912
913
|
this.#hostListener = (event) => this.#onMessage(event);
|
|
913
914
|
this.#hostWindow.addEventListener("message", this.#hostListener);
|
|
915
|
+
if (readyTimeout) this.#readyTimer = setTimeout(() => {
|
|
916
|
+
this.#failQueue(`Sandbox document did not become ready within ${readyTimeout.ms}ms: ${readyTimeout.documentUrl}. Ensure it is listed in both manifest sandbox.pages and web_accessible_resources.`);
|
|
917
|
+
}, readyTimeout.ms);
|
|
914
918
|
}
|
|
915
919
|
get ready() {
|
|
916
920
|
return this.#ready;
|
|
@@ -919,10 +923,38 @@ var IframeWorkerLike = class {
|
|
|
919
923
|
return this.#channelId;
|
|
920
924
|
}
|
|
921
925
|
markReady() {
|
|
926
|
+
this.#clearReadyTimer();
|
|
922
927
|
this.#ready = true;
|
|
923
928
|
for (const payload of this.#queue) this.#post(payload);
|
|
924
929
|
this.#queue = [];
|
|
925
930
|
}
|
|
931
|
+
#clearReadyTimer() {
|
|
932
|
+
if (this.#readyTimer !== void 0) {
|
|
933
|
+
clearTimeout(this.#readyTimer);
|
|
934
|
+
this.#readyTimer = void 0;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* 就绪超时(FR-12.4):`createIframeWorker` 同步返回,超时无处可抛,只能走回包通道。
|
|
939
|
+
* 而 `#request` 按 `data.id` 匹配回包,id 对不上的消息被静默丢弃——
|
|
940
|
+
* 所以必须**逐条**回复队列里每一条消息的 id,否则调用方一直挂着。
|
|
941
|
+
*/
|
|
942
|
+
#failQueue(message) {
|
|
943
|
+
const queued = this.#queue;
|
|
944
|
+
this.#queue = [];
|
|
945
|
+
for (const payload of queued) {
|
|
946
|
+
const id = payload.id;
|
|
947
|
+
if (typeof id === "string") this.emit({
|
|
948
|
+
id,
|
|
949
|
+
ok: false,
|
|
950
|
+
error: {
|
|
951
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
952
|
+
message
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
this.terminate();
|
|
957
|
+
}
|
|
926
958
|
#post(payload) {
|
|
927
959
|
this.#iframe.contentWindow?.postMessage({
|
|
928
960
|
[ENVELOPE]: true,
|
|
@@ -953,11 +985,18 @@ var IframeWorkerLike = class {
|
|
|
953
985
|
for (const listener of this.#listeners) listener({ data });
|
|
954
986
|
}
|
|
955
987
|
terminate() {
|
|
988
|
+
this.#clearReadyTimer();
|
|
956
989
|
this.#hostWindow.removeEventListener("message", this.#hostListener);
|
|
957
990
|
this.#iframe.remove();
|
|
958
991
|
}
|
|
959
992
|
};
|
|
960
|
-
/**
|
|
993
|
+
/**
|
|
994
|
+
* srcdoc 文档:内嵌 Worker 引导源码 + 双向中继(BOOTSTRAP 经 JSON 字符串注入,防 <\/script> 截断)。
|
|
995
|
+
*
|
|
996
|
+
* **这段文本被 FR-12.5 / AC-12.1 冻结在 0.13.0 基线上**:既有网页宿主不给 `documentUrl`,
|
|
997
|
+
* 必须逐字节拿到与升级前相同的沙箱文档。因此它不与 `SANDBOX_PAGE_SCRIPT_SOURCE` 共用模板——
|
|
998
|
+
* 抽模板必然改动生成文本。两份中继的一致性改由行为对拍守卫(见 `sandboxRelayParity` 用例)。
|
|
999
|
+
*/
|
|
961
1000
|
function sandboxDocument(bootstrapSource, channelId) {
|
|
962
1001
|
const bootstrapJson = JSON.stringify(bootstrapSource).replace(/<\//g, "<\\/");
|
|
963
1002
|
return `<!doctype html>
|
|
@@ -984,16 +1023,96 @@ parent.postMessage({ [ENVELOPE]: true, channelId: CHANNEL_ID, payload: { type: '
|
|
|
984
1023
|
<\/script></body></html>`;
|
|
985
1024
|
}
|
|
986
1025
|
/**
|
|
987
|
-
*
|
|
1026
|
+
* 静态沙箱页的中继脚本源码(分册 12 / FR-12.3)。
|
|
1027
|
+
*
|
|
1028
|
+
* 宿主把它落成一个独立文件并用 `<script src>` 引入——**不得内联**:
|
|
1029
|
+
* sandbox 页虽允许 `unsafe-inline`,但外链能让「沙箱页脚本 = SDK 原样产物」在审计时可核对。
|
|
1030
|
+
*
|
|
1031
|
+
* 与 srcdoc 形态的两点区别,都源于「静态页在加载完时还不知道要跑什么」:
|
|
1032
|
+
* - **引导源码**随宿主的 `sandbox-init` 消息到达,而不是内嵌在文档里;
|
|
1033
|
+
* - **channelId** 由第一条来自 `parent` 的消息 latch。SDK 导出的是一份与宿主无关的源码,
|
|
1034
|
+
* 不可能预知宿主这次生成的 id。latch 前先认 `event.source === parent`:少了这句,
|
|
1035
|
+
* 任何把本页嵌进去的第三方 frame 都能抢先 latch 一个自己的 id,
|
|
1036
|
+
* 让真正的宿主消息全部被丢弃——一个纯粹的拒绝服务面。
|
|
1037
|
+
*
|
|
1038
|
+
* 本源码不含任何扩展 API:sandbox 页拿不到它们(实证)。
|
|
1039
|
+
* `ENVELOPE` 与宿主侧同源(同一个模块常量),两边各写一份字面量会在改名时静默失配。
|
|
1040
|
+
* @experimental
|
|
1041
|
+
*/
|
|
1042
|
+
const SANDBOX_PAGE_SCRIPT_SOURCE = `const ENVELOPE = ${JSON.stringify(ENVELOPE)};
|
|
1043
|
+
let CHANNEL_ID = null;
|
|
1044
|
+
let worker = null;
|
|
1045
|
+
function post(payload) {
|
|
1046
|
+
parent.postMessage({ [ENVELOPE]: true, channelId: CHANNEL_ID, payload }, '*');
|
|
1047
|
+
}
|
|
1048
|
+
function startWorker(bootstrapSource) {
|
|
1049
|
+
if (worker || typeof bootstrapSource !== 'string') return;
|
|
1050
|
+
const blob = new Blob([bootstrapSource], { type: 'text/javascript' });
|
|
1051
|
+
const url = URL.createObjectURL(blob);
|
|
1052
|
+
// 注意:opaque origin 下 module Worker 无法加载(实证),classic Worker + data: URL 动态导入可用
|
|
1053
|
+
worker = new Worker(url);
|
|
1054
|
+
URL.revokeObjectURL(url);
|
|
1055
|
+
worker.addEventListener('message', (event) => post(event.data));
|
|
1056
|
+
worker.addEventListener('error', (event) => post({ type: 'sandbox-worker-error', message: event.message }));
|
|
1057
|
+
post({ type: 'sandbox-ready' });
|
|
1058
|
+
}
|
|
1059
|
+
window.addEventListener('message', (event) => {
|
|
1060
|
+
const data = event.data;
|
|
1061
|
+
if (!data || data[ENVELOPE] !== true) return;
|
|
1062
|
+
if (event.source !== parent) return;
|
|
1063
|
+
if (CHANNEL_ID === null) {
|
|
1064
|
+
if (typeof data.channelId !== 'string') return;
|
|
1065
|
+
CHANNEL_ID = data.channelId;
|
|
1066
|
+
} else if (data.channelId !== CHANNEL_ID) return;
|
|
1067
|
+
const payload = data.payload;
|
|
1068
|
+
if (payload && payload.type === 'sandbox-init') {
|
|
1069
|
+
startWorker(payload.bootstrapSource);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (worker) worker.postMessage(payload);
|
|
1073
|
+
});
|
|
1074
|
+
`;
|
|
1075
|
+
/**
|
|
1076
|
+
* `Document` 判别式。不用 `'documentUrl' in v`:`Document` 上没有该属性,
|
|
1077
|
+
* 但一个只给了 `doc` 的选项对象上也没有,两者会被判到同一侧。
|
|
1078
|
+
*/
|
|
1079
|
+
function isDocument(value) {
|
|
1080
|
+
return value.nodeType === 9;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* 创建 opaque iframe 沙箱 Worker:srcdoc(默认)或宿主指定的静态沙箱页承载 classic Worker。
|
|
988
1084
|
* 返回 IframeWorkerLike(协议与 blob Worker 完全一致:load/execute/bridge/network-blocked)。
|
|
989
1085
|
*/
|
|
990
|
-
function createIframeWorker(bootstrapSource,
|
|
1086
|
+
function createIframeWorker(bootstrapSource, options = {}) {
|
|
1087
|
+
const resolved = isDocument(options) ? { doc: options } : options;
|
|
1088
|
+
const doc = resolved.doc ?? document;
|
|
991
1089
|
const channelId = nextChannelId();
|
|
992
1090
|
const iframe = doc.createElement("iframe");
|
|
993
|
-
iframe.setAttribute("sandbox", "allow-scripts");
|
|
994
1091
|
iframe.style.display = "none";
|
|
995
|
-
|
|
996
|
-
const
|
|
1092
|
+
const hostWindow = doc.defaultView ?? window;
|
|
1093
|
+
const { documentUrl } = resolved;
|
|
1094
|
+
if (documentUrl === void 0) {
|
|
1095
|
+
iframe.setAttribute("sandbox", "allow-scripts");
|
|
1096
|
+
iframe.srcdoc = sandboxDocument(bootstrapSource, channelId);
|
|
1097
|
+
const worker = new IframeWorkerLike(iframe, hostWindow, channelId);
|
|
1098
|
+
doc.body.appendChild(iframe);
|
|
1099
|
+
return worker;
|
|
1100
|
+
}
|
|
1101
|
+
const worker = new IframeWorkerLike(iframe, hostWindow, channelId, {
|
|
1102
|
+
ms: resolved.readyTimeoutMs ?? 1e4,
|
|
1103
|
+
documentUrl
|
|
1104
|
+
});
|
|
1105
|
+
iframe.addEventListener("load", () => {
|
|
1106
|
+
iframe.contentWindow?.postMessage({
|
|
1107
|
+
[ENVELOPE]: true,
|
|
1108
|
+
channelId,
|
|
1109
|
+
payload: {
|
|
1110
|
+
type: "sandbox-init",
|
|
1111
|
+
bootstrapSource
|
|
1112
|
+
}
|
|
1113
|
+
}, "*");
|
|
1114
|
+
});
|
|
1115
|
+
iframe.src = documentUrl;
|
|
997
1116
|
doc.body.appendChild(iframe);
|
|
998
1117
|
return worker;
|
|
999
1118
|
}
|
|
@@ -1195,7 +1314,7 @@ var BrowserWorkerScriptExecutor = class {
|
|
|
1195
1314
|
...details !== void 0 ? { details } : {}
|
|
1196
1315
|
});
|
|
1197
1316
|
if (decision === "allowed") return void 0;
|
|
1198
|
-
return bridgeError(request.id, "TOOL_UNSUPPORTED", decision === "disabled" ? `Capability "${capability}" is disabled` : `Capability "${capability}" was denied by the user`);
|
|
1317
|
+
return bridgeError(request.id, "TOOL_UNSUPPORTED", decision === "disabled" ? `Capability "${capability}" is disabled by the sandbox capability settings` : `Capability "${capability}" was denied by the user`);
|
|
1199
1318
|
};
|
|
1200
1319
|
try {
|
|
1201
1320
|
switch (request.kind) {
|
|
@@ -1222,7 +1341,7 @@ var BrowserWorkerScriptExecutor = class {
|
|
|
1222
1341
|
};
|
|
1223
1342
|
}
|
|
1224
1343
|
case "fetchData": {
|
|
1225
|
-
if (!context.fetchData) return bridgeError(request.id, "TOOL_UNSUPPORTED", "Capability \"fetchData\" is
|
|
1344
|
+
if (!context.fetchData) return bridgeError(request.id, "TOOL_UNSUPPORTED", "Capability \"fetchData\" is unavailable: this host wired no data source access at all. This is not the capability switch being off — a data source has to be declared in the host settings first.");
|
|
1226
1345
|
const denied = await gate("fetchData", `Script "${context.skillName}" wants to fetch data source "${request.sourceId}"`, { sourceId: request.sourceId });
|
|
1227
1346
|
if (denied) return denied;
|
|
1228
1347
|
return {
|
|
@@ -1633,10 +1752,12 @@ function splitDataUrl(dataUrl) {
|
|
|
1633
1752
|
/** 压到预算内;压不下去时把失败原因原样上抛给调用方转成 `CaptureFailure` */
|
|
1634
1753
|
async function toBudget(id, level, blob, maxBytes) {
|
|
1635
1754
|
const compressed = await compressImageToBudget(blob, maxBytes);
|
|
1755
|
+
const mimeType = compressed.blob.type.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1756
|
+
if (!DELIVERABLE_IMAGE_MIME_TYPES.has(mimeType)) throw new Error(`the bytes normalized to "${mimeType === "" ? "no content type" : mimeType}", which is not one of ${[...DELIVERABLE_IMAGE_MIME_TYPES].join(", ")}`);
|
|
1636
1757
|
const buffer = await compressed.blob.arrayBuffer();
|
|
1637
1758
|
return {
|
|
1638
1759
|
id,
|
|
1639
|
-
mimeType
|
|
1760
|
+
mimeType,
|
|
1640
1761
|
data: base64Of(new Uint8Array(buffer)),
|
|
1641
1762
|
level,
|
|
1642
1763
|
originalBytes: compressed.originalBytes,
|
|
@@ -1647,15 +1768,16 @@ async function toBudget(id, level, blob, maxBytes) {
|
|
|
1647
1768
|
async function captureImg(element, id, maxBytes) {
|
|
1648
1769
|
const src = element.currentSrc !== "" ? element.currentSrc : element.src;
|
|
1649
1770
|
if (src === "") throw new Error("the <img> element has no resolved source");
|
|
1771
|
+
const oversized = Math.max(element.naturalWidth, element.naturalHeight) > MAX_RASTER_EDGE;
|
|
1650
1772
|
const inline = splitDataUrl(src);
|
|
1651
1773
|
if (inline) {
|
|
1652
1774
|
const bytes = Uint8Array.from(atob(inline.data), (char) => char.charCodeAt(0));
|
|
1653
|
-
return toBudget(id, "src", new Blob([bytes], { type: inline.mimeType }), maxBytes);
|
|
1775
|
+
return toBudget(id, "src", await toDeliverableBlob(new Blob([bytes], { type: inline.mimeType }), oversized), maxBytes);
|
|
1654
1776
|
}
|
|
1655
1777
|
const response = await fetch(src, { mode: "cors" });
|
|
1656
1778
|
if (response.type === "opaque") throw new Error("the response is opaque, so its bytes cannot be read");
|
|
1657
1779
|
if (!response.ok) throw new Error(`fetching the source returned HTTP ${response.status}`);
|
|
1658
|
-
return toBudget(id, "src", await response.blob(), maxBytes);
|
|
1780
|
+
return toBudget(id, "src", await toDeliverableBlob(await response.blob(), oversized), maxBytes);
|
|
1659
1781
|
}
|
|
1660
1782
|
/** L2:`<canvas>` 读回。画布被跨域内容污染时 `toDataURL` 抛 SecurityError,不再兜底(裁决 D-3) */
|
|
1661
1783
|
async function captureCanvas(element, id, maxBytes) {
|
|
@@ -1664,29 +1786,229 @@ async function captureCanvas(element, id, maxBytes) {
|
|
|
1664
1786
|
const bytes = Uint8Array.from(atob(parsed.data), (char) => char.charCodeAt(0));
|
|
1665
1787
|
return toBudget(id, "canvas", new Blob([bytes], { type: parsed.mimeType }), maxBytes);
|
|
1666
1788
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1789
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
1790
|
+
/** 无固有尺寸的 SVG 在浏览器里的默认渲染尺寸,照抄规范而不是自创 */
|
|
1791
|
+
const SVG_FALLBACK_SIZE = {
|
|
1792
|
+
width: 300,
|
|
1793
|
+
height: 150
|
|
1794
|
+
};
|
|
1795
|
+
/** 栅格画布的长边上限;再大也会被 `compressImageToBudget` 缩回去,只是白占内存 */
|
|
1796
|
+
const MAX_RASTER_EDGE = 2048;
|
|
1797
|
+
function numberAttr(element, name) {
|
|
1798
|
+
const raw = element.getAttribute?.(name);
|
|
1799
|
+
if (raw === null || raw === void 0) return void 0;
|
|
1800
|
+
const value = Number.parseFloat(raw);
|
|
1801
|
+
return Number.isFinite(value) && value > 0 ? value : void 0;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* 元素的显示尺寸,取不到返回 `undefined`(0.13.0 FR-20.3)。
|
|
1805
|
+
*
|
|
1806
|
+
* 逐级回退而不是只看 `getBoundingClientRect()`:没有布局的环境里它恒为 0,
|
|
1807
|
+
* 只认它会把所有图都判成 0 面积。**取不到就是取不到**,由调用方决定不过滤,
|
|
1808
|
+
* 不在这里编一个尺寸出来。
|
|
1809
|
+
*/
|
|
1810
|
+
function measuredSizeOf(element) {
|
|
1811
|
+
const rect = element.getBoundingClientRect?.();
|
|
1812
|
+
if (rect !== void 0 && rect.width > 0 && rect.height > 0) return {
|
|
1813
|
+
width: rect.width,
|
|
1814
|
+
height: rect.height
|
|
1815
|
+
};
|
|
1816
|
+
const { naturalWidth, naturalHeight } = element;
|
|
1817
|
+
if (typeof naturalWidth === "number" && typeof naturalHeight === "number" && naturalWidth > 0 && naturalHeight > 0) return {
|
|
1818
|
+
width: naturalWidth,
|
|
1819
|
+
height: naturalHeight
|
|
1820
|
+
};
|
|
1821
|
+
const { width: propWidth, height: propHeight } = element;
|
|
1822
|
+
if (typeof propWidth === "number" && typeof propHeight === "number" && propWidth > 0 && propHeight > 0) return {
|
|
1823
|
+
width: propWidth,
|
|
1824
|
+
height: propHeight
|
|
1825
|
+
};
|
|
1826
|
+
const attrWidth = numberAttr(element, "width");
|
|
1827
|
+
const attrHeight = numberAttr(element, "height");
|
|
1828
|
+
if (attrWidth !== void 0 && attrHeight !== void 0) return {
|
|
1829
|
+
width: attrWidth,
|
|
1830
|
+
height: attrHeight
|
|
1831
|
+
};
|
|
1832
|
+
const box = element.getAttribute?.("viewBox")?.trim().split(/[\s,]+/);
|
|
1833
|
+
if (box?.length === 4) {
|
|
1834
|
+
const width = Number.parseFloat(box[2] ?? "");
|
|
1835
|
+
const height = Number.parseFloat(box[3] ?? "");
|
|
1836
|
+
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) return {
|
|
1837
|
+
width,
|
|
1838
|
+
height
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
function rasterSizeOf(element) {
|
|
1843
|
+
const measured = measuredSizeOf(element) ?? SVG_FALLBACK_SIZE;
|
|
1844
|
+
const scale = Math.min(1, MAX_RASTER_EDGE / Math.max(measured.width, measured.height));
|
|
1673
1845
|
return {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
data,
|
|
1677
|
-
level: "canvas",
|
|
1678
|
-
originalBytes: bytes,
|
|
1679
|
-
bytes
|
|
1846
|
+
width: Math.max(1, Math.round(measured.width * scale)),
|
|
1847
|
+
height: Math.max(1, Math.round(measured.height * scale))
|
|
1680
1848
|
};
|
|
1681
1849
|
}
|
|
1682
1850
|
/**
|
|
1851
|
+
* 解码超时。真实浏览器里 data URL 的 SVG 解码是瞬时的(不加载任何外部资源),
|
|
1852
|
+
* 但环境不支持图像管线时 load / error 可能一个都不来——没有这道闸门整次感知会永远挂着。
|
|
1853
|
+
*/
|
|
1854
|
+
const DECODE_TIMEOUT_MS = 2e3;
|
|
1855
|
+
async function loadImage(url) {
|
|
1856
|
+
const image = new Image();
|
|
1857
|
+
image.src = url;
|
|
1858
|
+
const decoded = typeof image.decode === "function" ? image.decode() : new Promise((resolve, reject) => {
|
|
1859
|
+
image.onload = () => resolve();
|
|
1860
|
+
image.onerror = () => reject(/* @__PURE__ */ new Error("the browser could not decode the serialized SVG"));
|
|
1861
|
+
});
|
|
1862
|
+
let timer;
|
|
1863
|
+
try {
|
|
1864
|
+
await Promise.race([decoded, new Promise((_, reject) => {
|
|
1865
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("decoding the serialized SVG timed out")), DECODE_TIMEOUT_MS);
|
|
1866
|
+
})]);
|
|
1867
|
+
} finally {
|
|
1868
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1869
|
+
}
|
|
1870
|
+
return image;
|
|
1871
|
+
}
|
|
1872
|
+
/** 逐像素查 alpha,命中一个不透明点立刻返回;只有真正的空图才会扫完全图 */
|
|
1873
|
+
function isFullyTransparent(ctx, width, height) {
|
|
1874
|
+
const { data } = ctx.getImageData(0, 0, width, height);
|
|
1875
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] !== 0) return false;
|
|
1876
|
+
return true;
|
|
1877
|
+
}
|
|
1878
|
+
async function toPngBlob(canvas) {
|
|
1879
|
+
return await new Promise((resolve, reject) => {
|
|
1880
|
+
canvas.toBlob((blob) => {
|
|
1881
|
+
if (blob === null) reject(/* @__PURE__ */ new Error("the canvas could not be encoded as PNG"));
|
|
1882
|
+
else resolve(blob);
|
|
1883
|
+
}, "image/png");
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* L2:内联 `<svg>` 栅格化成 PNG(0.13.0 FR-20.1)。
|
|
1888
|
+
*
|
|
1889
|
+
* 三家提供方的图片通道都不收 `image/svg+xml`(分册 20 §0.1),原样序列化下发等于取不到图。
|
|
1890
|
+
* 经 `<img>` 加载的 SVG 被浏览器强制资源隔离:不执行脚本,不加载外部图片 / 样式 / 字体。
|
|
1891
|
+
* 因此这里不做污染处理**不是漏了**——画布根本不会被跨源内容污染,也不会因为取像发起外部请求。
|
|
1892
|
+
* 代价是页面 CSS 类与外部字体不生效,由下面的空图检测兜底(FR-20.2)。
|
|
1893
|
+
*/
|
|
1894
|
+
async function captureSvg(element, id, maxBytes) {
|
|
1895
|
+
if (typeof Image !== "function" || typeof document === "undefined") throw new Error("this environment has no DOM image pipeline, so SVG cannot be rasterized");
|
|
1896
|
+
const { width, height } = rasterSizeOf(element);
|
|
1897
|
+
const clone = element.cloneNode(true);
|
|
1898
|
+
clone.setAttribute("xmlns", SVG_NS);
|
|
1899
|
+
clone.setAttribute("width", String(width));
|
|
1900
|
+
clone.setAttribute("height", String(height));
|
|
1901
|
+
const markup = new XMLSerializer().serializeToString(clone);
|
|
1902
|
+
const image = await loadImage(`data:image/svg+xml;base64,${base64Of(new TextEncoder().encode(markup))}`);
|
|
1903
|
+
const canvas = document.createElement("canvas");
|
|
1904
|
+
canvas.width = width;
|
|
1905
|
+
canvas.height = height;
|
|
1906
|
+
const ctx = canvas.getContext("2d");
|
|
1907
|
+
if (ctx === null) throw new Error("a 2D canvas context is unavailable");
|
|
1908
|
+
ctx.drawImage(image, 0, 0, width, height);
|
|
1909
|
+
if (isFullyTransparent(ctx, width, height)) throw new Error("the SVG rendered blank once detached from the page, so its appearance most likely comes from page CSS or external assets that do not apply inside an <img>");
|
|
1910
|
+
return toBudget(id, "canvas", await toPngBlob(canvas), maxBytes);
|
|
1911
|
+
}
|
|
1912
|
+
/** 模型图片通道只收这几种位图(与 runtime 的 `MODEL_IMAGE_MIME_TYPES` 同一组) */
|
|
1913
|
+
const DELIVERABLE_IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
1914
|
+
"image/png",
|
|
1915
|
+
"image/jpeg",
|
|
1916
|
+
"image/gif",
|
|
1917
|
+
"image/webp"
|
|
1918
|
+
]);
|
|
1919
|
+
/** 用于认出 SVG 正文的前缀嗅探长度:`<?xml`、注释、DOCTYPE 都可能排在 `<svg` 之前 */
|
|
1920
|
+
const SVG_SNIFF_BYTES = 1024;
|
|
1921
|
+
/**
|
|
1922
|
+
* WebP 动图:扩展头 `VP8X` 的 flags 字节第 1 位。位置固定在文件前 24 字节内,
|
|
1923
|
+
* 不必解码整图就能判出来。
|
|
1924
|
+
*/
|
|
1925
|
+
function isAnimatedWebp(bytes) {
|
|
1926
|
+
return [
|
|
1927
|
+
86,
|
|
1928
|
+
80,
|
|
1929
|
+
56,
|
|
1930
|
+
88
|
|
1931
|
+
].every((byte, index) => bytes[12 + index] === byte) && ((bytes[20] ?? 0) & 2) !== 0;
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* 按魔数认图片类型(服务端声明只作兜底)。
|
|
1935
|
+
*
|
|
1936
|
+
* 图片接口常年是「无扩展名 + `application/octet-stream`」这种组合(查询串带 file id 的尤其多),
|
|
1937
|
+
* 而模型侧只按 MIME 判能不能投递——照抄服务端的声明,整张图会在最后一步被丢掉,
|
|
1938
|
+
* 模型只收到一句「这个格式发不了」。
|
|
1939
|
+
*/
|
|
1940
|
+
function sniffImageMime(bytes) {
|
|
1941
|
+
const at = (offset, ...signature) => signature.every((byte, index) => bytes[offset + index] === byte);
|
|
1942
|
+
if (at(0, 137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
1943
|
+
if (at(0, 255, 216, 255)) return "image/jpeg";
|
|
1944
|
+
if (at(0, 71, 73, 70, 56)) return "image/gif";
|
|
1945
|
+
if (at(0, 82, 73, 70, 70) && at(8, 87, 69, 66, 80)) return "image/webp";
|
|
1946
|
+
if (at(0, 66, 77)) return "image/bmp";
|
|
1947
|
+
const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, SVG_SNIFF_BYTES));
|
|
1948
|
+
if (/<svg[\s>]/i.test(head)) return "image/svg+xml";
|
|
1949
|
+
}
|
|
1950
|
+
/** 把任意 blob 重新编码成 PNG:先让浏览器解码,再画进画布 */
|
|
1951
|
+
async function rasterizeToPng(blob) {
|
|
1952
|
+
if (typeof document === "undefined" || typeof Image !== "function" || typeof URL?.createObjectURL !== "function") throw new Error("this environment has no DOM image pipeline, so the image cannot be re-encoded");
|
|
1953
|
+
const url = URL.createObjectURL(blob);
|
|
1954
|
+
try {
|
|
1955
|
+
const image = await loadImage(url);
|
|
1956
|
+
const natural = {
|
|
1957
|
+
width: image.naturalWidth,
|
|
1958
|
+
height: image.naturalHeight
|
|
1959
|
+
};
|
|
1960
|
+
const measured = natural.width > 0 && natural.height > 0 ? natural : SVG_FALLBACK_SIZE;
|
|
1961
|
+
const scale = Math.min(1, MAX_RASTER_EDGE / Math.max(measured.width, measured.height));
|
|
1962
|
+
const width = Math.max(1, Math.round(measured.width * scale));
|
|
1963
|
+
const height = Math.max(1, Math.round(measured.height * scale));
|
|
1964
|
+
const canvas = document.createElement("canvas");
|
|
1965
|
+
canvas.width = width;
|
|
1966
|
+
canvas.height = height;
|
|
1967
|
+
const ctx = canvas.getContext("2d");
|
|
1968
|
+
if (ctx === null) throw new Error("a 2D canvas context is unavailable");
|
|
1969
|
+
ctx.drawImage(image, 0, 0, width, height);
|
|
1970
|
+
return await toPngBlob(canvas);
|
|
1971
|
+
} finally {
|
|
1972
|
+
URL.revokeObjectURL(url);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* 把取到的字节归一成模型收得下的位图(0.13.0)。
|
|
1977
|
+
*
|
|
1978
|
+
* 两类真实站点上很常见的图会在最后一步被模型侧丢掉:服务端把图报成 `application/octet-stream`,
|
|
1979
|
+
* 以及矢量图(三家提供方的图片通道都不收 `image/svg+xml`,分册 20 §0.1)。
|
|
1980
|
+
* 前者只是声明错了,按魔数改回来即可;后者必须就地栅格化,否则等于没取到图。
|
|
1981
|
+
*
|
|
1982
|
+
* **声明一律不采信**:大站 CDN 会按 `Accept` 头改发 AVIF 却仍旧报 `image/webp`,
|
|
1983
|
+
* 照抄声明会让端点解码失败并连累整次请求(一张图 400,37 张全废)。
|
|
1984
|
+
*
|
|
1985
|
+
* 魔数对得上也不等于发得出去,还有两类会被端点整次拒收,必须重编码成单帧位图:
|
|
1986
|
+
* - **动图**(GIF 天然多帧,WebP 看 `VP8X` 的动画位):图片通道普遍只收静态图;
|
|
1987
|
+
* - **超长边**:横幅类素材常见 7680×120 这种尺寸,字节数却很小,
|
|
1988
|
+
* 纯按字节压缩的预算根本不会触发,原样发出去就撞上端点的边长上限。
|
|
1989
|
+
*
|
|
1990
|
+
* @param oversized 调用方已知原图长边超限(`<img>` 能直接读到 `naturalWidth`,不必为此解码)
|
|
1991
|
+
*/
|
|
1992
|
+
async function toDeliverableBlob(blob, oversized = false) {
|
|
1993
|
+
const declared = blob.type.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1994
|
+
const head = new Uint8Array(await blob.slice(0, SVG_SNIFF_BYTES).arrayBuffer());
|
|
1995
|
+
const sniffed = sniffImageMime(head);
|
|
1996
|
+
if (sniffed !== void 0 && DELIVERABLE_IMAGE_MIME_TYPES.has(sniffed)) {
|
|
1997
|
+
const typed = sniffed === declared ? blob : new Blob([blob], { type: sniffed });
|
|
1998
|
+
const animated = sniffed === "image/gif" || sniffed === "image/webp" && isAnimatedWebp(head);
|
|
1999
|
+
return oversized || animated ? await rasterizeToPng(typed) : typed;
|
|
2000
|
+
}
|
|
2001
|
+
const mimeType = sniffed ?? (declared.startsWith("image/") ? declared : void 0);
|
|
2002
|
+
if (mimeType === void 0) throw new Error(`the response is not a recognizable image (the server described it as "${blob.type === "" ? "no content type" : blob.type}")`);
|
|
2003
|
+
return await rasterizeToPng(new Blob([blob], { type: mimeType }));
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
1683
2006
|
* 取一个元素的图像(0.6.0 FR-12.1)。
|
|
1684
2007
|
*
|
|
1685
2008
|
* 按元素类型分派,不做无意义的尝试:`<img>` 只走 L1,`<canvas>` / `<svg>` 只走 L2,
|
|
1686
2009
|
* 两者互不兜底。失败返回 `CaptureFailure` 而不是抛错——一张图抓不到不该让整次感知失败。
|
|
1687
2010
|
* @experimental
|
|
1688
|
-
*/
|
|
1689
|
-
async function captureElementImage(element, options) {
|
|
2011
|
+
*/ async function captureElementImage(element, options) {
|
|
1690
2012
|
const { id, maxBytes } = options;
|
|
1691
2013
|
const tag = element.tagName.toUpperCase();
|
|
1692
2014
|
if (tag === "IMG") try {
|
|
@@ -1708,11 +2030,11 @@ async function captureElementImage(element, options) {
|
|
|
1708
2030
|
};
|
|
1709
2031
|
}
|
|
1710
2032
|
if (tag === "SVG") try {
|
|
1711
|
-
return captureSvg(element, id, maxBytes);
|
|
2033
|
+
return await captureSvg(element, id, maxBytes);
|
|
1712
2034
|
} catch (e) {
|
|
1713
2035
|
return {
|
|
1714
2036
|
id,
|
|
1715
|
-
reason: `L2 could not
|
|
2037
|
+
reason: `L2 could not rasterize the SVG: ${describeError(e)}`,
|
|
1716
2038
|
triedLevels: ["canvas"]
|
|
1717
2039
|
};
|
|
1718
2040
|
}
|
|
@@ -1727,6 +2049,148 @@ function describeError(e) {
|
|
|
1727
2049
|
if (e instanceof TypeError) return "the request was blocked, most likely by CORS";
|
|
1728
2050
|
return e instanceof Error ? e.message : String(e);
|
|
1729
2051
|
}
|
|
2052
|
+
/**
|
|
2053
|
+
* 非标可交互元素的识别与角色提升(0.14.0 分册 15)。
|
|
2054
|
+
*
|
|
2055
|
+
* 现代前端用 `div` / `span` 做按钮是常态,它们在无障碍树里全落到 `generic`,
|
|
2056
|
+
* 于是拿不到句柄——模型读得到那几个字,却点不动。本模块补的就是这一段判定。
|
|
2057
|
+
*
|
|
2058
|
+
* 整套逻辑默认关闭,由 `createDomPerceptionReader` 的 `promoteRoles` 开启(D-15-1)。
|
|
2059
|
+
*/
|
|
2060
|
+
/**
|
|
2061
|
+
* class / id 里的交互语义词根(FR-15.2)。
|
|
2062
|
+
*
|
|
2063
|
+
* 带词边界匹配:`table` 含 `tab`、`container` 含 `nav`,子串匹配会把它们全误伤(D-15-8)。
|
|
2064
|
+
* 图标类单列一支:`icon-view` / `el-icon-download` / `anticon-*` 这类命名带着具体动作语义。
|
|
2065
|
+
*/
|
|
2066
|
+
const INTERACTIVE_TOKEN = /(?:^|[^a-z])(btn|button|actions?|click(?:able)?|link|menu|nav|tabs?|trigger|operate|operations?|handle|entry|toolbar|[a-z]*icons?(?:[-_][a-z0-9]+)?)(?:[^a-z]|$)/i;
|
|
2067
|
+
/** 从图标类名反推语义:`el-icon-view` → `view`、`icon-download` → `download`(FR-15.4 第 2 级) */
|
|
2068
|
+
const ICON_CLASS = /[a-z]*icons?[-_]([a-z0-9][a-z0-9-_]*)$/i;
|
|
2069
|
+
/** 框架路由属性:值非空即视为确凿的导航线索(FR-15.3 第 2 条) */
|
|
2070
|
+
const ROUTE_ATTRIBUTES = [
|
|
2071
|
+
"to",
|
|
2072
|
+
"routerlink",
|
|
2073
|
+
"data-href",
|
|
2074
|
+
"data-url"
|
|
2075
|
+
];
|
|
2076
|
+
/** 只有这三种角色会被提升:其余角色要么本就能发句柄,要么带着不该被覆盖的语义(D-15-3) */
|
|
2077
|
+
const PROMOTABLE_ROLES = /* @__PURE__ */ new Set([
|
|
2078
|
+
"generic",
|
|
2079
|
+
"img",
|
|
2080
|
+
"cell"
|
|
2081
|
+
]);
|
|
2082
|
+
/** 合成名的前缀,必须让模型看得出「这不是页面上的字,别拿它去搜」(FR-15.4 第 3 级) */
|
|
2083
|
+
const SYNTHETIC_NAME_PREFIX = "unlabeled control";
|
|
2084
|
+
/** 往上找具名祖先的层数上限:再往上就是整页容器,拿它当限定语反而更含糊 */
|
|
2085
|
+
const SYNTHETIC_NAME_LOOKUP_DEPTH = 5;
|
|
2086
|
+
const classAndId = (element) => `${element.getAttribute("class") ?? ""} ${element.getAttribute("id") ?? ""}`;
|
|
2087
|
+
/** `cursor: pointer` —— 页面自己声明了可点。`getComputedStyle` 最贵,殿后 */
|
|
2088
|
+
function looksClickable(element, view) {
|
|
2089
|
+
if (view === null) return false;
|
|
2090
|
+
try {
|
|
2091
|
+
return view.getComputedStyle(element).cursor === "pointer";
|
|
2092
|
+
} catch {
|
|
2093
|
+
return false;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* 五级信号,命中任一即为真(FR-15.1)。
|
|
2098
|
+
*
|
|
2099
|
+
* 求值顺序按**成本**排而不是按强度排:前面的廉价信号命中就短路,
|
|
2100
|
+
* 把 `getComputedStyle` 这条唯一会触发样式重算的路径留到最后。
|
|
2101
|
+
*/
|
|
2102
|
+
function looksInteractive(element, context) {
|
|
2103
|
+
if (context.interactiveHint?.(element) === true) return true;
|
|
2104
|
+
const tabindex = element.getAttribute("tabindex");
|
|
2105
|
+
if (tabindex !== null && tabindex.trim() !== "" && tabindex.trim() !== "-1") return true;
|
|
2106
|
+
const title = element.getAttribute("title");
|
|
2107
|
+
if (title !== null && title.trim() !== "") return true;
|
|
2108
|
+
if (INTERACTIVE_TOKEN.test(classAndId(element))) return true;
|
|
2109
|
+
return looksClickable(element, context.view);
|
|
2110
|
+
}
|
|
2111
|
+
/** 图标类名反推出的语义词;没有图标类则 undefined */
|
|
2112
|
+
function iconHintOf(element) {
|
|
2113
|
+
for (const cls of (element.getAttribute("class") ?? "").split(/\s+/)) {
|
|
2114
|
+
const match = ICON_CLASS.exec(cls);
|
|
2115
|
+
if (match === null) continue;
|
|
2116
|
+
const semantic = match[1]?.split("__")[0];
|
|
2117
|
+
if (semantic !== void 0 && semantic !== "") return semantic;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
/** 宿主逃生舱的角色;命中即无条件提升成声明的角色(FR-15.6) */
|
|
2121
|
+
function hintedRoleOf(element, hints) {
|
|
2122
|
+
if (hints === void 0) return void 0;
|
|
2123
|
+
for (const hint of hints) for (const selector of hint.selectors) try {
|
|
2124
|
+
if (element.matches(selector)) return hint.role;
|
|
2125
|
+
} catch {}
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* 确凿的导航线索(FR-15.3):拿得到就判 `link`,拿不到一律 `button`。
|
|
2129
|
+
*
|
|
2130
|
+
* **不看** `cursor` / class 词根 / `onclick` 这类间接迹象——猜错会让模型以为「点了会跳走」
|
|
2131
|
+
* 而放弃先把页内信息读完,代价比多标一个 `button` 大。
|
|
2132
|
+
*/
|
|
2133
|
+
function navigationTargetOf(element, doc) {
|
|
2134
|
+
const own = resolvedHref(element, doc);
|
|
2135
|
+
if (own !== void 0) return own;
|
|
2136
|
+
for (const attribute of ROUTE_ATTRIBUTES) {
|
|
2137
|
+
const value = element.getAttribute(attribute);
|
|
2138
|
+
if (value !== null && value.trim() !== "") return value.trim();
|
|
2139
|
+
}
|
|
2140
|
+
const anchor = element.closest("a[href]");
|
|
2141
|
+
if (anchor !== null && anchor.getAttribute("role") === null) return resolvedHref(anchor, doc);
|
|
2142
|
+
}
|
|
2143
|
+
/** `href` 解析成绝对地址;不导航的形态(`#`、`javascript:`、空值)一律不算线索 */
|
|
2144
|
+
function resolvedHref(element, doc) {
|
|
2145
|
+
const raw = element.getAttribute("href") ?? element.getAttribute("xlink:href");
|
|
2146
|
+
if (raw === null) return void 0;
|
|
2147
|
+
const trimmed = raw.trim();
|
|
2148
|
+
if (trimmed === "" || trimmed === "#" || /^javascript:/i.test(trimmed)) return void 0;
|
|
2149
|
+
try {
|
|
2150
|
+
const url = new URL(trimmed, doc.baseURI);
|
|
2151
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : void 0;
|
|
2152
|
+
} catch {
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* 算不出名字时合成一个可指称的称呼(FR-15.4 第 3 级)。
|
|
2158
|
+
*
|
|
2159
|
+
* 用「最近具名祖先 + 同级序位」而不是坐标:坐标随滚动和窗口尺寸变,
|
|
2160
|
+
* 用户在屏幕上对不上号,模型跨两次感知也认不出是同一个东西。
|
|
2161
|
+
*/
|
|
2162
|
+
function synthesizeName(element, nameOf) {
|
|
2163
|
+
const siblings = element.parentElement?.children;
|
|
2164
|
+
let position = 0;
|
|
2165
|
+
if (siblings !== void 0) {
|
|
2166
|
+
for (const [index, sibling] of [...siblings].entries()) if (sibling === element) {
|
|
2167
|
+
position = index + 1;
|
|
2168
|
+
break;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
const ordinal = position > 0 ? `${SYNTHETIC_NAME_PREFIX} #${position}` : SYNTHETIC_NAME_PREFIX;
|
|
2172
|
+
let ancestor = element.parentElement;
|
|
2173
|
+
for (let depth = 0; depth < SYNTHETIC_NAME_LOOKUP_DEPTH && ancestor !== null; depth += 1) {
|
|
2174
|
+
const label = nameOf(ancestor);
|
|
2175
|
+
if (label !== void 0 && label !== "") return `${ordinal} in "${label}"`;
|
|
2176
|
+
ancestor = ancestor.parentElement;
|
|
2177
|
+
}
|
|
2178
|
+
return ordinal;
|
|
2179
|
+
}
|
|
2180
|
+
/** 提升后的角色与导航目标;未提升时返回 undefined */
|
|
2181
|
+
function promoteRole(input) {
|
|
2182
|
+
const { element, role, doc, hasActionableDescendant, context } = input;
|
|
2183
|
+
const hinted = hintedRoleOf(element, context.roleHints);
|
|
2184
|
+
if (hinted !== void 0) return { role: hinted };
|
|
2185
|
+
if (!PROMOTABLE_ROLES.has(role)) return void 0;
|
|
2186
|
+
if (hasActionableDescendant && role !== "img") return void 0;
|
|
2187
|
+
if (!looksInteractive(element, context)) return void 0;
|
|
2188
|
+
const href = navigationTargetOf(element, doc);
|
|
2189
|
+
return href !== void 0 ? {
|
|
2190
|
+
role: "link",
|
|
2191
|
+
href
|
|
2192
|
+
} : { role: "button" };
|
|
2193
|
+
}
|
|
1730
2194
|
/** 值可能是凭据的输入类型:即便宿主忘了写进 exclude 也不读值。操作侧复用同一份,不另写 */
|
|
1731
2195
|
const SECRET_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "hidden"]);
|
|
1732
2196
|
/** 可被操作的角色:只给这些节点发句柄,其余节点模型连提都提不出来 */
|
|
@@ -1804,6 +2268,33 @@ function ownText(element) {
|
|
|
1804
2268
|
for (const node of element.childNodes) if (node.nodeType === 3) text += node.nodeValue ?? "";
|
|
1805
2269
|
return clip(text);
|
|
1806
2270
|
}
|
|
2271
|
+
/** 名字全在元素外面的那几种控件 */
|
|
2272
|
+
const FORM_CONTROL_TAGS = /* @__PURE__ */ new Set([
|
|
2273
|
+
"INPUT",
|
|
2274
|
+
"TEXTAREA",
|
|
2275
|
+
"SELECT"
|
|
2276
|
+
]);
|
|
2277
|
+
/**
|
|
2278
|
+
* 表单控件的名字来源:`<label>`(关联式与包裹式)、`placeholder`、`title`。
|
|
2279
|
+
*
|
|
2280
|
+
* 缺了这几级,一个填着「基小建」的输入框在模型眼里就是无名的 textbox——
|
|
2281
|
+
* 旁边的 `<label>` 文字它读得到,却接不到这个框上,于是「值明明在结果里,
|
|
2282
|
+
* 模型却说不出这是谁」。操作侧的 `accessibleName` 一直取这几级,两边不一致
|
|
2283
|
+
* 还会让「感知时叫不出名字、点下去审计里却有名字」。
|
|
2284
|
+
*/
|
|
2285
|
+
function externalNameOf(element) {
|
|
2286
|
+
const labels = element.labels;
|
|
2287
|
+
if (labels && labels.length > 0) {
|
|
2288
|
+
const joined = [...labels].map((node) => node.textContent ?? "").filter((part) => part.trim() !== "").join(" ");
|
|
2289
|
+
if (joined.trim() !== "") return clip(joined);
|
|
2290
|
+
}
|
|
2291
|
+
const wrapping = element.closest("label")?.textContent ?? "";
|
|
2292
|
+
if (wrapping.trim() !== "") return clip(wrapping);
|
|
2293
|
+
for (const attribute of ["placeholder", "title"]) {
|
|
2294
|
+
const value = element.getAttribute(attribute);
|
|
2295
|
+
if (value !== null && value.trim() !== "") return clip(value);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
1807
2298
|
function nameOf(element, doc) {
|
|
1808
2299
|
const label = element.getAttribute("aria-label");
|
|
1809
2300
|
if (label !== null && label.trim() !== "") return clip(label);
|
|
@@ -1816,6 +2307,7 @@ function nameOf(element, doc) {
|
|
|
1816
2307
|
const alt = element.getAttribute("alt");
|
|
1817
2308
|
if (alt !== null && alt.trim() !== "") return clip(alt);
|
|
1818
2309
|
}
|
|
2310
|
+
if (FORM_CONTROL_TAGS.has(element.tagName)) return externalNameOf(element);
|
|
1819
2311
|
const own = ownText(element);
|
|
1820
2312
|
return own === "" ? void 0 : own;
|
|
1821
2313
|
}
|
|
@@ -1859,20 +2351,50 @@ const SKIPPED_TAGS = /* @__PURE__ */ new Set([
|
|
|
1859
2351
|
]);
|
|
1860
2352
|
/** 活动模态的三种形态;Radix 之流用 aria-modal,原生用 <dialog open> */
|
|
1861
2353
|
const MODAL_SELECTOR = "[role=dialog][open], dialog[open], [aria-modal=true]";
|
|
1862
|
-
|
|
2354
|
+
/** 按尺寸落选取像的说明(FR-20.3);与「超名额」「取像失败」必须可区分,三者排查方向不同 */
|
|
2355
|
+
const ICON_SKIPPED_NOTE = "Not captured: this image is smaller than the icon threshold";
|
|
2356
|
+
/**
|
|
2357
|
+
* 元素是否小到该当图标看待(FR-20.3)。
|
|
2358
|
+
* 测不到尺寸就**不过滤**:没有布局的环境里宁可多取一张,也不能把图全吞了。
|
|
2359
|
+
*/
|
|
2360
|
+
function isBelowMinArea(element, minImageArea) {
|
|
2361
|
+
if (minImageArea <= 0) return false;
|
|
2362
|
+
const size = measuredSizeOf(element);
|
|
2363
|
+
if (size === void 0) return false;
|
|
2364
|
+
return size.width * size.height < minImageArea;
|
|
2365
|
+
}
|
|
2366
|
+
function describe(element, excluded, doc, view, capture, handles, frame, provenance, promotion) {
|
|
1863
2367
|
if (excluded.has(element)) return void 0;
|
|
1864
2368
|
if (SKIPPED_TAGS.has(element.tagName)) return void 0;
|
|
1865
2369
|
if (hidden$1(element, view)) return void 0;
|
|
1866
2370
|
const children = [];
|
|
1867
2371
|
for (const child of element.children) {
|
|
1868
|
-
const node = describe(child, excluded, doc, view,
|
|
2372
|
+
const node = describe(child, excluded, doc, view, capture, handles, frame, provenance, promotion);
|
|
1869
2373
|
if (node !== void 0) children.push(node);
|
|
1870
2374
|
}
|
|
1871
|
-
|
|
2375
|
+
let name = nameOf(element, doc);
|
|
1872
2376
|
const value = valueOf(element);
|
|
1873
|
-
|
|
1874
|
-
|
|
2377
|
+
let role = roleOf$1(element);
|
|
2378
|
+
let promotedHref;
|
|
2379
|
+
if (promotion !== void 0) {
|
|
2380
|
+
const promoted = promoteRole({
|
|
2381
|
+
element,
|
|
2382
|
+
role,
|
|
2383
|
+
doc,
|
|
2384
|
+
hasActionableDescendant: children.some((child) => child.ref !== void 0),
|
|
2385
|
+
context: promotion
|
|
2386
|
+
});
|
|
2387
|
+
if (promoted !== void 0) {
|
|
2388
|
+
role = promoted.role;
|
|
2389
|
+
promotedHref = promoted.href;
|
|
2390
|
+
if (name === void 0) name = clip(iconHintOf(element) ?? synthesizeName(element, (a) => nameOf(a, doc)));
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
const href = promotedHref ?? (role === "link" ? hrefOf(element) : void 0);
|
|
1875
2394
|
if (role === "generic" && name === void 0 && value === void 0 && children.length === 0) return void 0;
|
|
2395
|
+
if (promotion !== void 0 && role === "generic" && name === void 0 && value === void 0) {
|
|
2396
|
+
if (children.length === 1) return children[0];
|
|
2397
|
+
}
|
|
1876
2398
|
const node = {
|
|
1877
2399
|
role,
|
|
1878
2400
|
...name !== void 0 ? { name } : {},
|
|
@@ -1884,18 +2406,94 @@ function describe(element, excluded, doc, view, targets, handles, frame, provena
|
|
|
1884
2406
|
};
|
|
1885
2407
|
const ref = handles?.issue(element, role, frame);
|
|
1886
2408
|
if (ref !== void 0) node.ref = ref;
|
|
1887
|
-
if (
|
|
2409
|
+
if (capture !== void 0 && isCapturableElement(element)) if (isBelowMinArea(element, capture.minImageArea)) node.imageNote = ICON_SKIPPED_NOTE;
|
|
2410
|
+
else capture.targets.push({
|
|
1888
2411
|
element,
|
|
1889
2412
|
node
|
|
1890
2413
|
});
|
|
1891
2414
|
return node;
|
|
1892
2415
|
}
|
|
1893
2416
|
/**
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
2417
|
+
* 子树里的 `<iframe>`(含根自身),跳过 exclude 命中的整棵子树。
|
|
2418
|
+
* 供 FR-18.4 判断「范围里有帧但没被授权」;本函数**不读**这些帧的内容。
|
|
2419
|
+
*/
|
|
2420
|
+
function nestedFramesIn(root, excluded) {
|
|
2421
|
+
const inExcluded = (element) => {
|
|
2422
|
+
for (let node = element; node !== null; node = node.parentElement) if (excluded.has(node)) return true;
|
|
2423
|
+
return false;
|
|
2424
|
+
};
|
|
2425
|
+
const found = [];
|
|
2426
|
+
if (root.tagName === "IFRAME" && !inExcluded(root)) found.push(root);
|
|
2427
|
+
for (const iframe of root.querySelectorAll("iframe")) if (!inExcluded(iframe)) found.push(iframe);
|
|
2428
|
+
return found;
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* 未授权嵌套帧的指称。`<iframe>` 的可访问名来自 `title`,而 `nameOf` 走的是
|
|
2432
|
+
* aria/文本那一套(帧没有自身文本),所以这里单独取——**只用于说明文本**,
|
|
2433
|
+
* 不进任何可操作路径。
|
|
2434
|
+
*/
|
|
2435
|
+
function frameHint(iframe) {
|
|
2436
|
+
for (const attribute of [
|
|
2437
|
+
"aria-label",
|
|
2438
|
+
"title",
|
|
2439
|
+
"name",
|
|
2440
|
+
"id"
|
|
2441
|
+
]) {
|
|
2442
|
+
const value = iframe.getAttribute(attribute);
|
|
2443
|
+
if (value !== null && value.trim() !== "") return clip(value);
|
|
2444
|
+
}
|
|
2445
|
+
return "unnamed";
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* 可路由的绝对地址(分册 16 FR-16.1)。
|
|
2449
|
+
*
|
|
2450
|
+
* 只认 http(s):`about:blank` / `srcdoc` / `data:` 这些外壳寻不到址,
|
|
2451
|
+
* 交出去只会让它拿一个匹配不上任何 frameId 的字符串去猜。
|
|
2452
|
+
*/
|
|
2453
|
+
function httpUrlOf(value, base) {
|
|
2454
|
+
try {
|
|
2455
|
+
const url = new URL(value, base);
|
|
2456
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : void 0;
|
|
2457
|
+
} catch {
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
function routableUrlOf(iframe, doc) {
|
|
2462
|
+
try {
|
|
2463
|
+
const live = iframe.contentWindow?.location?.href;
|
|
2464
|
+
if (live !== void 0 && live !== "about:blank") {
|
|
2465
|
+
const routable = httpUrlOf(live, doc.baseURI);
|
|
2466
|
+
if (routable !== void 0) return routable;
|
|
2467
|
+
}
|
|
2468
|
+
} catch {}
|
|
2469
|
+
const src = iframe.getAttribute("src");
|
|
2470
|
+
if (src === null || src.trim() === "") return void 0;
|
|
2471
|
+
return httpUrlOf(src, doc.baseURI);
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* 同源嵌套帧的文档;跨源与尚未就绪时返回 `undefined`。
|
|
2475
|
+
*
|
|
2476
|
+
* 跨源时 `contentDocument` 返回 `null` 而**不抛异常**,某些引擎在别的属性上才抛——
|
|
2477
|
+
* 两种形态都要接住,只写 try/catch 会漏掉前一种。
|
|
2478
|
+
*/
|
|
2479
|
+
function sameOriginDocumentOf(iframe) {
|
|
2480
|
+
try {
|
|
2481
|
+
const inner = iframe.contentDocument;
|
|
2482
|
+
return inner === null || inner.body === null ? void 0 : inner;
|
|
2483
|
+
} catch {
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* 句柄发放与解析。表在 reader 实例内、**跨感知保留**(分册 18 FR-18.5):
|
|
2489
|
+
* 每次 read() 作废全部旧句柄会让多步任务陷入「读—点—失效—再读」的空转,
|
|
2490
|
+
* 而安全性本就不由句柄寿命承担——执行前的 `modalStateOf` / `inActionScope` /
|
|
2491
|
+
* 可见性 / 可用性四道校验才是闸门,它们按**执行那一刻**的 DOM 实时判定。
|
|
1896
2492
|
*/
|
|
1897
2493
|
var HandleIssuer = class {
|
|
1898
2494
|
#table = /* @__PURE__ */ new Map();
|
|
2495
|
+
/** 同一元素复用同一个 ref:模型重读页面后手里的句柄仍然指向同一个东西 */
|
|
2496
|
+
#refByElement = /* @__PURE__ */ new WeakMap();
|
|
1899
2497
|
#actionable;
|
|
1900
2498
|
#excluded;
|
|
1901
2499
|
constructor(actionable, excluded) {
|
|
@@ -1907,9 +2505,24 @@ var HandleIssuer = class {
|
|
|
1907
2505
|
this.#actionable = actionable;
|
|
1908
2506
|
this.#excluded = excluded;
|
|
1909
2507
|
}
|
|
2508
|
+
/**
|
|
2509
|
+
* 丢弃已离开文档的条目。**只防表随长会话无限膨胀,不是安全判据**——
|
|
2510
|
+
* 「元素还在但已挪出授权范围」这一类由执行前的第二道闸门拦(FR-18.5)。
|
|
2511
|
+
*/
|
|
2512
|
+
prune() {
|
|
2513
|
+
for (const [ref, entry] of this.#table) if (!entry.element.isConnected) this.#table.delete(ref);
|
|
2514
|
+
}
|
|
1910
2515
|
issue(element, role, frame) {
|
|
1911
2516
|
if (!this.#actionable.has(element) || this.#excluded.has(element)) return void 0;
|
|
1912
2517
|
if (!ACTIONABLE_ROLES.has(role)) return void 0;
|
|
2518
|
+
const known = this.#refByElement.get(element);
|
|
2519
|
+
if (known !== void 0 && this.#table.has(known)) {
|
|
2520
|
+
this.#table.set(known, {
|
|
2521
|
+
element,
|
|
2522
|
+
...frame !== void 0 ? { frame } : {}
|
|
2523
|
+
});
|
|
2524
|
+
return known;
|
|
2525
|
+
}
|
|
1913
2526
|
const bytes = /* @__PURE__ */ new Uint8Array(8);
|
|
1914
2527
|
crypto.getRandomValues(bytes);
|
|
1915
2528
|
const ref = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -1917,6 +2530,7 @@ var HandleIssuer = class {
|
|
|
1917
2530
|
element,
|
|
1918
2531
|
...frame !== void 0 ? { frame } : {}
|
|
1919
2532
|
});
|
|
2533
|
+
this.#refByElement.set(element, ref);
|
|
1920
2534
|
return ref;
|
|
1921
2535
|
}
|
|
1922
2536
|
get table() {
|
|
@@ -1941,41 +2555,60 @@ function createDomPerceptionReader(options = {}) {
|
|
|
1941
2555
|
*/
|
|
1942
2556
|
const elevatedModals = /* @__PURE__ */ new Set();
|
|
1943
2557
|
const rootDocument = () => options.document ?? globalThis.document;
|
|
2558
|
+
/** 提升未开启时返回 undefined,遍历侧据此整段跳过(AC-G10) */
|
|
2559
|
+
const promotionOf = (view) => options.promoteRoles === true ? {
|
|
2560
|
+
view,
|
|
2561
|
+
...options.interactiveHint !== void 0 ? { interactiveHint: options.interactiveHint } : {},
|
|
2562
|
+
...options.roleHints !== void 0 ? { roleHints: options.roleHints } : {}
|
|
2563
|
+
} : void 0;
|
|
1944
2564
|
/**
|
|
1945
|
-
* 把一条帧授权解析成 Document
|
|
2565
|
+
* 把一条帧授权解析成 Document,逐层下钻(分册 18 FR-18.3)。
|
|
1946
2566
|
*
|
|
1947
2567
|
* 跨源时 `contentDocument` 在多数浏览器返回 `null` 而**不抛异常**,
|
|
1948
2568
|
* 只有部分属性访问才抛 —— 所以两种形态都要处理,只写 try/catch 会漏。
|
|
1949
2569
|
*/
|
|
1950
2570
|
function resolveFrame(frame, root) {
|
|
1951
|
-
|
|
1952
|
-
const
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
2571
|
+
const steps = frameSteps(frame);
|
|
2572
|
+
const label = frameLabel(frame);
|
|
2573
|
+
let doc = root;
|
|
2574
|
+
const frames = [];
|
|
2575
|
+
for (const [index, selector] of steps.entries()) {
|
|
2576
|
+
const hop = `step ${index + 1} ("${selector}") of frame path "${label}"`;
|
|
2577
|
+
const element = doc.querySelector(selector);
|
|
2578
|
+
if (element === null || element.tagName !== "IFRAME") return { note: {
|
|
2579
|
+
frame: label,
|
|
2580
|
+
reason: "not-found",
|
|
2581
|
+
message: `Frame ${hop} was not found in the page.`
|
|
2582
|
+
} };
|
|
2583
|
+
const iframe = element;
|
|
2584
|
+
let next;
|
|
2585
|
+
let origin;
|
|
2586
|
+
try {
|
|
2587
|
+
next = iframe.contentDocument;
|
|
2588
|
+
origin = iframe.contentWindow?.location.origin;
|
|
2589
|
+
} catch {
|
|
2590
|
+
next = null;
|
|
2591
|
+
}
|
|
2592
|
+
if (next === null || origin === void 0) return { note: {
|
|
2593
|
+
frame: label,
|
|
2594
|
+
reason: "cross-origin",
|
|
2595
|
+
message: `Frame ${hop} is cross-origin and was not read.`
|
|
2596
|
+
} };
|
|
2597
|
+
const key = frameLabel(steps.slice(0, index + 1));
|
|
2598
|
+
const known = frameOrigins.get(key);
|
|
2599
|
+
if (known === void 0) frameOrigins.set(key, origin);
|
|
2600
|
+
else if (known !== origin) return { note: {
|
|
2601
|
+
frame: label,
|
|
2602
|
+
reason: "origin-changed",
|
|
2603
|
+
message: `Frame ${hop} now points at ${origin} instead of the authorized ${known}; the grant was revoked.`
|
|
2604
|
+
} };
|
|
2605
|
+
doc = next;
|
|
2606
|
+
frames.push(iframe);
|
|
2607
|
+
}
|
|
2608
|
+
return {
|
|
2609
|
+
doc,
|
|
2610
|
+
frames
|
|
2611
|
+
};
|
|
1979
2612
|
}
|
|
1980
2613
|
/** 按当前 DOM 重算一次可操作集合;发句柄与执行前各算一次 */
|
|
1981
2614
|
const actionSets = (doc, scope) => {
|
|
@@ -1998,49 +2631,126 @@ function createDomPerceptionReader(options = {}) {
|
|
|
1998
2631
|
excluded
|
|
1999
2632
|
};
|
|
2000
2633
|
};
|
|
2001
|
-
/**
|
|
2002
|
-
const actionScopeFor = (
|
|
2003
|
-
function collect(scope,
|
|
2634
|
+
/** 该帧的操作范围;没有对应条目就是「这个帧只可读不可操作」。按展示串匹配(FR-18.2) */
|
|
2635
|
+
const actionScopeFor = (label) => options.actionScope === void 0 ? void 0 : toActionFrameScopes(options.actionScope).find((entry) => frameLabel(entry.frame) === label);
|
|
2636
|
+
function collect(scope, capture) {
|
|
2004
2637
|
const root = rootDocument();
|
|
2005
2638
|
if (root === void 0) return {
|
|
2006
2639
|
nodes: [],
|
|
2007
|
-
frameNotes: []
|
|
2640
|
+
frameNotes: [],
|
|
2641
|
+
nestedFrames: []
|
|
2008
2642
|
};
|
|
2009
|
-
handles
|
|
2643
|
+
handles ??= new HandleIssuer(/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set());
|
|
2644
|
+
handles.prune();
|
|
2645
|
+
const issuer = handles;
|
|
2010
2646
|
const nodes = [];
|
|
2011
2647
|
const frameNotes = [];
|
|
2648
|
+
const nestedFrames = [];
|
|
2649
|
+
/** 同一个未授权 iframe 至多报一次(它可能同时落在多个 include 根的子树里) */
|
|
2650
|
+
const reportedUnauthorized = /* @__PURE__ */ new Set();
|
|
2651
|
+
const resolvedScopes = [];
|
|
2652
|
+
const authorizedFrames = /* @__PURE__ */ new Set();
|
|
2012
2653
|
for (const frameScope of toFrameScopes(scope)) {
|
|
2013
2654
|
if (frameScope.include.length === 0) continue;
|
|
2655
|
+
const label = frameLabel(frameScope.frame);
|
|
2014
2656
|
const resolved = resolveFrame(frameScope.frame, root);
|
|
2015
2657
|
if ("note" in resolved) {
|
|
2016
2658
|
frameNotes.push(resolved.note);
|
|
2017
2659
|
nodes.push({
|
|
2018
2660
|
role: "note",
|
|
2019
2661
|
name: resolved.note.message,
|
|
2020
|
-
frame:
|
|
2662
|
+
frame: label
|
|
2021
2663
|
});
|
|
2022
2664
|
continue;
|
|
2023
2665
|
}
|
|
2024
|
-
const
|
|
2666
|
+
for (const iframe of resolved.frames) authorizedFrames.add(iframe);
|
|
2667
|
+
resolvedScopes.push({
|
|
2668
|
+
scope: frameScope,
|
|
2669
|
+
label,
|
|
2670
|
+
doc: resolved.doc
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* 范围内未授权嵌套帧的处置(分册 16 FR-16.1)。
|
|
2675
|
+
*
|
|
2676
|
+
* 递归而不是循环固定层数:0.13.0 D-18-8 用「路径是宿主写死的数组」论证深度上限,
|
|
2677
|
+
* 自动发现一进来这个前提就没了。兜底改由**帧数与节点总预算**承担,
|
|
2678
|
+
* 层数是错的度量(见分册 16 §0.2)。
|
|
2679
|
+
*/
|
|
2680
|
+
function descendFrames(roots, doc, excluded, frameScope, label, tag, actionable, actionExcluded) {
|
|
2681
|
+
for (const element of roots) for (const iframe of nestedFramesIn(element, excluded)) {
|
|
2682
|
+
if (authorizedFrames.has(iframe)) continue;
|
|
2683
|
+
if (reportedUnauthorized.has(iframe)) continue;
|
|
2684
|
+
reportedUnauthorized.add(iframe);
|
|
2685
|
+
const hint = frameHint(iframe);
|
|
2686
|
+
if (options.discoverNestedFrames === true) {
|
|
2687
|
+
const url = routableUrlOf(iframe, doc);
|
|
2688
|
+
if (url !== void 0) {
|
|
2689
|
+
nestedFrames.push({
|
|
2690
|
+
url,
|
|
2691
|
+
hint,
|
|
2692
|
+
...tag !== void 0 ? { frame: tag } : {}
|
|
2693
|
+
});
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2696
|
+
const inner = sameOriginDocumentOf(iframe);
|
|
2697
|
+
if (inner !== void 0 && inlineFrame(inner, iframe, frameScope, label, tag, actionable, actionExcluded)) continue;
|
|
2698
|
+
}
|
|
2699
|
+
nodes.push({
|
|
2700
|
+
role: "note",
|
|
2701
|
+
name: `A nested frame "${hint}" inside "${label}" is not part of the authorized scope and was not read. Ask the user to authorize it if its content is needed.`,
|
|
2702
|
+
...tag !== void 0 ? { frame: tag } : {}
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* 无地址同源帧的就地内联;成功返回 `true`。
|
|
2708
|
+
*
|
|
2709
|
+
* `srcdoc` / `about:blank` 没有可路由地址,外壳寻不到址,只能由父帧代读。
|
|
2710
|
+
*
|
|
2711
|
+
* 可操作性**继承自那个 `<iframe>` 元素本身**:它由父页面写入内容,视觉上就是父页面的一部分,
|
|
2712
|
+
* 父帧的 action scope 把它圈进来了就等于圈进了它的内容;没圈进来则帧内一个句柄都不发。
|
|
2713
|
+
* exclude 在子文档里重新求值——敏感控件在每一层都要剪掉,不因为它在帧里就放宽(§0.1 边界 3)。
|
|
2714
|
+
*/
|
|
2715
|
+
function inlineFrame(inner, iframe, frameScope, label, tag, actionable, actionExcluded) {
|
|
2716
|
+
const innerExcluded = /* @__PURE__ */ new Set();
|
|
2717
|
+
for (const selector of frameScope.exclude ?? []) for (const target of inner.querySelectorAll(selector)) innerExcluded.add(target);
|
|
2718
|
+
const innerActionable = /* @__PURE__ */ new Set();
|
|
2719
|
+
const innerActionExcluded = /* @__PURE__ */ new Set();
|
|
2720
|
+
if (actionable.has(iframe)) {
|
|
2721
|
+
innerActionable.add(inner.body);
|
|
2722
|
+
for (const descendant of inner.body.querySelectorAll("*")) innerActionable.add(descendant);
|
|
2723
|
+
for (const selector of actionScopeFor(label)?.exclude ?? []) for (const target of inner.querySelectorAll(selector)) innerActionExcluded.add(target);
|
|
2724
|
+
}
|
|
2725
|
+
issuer.useSets(innerActionable, innerActionExcluded);
|
|
2726
|
+
const innerView = inner.defaultView;
|
|
2727
|
+
const node = describe(inner.body, innerExcluded, inner, innerView, capture, issuer, tag, "inline-frame", promotionOf(innerView));
|
|
2728
|
+
if (node !== void 0) nodes.push(node);
|
|
2729
|
+
descendFrames([inner.body], inner, innerExcluded, frameScope, label, tag, innerActionable, innerActionExcluded);
|
|
2730
|
+
issuer.useSets(actionable, actionExcluded);
|
|
2731
|
+
return node !== void 0;
|
|
2732
|
+
}
|
|
2733
|
+
for (const { scope: frameScope, label, doc } of resolvedScopes) {
|
|
2025
2734
|
const view = doc.defaultView;
|
|
2026
|
-
const tag =
|
|
2027
|
-
const sets = actionSets(doc, actionScopeFor(
|
|
2028
|
-
|
|
2735
|
+
const tag = label === "self" ? void 0 : label;
|
|
2736
|
+
const sets = actionSets(doc, actionScopeFor(label));
|
|
2737
|
+
issuer.useSets(sets.actionable, sets.excluded);
|
|
2029
2738
|
const excluded = /* @__PURE__ */ new Set();
|
|
2030
2739
|
for (const selector of frameScope.exclude ?? []) for (const element of doc.querySelectorAll(selector)) excluded.add(element);
|
|
2031
2740
|
const roots = [];
|
|
2032
2741
|
for (const selector of frameScope.include) for (const element of doc.querySelectorAll(selector)) if (!roots.includes(element)) roots.push(element);
|
|
2033
2742
|
for (const element of roots) {
|
|
2034
|
-
const node = describe(element, excluded, doc, view,
|
|
2743
|
+
const node = describe(element, excluded, doc, view, capture, issuer, tag, void 0, promotionOf(view));
|
|
2035
2744
|
if (node !== void 0) nodes.push(node);
|
|
2036
2745
|
}
|
|
2746
|
+
descendFrames(roots, doc, excluded, frameScope, label, tag, sets.actionable, sets.excluded);
|
|
2037
2747
|
}
|
|
2038
2748
|
pruneElevated();
|
|
2039
2749
|
const view = root.defaultView;
|
|
2040
2750
|
const sets = actionSets(root, actionScopeFor("self"));
|
|
2041
2751
|
for (const modal of elevatedModals) {
|
|
2042
|
-
|
|
2043
|
-
const node = describe(modal, /* @__PURE__ */ new Set(), root, view,
|
|
2752
|
+
issuer.useSets(/* @__PURE__ */ new Set([modal, ...modal.querySelectorAll("*")]), sets.excluded);
|
|
2753
|
+
const node = describe(modal, /* @__PURE__ */ new Set(), root, view, capture, issuer, void 0, "modal-elevated", promotionOf(view));
|
|
2044
2754
|
if (node !== void 0) nodes.push(node);
|
|
2045
2755
|
}
|
|
2046
2756
|
for (const modal of root.querySelectorAll(MODAL_SELECTOR)) {
|
|
@@ -2053,12 +2763,16 @@ function createDomPerceptionReader(options = {}) {
|
|
|
2053
2763
|
}
|
|
2054
2764
|
return {
|
|
2055
2765
|
nodes,
|
|
2056
|
-
frameNotes
|
|
2766
|
+
frameNotes,
|
|
2767
|
+
nestedFrames
|
|
2057
2768
|
};
|
|
2058
2769
|
}
|
|
2059
2770
|
async function readWithImages(scope, capture) {
|
|
2060
2771
|
const targets = [];
|
|
2061
|
-
const { nodes, frameNotes } = collect(scope,
|
|
2772
|
+
const { nodes, frameNotes, nestedFrames } = collect(scope, {
|
|
2773
|
+
targets,
|
|
2774
|
+
minImageArea: capture.minImageArea ?? 0
|
|
2775
|
+
});
|
|
2062
2776
|
const selected = targets.slice(0, capture.maxImages);
|
|
2063
2777
|
const images = [];
|
|
2064
2778
|
let imageFailures = 0;
|
|
@@ -2086,16 +2800,18 @@ function createDomPerceptionReader(options = {}) {
|
|
|
2086
2800
|
images,
|
|
2087
2801
|
imagesOmitted: targets.length - selected.length,
|
|
2088
2802
|
imageFailures,
|
|
2089
|
-
...frameNotes.length > 0 ? { frameNotes } : {}
|
|
2803
|
+
...frameNotes.length > 0 ? { frameNotes } : {},
|
|
2804
|
+
...nestedFrames.length > 0 ? { nestedFrames } : {}
|
|
2090
2805
|
};
|
|
2091
2806
|
}
|
|
2092
2807
|
function read(scope, capture) {
|
|
2093
2808
|
if (capture === void 0) return collect(scope, void 0).nodes;
|
|
2094
2809
|
if (!capture.images || capture.maxImages <= 0) {
|
|
2095
|
-
const { nodes, frameNotes } = collect(scope, void 0);
|
|
2810
|
+
const { nodes, frameNotes, nestedFrames } = collect(scope, void 0);
|
|
2096
2811
|
return Promise.resolve({
|
|
2097
2812
|
nodes,
|
|
2098
|
-
...frameNotes.length > 0 ? { frameNotes } : {}
|
|
2813
|
+
...frameNotes.length > 0 ? { frameNotes } : {},
|
|
2814
|
+
...nestedFrames.length > 0 ? { nestedFrames } : {}
|
|
2099
2815
|
});
|
|
2100
2816
|
}
|
|
2101
2817
|
return readWithImages(scope, capture);
|
|
@@ -2137,14 +2853,21 @@ function createDomPerceptionReader(options = {}) {
|
|
|
2137
2853
|
return sets.actionable.has(element) && !sets.excluded.has(element);
|
|
2138
2854
|
}
|
|
2139
2855
|
};
|
|
2140
|
-
/**
|
|
2856
|
+
/**
|
|
2857
|
+
* 反查一个 Document 属于哪条帧授权,返回它的展示串;不在授权列表里的帧一律判不可操作。
|
|
2858
|
+
* 没有反向解析展示串(那是不成立的,D-18-1),而是把每条授权路径重新走一遍后比对
|
|
2859
|
+
* 文档对象:路径中任一层被换掉 / origin 变了,这里就对不上,旧句柄因此失效。
|
|
2860
|
+
*/
|
|
2141
2861
|
function frameOfDocument(doc) {
|
|
2142
2862
|
const root = rootDocument();
|
|
2143
2863
|
if (root === void 0) return void 0;
|
|
2144
2864
|
if (doc === root) return "self";
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
if (
|
|
2865
|
+
if (options.actionScope === void 0) return void 0;
|
|
2866
|
+
for (const entry of toActionFrameScopes(options.actionScope)) {
|
|
2867
|
+
if (frameSteps(entry.frame).length === 0) continue;
|
|
2868
|
+
const resolved = resolveFrame(entry.frame, root);
|
|
2869
|
+
if ("note" in resolved) continue;
|
|
2870
|
+
if (resolved.doc === doc) return frameLabel(entry.frame);
|
|
2148
2871
|
}
|
|
2149
2872
|
}
|
|
2150
2873
|
}
|
|
@@ -2367,13 +3090,27 @@ function createDomPageActionExecutor(options) {
|
|
|
2367
3090
|
if (hidden(element)) return fail("The element is not visible.");
|
|
2368
3091
|
if (element.hasAttribute("disabled")) return fail("The element is disabled.");
|
|
2369
3092
|
const modalsBefore = reader.modalSnapshot();
|
|
3093
|
+
const frameView = element.ownerDocument.defaultView;
|
|
3094
|
+
const urlBefore = frameView?.location.href;
|
|
2370
3095
|
const done = async (outcome) => {
|
|
2371
3096
|
if (!outcome.ok) return outcome;
|
|
2372
|
-
const
|
|
3097
|
+
const settled = await waitFor(() => {
|
|
3098
|
+
const modal = reader.elevateNewModals(modalsBefore);
|
|
3099
|
+
if (modal !== void 0) return { modal };
|
|
3100
|
+
const url = frameView?.location.href;
|
|
3101
|
+
return url !== void 0 && url !== urlBefore ? { url } : void 0;
|
|
3102
|
+
}, 500);
|
|
3103
|
+
const elevated = settled !== void 0 && "modal" in settled ? settled.modal : reader.elevateNewModals(modalsBefore);
|
|
2373
3104
|
const name = elevated === void 0 ? void 0 : accessibleName(elevated) ?? "dialog";
|
|
2374
|
-
|
|
3105
|
+
const urlAfter = frameView?.location.href;
|
|
3106
|
+
const navigated = urlAfter !== void 0 && urlBefore !== void 0 && urlAfter !== urlBefore;
|
|
3107
|
+
return {
|
|
2375
3108
|
...outcome,
|
|
2376
|
-
elevatedModal: name
|
|
3109
|
+
...name !== void 0 ? { elevatedModal: name } : {},
|
|
3110
|
+
...navigated ? {
|
|
3111
|
+
navigated: true,
|
|
3112
|
+
documentUrl: urlAfter
|
|
3113
|
+
} : {}
|
|
2377
3114
|
};
|
|
2378
3115
|
};
|
|
2379
3116
|
if (request.action === "click") {
|
|
@@ -2431,6 +3168,182 @@ function createDomPageActionExecutor(options) {
|
|
|
2431
3168
|
describe
|
|
2432
3169
|
};
|
|
2433
3170
|
}
|
|
3171
|
+
/** @experimental */
|
|
3172
|
+
function createRemoteTargetRegistry() {
|
|
3173
|
+
let table = /* @__PURE__ */ new Map();
|
|
3174
|
+
return {
|
|
3175
|
+
/**
|
|
3176
|
+
* 整表替换而非合并(FR-13.2):合并会让上一次感知的 ref 在本次感知后仍然可执行——
|
|
3177
|
+
* 那正是 `PAGE_ACTION_STALE_REF` 要拦的东西。本地形态里旧 ref 天然失效
|
|
3178
|
+
* (DOM 重新遍历、句柄表 prune 掉离开文档的条目),远端必须手动复现这个失效语义,
|
|
3179
|
+
* 否则远端比本地更松。
|
|
3180
|
+
*/
|
|
3181
|
+
replace(entries) {
|
|
3182
|
+
const next = /* @__PURE__ */ new Map();
|
|
3183
|
+
for (const entry of entries) {
|
|
3184
|
+
const { ref, role, name, secret, frame, elevated } = entry;
|
|
3185
|
+
next.set(ref, {
|
|
3186
|
+
role,
|
|
3187
|
+
...name !== void 0 ? { name } : {},
|
|
3188
|
+
...secret === true ? { secret: true } : {},
|
|
3189
|
+
...frame !== void 0 ? { frame } : {},
|
|
3190
|
+
...elevated === true ? { elevated: true } : {}
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
table = next;
|
|
3194
|
+
},
|
|
3195
|
+
get(ref) {
|
|
3196
|
+
return table.get(ref);
|
|
3197
|
+
},
|
|
3198
|
+
get size() {
|
|
3199
|
+
return table.size;
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
/** 缺省单次往返上限(FR-13.2 / FR-13.3) */
|
|
3204
|
+
const DEFAULT_PAGE_AGENT_TIMEOUT_MS = 1e4;
|
|
3205
|
+
/**
|
|
3206
|
+
* 一次带超时的往返,并把三类失败统一成 `WebSkillError`(FR-13.6)。
|
|
3207
|
+
*
|
|
3208
|
+
* 三类都必须显式失败,**绝不降级成空结果**:
|
|
3209
|
+
* 空节点数组的含义是「页面上什么都没有」,模型收到它会据此作答,
|
|
3210
|
+
* 比报错危险得多——报错至少不会产出一个自信的错误回答。
|
|
3211
|
+
*
|
|
3212
|
+
* @param expect 期望的应答类型;对不上通常意味着装错了 agent 版本,
|
|
3213
|
+
* 静默容忍会把问题推到很远的地方才暴露。
|
|
3214
|
+
*/
|
|
3215
|
+
async function roundTrip(transport, request, expect, code, timeoutMs) {
|
|
3216
|
+
let timer;
|
|
3217
|
+
let reply;
|
|
3218
|
+
try {
|
|
3219
|
+
reply = await Promise.race([transport.send(request), new Promise((_resolve, reject) => {
|
|
3220
|
+
timer = setTimeout(() => reject(new WebSkillError(code, `Page agent request "${request.type}" timed out after ${timeoutMs}ms with no reply from the page.`)), timeoutMs);
|
|
3221
|
+
})]);
|
|
3222
|
+
} catch (e) {
|
|
3223
|
+
if (e instanceof WebSkillError) throw e;
|
|
3224
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
3225
|
+
throw new WebSkillError(code, `Page agent request "${request.type}" failed: ${message}`);
|
|
3226
|
+
} finally {
|
|
3227
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
3228
|
+
}
|
|
3229
|
+
if (reply.type === "error") throw new WebSkillError(reply.code, reply.message);
|
|
3230
|
+
if (reply.type !== expect) throw new WebSkillError(code, `Page agent replied with "${reply.type}" to a "${request.type}" request; expected "${expect}".`);
|
|
3231
|
+
return reply;
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* 跨上下文的页面感知(分册 13 / FR-13.2)。
|
|
3235
|
+
*
|
|
3236
|
+
* 把句柄描述表写进 registry,**只把 `result` 返回给调用方**——
|
|
3237
|
+
* 返回值就是标准 `PerceptionResult`,不夹带任何新字段(AC-13.2)。
|
|
3238
|
+
* @experimental
|
|
3239
|
+
*/
|
|
3240
|
+
function createRemotePerceptionReader(options) {
|
|
3241
|
+
const { transport, targets, timeoutMs = DEFAULT_PAGE_AGENT_TIMEOUT_MS } = options;
|
|
3242
|
+
return { async read(scope, capture) {
|
|
3243
|
+
const reply = await roundTrip(transport, {
|
|
3244
|
+
type: "perceive",
|
|
3245
|
+
scope,
|
|
3246
|
+
...capture !== void 0 ? { capture } : {}
|
|
3247
|
+
}, "perceive-result", "PERCEPTION_FAILED", timeoutMs);
|
|
3248
|
+
targets.replace(reply.targets);
|
|
3249
|
+
return reply.result;
|
|
3250
|
+
} };
|
|
3251
|
+
}
|
|
3252
|
+
/**
|
|
3253
|
+
* 跨上下文的页面操作执行器(分册 13 / FR-13.3)。
|
|
3254
|
+
* @experimental
|
|
3255
|
+
*/
|
|
3256
|
+
function createRemotePageActionExecutor(options) {
|
|
3257
|
+
const { transport, targets, timeoutMs = DEFAULT_PAGE_AGENT_TIMEOUT_MS } = options;
|
|
3258
|
+
return {
|
|
3259
|
+
/**
|
|
3260
|
+
* **同步**查表。`PageActionPolicy.act()` 在弹确认卡之前调它,
|
|
3261
|
+
* 拿到的 target 决定卡上写什么、是否 `secret`、是否 `elevated`——
|
|
3262
|
+
* 改成异步就是改 `@webskill/agent` 的公开契约。
|
|
3263
|
+
*
|
|
3264
|
+
* 表里没有即 `undefined`,于是 `act()` 抛 `PAGE_ACTION_STALE_REF`:
|
|
3265
|
+
* 没感知过就执行 = 拒绝,宁可让模型多感知一次(fail-closed)。
|
|
3266
|
+
*/
|
|
3267
|
+
describe(ref) {
|
|
3268
|
+
return targets.get(ref);
|
|
3269
|
+
},
|
|
3270
|
+
/**
|
|
3271
|
+
* 原样返回远端**执行那一刻**回填的 outcome(FR-13.5):
|
|
3272
|
+
* 不用 registry 里的快照拼装,也不补默认值——
|
|
3273
|
+
* 补出来的 `false` 与「远端没报」在审计上不是一回事。
|
|
3274
|
+
*/
|
|
3275
|
+
async execute(request) {
|
|
3276
|
+
return (await roundTrip(transport, {
|
|
3277
|
+
type: "execute",
|
|
3278
|
+
request
|
|
3279
|
+
}, "execute-result", "TOOL_EXECUTION_FAILED", timeoutMs)).outcome;
|
|
3280
|
+
}
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
/** 深度收集句柄;顺序即遍历序,便于与本地形态逐条比对 */
|
|
3284
|
+
function collectRefs(nodes, out = []) {
|
|
3285
|
+
for (const node of nodes) {
|
|
3286
|
+
if (node.ref !== void 0) out.push(node.ref);
|
|
3287
|
+
if (node.children !== void 0) collectRefs(node.children, out);
|
|
3288
|
+
}
|
|
3289
|
+
return out;
|
|
3290
|
+
}
|
|
3291
|
+
/**
|
|
3292
|
+
* 页面侧处理器(分册 13 / FR-13.7)。
|
|
3293
|
+
*
|
|
3294
|
+
* 必须是工厂而不是裸函数:`DomPerceptionReader` **是有状态的**——
|
|
3295
|
+
* `resolve(ref)` 依赖上一次感知发出的句柄表,模态提升状态也跨调用累积。
|
|
3296
|
+
* 每次请求新建 reader,`execute` 就永远 resolve 不到 `perceive` 发出的 ref。
|
|
3297
|
+
*
|
|
3298
|
+
* 它**不自己收发消息**:只做「请求进、应答出」。监听消息通道、判断消息是不是给自己的、
|
|
3299
|
+
* 回不回 `true`——那些全是宿主的事,本包不得出现任何扩展 API。
|
|
3300
|
+
* @experimental
|
|
3301
|
+
*/
|
|
3302
|
+
function createPageAgentHandler(options = {}) {
|
|
3303
|
+
const reader = createDomPerceptionReader({
|
|
3304
|
+
...options.document !== void 0 ? { document: options.document } : {},
|
|
3305
|
+
...options.actionScope !== void 0 ? { actionScope: options.actionScope } : {},
|
|
3306
|
+
...options.promoteRoles !== void 0 ? { promoteRoles: options.promoteRoles } : {},
|
|
3307
|
+
...options.interactiveHint !== void 0 ? { interactiveHint: options.interactiveHint } : {},
|
|
3308
|
+
...options.roleHints !== void 0 ? { roleHints: options.roleHints } : {},
|
|
3309
|
+
...options.discoverNestedFrames !== void 0 ? { discoverNestedFrames: options.discoverNestedFrames } : {}
|
|
3310
|
+
});
|
|
3311
|
+
const executor = createDomPageActionExecutor({
|
|
3312
|
+
reader,
|
|
3313
|
+
...options.pickFiles !== void 0 ? { pickFiles: options.pickFiles } : {}
|
|
3314
|
+
});
|
|
3315
|
+
return { async handle(request) {
|
|
3316
|
+
try {
|
|
3317
|
+
if (request.type === "perceive") {
|
|
3318
|
+
const raw = request.capture === void 0 ? reader.read(request.scope) : await reader.read(request.scope, request.capture);
|
|
3319
|
+
const result = Array.isArray(raw) ? { nodes: raw } : raw;
|
|
3320
|
+
const targets = [];
|
|
3321
|
+
for (const ref of collectRefs(result.nodes)) {
|
|
3322
|
+
const target = executor.describe(ref);
|
|
3323
|
+
if (target !== void 0) targets.push({
|
|
3324
|
+
ref,
|
|
3325
|
+
...target
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
return {
|
|
3329
|
+
type: "perceive-result",
|
|
3330
|
+
result,
|
|
3331
|
+
targets
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3334
|
+
return {
|
|
3335
|
+
type: "execute-result",
|
|
3336
|
+
outcome: await executor.execute(request.request)
|
|
3337
|
+
};
|
|
3338
|
+
} catch (e) {
|
|
3339
|
+
return {
|
|
3340
|
+
type: "error",
|
|
3341
|
+
code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
|
|
3342
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
} };
|
|
3346
|
+
}
|
|
2434
3347
|
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
2435
3348
|
const isNonEmptyString = (v) => typeof v === "string" && v !== "";
|
|
2436
3349
|
/** Worker 侧入站校验:非法消息返回 undefined(忽略) */
|
|
@@ -3392,6 +4305,255 @@ async function extractDocxText(bytes) {
|
|
|
3392
4305
|
return blocks.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
3393
4306
|
}
|
|
3394
4307
|
/**
|
|
4308
|
+
* xlsx 文本抽取(0.13.0 分册 12)。
|
|
4309
|
+
*
|
|
4310
|
+
* **零新增依赖**:xlsx 和 docx 是同一种容器(OPC = zip + XML),
|
|
4311
|
+
* 所以沿用 docx 那条路 —— zip 走仓内既有的 `unzipWithLimits`(fflate,
|
|
4312
|
+
* 自带解压体积上限,顺带挡住 zip 炸弹),XML 走浏览器原生 `DOMParser`。
|
|
4313
|
+
*
|
|
4314
|
+
* ⚠️ `DOMParser` 是浏览器 API,Node 没有 —— 所以这个模块只在 `@webskill/browser`。
|
|
4315
|
+
* Node 宿主没有 xlsx 能力,与 docx 同一条**显式声明**的限制,不是静默降级(备案 D55)。
|
|
4316
|
+
*/
|
|
4317
|
+
/**
|
|
4318
|
+
* **不抽取**的内容,逐条写明而不是笼统说「尽力而为」(FR-12.4)。
|
|
4319
|
+
*
|
|
4320
|
+
* 公式只取 Excel 自己写进文件的缓存值:零依赖求值等于自写一个表达式引擎。
|
|
4321
|
+
*/
|
|
4322
|
+
const XLSX_UNEXTRACTED = [
|
|
4323
|
+
"styles",
|
|
4324
|
+
"conditional formatting",
|
|
4325
|
+
"merged cell reconstruction",
|
|
4326
|
+
"charts",
|
|
4327
|
+
"images",
|
|
4328
|
+
"comments",
|
|
4329
|
+
"pivot tables",
|
|
4330
|
+
"macros",
|
|
4331
|
+
"hidden row and column marking",
|
|
4332
|
+
"formula expressions (only cached values are read)"
|
|
4333
|
+
];
|
|
4334
|
+
const WORKBOOK = "xl/workbook.xml";
|
|
4335
|
+
const WORKBOOK_RELS = "xl/_rels/workbook.xml.rels";
|
|
4336
|
+
const SHARED_STRINGS = "xl/sharedStrings.xml";
|
|
4337
|
+
const STYLES = "xl/styles.xml";
|
|
4338
|
+
/** OLE2 复合文档签名:旧版 .xls 的开头,它不是 zip,走不到解压那一步 */
|
|
4339
|
+
const OLE2_SIGNATURE = [
|
|
4340
|
+
208,
|
|
4341
|
+
207,
|
|
4342
|
+
17,
|
|
4343
|
+
224,
|
|
4344
|
+
161,
|
|
4345
|
+
177,
|
|
4346
|
+
26,
|
|
4347
|
+
225
|
|
4348
|
+
];
|
|
4349
|
+
/** 内建的日期/时间数字格式 id(ECMA-376 §18.8.30) */
|
|
4350
|
+
const BUILTIN_DATE_FORMATS = /* @__PURE__ */ new Set([
|
|
4351
|
+
14,
|
|
4352
|
+
15,
|
|
4353
|
+
16,
|
|
4354
|
+
17,
|
|
4355
|
+
18,
|
|
4356
|
+
19,
|
|
4357
|
+
20,
|
|
4358
|
+
21,
|
|
4359
|
+
22,
|
|
4360
|
+
45,
|
|
4361
|
+
46,
|
|
4362
|
+
47
|
|
4363
|
+
]);
|
|
4364
|
+
function fail(message) {
|
|
4365
|
+
throw new WebSkillError("TOOL_EXECUTION_FAILED", message);
|
|
4366
|
+
}
|
|
4367
|
+
/** 按 localName 取子孙元素,忽略命名空间前缀:不同生成器的前缀写法并不统一 */
|
|
4368
|
+
const byTag = (scope, name) => [...scope.getElementsByTagNameNS("*", name)];
|
|
4369
|
+
function parseXml(bytes, what) {
|
|
4370
|
+
const doc = new DOMParser().parseFromString(new TextDecoder().decode(bytes), "application/xml");
|
|
4371
|
+
if (doc.getElementsByTagName("parsererror").length > 0) fail(`The workbook ${what} could not be parsed as XML.`);
|
|
4372
|
+
return doc;
|
|
4373
|
+
}
|
|
4374
|
+
/** `B` → 1、`AA` → 26;拿不到列号时返回 undefined,由调用方顺位补上 */
|
|
4375
|
+
function columnIndex(ref) {
|
|
4376
|
+
if (ref === null) return void 0;
|
|
4377
|
+
let index = 0;
|
|
4378
|
+
let seen = false;
|
|
4379
|
+
for (const ch of ref) {
|
|
4380
|
+
const code = ch.charCodeAt(0);
|
|
4381
|
+
if (code < 65 || code > 90) break;
|
|
4382
|
+
index = index * 26 + (code - 64);
|
|
4383
|
+
seen = true;
|
|
4384
|
+
}
|
|
4385
|
+
return seen ? index - 1 : void 0;
|
|
4386
|
+
}
|
|
4387
|
+
/**
|
|
4388
|
+
* 一个 `<si>` 的文本。富文本会被切成多个 `<r>`,须拼接;
|
|
4389
|
+
* `<rPh>` 是日文注音,拼进去会在正文里插入读音噪声,跳过。
|
|
4390
|
+
*/
|
|
4391
|
+
function sharedStringText(si) {
|
|
4392
|
+
let out = "";
|
|
4393
|
+
for (const child of si.children) {
|
|
4394
|
+
const tag = child.localName;
|
|
4395
|
+
if (tag === "t") out += child.textContent ?? "";
|
|
4396
|
+
else if (tag === "r") {
|
|
4397
|
+
for (const run of child.children) if (run.localName === "t") out += run.textContent ?? "";
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
return out;
|
|
4401
|
+
}
|
|
4402
|
+
/**
|
|
4403
|
+
* 数字格式是否表示日期/时间。
|
|
4404
|
+
*
|
|
4405
|
+
* `[h]:mm:ss` 这类方括号包住时分秒的是**经过时长**(可以是 25:30),不是时刻,
|
|
4406
|
+
* 按日期换算会得出荒谬结果,先排掉。
|
|
4407
|
+
*/
|
|
4408
|
+
function isDateFormatCode(code) {
|
|
4409
|
+
if (/\[[hms]+\]/i.test(code)) return false;
|
|
4410
|
+
const stripped = code.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, "").replace(/\\./g, "");
|
|
4411
|
+
return /[ymdhs]/i.test(stripped);
|
|
4412
|
+
}
|
|
4413
|
+
/** 单元格样式索引 → 是否日期格式 */
|
|
4414
|
+
function readDateStyles(bytes) {
|
|
4415
|
+
if (bytes === void 0) return [];
|
|
4416
|
+
const doc = parseXml(bytes, "styles");
|
|
4417
|
+
const custom = /* @__PURE__ */ new Map();
|
|
4418
|
+
for (const fmt of byTag(doc, "numFmt")) {
|
|
4419
|
+
const id = Number(fmt.getAttribute("numFmtId"));
|
|
4420
|
+
const code = fmt.getAttribute("formatCode");
|
|
4421
|
+
if (Number.isFinite(id) && code !== null) custom.set(id, code);
|
|
4422
|
+
}
|
|
4423
|
+
const cellXfs = byTag(doc, "cellXfs")[0];
|
|
4424
|
+
if (cellXfs === void 0) return [];
|
|
4425
|
+
return [...cellXfs.children].filter((xf) => xf.localName === "xf").map((xf) => {
|
|
4426
|
+
const id = Number(xf.getAttribute("numFmtId") ?? "0");
|
|
4427
|
+
if (!Number.isFinite(id)) return false;
|
|
4428
|
+
const code = custom.get(id);
|
|
4429
|
+
return code !== void 0 ? isDateFormatCode(code) : BUILTIN_DATE_FORMATS.has(id);
|
|
4430
|
+
});
|
|
4431
|
+
}
|
|
4432
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
4433
|
+
/**
|
|
4434
|
+
* Excel 日期序列号 → ISO 8601。
|
|
4435
|
+
*
|
|
4436
|
+
* 1900 历制里序列号 60 是**不存在的 1900-02-29**:Excel 为兼容 Lotus 1-2-3
|
|
4437
|
+
* 保留了这个错误。不特判它,1900-03-01 之后的全部日期都会差一天。
|
|
4438
|
+
*/
|
|
4439
|
+
function serialToIso(serial, date1904) {
|
|
4440
|
+
const whole = Math.floor(serial);
|
|
4441
|
+
const fraction = serial - whole;
|
|
4442
|
+
let seconds = Math.round(fraction * 86400);
|
|
4443
|
+
let days = whole;
|
|
4444
|
+
if (seconds >= 86400) {
|
|
4445
|
+
seconds -= 86400;
|
|
4446
|
+
days += 1;
|
|
4447
|
+
}
|
|
4448
|
+
let iso;
|
|
4449
|
+
if (!date1904 && days === 60) iso = "1900-02-29";
|
|
4450
|
+
else {
|
|
4451
|
+
const at = new Date((date1904 ? Date.UTC(1904, 0, 1) : days < 60 ? Date.UTC(1899, 11, 31) : Date.UTC(1899, 11, 30)) + days * 864e5);
|
|
4452
|
+
iso = `${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())}`;
|
|
4453
|
+
}
|
|
4454
|
+
if (seconds === 0) return iso;
|
|
4455
|
+
const h = Math.floor(seconds / 3600);
|
|
4456
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
4457
|
+
const s = seconds % 60;
|
|
4458
|
+
return `${iso}T${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
4459
|
+
}
|
|
4460
|
+
/** 一个 `<c>` 的呈现文本(FR-12.3) */
|
|
4461
|
+
function cellText(cell, shared, dateStyles, date1904) {
|
|
4462
|
+
const type = cell.getAttribute("t");
|
|
4463
|
+
if (type === "inlineStr") {
|
|
4464
|
+
const is = [...cell.children].find((child) => child.localName === "is");
|
|
4465
|
+
return is !== void 0 ? sharedStringText(is) : "";
|
|
4466
|
+
}
|
|
4467
|
+
const raw = [...cell.children].find((child) => child.localName === "v")?.textContent ?? "";
|
|
4468
|
+
if (raw === "") return "";
|
|
4469
|
+
if (type === "s") {
|
|
4470
|
+
const index = Number(raw);
|
|
4471
|
+
return Number.isInteger(index) ? shared[index] ?? "" : "";
|
|
4472
|
+
}
|
|
4473
|
+
if (type === "str" || type === "e") return raw;
|
|
4474
|
+
if (type === "b") return raw === "1" ? "TRUE" : "FALSE";
|
|
4475
|
+
const numeric = Number(raw);
|
|
4476
|
+
if (!Number.isFinite(numeric)) return raw;
|
|
4477
|
+
const styleIndex = Number(cell.getAttribute("s") ?? "0");
|
|
4478
|
+
if (Number.isInteger(styleIndex) && dateStyles[styleIndex] === true) return serialToIso(numeric, date1904);
|
|
4479
|
+
return raw;
|
|
4480
|
+
}
|
|
4481
|
+
/**
|
|
4482
|
+
* 一张工作表的文本:一行一行,单元格用制表符分隔(与 docx 的表格同口径)。
|
|
4483
|
+
* 空行与空列**照原样留着** —— 位置本身是语义,压掉之后「第 3 列」就对不上了。
|
|
4484
|
+
*/
|
|
4485
|
+
function sheetText(doc, shared, dateStyles, date1904) {
|
|
4486
|
+
const lines = [];
|
|
4487
|
+
let previousRow = 0;
|
|
4488
|
+
for (const row of byTag(doc, "row")) {
|
|
4489
|
+
const declared = Number(row.getAttribute("r"));
|
|
4490
|
+
const rowNumber = Number.isInteger(declared) && declared > 0 ? declared : previousRow + 1;
|
|
4491
|
+
for (let gap = previousRow + 1; gap < rowNumber; gap++) lines.push("");
|
|
4492
|
+
previousRow = rowNumber;
|
|
4493
|
+
const cells = [];
|
|
4494
|
+
let nextColumn = 0;
|
|
4495
|
+
for (const cell of row.children) {
|
|
4496
|
+
if (cell.localName !== "c") continue;
|
|
4497
|
+
const column = columnIndex(cell.getAttribute("r")) ?? nextColumn;
|
|
4498
|
+
for (let gap = cells.length; gap < column; gap++) cells.push("");
|
|
4499
|
+
const text = cellText(cell, shared, dateStyles, date1904);
|
|
4500
|
+
if (cells.length > column) cells[column] = text;
|
|
4501
|
+
else cells.push(text);
|
|
4502
|
+
nextColumn = column + 1;
|
|
4503
|
+
}
|
|
4504
|
+
lines.push(cells.join(" ").replace(/\t+$/, ""));
|
|
4505
|
+
}
|
|
4506
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
4507
|
+
return lines.join("\n");
|
|
4508
|
+
}
|
|
4509
|
+
const looksLikeOle2 = (bytes) => bytes.length >= OLE2_SIGNATURE.length && OLE2_SIGNATURE.every((byte, i) => bytes[i] === byte);
|
|
4510
|
+
/** rels 里的 Target 可能是相对 `xl/` 的,也可能是包根绝对路径 */
|
|
4511
|
+
function resolveSheetPath(target) {
|
|
4512
|
+
const clean = target.replace(/^\.\//, "");
|
|
4513
|
+
return clean.startsWith("/") ? clean.slice(1) : `xl/${clean}`;
|
|
4514
|
+
}
|
|
4515
|
+
/**
|
|
4516
|
+
* 从 xlsx 字节里抽出文本,每个工作表一段、段首是表名。
|
|
4517
|
+
*
|
|
4518
|
+
* @throws WebSkillError 归档损坏、旧版 `.xls`、缺 `xl/workbook.xml`、工作表关系解不开、XML 解析失败
|
|
4519
|
+
*/
|
|
4520
|
+
async function extractXlsxText(bytes) {
|
|
4521
|
+
if (looksLikeOle2(bytes)) fail("This is a legacy .xls workbook, which is not supported. Save it as .xlsx and try again.");
|
|
4522
|
+
let entries;
|
|
4523
|
+
try {
|
|
4524
|
+
entries = await unzipWithLimits(bytes);
|
|
4525
|
+
} catch {
|
|
4526
|
+
fail("This file could not be read as an Excel workbook: the archive is damaged or not a .xlsx file.");
|
|
4527
|
+
}
|
|
4528
|
+
const parts = new Map(entries);
|
|
4529
|
+
const workbookBytes = parts.get(WORKBOOK);
|
|
4530
|
+
if (workbookBytes === void 0) fail(`This file is not an Excel workbook: it has no ${WORKBOOK} entry.`);
|
|
4531
|
+
const workbook = parseXml(workbookBytes, "index");
|
|
4532
|
+
const workbookPr = byTag(workbook, "workbookPr")[0];
|
|
4533
|
+
const date1904 = workbookPr?.getAttribute("date1904") === "1" || workbookPr?.getAttribute("date1904") === "true";
|
|
4534
|
+
const relsBytes = parts.get(WORKBOOK_RELS);
|
|
4535
|
+
const targets = /* @__PURE__ */ new Map();
|
|
4536
|
+
if (relsBytes !== void 0) for (const rel of byTag(parseXml(relsBytes, "relationships"), "Relationship")) {
|
|
4537
|
+
const id = rel.getAttribute("Id");
|
|
4538
|
+
const target = rel.getAttribute("Target");
|
|
4539
|
+
if (id !== null && target !== null) targets.set(id, resolveSheetPath(target));
|
|
4540
|
+
}
|
|
4541
|
+
const sharedBytes = parts.get(SHARED_STRINGS);
|
|
4542
|
+
const shared = sharedBytes === void 0 ? [] : byTag(parseXml(sharedBytes, "shared strings"), "si").map(sharedStringText);
|
|
4543
|
+
const dateStyles = readDateStyles(parts.get(STYLES));
|
|
4544
|
+
const sections = [];
|
|
4545
|
+
for (const [ordinal, sheet] of byTag(workbook, "sheet").entries()) {
|
|
4546
|
+
const name = sheet.getAttribute("name") ?? `Sheet${ordinal + 1}`;
|
|
4547
|
+
const relId = sheet.getAttributeNS("http://schemas.openxmlformats.org/officeDocument/2006/relationships", "id") ?? sheet.getAttribute("r:id");
|
|
4548
|
+
const path = relId !== null ? targets.get(relId) : void 0;
|
|
4549
|
+
const sheetBytes = path !== void 0 ? parts.get(path) : void 0;
|
|
4550
|
+
if (sheetBytes === void 0) fail(`The worksheet "${name}" could not be located inside the workbook.`);
|
|
4551
|
+
const body = sheetText(parseXml(sheetBytes, `worksheet "${name}"`), shared, dateStyles, date1904);
|
|
4552
|
+
sections.push(body === "" ? `Sheet: ${name}` : `Sheet: ${name}\n${body}`);
|
|
4553
|
+
}
|
|
4554
|
+
return sections.join("\n\n");
|
|
4555
|
+
}
|
|
4556
|
+
/**
|
|
3395
4557
|
* 文档投放面的 CSP 单一来源(0.11.0 分册 18 FR-18.3 / AC-G21)。
|
|
3396
4558
|
*
|
|
3397
4559
|
* ⚠️ 策略**只能**由 viewer 路由的响应头下发。
|
|
@@ -3635,4 +4797,4 @@ function originOf(blockedURI) {
|
|
|
3635
4797
|
}
|
|
3636
4798
|
|
|
3637
4799
|
//#endregion
|
|
3638
|
-
export { BrowserSkillManager, BrowserWorkerScriptExecutor, ChromeBuiltinLlmClient, DEFAULT_MAX_VIEWER_PAYLOAD_BYTES, DOCUMENT_SURFACE_AUDIT_EVENT, DOCX_UNEXTRACTED, HOST_PORT_MATRIX, IframeWorkerLike, OpfsProvider, TsTranspiler, VIEWER_SANDBOX_TOKENS, WORKER_BOOTSTRAP_SOURCE, WorkerRuntimeClient, WorkerUiBridge, blockedMessage, bridgeError, captureElementImage, checkDictationAvailability, compressImageToBudget, createBrowserChatbotHost, createDomPageActionExecutor, createDomPerceptionReader, createEncryptedMemoryStore, createFetchLinkedDocumentReader, createIframeWorker, createLlmClient, deleteMemoryEncryptionKey, extractDocxText, extractZipWeb, generateMemoryEncryptionKey, inspectHostWiring, installWebSkillNavigator, isCapturableElement, isCaptureFailure, isEncryptedMemoryValue, isOpfsAvailable, missingPorts, openDocumentSurface, openMemoryEncryptionKey, parseBridgeRequest, parseMainMessage, parseWorkerEvent, probeChromeBuiltinAvailability, sha256HexWeb, startDictation, startViewerShell, startWorkerRuntimeHost, viewerCspHeader, watchBlockedResources };
|
|
4800
|
+
export { BrowserSkillManager, BrowserWorkerScriptExecutor, ChromeBuiltinLlmClient, DEFAULT_MAX_VIEWER_PAYLOAD_BYTES, DOCUMENT_SURFACE_AUDIT_EVENT, DOCX_UNEXTRACTED, HOST_PORT_MATRIX, IframeWorkerLike, OpfsProvider, SANDBOX_PAGE_SCRIPT_SOURCE, TsTranspiler, VIEWER_SANDBOX_TOKENS, WORKER_BOOTSTRAP_SOURCE, WorkerRuntimeClient, WorkerUiBridge, XLSX_UNEXTRACTED, blockedMessage, bridgeError, captureElementImage, checkDictationAvailability, compressImageToBudget, createBrowserChatbotHost, createDomPageActionExecutor, createDomPerceptionReader, createEncryptedMemoryStore, createFetchLinkedDocumentReader, createIframeWorker, createLlmClient, createPageAgentHandler, createRemotePageActionExecutor, createRemotePerceptionReader, createRemoteTargetRegistry, deleteMemoryEncryptionKey, extractDocxText, extractXlsxText, extractZipWeb, generateMemoryEncryptionKey, inspectHostWiring, installWebSkillNavigator, isCapturableElement, isCaptureFailure, isEncryptedMemoryValue, isOpfsAvailable, missingPorts, openDocumentSurface, openMemoryEncryptionKey, parseBridgeRequest, parseMainMessage, parseWorkerEvent, probeChromeBuiltinAvailability, sha256HexWeb, startDictation, startViewerShell, startWorkerRuntimeHost, viewerCspHeader, watchBlockedResources };
|