@webskill/sdk 0.10.0 → 0.11.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 -2
- package/dist/agent.js +3 -1102
- package/dist/browser.d.ts +258 -11
- package/dist/browser.js +758 -46
- package/dist/{catalogComponents-DTcYfpLQ-CcoOaz-Z.js → catalogComponents-BFoqpT1v-CjUBZ3bc.js} +253 -14
- package/dist/{dist-BViUeszk.js → dist-B-cOu08W.js} +526 -76
- package/dist/{dist-1OFC-zax.js → dist-DTHZS2k1.js} +520 -28
- package/dist/dist-qnlI2Iup.js +1280 -0
- package/dist/{eventTypes-s2uwAcLG-Go3l_dUe.js → eventTypes-FllCrX-Z-DNDeHWoG.js} +6 -2
- package/dist/governance.d.ts +7 -3
- package/dist/governance.js +1 -1
- package/dist/{index-DtFdMKBX.d.ts → index-D3mONFHD.d.ts} +220 -7
- package/dist/{index-B0QPLWPZ.d.ts → index-DACk2_XZ.d.ts} +110 -7
- package/dist/{index-BF4E1a9j.d.ts → index-DWbs58LF.d.ts} +227 -14
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +35 -7
- package/dist/mcp.js +88 -21
- package/dist/node.d.ts +3 -3
- package/dist/node.js +52 -2
- package/dist/{openUiLibrary-CIrV--Ad-B8zG_91e.js → openUiLibrary-D5u8oIvx-BLOAQCho.js} +3 -3
- package/dist/processSandboxEntry.js +6 -0
- package/dist/sandboxWorkerEntry.js +6 -0
- package/dist/{skillVersionStore-D-qHk9ZE-DBsYYCWn.d.ts → skillVersionStore-D-qHk9ZE-BcmFLykd.d.ts} +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{types-CrRcT-LM-DZAp8sWv.d.ts → types-C26b05fW-CdrRCRDb.d.ts} +20 -4
- package/dist/ui-react.d.ts +2 -2
- package/dist/ui-react.js +28 -10
- package/dist/ui-vue.d.ts +1 -1
- package/dist/ui-vue.js +2 -2
- package/dist/ui.d.ts +4 -4
- package/dist/ui.js +3 -3
- package/dist/{webskillLitCatalog-BJrphK0y-SGmRXaaO.js → webskillLitCatalog-DME6PBkV-CmYNLlIT.js} +135 -16
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { D as exportSkills, F as parseSkillPackManifest, G as unzipWithLimits, I as readResponseWithLimit, J as verifySkillSignature, K as validateSkills, L as readSkillSignature, M as messageOf, O as isAtomicTempPath, P as parseSkillMarkdown, T as detectSkillArchiveShapeFromFs, V as resolveInsideRoot, b as buildManifest, c as SKILL_MANIFEST_FILE, d as SKILL_PACK_FILE, g as assertRemoteUrlAllowed, h as WebSkillError, i as MANIFEST_EXCLUDED_FILES, k as isValidSkillName, p as SkillDiscovery, q as verifyManifest, s as SKILLS_LOCKFILE, v as atomicWriteText, y as buildCatalog } from "./dist-Bev6i6Ip.js";
|
|
2
|
-
import {
|
|
2
|
+
import { $ as createWebSkillApi, D as OpenAiCompatibleClient, O as ProgressiveRouter, T as GoogleGenAiClient, X as bridgeError, b as FsMemoryStore, bt as normalizeToolError, c as AnthropicClient, gt as networkPolicyLibSource, mt as mergeCatalogEntries, s as AgentLoop, u as CapabilityApproval, x as FsRunSnapshotStore, xt as parseBridgeRequest, y as FsArtifactStore, yt as normalizeToolContent } from "./dist-B-cOu08W.js";
|
|
3
|
+
import { T as toFrameScopes, w as toActionFrameScopes } from "./dist-qnlI2Iup.js";
|
|
3
4
|
import { n as MockLlmClient } from "./testing-WPTyXQYt.js";
|
|
4
5
|
|
|
5
6
|
//#region ../browser/dist/index.js
|
|
@@ -304,6 +305,18 @@ function makeContext(msg) {
|
|
|
304
305
|
readReference: function (path) {
|
|
305
306
|
return callCapability('readReference', { path: path });
|
|
306
307
|
},
|
|
308
|
+
readAsset: function (path) {
|
|
309
|
+
return callCapability('readAsset', { path: path });
|
|
310
|
+
},
|
|
311
|
+
fetchData: function (sourceId, params) {
|
|
312
|
+
return callCapability('fetchData', { sourceId: sourceId, params: params });
|
|
313
|
+
},
|
|
314
|
+
readAssetBinary: function (path) {
|
|
315
|
+
// 线上是 number[](与 writeArtifact 同口径),还给脚本的必须是 Uint8Array
|
|
316
|
+
return callCapability('readAssetBinary', { path: path }).then(function (bytes) {
|
|
317
|
+
return new Uint8Array(bytes);
|
|
318
|
+
});
|
|
319
|
+
},
|
|
307
320
|
writeArtifact: function (path, content, options) {
|
|
308
321
|
return callCapability('writeArtifact', {
|
|
309
322
|
path: path,
|
|
@@ -1018,8 +1031,10 @@ var BrowserWorkerScriptExecutor = class {
|
|
|
1018
1031
|
}) : void 0;
|
|
1019
1032
|
this.#capabilities = {
|
|
1020
1033
|
readReference: deps.capabilities?.readReference ?? true,
|
|
1034
|
+
readAsset: deps.capabilities?.readAsset ?? true,
|
|
1021
1035
|
writeArtifact: deps.capabilities?.writeArtifact ?? true,
|
|
1022
|
-
confirm: deps.capabilities?.confirm ?? true
|
|
1036
|
+
confirm: deps.capabilities?.confirm ?? true,
|
|
1037
|
+
fetchData: deps.capabilities?.fetchData ?? false
|
|
1023
1038
|
};
|
|
1024
1039
|
this.#networkPolicy = deps.networkPolicy ?? "deny-all";
|
|
1025
1040
|
this.#approval = new CapabilityApproval({
|
|
@@ -1192,6 +1207,29 @@ var BrowserWorkerScriptExecutor = class {
|
|
|
1192
1207
|
value: await context.readReference(request.path)
|
|
1193
1208
|
};
|
|
1194
1209
|
}
|
|
1210
|
+
case "readAsset":
|
|
1211
|
+
case "readAssetBinary": {
|
|
1212
|
+
const read = request.kind === "readAsset" ? context.readAsset : context.readAssetBinary;
|
|
1213
|
+
if (!read) return bridgeError(request.id, "TOOL_UNSUPPORTED", "Capability \"readAsset\" is disabled");
|
|
1214
|
+
const denied = await gate("readAsset", `Script "${context.skillName}" wants to read asset "${request.path}"`, { path: request.path });
|
|
1215
|
+
if (denied) return denied;
|
|
1216
|
+
const value = await read(request.path);
|
|
1217
|
+
return {
|
|
1218
|
+
id: request.id,
|
|
1219
|
+
ok: true,
|
|
1220
|
+
value: typeof value === "string" ? value : Array.from(value)
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
case "fetchData": {
|
|
1224
|
+
if (!context.fetchData) return bridgeError(request.id, "TOOL_UNSUPPORTED", "Capability \"fetchData\" is disabled");
|
|
1225
|
+
const denied = await gate("fetchData", `Script "${context.skillName}" wants to fetch data source "${request.sourceId}"`, { sourceId: request.sourceId });
|
|
1226
|
+
if (denied) return denied;
|
|
1227
|
+
return {
|
|
1228
|
+
id: request.id,
|
|
1229
|
+
ok: true,
|
|
1230
|
+
value: await context.fetchData(request.sourceId, request.params)
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1195
1233
|
case "writeArtifact": {
|
|
1196
1234
|
const denied = await gate("writeArtifact", `Script "${context.skillName}" wants to write artifact "${request.path}"`, {
|
|
1197
1235
|
path: request.path,
|
|
@@ -1282,10 +1320,10 @@ async function probeChromeBuiltinAvailability() {
|
|
|
1282
1320
|
};
|
|
1283
1321
|
}
|
|
1284
1322
|
}
|
|
1285
|
-
const textOf = (message) => message.content.map((part) => part.type === "text" ? part.text : `[${part.type}]`).join("").trim();
|
|
1323
|
+
const textOf$1 = (message) => message.content.map((part) => part.type === "text" ? part.text : `[${part.type}]`).join("").trim();
|
|
1286
1324
|
/** tool 角色 Prompt API 不认识,降级成 user 文本——内置模型本来就不做工具调用 */
|
|
1287
1325
|
function toInitialPrompt(message) {
|
|
1288
|
-
const text = textOf(message);
|
|
1326
|
+
const text = textOf$1(message);
|
|
1289
1327
|
if (message.role === "assistant") return {
|
|
1290
1328
|
role: "assistant",
|
|
1291
1329
|
content: text
|
|
@@ -1296,7 +1334,7 @@ function toInitialPrompt(message) {
|
|
|
1296
1334
|
};
|
|
1297
1335
|
}
|
|
1298
1336
|
function splitInput(input) {
|
|
1299
|
-
const system = input.messages.filter((m) => m.role === "system").map(textOf).filter((text) => text !== "");
|
|
1337
|
+
const system = input.messages.filter((m) => m.role === "system").map(textOf$1).filter((text) => text !== "");
|
|
1300
1338
|
const rest = input.messages.filter((m) => m.role !== "system");
|
|
1301
1339
|
const last = rest.at(-1);
|
|
1302
1340
|
return {
|
|
@@ -1736,6 +1774,17 @@ const clip = (text) => {
|
|
|
1736
1774
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
1737
1775
|
return normalized.length > MAX_TEXT ? `${normalized.slice(0, MAX_TEXT)}…` : normalized;
|
|
1738
1776
|
};
|
|
1777
|
+
/**
|
|
1778
|
+
* 链接目标,已按文档基准解析成绝对 URL。
|
|
1779
|
+
*
|
|
1780
|
+
* `<a>` / `<area>` 的 `.href` 属性天然是解析过的;`role="link"` 的其它元素没有这个属性,
|
|
1781
|
+
* 只能退回原始属性值——那种情况下 URL 是否可用由取数侧的准入判定说了算。
|
|
1782
|
+
*/
|
|
1783
|
+
function hrefOf(element) {
|
|
1784
|
+
const anchor = element;
|
|
1785
|
+
if (typeof anchor.href === "string" && anchor.href !== "") return anchor.href;
|
|
1786
|
+
return element.getAttribute("href") ?? void 0;
|
|
1787
|
+
}
|
|
1739
1788
|
function roleOf$1(element) {
|
|
1740
1789
|
const explicit = element.getAttribute("role");
|
|
1741
1790
|
if (explicit !== null && explicit.trim() !== "") return explicit.trim();
|
|
@@ -1804,26 +1853,32 @@ const SKIPPED_TAGS = /* @__PURE__ */ new Set([
|
|
|
1804
1853
|
"NOSCRIPT",
|
|
1805
1854
|
"TEMPLATE"
|
|
1806
1855
|
]);
|
|
1807
|
-
|
|
1856
|
+
/** 活动模态的三种形态;Radix 之流用 aria-modal,原生用 <dialog open> */
|
|
1857
|
+
const MODAL_SELECTOR = "[role=dialog][open], dialog[open], [aria-modal=true]";
|
|
1858
|
+
function describe(element, excluded, doc, view, targets, handles, frame, provenance) {
|
|
1808
1859
|
if (excluded.has(element)) return void 0;
|
|
1809
1860
|
if (SKIPPED_TAGS.has(element.tagName)) return void 0;
|
|
1810
1861
|
if (hidden$1(element, view)) return void 0;
|
|
1811
1862
|
const children = [];
|
|
1812
1863
|
for (const child of element.children) {
|
|
1813
|
-
const node = describe(child, excluded, doc, view, targets, handles);
|
|
1864
|
+
const node = describe(child, excluded, doc, view, targets, handles, frame, provenance);
|
|
1814
1865
|
if (node !== void 0) children.push(node);
|
|
1815
1866
|
}
|
|
1816
1867
|
const name = nameOf(element, doc);
|
|
1817
1868
|
const value = valueOf(element);
|
|
1818
1869
|
const role = roleOf$1(element);
|
|
1870
|
+
const href = role === "link" ? hrefOf(element) : void 0;
|
|
1819
1871
|
if (role === "generic" && name === void 0 && value === void 0 && children.length === 0) return void 0;
|
|
1820
1872
|
const node = {
|
|
1821
1873
|
role,
|
|
1822
1874
|
...name !== void 0 ? { name } : {},
|
|
1823
1875
|
...value !== void 0 ? { value } : {},
|
|
1876
|
+
...frame !== void 0 ? { frame } : {},
|
|
1877
|
+
...href !== void 0 ? { href } : {},
|
|
1878
|
+
...provenance !== void 0 ? { provenance } : {},
|
|
1824
1879
|
...children.length > 0 ? { children } : {}
|
|
1825
1880
|
};
|
|
1826
|
-
const ref = handles?.issue(element, role);
|
|
1881
|
+
const ref = handles?.issue(element, role, frame);
|
|
1827
1882
|
if (ref !== void 0) node.ref = ref;
|
|
1828
1883
|
if (targets !== void 0 && isCapturableElement(element)) targets.push({
|
|
1829
1884
|
element,
|
|
@@ -1843,13 +1898,21 @@ var HandleIssuer = class {
|
|
|
1843
1898
|
this.#actionable = actionable;
|
|
1844
1899
|
this.#excluded = excluded;
|
|
1845
1900
|
}
|
|
1846
|
-
|
|
1901
|
+
/** 每帧一份可操作集合;发句柄时按帧替换,避免 A 帧的集合拿去判 B 帧的元素 */
|
|
1902
|
+
useSets(actionable, excluded) {
|
|
1903
|
+
this.#actionable = actionable;
|
|
1904
|
+
this.#excluded = excluded;
|
|
1905
|
+
}
|
|
1906
|
+
issue(element, role, frame) {
|
|
1847
1907
|
if (!this.#actionable.has(element) || this.#excluded.has(element)) return void 0;
|
|
1848
1908
|
if (!ACTIONABLE_ROLES.has(role)) return void 0;
|
|
1849
1909
|
const bytes = /* @__PURE__ */ new Uint8Array(8);
|
|
1850
1910
|
crypto.getRandomValues(bytes);
|
|
1851
1911
|
const ref = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1852
|
-
this.#table.set(ref,
|
|
1912
|
+
this.#table.set(ref, {
|
|
1913
|
+
element,
|
|
1914
|
+
...frame !== void 0 ? { frame } : {}
|
|
1915
|
+
});
|
|
1853
1916
|
return ref;
|
|
1854
1917
|
}
|
|
1855
1918
|
get table() {
|
|
@@ -1866,12 +1929,54 @@ var HandleIssuer = class {
|
|
|
1866
1929
|
*/
|
|
1867
1930
|
function createDomPerceptionReader(options = {}) {
|
|
1868
1931
|
let handles;
|
|
1869
|
-
|
|
1932
|
+
/** 授权生效时记下各帧的 origin,之后每次感知比对(FR-24.3) */
|
|
1933
|
+
const frameOrigins = /* @__PURE__ */ new Map();
|
|
1934
|
+
/**
|
|
1935
|
+
* 被提升的模态(FR-25.6):**只装由已授权操作打开的那些**。
|
|
1936
|
+
* 用户自己点开的对话框不在里面——它在操作前的快照里,差集会把它排除。
|
|
1937
|
+
*/
|
|
1938
|
+
const elevatedModals = /* @__PURE__ */ new Set();
|
|
1939
|
+
const rootDocument = () => options.document ?? globalThis.document;
|
|
1940
|
+
/**
|
|
1941
|
+
* 把一条帧授权解析成 Document。
|
|
1942
|
+
*
|
|
1943
|
+
* 跨源时 `contentDocument` 在多数浏览器返回 `null` 而**不抛异常**,
|
|
1944
|
+
* 只有部分属性访问才抛 —— 所以两种形态都要处理,只写 try/catch 会漏。
|
|
1945
|
+
*/
|
|
1946
|
+
function resolveFrame(frame, root) {
|
|
1947
|
+
if (frame === "self") return { doc: root };
|
|
1948
|
+
const element = root.querySelector(frame);
|
|
1949
|
+
if (element === null || element.tagName !== "IFRAME") return { note: {
|
|
1950
|
+
frame,
|
|
1951
|
+
reason: "not-found",
|
|
1952
|
+
message: `Frame "${frame}" was not found in the page.`
|
|
1953
|
+
} };
|
|
1954
|
+
let doc;
|
|
1955
|
+
let origin;
|
|
1956
|
+
try {
|
|
1957
|
+
doc = element.contentDocument;
|
|
1958
|
+
origin = element.contentWindow?.location.origin;
|
|
1959
|
+
} catch {
|
|
1960
|
+
doc = null;
|
|
1961
|
+
}
|
|
1962
|
+
if (doc === null || origin === void 0) return { note: {
|
|
1963
|
+
frame,
|
|
1964
|
+
reason: "cross-origin",
|
|
1965
|
+
message: `Frame "${frame}" is cross-origin and was not read.`
|
|
1966
|
+
} };
|
|
1967
|
+
const known = frameOrigins.get(frame);
|
|
1968
|
+
if (known === void 0) frameOrigins.set(frame, origin);
|
|
1969
|
+
else if (known !== origin) return { note: {
|
|
1970
|
+
frame,
|
|
1971
|
+
reason: "origin-changed",
|
|
1972
|
+
message: `Frame "${frame}" now points at ${origin} instead of the authorized ${known}; the grant was revoked.`
|
|
1973
|
+
} };
|
|
1974
|
+
return { doc };
|
|
1975
|
+
}
|
|
1870
1976
|
/** 按当前 DOM 重算一次可操作集合;发句柄与执行前各算一次 */
|
|
1871
|
-
const actionSets = (doc) => {
|
|
1977
|
+
const actionSets = (doc, scope) => {
|
|
1872
1978
|
const actionable = /* @__PURE__ */ new Set();
|
|
1873
1979
|
const excluded = /* @__PURE__ */ new Set();
|
|
1874
|
-
const scope = options.actionScope;
|
|
1875
1980
|
if (scope === void 0 || scope.include.length === 0) return {
|
|
1876
1981
|
actionable,
|
|
1877
1982
|
excluded
|
|
@@ -1889,26 +1994,67 @@ function createDomPerceptionReader(options = {}) {
|
|
|
1889
1994
|
excluded
|
|
1890
1995
|
};
|
|
1891
1996
|
};
|
|
1997
|
+
/** 该帧的操作范围;没有对应条目就是「这个帧只可读不可操作」 */
|
|
1998
|
+
const actionScopeFor = (frame) => options.actionScope === void 0 ? void 0 : toActionFrameScopes(options.actionScope).find((entry) => entry.frame === frame);
|
|
1892
1999
|
function collect(scope, targets) {
|
|
1893
|
-
const
|
|
1894
|
-
if (
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
for (const selector of scope.exclude ?? []) for (const element of doc.querySelectorAll(selector)) excluded.add(element);
|
|
1900
|
-
const roots = [];
|
|
1901
|
-
for (const selector of scope.include) for (const element of doc.querySelectorAll(selector)) if (!roots.includes(element)) roots.push(element);
|
|
2000
|
+
const root = rootDocument();
|
|
2001
|
+
if (root === void 0) return {
|
|
2002
|
+
nodes: [],
|
|
2003
|
+
frameNotes: []
|
|
2004
|
+
};
|
|
2005
|
+
handles = new HandleIssuer(/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set());
|
|
1902
2006
|
const nodes = [];
|
|
1903
|
-
|
|
1904
|
-
|
|
2007
|
+
const frameNotes = [];
|
|
2008
|
+
for (const frameScope of toFrameScopes(scope)) {
|
|
2009
|
+
if (frameScope.include.length === 0) continue;
|
|
2010
|
+
const resolved = resolveFrame(frameScope.frame, root);
|
|
2011
|
+
if ("note" in resolved) {
|
|
2012
|
+
frameNotes.push(resolved.note);
|
|
2013
|
+
nodes.push({
|
|
2014
|
+
role: "note",
|
|
2015
|
+
name: resolved.note.message,
|
|
2016
|
+
frame: frameScope.frame
|
|
2017
|
+
});
|
|
2018
|
+
continue;
|
|
2019
|
+
}
|
|
2020
|
+
const doc = resolved.doc;
|
|
2021
|
+
const view = doc.defaultView;
|
|
2022
|
+
const tag = frameScope.frame === "self" ? void 0 : frameScope.frame;
|
|
2023
|
+
const sets = actionSets(doc, actionScopeFor(frameScope.frame));
|
|
2024
|
+
handles.useSets(sets.actionable, sets.excluded);
|
|
2025
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
2026
|
+
for (const selector of frameScope.exclude ?? []) for (const element of doc.querySelectorAll(selector)) excluded.add(element);
|
|
2027
|
+
const roots = [];
|
|
2028
|
+
for (const selector of frameScope.include) for (const element of doc.querySelectorAll(selector)) if (!roots.includes(element)) roots.push(element);
|
|
2029
|
+
for (const element of roots) {
|
|
2030
|
+
const node = describe(element, excluded, doc, view, targets, handles, tag, void 0);
|
|
2031
|
+
if (node !== void 0) nodes.push(node);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
pruneElevated();
|
|
2035
|
+
const view = root.defaultView;
|
|
2036
|
+
const sets = actionSets(root, actionScopeFor("self"));
|
|
2037
|
+
for (const modal of elevatedModals) {
|
|
2038
|
+
handles.useSets(/* @__PURE__ */ new Set([modal, ...modal.querySelectorAll("*")]), sets.excluded);
|
|
2039
|
+
const node = describe(modal, /* @__PURE__ */ new Set(), root, view, targets, handles, void 0, "modal-elevated");
|
|
1905
2040
|
if (node !== void 0) nodes.push(node);
|
|
1906
2041
|
}
|
|
1907
|
-
|
|
2042
|
+
for (const modal of root.querySelectorAll(MODAL_SELECTOR)) {
|
|
2043
|
+
if (elevatedModals.has(modal)) continue;
|
|
2044
|
+
const name = nameOf(modal, root) ?? "dialog";
|
|
2045
|
+
nodes.push({
|
|
2046
|
+
role: "note",
|
|
2047
|
+
name: `The dialog "${name}" is open but outside the authorized scope because it was opened manually. Ask the user to let you open it instead.`
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
return {
|
|
2051
|
+
nodes,
|
|
2052
|
+
frameNotes
|
|
2053
|
+
};
|
|
1908
2054
|
}
|
|
1909
2055
|
async function readWithImages(scope, capture) {
|
|
1910
2056
|
const targets = [];
|
|
1911
|
-
const nodes = collect(scope, targets);
|
|
2057
|
+
const { nodes, frameNotes } = collect(scope, targets);
|
|
1912
2058
|
const selected = targets.slice(0, capture.maxImages);
|
|
1913
2059
|
const images = [];
|
|
1914
2060
|
let imageFailures = 0;
|
|
@@ -1935,24 +2081,68 @@ function createDomPerceptionReader(options = {}) {
|
|
|
1935
2081
|
nodes,
|
|
1936
2082
|
images,
|
|
1937
2083
|
imagesOmitted: targets.length - selected.length,
|
|
1938
|
-
imageFailures
|
|
2084
|
+
imageFailures,
|
|
2085
|
+
...frameNotes.length > 0 ? { frameNotes } : {}
|
|
1939
2086
|
};
|
|
1940
2087
|
}
|
|
1941
2088
|
function read(scope, capture) {
|
|
1942
|
-
if (capture === void 0) return collect(scope, void 0);
|
|
1943
|
-
if (!capture.images || capture.maxImages <= 0)
|
|
2089
|
+
if (capture === void 0) return collect(scope, void 0).nodes;
|
|
2090
|
+
if (!capture.images || capture.maxImages <= 0) {
|
|
2091
|
+
const { nodes, frameNotes } = collect(scope, void 0);
|
|
2092
|
+
return Promise.resolve({
|
|
2093
|
+
nodes,
|
|
2094
|
+
...frameNotes.length > 0 ? { frameNotes } : {}
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
1944
2097
|
return readWithImages(scope, capture);
|
|
1945
2098
|
}
|
|
2099
|
+
/** 当前活动模态;跨帧不适用(模态提升只在主文档语义下成立) */
|
|
2100
|
+
function modalSnapshot() {
|
|
2101
|
+
const root = rootDocument();
|
|
2102
|
+
if (root === void 0) return /* @__PURE__ */ new Set();
|
|
2103
|
+
return new Set(root.querySelectorAll(MODAL_SELECTOR));
|
|
2104
|
+
}
|
|
2105
|
+
/** 关掉或已从文档移除的模态立刻失去提升:授权不得跨对话框生命周期存活 */
|
|
2106
|
+
function pruneElevated() {
|
|
2107
|
+
for (const modal of elevatedModals) if (!(modal.isConnected && modal.matches(MODAL_SELECTOR))) elevatedModals.delete(modal);
|
|
2108
|
+
}
|
|
1946
2109
|
return {
|
|
1947
2110
|
read,
|
|
1948
|
-
resolve: (ref) => handles?.table.get(ref),
|
|
2111
|
+
resolve: (ref) => handles?.table.get(ref)?.element,
|
|
2112
|
+
frameOf: (ref) => handles?.table.get(ref)?.frame,
|
|
2113
|
+
modalSnapshot,
|
|
2114
|
+
elevateNewModals: (before) => {
|
|
2115
|
+
pruneElevated();
|
|
2116
|
+
for (const modal of modalSnapshot()) {
|
|
2117
|
+
if (before.has(modal)) continue;
|
|
2118
|
+
elevatedModals.add(modal);
|
|
2119
|
+
return modal;
|
|
2120
|
+
}
|
|
2121
|
+
},
|
|
2122
|
+
modalStateOf: (element) => {
|
|
2123
|
+
pruneElevated();
|
|
2124
|
+
const modal = element.closest(MODAL_SELECTOR);
|
|
2125
|
+
if (modal === null) return "none";
|
|
2126
|
+
return elevatedModals.has(modal) ? "elevated" : "unelevated";
|
|
2127
|
+
},
|
|
1949
2128
|
inActionScope: (element) => {
|
|
1950
|
-
const doc =
|
|
1951
|
-
|
|
1952
|
-
|
|
2129
|
+
const doc = element.ownerDocument;
|
|
2130
|
+
const frame = frameOfDocument(doc);
|
|
2131
|
+
if (frame === void 0) return false;
|
|
2132
|
+
const sets = actionSets(doc, actionScopeFor(frame));
|
|
1953
2133
|
return sets.actionable.has(element) && !sets.excluded.has(element);
|
|
1954
2134
|
}
|
|
1955
2135
|
};
|
|
2136
|
+
/** 反查一个 Document 属于哪条帧授权;不在授权列表里的帧一律判不可操作 */
|
|
2137
|
+
function frameOfDocument(doc) {
|
|
2138
|
+
const root = rootDocument();
|
|
2139
|
+
if (root === void 0) return void 0;
|
|
2140
|
+
if (doc === root) return "self";
|
|
2141
|
+
for (const frame of frameOrigins.keys()) {
|
|
2142
|
+
const element = root.querySelector(frame);
|
|
2143
|
+
if (element?.tagName === "IFRAME" && element.contentDocument === doc) return frame;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
1956
2146
|
}
|
|
1957
2147
|
const FILLABLE_TAGS = /* @__PURE__ */ new Set(["INPUT", "TEXTAREA"]);
|
|
1958
2148
|
/** 只看显式声明,不看尺寸:jsdom 有 getComputedStyle 但没有布局 */
|
|
@@ -1970,13 +2160,36 @@ function hidden(element) {
|
|
|
1970
2160
|
}
|
|
1971
2161
|
const inputType = (element) => (element.getAttribute("type") ?? "text").toLowerCase();
|
|
1972
2162
|
const isSecret = (element) => element.tagName === "INPUT" && SECRET_INPUT_TYPES.has(inputType(element));
|
|
2163
|
+
const textOf = (element) => element?.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
2164
|
+
/**
|
|
2165
|
+
* 可访问名称,按 accname 的优先级取。
|
|
2166
|
+
*
|
|
2167
|
+
* `aria-labelledby` 与关联 `<label>` 这两级不能省:`<input type="radio">`
|
|
2168
|
+
* 自己既没有 textContent 也没有 placeholder,少了它们审计里只剩 role + frame,
|
|
2169
|
+
* 事后看不出用户批准的到底是哪个单选组(UI-UX8 UX-08)。
|
|
2170
|
+
*/
|
|
1973
2171
|
function accessibleName(element) {
|
|
2172
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
2173
|
+
if (labelledBy !== null && labelledBy.trim() !== "") {
|
|
2174
|
+
const doc = element.ownerDocument;
|
|
2175
|
+
const joined = labelledBy.split(/\s+/).map((id) => textOf(doc.getElementById(id))).filter((part) => part !== "").join(" ");
|
|
2176
|
+
if (joined !== "") return joined;
|
|
2177
|
+
}
|
|
1974
2178
|
const label = element.getAttribute("aria-label");
|
|
1975
2179
|
if (label !== null && label.trim() !== "") return label.trim();
|
|
1976
|
-
const
|
|
1977
|
-
if (
|
|
2180
|
+
const labels = element.labels;
|
|
2181
|
+
if (labels && labels.length > 0) {
|
|
2182
|
+
const joined = [...labels].map((node) => textOf(node)).filter((part) => part !== "").join(" ");
|
|
2183
|
+
if (joined !== "") return joined;
|
|
2184
|
+
}
|
|
2185
|
+
const own = textOf(element);
|
|
2186
|
+
if (own !== "") return own;
|
|
2187
|
+
const wrapping = textOf(element.closest("label"));
|
|
2188
|
+
if (wrapping !== "") return wrapping;
|
|
1978
2189
|
const placeholder = element.getAttribute("placeholder");
|
|
1979
|
-
|
|
2190
|
+
if (placeholder !== null && placeholder.trim() !== "") return placeholder.trim();
|
|
2191
|
+
const title = element.getAttribute("title");
|
|
2192
|
+
return title !== null && title.trim() !== "" ? title.trim() : void 0;
|
|
1980
2193
|
}
|
|
1981
2194
|
function roleOf(element) {
|
|
1982
2195
|
const explicit = element.getAttribute("role");
|
|
@@ -2012,52 +2225,202 @@ function fillValue(element, value) {
|
|
|
2012
2225
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2013
2226
|
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2014
2227
|
}
|
|
2228
|
+
const norm = (text) => (text ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
2229
|
+
/** 轮询等待,上限固定:等不到就失败,不能无限挂着一次工具调用 */
|
|
2230
|
+
async function waitFor(probe, timeoutMs = 2e3) {
|
|
2231
|
+
const deadline = Date.now() + timeoutMs;
|
|
2232
|
+
for (;;) {
|
|
2233
|
+
const hit = probe();
|
|
2234
|
+
if (hit !== void 0) return hit;
|
|
2235
|
+
if (Date.now() >= deadline) return void 0;
|
|
2236
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* 一个动作覆盖三种选择控件(FR-25.2):原生 select 直接设值,
|
|
2241
|
+
* ARIA 组合与日期格点走「点开 → 等浮层 → 按可访问名匹配 → 点中」。
|
|
2242
|
+
*
|
|
2243
|
+
* **收尾一定要回读确认**:浮层里点了不等于选上了(受控组件可能拒绝、
|
|
2244
|
+
* 也可能匹配到了另一个同名项)。少了这一步就会向模型报假成功。
|
|
2245
|
+
*/
|
|
2246
|
+
async function selectOption(element, value) {
|
|
2247
|
+
const target = describeElement(element);
|
|
2248
|
+
const fail = (reason) => ({
|
|
2249
|
+
ok: false,
|
|
2250
|
+
target,
|
|
2251
|
+
reason
|
|
2252
|
+
});
|
|
2253
|
+
if (value === "") return fail("The select action needs a value.");
|
|
2254
|
+
if (element.tagName === "SELECT") {
|
|
2255
|
+
const select = element;
|
|
2256
|
+
const option = [...select.options].find((candidate) => norm(candidate.label) === norm(value) || norm(candidate.value) === norm(value));
|
|
2257
|
+
if (option === void 0) return fail(`No option named "${value}" is available.`);
|
|
2258
|
+
select.value = option.value;
|
|
2259
|
+
select.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2260
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2261
|
+
return norm(select.selectedOptions[0]?.label) === norm(option.label) ? {
|
|
2262
|
+
ok: true,
|
|
2263
|
+
target
|
|
2264
|
+
} : fail(`The control did not accept "${value}".`);
|
|
2265
|
+
}
|
|
2266
|
+
const doc = element.ownerDocument;
|
|
2267
|
+
element.click();
|
|
2268
|
+
const listbox = await waitFor(() => doc.querySelector("[role=listbox], [role=grid], [role=menu]") ?? void 0);
|
|
2269
|
+
if (listbox === null || listbox === void 0) return fail("The options panel did not open.");
|
|
2270
|
+
const option = [...listbox.querySelectorAll("[role=option], [role=gridcell], [role=menuitem], option")].find((candidate) => norm(accessibleName(candidate)) === norm(value));
|
|
2271
|
+
if (option === void 0) return fail(`No option named "${value}" is available.`);
|
|
2272
|
+
option.click();
|
|
2273
|
+
return await waitFor(() => {
|
|
2274
|
+
const shown = `${accessibleName(element) ?? ""} ${element.value ?? ""}`;
|
|
2275
|
+
return norm(shown).includes(norm(value)) ? true : void 0;
|
|
2276
|
+
}) === true ? {
|
|
2277
|
+
ok: true,
|
|
2278
|
+
target
|
|
2279
|
+
} : fail(`The control did not settle on "${value}".`);
|
|
2280
|
+
}
|
|
2281
|
+
/**
|
|
2282
|
+
* 幂等置值(FR-25.4):当前态已是目标态就**不点**,避免「点一下反而关掉」。
|
|
2283
|
+
* 未操作时仍返回 ok —— 返回失败模型会重试,假装点了审计会失真。
|
|
2284
|
+
*/
|
|
2285
|
+
function setToggle(element, value, target) {
|
|
2286
|
+
const wanted = norm(value);
|
|
2287
|
+
if (wanted !== "true" && wanted !== "false") return {
|
|
2288
|
+
ok: false,
|
|
2289
|
+
target,
|
|
2290
|
+
reason: "Use \"true\" or \"false\"."
|
|
2291
|
+
};
|
|
2292
|
+
const desired = wanted === "true";
|
|
2293
|
+
const aria = element.getAttribute("aria-checked") ?? element.getAttribute("aria-pressed");
|
|
2294
|
+
const current = aria !== null ? aria === "true" : element.tagName === "INPUT" ? element.checked : void 0;
|
|
2295
|
+
if (current === void 0) return {
|
|
2296
|
+
ok: false,
|
|
2297
|
+
target,
|
|
2298
|
+
reason: "The element is not a toggle."
|
|
2299
|
+
};
|
|
2300
|
+
if (current === desired) return {
|
|
2301
|
+
ok: true,
|
|
2302
|
+
target,
|
|
2303
|
+
noop: true
|
|
2304
|
+
};
|
|
2305
|
+
element.click();
|
|
2306
|
+
return {
|
|
2307
|
+
ok: true,
|
|
2308
|
+
target
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* 用户选中的文件写进目标 input;`DataTransfer` 是唯一能构造 `FileList` 的途径。
|
|
2313
|
+
*
|
|
2314
|
+
* 失败要**返回**而不是抛:执行器的契约是「一律给出结构化结果」,
|
|
2315
|
+
* 抛出去会连带跳过策略层的审计,那比操作失败本身更糟。
|
|
2316
|
+
*/
|
|
2317
|
+
function attachFiles(input, files) {
|
|
2318
|
+
try {
|
|
2319
|
+
const transfer = new DataTransfer();
|
|
2320
|
+
for (const file of files) transfer.items.add(file);
|
|
2321
|
+
input.files = transfer.files;
|
|
2322
|
+
} catch (e) {
|
|
2323
|
+
return `This browser cannot attach files programmatically: ${e instanceof Error ? e.message : String(e)}`;
|
|
2324
|
+
}
|
|
2325
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2326
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2327
|
+
}
|
|
2015
2328
|
/** @experimental */
|
|
2016
2329
|
function createDomPageActionExecutor(options) {
|
|
2017
2330
|
const { reader } = options;
|
|
2331
|
+
/** 帧标识从 reader 的句柄表取,不另存一份:两张表会有两套过期时机 */
|
|
2332
|
+
const withFrame = (ref, described) => {
|
|
2333
|
+
const frame = reader.frameOf(ref);
|
|
2334
|
+
return {
|
|
2335
|
+
...described,
|
|
2336
|
+
...frame !== void 0 ? { frame } : {}
|
|
2337
|
+
};
|
|
2338
|
+
};
|
|
2018
2339
|
const describe = (ref) => {
|
|
2019
2340
|
const element = reader.resolve(ref);
|
|
2020
|
-
|
|
2341
|
+
if (element === void 0) return void 0;
|
|
2342
|
+
const described = withFrame(ref, describeElement(element));
|
|
2343
|
+
return reader.modalStateOf(element) === "elevated" ? {
|
|
2344
|
+
...described,
|
|
2345
|
+
elevated: true
|
|
2346
|
+
} : described;
|
|
2021
2347
|
};
|
|
2022
|
-
const execute = (request) => {
|
|
2348
|
+
const execute = async (request) => {
|
|
2023
2349
|
const element = reader.resolve(request.ref);
|
|
2024
2350
|
if (element === void 0) return {
|
|
2025
2351
|
ok: false,
|
|
2026
2352
|
target: { role: "generic" },
|
|
2027
2353
|
reason: "The element reference is unknown or expired."
|
|
2028
2354
|
};
|
|
2029
|
-
const target = describeElement(element);
|
|
2355
|
+
const target = withFrame(request.ref, describeElement(element));
|
|
2030
2356
|
const fail = (reason) => ({
|
|
2031
2357
|
ok: false,
|
|
2032
2358
|
target,
|
|
2033
2359
|
reason
|
|
2034
2360
|
});
|
|
2035
|
-
if (
|
|
2361
|
+
if (reader.modalStateOf(element) === "unelevated") return fail("That dialog is not in the authorized scope because it was opened manually. Ask me to open it, or add it to the host allowlist.");
|
|
2362
|
+
if (reader.modalStateOf(element) !== "elevated" && !reader.inActionScope(element)) return fail("The element is no longer inside the actionable scope.");
|
|
2036
2363
|
if (hidden(element)) return fail("The element is not visible.");
|
|
2037
2364
|
if (element.hasAttribute("disabled")) return fail("The element is disabled.");
|
|
2365
|
+
const modalsBefore = reader.modalSnapshot();
|
|
2366
|
+
const done = async (outcome) => {
|
|
2367
|
+
if (!outcome.ok) return outcome;
|
|
2368
|
+
const elevated = await waitFor(() => reader.elevateNewModals(modalsBefore), 500) ?? reader.elevateNewModals(modalsBefore);
|
|
2369
|
+
const name = elevated === void 0 ? void 0 : accessibleName(elevated) ?? "dialog";
|
|
2370
|
+
return name === void 0 ? outcome : {
|
|
2371
|
+
...outcome,
|
|
2372
|
+
elevatedModal: name
|
|
2373
|
+
};
|
|
2374
|
+
};
|
|
2038
2375
|
if (request.action === "click") {
|
|
2039
2376
|
element.click();
|
|
2040
|
-
return {
|
|
2377
|
+
return await done({
|
|
2041
2378
|
ok: true,
|
|
2042
2379
|
target
|
|
2043
|
-
};
|
|
2380
|
+
});
|
|
2044
2381
|
}
|
|
2045
2382
|
if (request.action === "fill") {
|
|
2046
2383
|
if (!FILLABLE_TAGS.has(element.tagName)) return fail("The element is not a text control.");
|
|
2047
2384
|
if (element.hasAttribute("readonly")) return fail("The element is read-only.");
|
|
2048
2385
|
fillValue(element, request.value ?? "");
|
|
2049
|
-
return {
|
|
2386
|
+
return await done({
|
|
2050
2387
|
ok: true,
|
|
2051
2388
|
target
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
if (request.action === "select") {
|
|
2392
|
+
const outcome = await selectOption(element, request.value ?? "");
|
|
2393
|
+
return outcome.ok ? await done({
|
|
2394
|
+
...outcome,
|
|
2395
|
+
target
|
|
2396
|
+
}) : {
|
|
2397
|
+
...outcome,
|
|
2398
|
+
target
|
|
2052
2399
|
};
|
|
2053
2400
|
}
|
|
2401
|
+
if (request.action === "set") return await done(setToggle(element, request.value ?? "", target));
|
|
2402
|
+
if (request.action === "attach") {
|
|
2403
|
+
const files = await options.pickFiles?.();
|
|
2404
|
+
if (files === void 0 || files.length === 0) return {
|
|
2405
|
+
ok: false,
|
|
2406
|
+
target,
|
|
2407
|
+
reason: "The user cancelled the file selection."
|
|
2408
|
+
};
|
|
2409
|
+
if (element.tagName !== "INPUT" || element.type !== "file") return fail("The element is not a file input.");
|
|
2410
|
+
const failure = attachFiles(element, files);
|
|
2411
|
+
if (failure !== void 0) return fail(failure);
|
|
2412
|
+
return await done({
|
|
2413
|
+
ok: true,
|
|
2414
|
+
target
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2054
2417
|
const form = element.tagName === "FORM" ? element : element.form;
|
|
2055
2418
|
if (!form) return fail("The element does not belong to a form.");
|
|
2056
2419
|
form.requestSubmit();
|
|
2057
|
-
return {
|
|
2420
|
+
return await done({
|
|
2058
2421
|
ok: true,
|
|
2059
2422
|
target
|
|
2060
|
-
};
|
|
2423
|
+
});
|
|
2061
2424
|
};
|
|
2062
2425
|
return {
|
|
2063
2426
|
execute,
|
|
@@ -2917,6 +3280,355 @@ function createBrowserChatbotHost(options) {
|
|
|
2917
3280
|
connectFacade: overrides?.connectFacade ?? connectFacade
|
|
2918
3281
|
};
|
|
2919
3282
|
}
|
|
3283
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
3284
|
+
/**
|
|
3285
|
+
* 默认的链接文档读取实现(分册 22 §2.2)。
|
|
3286
|
+
*
|
|
3287
|
+
* 出站准入(scheme / 私有地址)与同源判定都在**引擎侧**完成,这里只负责取字节:
|
|
3288
|
+
* 把判定散到实现里,宿主换一个 reader 就能绕过整套闸门。
|
|
3289
|
+
* @experimental
|
|
3290
|
+
*/
|
|
3291
|
+
function createFetchLinkedDocumentReader(options = {}) {
|
|
3292
|
+
const origin = "origin" in options ? options.origin : globalThis.location?.origin;
|
|
3293
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
3294
|
+
return {
|
|
3295
|
+
...origin !== void 0 ? { origin } : {},
|
|
3296
|
+
async read(url) {
|
|
3297
|
+
const controller = new AbortController();
|
|
3298
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
3299
|
+
try {
|
|
3300
|
+
const response = await doFetch(url, { signal: controller.signal });
|
|
3301
|
+
if (!response.ok) throw new WebSkillError("TOOL_EXECUTION_FAILED", `The document responded with HTTP ${response.status}.`);
|
|
3302
|
+
const buffer = await response.arrayBuffer();
|
|
3303
|
+
return {
|
|
3304
|
+
mimeType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
3305
|
+
bytes: new Uint8Array(buffer)
|
|
3306
|
+
};
|
|
3307
|
+
} finally {
|
|
3308
|
+
clearTimeout(timer);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
/**
|
|
3314
|
+
* docx 正文抽取(分册 22 §2.4 / FR-23.7)。
|
|
3315
|
+
*
|
|
3316
|
+
* **零新增依赖**:zip 用仓内既有的 `unzipWithLimits`(自带解压体积上限,
|
|
3317
|
+
* 顺带挡住 zip 炸弹),XML 用浏览器原生 `DOMParser`。
|
|
3318
|
+
*
|
|
3319
|
+
* ⚠️ `DOMParser` 是浏览器 API,Node 没有 —— 所以这个模块只在 `@webskill/browser`。
|
|
3320
|
+
* Node 宿主没有 docx 能力,这是**显式声明**的限制(AC-G15),不是静默降级;
|
|
3321
|
+
* 收紧到 Node 侧的备案是 D55。
|
|
3322
|
+
*/
|
|
3323
|
+
/**
|
|
3324
|
+
* **不抽取**的内容,逐条写明而不是笼统说「尽力而为」:
|
|
3325
|
+
* 样式、标题层级、图片、批注、修订、页眉页脚、脚注。
|
|
3326
|
+
*
|
|
3327
|
+
* 模型拿到的是纯正文文本流。把「这是 H1」这类信息也抽出来需要解析
|
|
3328
|
+
* `styles.xml` 的样式继承链,那是另一个量级的工作,本版不做。
|
|
3329
|
+
*/
|
|
3330
|
+
const DOCX_UNEXTRACTED = [
|
|
3331
|
+
"styles",
|
|
3332
|
+
"heading levels",
|
|
3333
|
+
"images",
|
|
3334
|
+
"comments",
|
|
3335
|
+
"tracked changes",
|
|
3336
|
+
"headers and footers",
|
|
3337
|
+
"footnotes"
|
|
3338
|
+
];
|
|
3339
|
+
const DOCX_ENTRY = "word/document.xml";
|
|
3340
|
+
/** 段落内的行内元素:文本、软换行、制表符 */
|
|
3341
|
+
function inlineText(node) {
|
|
3342
|
+
let out = "";
|
|
3343
|
+
for (const child of node.children) {
|
|
3344
|
+
const tag = child.tagName.replace(/^w:/, "");
|
|
3345
|
+
if (tag === "t") out += child.textContent ?? "";
|
|
3346
|
+
else if (tag === "br") out += "\n";
|
|
3347
|
+
else if (tag === "tab") out += " ";
|
|
3348
|
+
else out += inlineText(child);
|
|
3349
|
+
}
|
|
3350
|
+
return out;
|
|
3351
|
+
}
|
|
3352
|
+
/**
|
|
3353
|
+
* 表格按「行一行、单元格用制表符分隔」展开。
|
|
3354
|
+
* 不还原合并单元格——还原它需要读 gridSpan/vMerge,且模型多半用不上。
|
|
3355
|
+
*/
|
|
3356
|
+
function tableText(table, paragraphOf) {
|
|
3357
|
+
const rows = [];
|
|
3358
|
+
for (const row of table.getElementsByTagName("w:tr")) {
|
|
3359
|
+
const cells = [];
|
|
3360
|
+
for (const cell of row.getElementsByTagName("w:tc")) {
|
|
3361
|
+
const paragraphs = [...cell.getElementsByTagName("w:p")].map(paragraphOf);
|
|
3362
|
+
cells.push(paragraphs.join(" ").trim());
|
|
3363
|
+
}
|
|
3364
|
+
rows.push(cells.join(" "));
|
|
3365
|
+
}
|
|
3366
|
+
return rows.join("\n");
|
|
3367
|
+
}
|
|
3368
|
+
/**
|
|
3369
|
+
* 从 docx 字节里抽出正文文本。
|
|
3370
|
+
*
|
|
3371
|
+
* @throws WebSkillError 归档损坏、缺 `word/document.xml`、XML 解析失败
|
|
3372
|
+
*/
|
|
3373
|
+
async function extractDocxText(bytes) {
|
|
3374
|
+
const entry = (await unzipWithLimits(bytes)).find(([path]) => path === DOCX_ENTRY);
|
|
3375
|
+
if (entry === void 0) throw new WebSkillError("TOOL_EXECUTION_FAILED", `This file is not a Word document: it has no ${DOCX_ENTRY} entry.`);
|
|
3376
|
+
const xml = new TextDecoder().decode(entry[1]);
|
|
3377
|
+
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
|
3378
|
+
if (doc.getElementsByTagName("parsererror").length > 0) throw new WebSkillError("TOOL_EXECUTION_FAILED", "The Word document body could not be parsed as XML.");
|
|
3379
|
+
const body = doc.getElementsByTagName("w:body")[0];
|
|
3380
|
+
if (body === void 0) return "";
|
|
3381
|
+
const paragraphOf = (paragraph) => inlineText(paragraph);
|
|
3382
|
+
const blocks = [];
|
|
3383
|
+
for (const child of body.children) {
|
|
3384
|
+
const tag = child.tagName.replace(/^w:/, "");
|
|
3385
|
+
if (tag === "p") blocks.push(paragraphOf(child));
|
|
3386
|
+
else if (tag === "tbl") blocks.push(tableText(child, paragraphOf));
|
|
3387
|
+
}
|
|
3388
|
+
return blocks.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* 文档投放面的 CSP 单一来源(0.11.0 分册 18 FR-18.3 / AC-G21)。
|
|
3392
|
+
*
|
|
3393
|
+
* ⚠️ 策略**只能**由 viewer 路由的响应头下发。
|
|
3394
|
+
* 实测:`<meta http-equiv="Content-Security-Policy" content="sandbox …">` 里的
|
|
3395
|
+
* `sandbox` 指令**完全无效**——同一份文档,`<meta>` 下 `localStorage` 写入成功,
|
|
3396
|
+
* 换成响应头才抛 `SecurityError`。规范规定 `sandbox` / `frame-ancestors` /
|
|
3397
|
+
* `report-uri` 在 `<meta>` 里被忽略。
|
|
3398
|
+
* 不要试图用 `<meta>` 省掉那条 HTTP 路由。
|
|
3399
|
+
*/
|
|
3400
|
+
/**
|
|
3401
|
+
* 固定的 sandbox token,**不接受宿主配置**。
|
|
3402
|
+
*
|
|
3403
|
+
* `allow-same-origin` 给了就等于隔离归零(文档能读主应用存储、经 opener 反控),
|
|
3404
|
+
* 所以它连「可配置」都不该是。`allow-downloads` 本版不给:没有消费场景。
|
|
3405
|
+
*/
|
|
3406
|
+
const SANDBOX_TOKENS = ["allow-scripts", "allow-modals"];
|
|
3407
|
+
/**
|
|
3408
|
+
* 宿主配置值不得夹带 CSP 分隔符 —— 否则一个来源字符串就能追加任意指令,
|
|
3409
|
+
* 白名单形同虚设。这是配置注入,不是理论风险。
|
|
3410
|
+
*/
|
|
3411
|
+
function assertSource(value, directive) {
|
|
3412
|
+
if (value.trim() === "" || /[;,'"\s]/.test(value)) throw new WebSkillError("VALIDATION_FAILED", `Invalid ${directive} source ${JSON.stringify(value)}: sources must be a single token without whitespace, quotes, ";" or ","`);
|
|
3413
|
+
}
|
|
3414
|
+
function directive(name, defaults, extra) {
|
|
3415
|
+
for (const value of extra ?? []) assertSource(value, name);
|
|
3416
|
+
const sources = [...defaults, ...extra ?? []];
|
|
3417
|
+
return `${name} ${sources.length > 0 ? sources.join(" ") : "'none'"}`;
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* 生成 viewer 路由的 `Content-Security-Policy` 响应头值。
|
|
3421
|
+
*
|
|
3422
|
+
* 宿主只能**加白名单来源**,加不了 sandbox token —— 隔离边界的策略不由被隔离方
|
|
3423
|
+
* 也不由宿主的白名单配置决定。
|
|
3424
|
+
*/
|
|
3425
|
+
function viewerCspHeader(options) {
|
|
3426
|
+
const host = options.hostOrigin;
|
|
3427
|
+
assertSource(host, "host-origin");
|
|
3428
|
+
return [
|
|
3429
|
+
`sandbox ${SANDBOX_TOKENS.join(" ")}`,
|
|
3430
|
+
"default-src 'none'",
|
|
3431
|
+
`script-src ${host}`,
|
|
3432
|
+
`style-src ${host} 'unsafe-inline'`,
|
|
3433
|
+
directive("img-src", [host, "data:"], options.imgSrc),
|
|
3434
|
+
directive("font-src", [host], options.fontSrc),
|
|
3435
|
+
directive("connect-src", [], options.connectSrc)
|
|
3436
|
+
].join("; ");
|
|
3437
|
+
}
|
|
3438
|
+
/** 本版固定的 sandbox token(供判据与装配文档引用,避免第二份清单) */
|
|
3439
|
+
const VIEWER_SANDBOX_TOKENS = SANDBOX_TOKENS;
|
|
3440
|
+
const DEFAULT_MAX_VIEWER_PAYLOAD_BYTES = 2e6;
|
|
3441
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
3442
|
+
/** hello 的重发间隔:viewer 脚本加载完之前投出去的消息全部丢失 */
|
|
3443
|
+
const HELLO_RETRY_MS = 100;
|
|
3444
|
+
/** 审计事件类型(与既有事件同一条链) */
|
|
3445
|
+
const DOCUMENT_SURFACE_AUDIT_EVENT = "document.surface.open";
|
|
3446
|
+
const payloadBytes = (value) => new TextEncoder().encode(JSON.stringify(value) ?? "").length;
|
|
3447
|
+
/**
|
|
3448
|
+
* 打开一个文档投放面。
|
|
3449
|
+
*
|
|
3450
|
+
* `window.open` **必须在用户手势里调**,否则被浏览器拦截;不能在 run 收尾时自动开。
|
|
3451
|
+
* 也**不加 `noopener`** —— 需要句柄才能把内容 post 进去。反控面不靠 `noopener` 封,
|
|
3452
|
+
* 靠沙箱本身:无 `allow-same-origin` 读不到 opener 的任何东西,
|
|
3453
|
+
* 无 `allow-top-navigation` 改不了它的地址。
|
|
3454
|
+
*/
|
|
3455
|
+
async function openDocumentSurface(input, options) {
|
|
3456
|
+
const limit = options.maxPayloadBytes ?? 2e6;
|
|
3457
|
+
const size = payloadBytes(input.document);
|
|
3458
|
+
if (size > limit) {
|
|
3459
|
+
await audit(options, input, {
|
|
3460
|
+
outcome: "rejected",
|
|
3461
|
+
reason: "payload-too-large",
|
|
3462
|
+
bytes: size
|
|
3463
|
+
});
|
|
3464
|
+
throw new WebSkillError("VALIDATION_FAILED", `Document payload is ${size} bytes, over the ${limit} byte budget; reduce the data before opening the viewer`);
|
|
3465
|
+
}
|
|
3466
|
+
if (!await options.confirm({
|
|
3467
|
+
skillName: input.skillName,
|
|
3468
|
+
dataSource: input.dataSource
|
|
3469
|
+
})) {
|
|
3470
|
+
await audit(options, input, {
|
|
3471
|
+
outcome: "cancelled",
|
|
3472
|
+
bytes: size
|
|
3473
|
+
});
|
|
3474
|
+
throw new WebSkillError("DOCUMENT_SURFACE_DECLINED", "The user declined to open the document surface");
|
|
3475
|
+
}
|
|
3476
|
+
const target = (options.open ?? defaultOpen)(options.viewerUrl, "_blank");
|
|
3477
|
+
if (!target) {
|
|
3478
|
+
await audit(options, input, {
|
|
3479
|
+
outcome: "blocked",
|
|
3480
|
+
reason: "popup-blocked",
|
|
3481
|
+
bytes: size
|
|
3482
|
+
});
|
|
3483
|
+
throw new WebSkillError("DOCUMENT_SURFACE_UNAVAILABLE", "The browser blocked the viewer window; open it from a direct user gesture");
|
|
3484
|
+
}
|
|
3485
|
+
const channelId = newChannelId();
|
|
3486
|
+
try {
|
|
3487
|
+
await handshake(target, channelId, options);
|
|
3488
|
+
} catch (e) {
|
|
3489
|
+
target.close();
|
|
3490
|
+
await audit(options, input, {
|
|
3491
|
+
outcome: "failed",
|
|
3492
|
+
reason: messageOf(e),
|
|
3493
|
+
bytes: size
|
|
3494
|
+
});
|
|
3495
|
+
throw e;
|
|
3496
|
+
}
|
|
3497
|
+
target.postMessage({
|
|
3498
|
+
channelId,
|
|
3499
|
+
type: "webskill:viewer-document",
|
|
3500
|
+
document: input.document
|
|
3501
|
+
}, viewerOrigin(options));
|
|
3502
|
+
await audit(options, input, {
|
|
3503
|
+
outcome: "opened",
|
|
3504
|
+
bytes: size
|
|
3505
|
+
});
|
|
3506
|
+
return target;
|
|
3507
|
+
}
|
|
3508
|
+
function defaultOpen(url, target) {
|
|
3509
|
+
return globalThis.open?.(url, target) ?? null;
|
|
3510
|
+
}
|
|
3511
|
+
/** viewer 是 opaque origin,`postMessage` 的 targetOrigin 只能是 `*` —— 用 channelId 做配对校验 */
|
|
3512
|
+
function viewerOrigin(_options) {
|
|
3513
|
+
return "*";
|
|
3514
|
+
}
|
|
3515
|
+
function newChannelId() {
|
|
3516
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
3517
|
+
globalThis.crypto?.getRandomValues(bytes);
|
|
3518
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* 等 viewer 外壳回报就绪。
|
|
3522
|
+
*
|
|
3523
|
+
* hello 必须**重发**:`window.open` 一返回就 post 的那一条,viewer 的脚本还没加载,
|
|
3524
|
+
* 消息直接丢掉(`postMessage` 不排队等监听器)。只发一次的写法在本机偶尔能过——
|
|
3525
|
+
* 那是缓存命中让文档加载快过第一条消息,换台机器就必然超时。
|
|
3526
|
+
*
|
|
3527
|
+
* 必须同时校验 `event.source === target` 与 `channelId`:
|
|
3528
|
+
* 只看 origin 不行(opaque origin 的 `event.origin` 是 `"null"`,任何沙箱页面都一样)。
|
|
3529
|
+
*/
|
|
3530
|
+
function handshake(target, channelId, options) {
|
|
3531
|
+
const timeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
|
3532
|
+
const scope = globalThis;
|
|
3533
|
+
return new Promise((resolve, reject) => {
|
|
3534
|
+
const done = (fn) => {
|
|
3535
|
+
clearTimeout(timer);
|
|
3536
|
+
clearInterval(retry);
|
|
3537
|
+
scope.removeEventListener("message", onMessage);
|
|
3538
|
+
fn();
|
|
3539
|
+
};
|
|
3540
|
+
const timer = setTimeout(() => {
|
|
3541
|
+
done(() => reject(new WebSkillError("DOCUMENT_SURFACE_UNAVAILABLE", `The viewer did not report ready within ${timeoutMs}ms`)));
|
|
3542
|
+
}, timeoutMs);
|
|
3543
|
+
const onMessage = (event) => {
|
|
3544
|
+
if (event.source !== target) return;
|
|
3545
|
+
const data = event.data;
|
|
3546
|
+
if (data?.type !== "webskill:viewer-ready" || data.channelId !== channelId) return;
|
|
3547
|
+
done(resolve);
|
|
3548
|
+
};
|
|
3549
|
+
scope.addEventListener("message", onMessage);
|
|
3550
|
+
const hello = () => target.postMessage({
|
|
3551
|
+
channelId,
|
|
3552
|
+
type: "webskill:viewer-hello"
|
|
3553
|
+
}, "*");
|
|
3554
|
+
const retry = setInterval(hello, HELLO_RETRY_MS);
|
|
3555
|
+
hello();
|
|
3556
|
+
});
|
|
3557
|
+
}
|
|
3558
|
+
async function audit(options, input, data) {
|
|
3559
|
+
await options.audit?.append({
|
|
3560
|
+
type: DOCUMENT_SURFACE_AUDIT_EVENT,
|
|
3561
|
+
target: input.skillName,
|
|
3562
|
+
data: {
|
|
3563
|
+
skillName: input.skillName,
|
|
3564
|
+
dataSource: input.dataSource,
|
|
3565
|
+
...data
|
|
3566
|
+
}
|
|
3567
|
+
}).catch(() => void 0);
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* 启动外壳:等主应用的 hello → 回报 ready → 收文档 → 写进 DOM。
|
|
3571
|
+
*
|
|
3572
|
+
* `channelId` 必须由**主应用**在 hello 里给定并全程比对。opaque origin 下
|
|
3573
|
+
* `event.origin` 恒为 `"null"`,任何沙箱页面都一样,靠 origin 分辨不了来源;
|
|
3574
|
+
* 而 `event.source` 在这一侧只能确认「来自 opener」,确认不了是哪一次投放。
|
|
3575
|
+
*/
|
|
3576
|
+
function startViewerShell(host, mount, onData) {
|
|
3577
|
+
let channelId;
|
|
3578
|
+
const onMessage = (event) => {
|
|
3579
|
+
const data = event.data;
|
|
3580
|
+
if (typeof data?.channelId !== "string" || data.channelId === "") return;
|
|
3581
|
+
if (data.type === "webskill:viewer-hello") {
|
|
3582
|
+
channelId = data.channelId;
|
|
3583
|
+
host.opener?.postMessage({
|
|
3584
|
+
type: "webskill:viewer-ready",
|
|
3585
|
+
channelId
|
|
3586
|
+
}, "*");
|
|
3587
|
+
return;
|
|
3588
|
+
}
|
|
3589
|
+
if (data.type !== "webskill:viewer-document" || channelId === void 0 || data.channelId !== channelId) return;
|
|
3590
|
+
const doc = data.document;
|
|
3591
|
+
if (!doc) return;
|
|
3592
|
+
mount.style.textContent = doc.css ?? "";
|
|
3593
|
+
mount.content.innerHTML = doc.html;
|
|
3594
|
+
onData?.(doc.data);
|
|
3595
|
+
};
|
|
3596
|
+
host.addEventListener("message", onMessage);
|
|
3597
|
+
return { dispose: () => {
|
|
3598
|
+
host.removeEventListener("message", onMessage);
|
|
3599
|
+
} };
|
|
3600
|
+
}
|
|
3601
|
+
/**
|
|
3602
|
+
* 监听 CSP 违规并把被拦资源汇总成一句话。
|
|
3603
|
+
*
|
|
3604
|
+
* 必须在**文档写入之前**挂上:违规事件不补发,晚一步就永远收不到。
|
|
3605
|
+
* 同一个来源只报一次——一份文档引十个图标会刷出十条同样的提示。
|
|
3606
|
+
*/
|
|
3607
|
+
function watchBlockedResources(host, notice) {
|
|
3608
|
+
const origins = /* @__PURE__ */ new Set();
|
|
3609
|
+
const onViolation = (event) => {
|
|
3610
|
+
const origin = originOf(event.blockedURI);
|
|
3611
|
+
if (origin === "" || origins.has(origin)) return;
|
|
3612
|
+
origins.add(origin);
|
|
3613
|
+
notice.textContent = blockedMessage([...origins]);
|
|
3614
|
+
};
|
|
3615
|
+
host.addEventListener("securitypolicyviolation", onViolation);
|
|
3616
|
+
return { dispose: () => {
|
|
3617
|
+
host.removeEventListener("securitypolicyviolation", onViolation);
|
|
3618
|
+
} };
|
|
3619
|
+
}
|
|
3620
|
+
/** 提示文案的单一来源;判据直接引用,不各写一份字面量 */
|
|
3621
|
+
function blockedMessage(origins) {
|
|
3622
|
+
return `Blocked by this page's content policy: ${origins.join(", ")}. The document fell back to locally available resources, so printed output may differ from the author's intent.`;
|
|
3623
|
+
}
|
|
3624
|
+
/** 只留 origin:完整 URL 里的路径对用户没有信息量,还会把提示撑得很长 */
|
|
3625
|
+
function originOf(blockedURI) {
|
|
3626
|
+
try {
|
|
3627
|
+
return new URL(blockedURI).origin;
|
|
3628
|
+
} catch {
|
|
3629
|
+
return blockedURI;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
2920
3632
|
|
|
2921
3633
|
//#endregion
|
|
2922
|
-
export { BrowserSkillManager, BrowserWorkerScriptExecutor, ChromeBuiltinLlmClient, HOST_PORT_MATRIX, IframeWorkerLike, OpfsProvider, TsTranspiler, WORKER_BOOTSTRAP_SOURCE, WorkerRuntimeClient, WorkerUiBridge, bridgeError, captureElementImage, checkDictationAvailability, compressImageToBudget, createBrowserChatbotHost, createDomPageActionExecutor, createDomPerceptionReader, createEncryptedMemoryStore, createIframeWorker, createLlmClient, deleteMemoryEncryptionKey, extractZipWeb, generateMemoryEncryptionKey, inspectHostWiring, installWebSkillNavigator, isCapturableElement, isCaptureFailure, isEncryptedMemoryValue, isOpfsAvailable, missingPorts, openMemoryEncryptionKey, parseBridgeRequest, parseMainMessage, parseWorkerEvent, probeChromeBuiltinAvailability, sha256HexWeb, startDictation, startWorkerRuntimeHost };
|
|
3634
|
+
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 };
|