@webskill/sdk 0.4.0 → 0.6.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 +2 -0
- package/dist/agent.js +909 -0
- package/dist/browser.d.ts +233 -4
- package/dist/browser.js +869 -19
- package/dist/{catalogComponents-KsujmL4b-Clx1kCnU.js → catalogComponents-Dr5dFMAb-Dacibl1e.js} +372 -126
- package/dist/{dist-D9Lcn5Pp.js → dist-DnYG2-eY.js} +642 -39
- package/dist/{dist-C-Sh0MDU.js → dist-DusANsrn.js} +1035 -99
- package/dist/{env--jJB-TSX-04klhTYi.js → env-8cY40DXB-CGnEVZby.js} +7 -6
- package/dist/{env-BPUBZCwJ-4jat_SVG.d.ts → env-AK3cSMEA-Dli6QU5E.d.ts} +4 -3
- package/dist/governance.d.ts +46 -4
- package/dist/governance.js +45 -2
- package/dist/{index-CHXxDccV.d.ts → index-BMocOEi0.d.ts} +106 -10
- package/dist/index-BuTpBMzr.d.ts +474 -0
- package/dist/{index-DLfR2Y6I.d.ts → index-C-KFAZoF.d.ts} +337 -18
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +39 -8
- package/dist/mcp.js +53 -19
- package/dist/{memoryArtifactStore-BtOeB_hm-tj3fC5ip.js → memoryArtifactStore-52Zn9npI-BMPYwvoy.js} +10 -2
- package/dist/node.d.ts +4 -4
- package/dist/node.js +9 -2
- package/dist/{openUiLibrary-YLS-cxyT-C96jWDQq.js → openUiLibrary-Bdrji9qK-DzAxRlTY.js} +3 -3
- package/dist/{skillVersionStore-uyefLPR1-DXOzbksv.d.ts → skillVersionStore-BzLbzFOL-CxdAFWO2.d.ts} +4 -3
- package/dist/{testing-DDCJWvgA.js → testing-CYTFqkDm.js} +1 -1
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +3 -3
- package/dist/{types-7Wcg--Vh-1YlQ4jF9.d.ts → types-4pg-qp_I-Gq63X8Oa.d.ts} +55 -5
- package/dist/ui-react.d.ts +26 -5
- package/dist/ui-react.js +147 -29
- package/dist/ui-vue.d.ts +1 -1
- package/dist/ui-vue.js +30 -6
- package/dist/ui.d.ts +4 -4
- package/dist/ui.js +3 -3
- package/dist/{webskillLitCatalog-CSTbhBe_-CYIs5BX8.js → webskillLitCatalog-_mugzRHx-B_54vxum.js} +88 -2
- package/package.json +6 -1
package/dist/browser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { A as parseSkillMarkdown, B as unzipWithLimits, H as verifyManifest, L as resolveInsideRoot, M as readResponseWithLimit, O as messageOf, T as isValidSkillName, V as validateSkills, _ as atomicWriteText, f as SkillDiscovery, h as assertRemoteUrlAllowed, j as parseSkillPackManifest, m as WebSkillError, o as SKILLS_LOCKFILE, r as MANIFEST_EXCLUDED_FILES, s as SKILL_MANIFEST_FILE, u as SKILL_PACK_FILE, v as buildCatalog, w as exportSkills, y as buildManifest } from "./dist-8oQRa8Xz.js";
|
|
2
|
-
import { H as
|
|
3
|
-
import { n as MockLlmClient } from "./testing-
|
|
1
|
+
import { A as parseSkillMarkdown, B as unzipWithLimits, H as verifyManifest, L as resolveInsideRoot, M as readResponseWithLimit, N as readSkillSignature, O as messageOf, T as isValidSkillName, U as verifySkillSignature, V as validateSkills, _ as atomicWriteText, f as SkillDiscovery, h as assertRemoteUrlAllowed, j as parseSkillPackManifest, m as WebSkillError, o as SKILLS_LOCKFILE, r as MANIFEST_EXCLUDED_FILES, s as SKILL_MANIFEST_FILE, u as SKILL_PACK_FILE, v as buildCatalog, w as exportSkills, y as buildManifest } from "./dist-8oQRa8Xz.js";
|
|
2
|
+
import { H as createWebSkillApi, S as ProgressiveRouter, a as AgentLoop, at as normalizeToolContent, c as CapabilityApproval, et as mergeCatalogEntries, h as FsRunSnapshotStore, m as FsMemoryStore, nt as networkPolicyLibSource, o as AnthropicClient, ot as normalizeToolError, p as FsArtifactStore, st as parseBridgeRequest, x as OpenAiCompatibleClient, y as GoogleGenAiClient, z as bridgeError } from "./dist-DusANsrn.js";
|
|
3
|
+
import { n as MockLlmClient } from "./testing-CYTFqkDm.js";
|
|
4
4
|
|
|
5
5
|
//#region ../browser/dist/index.js
|
|
6
6
|
/** 检测当前环境是否可用 OPFS(navigator.storage.getDirectory) */
|
|
@@ -63,7 +63,12 @@ var OpfsProvider = class {
|
|
|
63
63
|
const name = segments.pop();
|
|
64
64
|
if (!name) throw new WebSkillError("FS_NOT_FOUND", `Invalid file path: ${p}`);
|
|
65
65
|
const handle = await (await this.#walkDir(`/${segments.join("/")}`, true)).getFileHandle(name, { create: true });
|
|
66
|
-
|
|
66
|
+
let size = 0;
|
|
67
|
+
try {
|
|
68
|
+
size = (await handle.getFile()).size;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (!isDomException(e, "NotFoundError")) throw e;
|
|
71
|
+
}
|
|
67
72
|
const writable = await handle.createWritable({ keepExistingData: true });
|
|
68
73
|
await writable.write({
|
|
69
74
|
type: "write",
|
|
@@ -371,17 +376,26 @@ self.onmessage = async function (event) {
|
|
|
371
376
|
}
|
|
372
377
|
};
|
|
373
378
|
`;
|
|
374
|
-
|
|
379
|
+
/** 按资源地址缓存,而不是全局单例:不同地址必须各自加载,否则先到的实例会顶替后来的配置 */
|
|
380
|
+
const esbuildCache = /* @__PURE__ */ new Map();
|
|
375
381
|
async function loadEsbuild(options) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
382
|
+
const key = `${options.esbuildUrl}\n${options.wasmUrl ?? ""}`;
|
|
383
|
+
let pending = esbuildCache.get(key);
|
|
384
|
+
if (!pending) {
|
|
385
|
+
pending = (async () => {
|
|
386
|
+
const mod = await import(
|
|
387
|
+
/* @vite-ignore */
|
|
388
|
+
options.esbuildUrl
|
|
380
389
|
);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
390
|
+
await mod.initialize(options.wasmUrl ? { wasmURL: options.wasmUrl } : {});
|
|
391
|
+
return mod;
|
|
392
|
+
})().catch((e) => {
|
|
393
|
+
esbuildCache.delete(key);
|
|
394
|
+
throw e;
|
|
395
|
+
});
|
|
396
|
+
esbuildCache.set(key, pending);
|
|
397
|
+
}
|
|
398
|
+
return pending;
|
|
385
399
|
}
|
|
386
400
|
async function sha256Hex(data) {
|
|
387
401
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
|
|
@@ -400,19 +414,25 @@ var TsTranspiler = class {
|
|
|
400
414
|
this.#options = deps.options;
|
|
401
415
|
this.#cacheDir = deps.cacheDir ?? ".webskill/ts-cache";
|
|
402
416
|
}
|
|
403
|
-
/** 返回 JS
|
|
417
|
+
/** 返回 JS 源码;转译器不可用 → TS_TRANSPILER_UNAVAILABLE,语法错 → TS_TRANSPILE_FAILED */
|
|
404
418
|
async transpile(scriptPath, source) {
|
|
405
419
|
const cacheKey = `${this.#cacheDir}/${await sha256Hex(`${scriptPath}\n${source}`)}.js`;
|
|
406
420
|
if (await this.#fs.exists(cacheKey)) return this.#fs.readText(cacheKey);
|
|
421
|
+
let esbuild;
|
|
422
|
+
try {
|
|
423
|
+
esbuild = await loadEsbuild(this.#options);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
throw new WebSkillError("TS_TRANSPILER_UNAVAILABLE", `Failed to load the TypeScript transpiler from "${this.#options.esbuildUrl}": ${messageOf(e)}`, e);
|
|
426
|
+
}
|
|
407
427
|
let code;
|
|
408
428
|
try {
|
|
409
|
-
code = (await
|
|
429
|
+
code = (await esbuild.transform(source, {
|
|
410
430
|
loader: "ts",
|
|
411
431
|
target: "es2022",
|
|
412
432
|
format: "esm"
|
|
413
433
|
})).code;
|
|
414
434
|
} catch (e) {
|
|
415
|
-
throw new WebSkillError("
|
|
435
|
+
throw new WebSkillError("TS_TRANSPILE_FAILED", `Failed to transpile ${scriptPath}: ${messageOf(e)}`, e);
|
|
416
436
|
}
|
|
417
437
|
await this.#fs.writeText(cacheKey, code);
|
|
418
438
|
return code;
|
|
@@ -461,8 +481,26 @@ async function extractZipWeb(fs, data, destRoot, limits) {
|
|
|
461
481
|
else await fs.writeBinary(target, content);
|
|
462
482
|
}
|
|
463
483
|
}
|
|
464
|
-
|
|
484
|
+
/**
|
|
485
|
+
* 验签失败不能被折叠成 INSTALL_FAILED:四种失败的宿主处置完全不同
|
|
486
|
+
* (引导选策略 / 提示重下 / 引导添加密钥 / 安全事件),与 Node 侧同口径。
|
|
487
|
+
*/
|
|
488
|
+
const PRESERVED_INSTALL_CODES = /* @__PURE__ */ new Set([
|
|
489
|
+
"SIGNATURE_MISSING",
|
|
490
|
+
"SIGNATURE_MALFORMED",
|
|
491
|
+
"SIGNATURE_UNTRUSTED_KEY",
|
|
492
|
+
"SIGNATURE_MISMATCH",
|
|
493
|
+
"SIGNATURE_UNSUPPORTED"
|
|
494
|
+
]);
|
|
495
|
+
const asInstallFailed = (e) => e instanceof WebSkillError && (e.code === "INSTALL_FAILED" || e.code === "TOOL_UNSUPPORTED" || PRESERVED_INSTALL_CODES.has(e.code)) ? e : new WebSkillError("INSTALL_FAILED", `Install failed: ${messageOf(e)}`, e);
|
|
465
496
|
const lockfilePath = (root) => `${root}/${SKILLS_LOCKFILE}`;
|
|
497
|
+
/** 未注入信任库时的空实现:策略仍然生效,只是没有任何受信公钥 */
|
|
498
|
+
const EMPTY_TRUSTED_KEYS = {
|
|
499
|
+
list: () => Promise.resolve([]),
|
|
500
|
+
get: () => Promise.resolve(void 0),
|
|
501
|
+
add: () => Promise.reject(new WebSkillError("TOOL_UNSUPPORTED", "No trusted key store is configured")),
|
|
502
|
+
remove: () => Promise.reject(new WebSkillError("TOOL_UNSUPPORTED", "No trusted key store is configured"))
|
|
503
|
+
};
|
|
466
504
|
/**
|
|
467
505
|
* D5:浏览器技能安装(http(s) zip / ArrayBuffer;tar/git/npm → TOOL_UNSUPPORTED)。
|
|
468
506
|
* 流程与 Node 对齐:staging → 解析 name → validateSkills(失败零残留)→ managed root →
|
|
@@ -475,12 +513,34 @@ var BrowserSkillManager = class {
|
|
|
475
513
|
#fetchImpl;
|
|
476
514
|
#archiveLimits;
|
|
477
515
|
#onChanged;
|
|
516
|
+
#trustedKeys;
|
|
517
|
+
#unsignedPolicy;
|
|
518
|
+
#onWarning;
|
|
478
519
|
constructor(deps) {
|
|
479
520
|
this.#fs = deps.fs;
|
|
480
521
|
this.#managedRoot = deps.managedRoot.replace(/\/+$/, "");
|
|
481
522
|
this.#fetchImpl = deps.fetchImpl;
|
|
482
523
|
this.#archiveLimits = deps.archiveLimits;
|
|
483
524
|
this.#onChanged = deps.onChanged;
|
|
525
|
+
this.#trustedKeys = deps.signature?.trustedKeys ?? EMPTY_TRUSTED_KEYS;
|
|
526
|
+
this.#unsignedPolicy = deps.signature?.unsigned ?? "warn";
|
|
527
|
+
this.#onWarning = deps.onWarning;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* 安装期验签:manifest 生成之后、提交 swap 之前。失败直接抛出,
|
|
531
|
+
* 暂存目录由 install 的 finally 清理,不落盘。
|
|
532
|
+
*/
|
|
533
|
+
async #verifySignature(skillDir, manifest) {
|
|
534
|
+
const verdict = await verifySkillSignature({
|
|
535
|
+
manifest,
|
|
536
|
+
signature: await readSkillSignature(this.#fs, skillDir),
|
|
537
|
+
trustedKeys: this.#trustedKeys,
|
|
538
|
+
policy: { unsigned: this.#unsignedPolicy }
|
|
539
|
+
});
|
|
540
|
+
if (verdict.warning !== void 0) this.#onWarning?.({
|
|
541
|
+
skill: manifest.name,
|
|
542
|
+
message: verdict.warning
|
|
543
|
+
});
|
|
484
544
|
}
|
|
485
545
|
async install(source, options) {
|
|
486
546
|
const fs = this.#fs;
|
|
@@ -546,6 +606,7 @@ var BrowserSkillManager = class {
|
|
|
546
606
|
...version ? { version } : {},
|
|
547
607
|
source
|
|
548
608
|
});
|
|
609
|
+
await this.#verifySignature(finalDir, manifest);
|
|
549
610
|
targetDir = `${this.#managedRoot}/${name}`;
|
|
550
611
|
const backupDir = `${stagingRoot}/backup`;
|
|
551
612
|
const hadPrevious = await fs.exists(targetDir);
|
|
@@ -614,6 +675,7 @@ var BrowserSkillManager = class {
|
|
|
614
675
|
source
|
|
615
676
|
});
|
|
616
677
|
if (manifest.integrity.digest !== entry.digest) throw new WebSkillError("INSTALL_FAILED", `Skill "${entry.name}" digest mismatch: expected ${entry.digest}, got ${manifest.integrity.digest}`);
|
|
678
|
+
await this.#verifySignature(`${finalRoot}/${entry.name}`, manifest);
|
|
617
679
|
manifests.push(manifest);
|
|
618
680
|
}
|
|
619
681
|
const swapped = [];
|
|
@@ -906,7 +968,7 @@ var BrowserWorkerScriptExecutor = class {
|
|
|
906
968
|
const [hasJs, hasTs] = await Promise.all([this.#fs.exists(jsPath), this.#fs.exists(tsPath)]);
|
|
907
969
|
if (hasJs && hasTs) throw new WebSkillError("TOOL_EXECUTION_FAILED", `Script "${scriptName}" is ambiguous: both .ts and .js exist under ${skillRoot}/scripts`);
|
|
908
970
|
if (hasTs) {
|
|
909
|
-
if (!this.#transpiler) throw new WebSkillError("TOOL_UNSUPPORTED", `Browser sandbox executes .js only unless TypeScript support is configured ("${scriptName}.ts")
|
|
971
|
+
if (!this.#transpiler) throw new WebSkillError("TOOL_UNSUPPORTED", `Browser sandbox executes .js only unless TypeScript support is configured ("${scriptName}.ts"). Enable it under runtime configuration sandbox.typescript and provide an esbuild-wasm URL.`);
|
|
910
972
|
return {
|
|
911
973
|
path: tsPath,
|
|
912
974
|
source: await this.#transpiler.transpile(tsPath, await this.#fs.readText(tsPath))
|
|
@@ -1123,6 +1185,627 @@ function installWebSkillNavigator(config) {
|
|
|
1123
1185
|
nav["webskill"] = api;
|
|
1124
1186
|
return api;
|
|
1125
1187
|
}
|
|
1188
|
+
function languageModel() {
|
|
1189
|
+
const candidate = globalThis.LanguageModel;
|
|
1190
|
+
return typeof candidate?.availability === "function" && typeof candidate.create === "function" ? candidate : void 0;
|
|
1191
|
+
}
|
|
1192
|
+
/** 缺 API 时区分「这个浏览器根本不支持」与「Chromium 但版本/开关没到位」,两者的用户动作不同 */
|
|
1193
|
+
function missingReason() {
|
|
1194
|
+
const nav = globalThis.navigator;
|
|
1195
|
+
return nav?.userAgentData?.brands?.some((entry) => /Chromium|Google Chrome/i.test(entry.brand)) === true || /Chrome\//.test(nav?.userAgent ?? "") ? "api-missing" : "unsupported-browser";
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* 进入模型配置界面时调用(FR-4.7):不可用时给出具体原因,禁止「选了之后才失败」。
|
|
1199
|
+
* 模型还在下载也算不可用——这一轮对话等不到它。
|
|
1200
|
+
*/
|
|
1201
|
+
async function probeChromeBuiltinAvailability() {
|
|
1202
|
+
const api = languageModel();
|
|
1203
|
+
if (!api) return {
|
|
1204
|
+
available: false,
|
|
1205
|
+
reason: missingReason()
|
|
1206
|
+
};
|
|
1207
|
+
try {
|
|
1208
|
+
return await api.availability() === "available" ? { available: true } : {
|
|
1209
|
+
available: false,
|
|
1210
|
+
reason: "model-not-ready"
|
|
1211
|
+
};
|
|
1212
|
+
} catch {
|
|
1213
|
+
return {
|
|
1214
|
+
available: false,
|
|
1215
|
+
reason: "model-not-ready"
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
const textOf = (message) => message.content.map((part) => part.type === "text" ? part.text : `[${part.type}]`).join("").trim();
|
|
1220
|
+
/** tool 角色 Prompt API 不认识,降级成 user 文本——内置模型本来就不做工具调用 */
|
|
1221
|
+
function toInitialPrompt(message) {
|
|
1222
|
+
const text = textOf(message);
|
|
1223
|
+
if (message.role === "assistant") return {
|
|
1224
|
+
role: "assistant",
|
|
1225
|
+
content: text
|
|
1226
|
+
};
|
|
1227
|
+
return {
|
|
1228
|
+
role: "user",
|
|
1229
|
+
content: message.role === "tool" ? `Tool result: ${text}` : text
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function splitInput(input) {
|
|
1233
|
+
const system = input.messages.filter((m) => m.role === "system").map(textOf).filter((text) => text !== "");
|
|
1234
|
+
const rest = input.messages.filter((m) => m.role !== "system");
|
|
1235
|
+
const last = rest.at(-1);
|
|
1236
|
+
return {
|
|
1237
|
+
initialPrompts: [...system.length > 0 ? [{
|
|
1238
|
+
role: "system",
|
|
1239
|
+
content: system.join("\n\n")
|
|
1240
|
+
}] : [], ...rest.slice(0, -1).map(toInitialPrompt)],
|
|
1241
|
+
prompt: last ? toInitialPrompt(last).content : ""
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* 浏览器内置模型客户端(Prompt API)。**不支持工具调用**——`input.tools` 被忽略,
|
|
1246
|
+
* 模型只会产出文本,因此技能里的脚本步骤在这一档跑不起来(FR-4.8 要求 UI 标注该限制)。
|
|
1247
|
+
* @experimental
|
|
1248
|
+
*/
|
|
1249
|
+
var ChromeBuiltinLlmClient = class {
|
|
1250
|
+
#temperature;
|
|
1251
|
+
constructor(options = {}) {
|
|
1252
|
+
if (options.temperature !== void 0) this.#temperature = options.temperature;
|
|
1253
|
+
}
|
|
1254
|
+
async #session(input) {
|
|
1255
|
+
const api = languageModel();
|
|
1256
|
+
if (!api) throw new WebSkillError("LLM_UNAVAILABLE", "The Chrome built-in model is not available in this browser (Prompt API missing)");
|
|
1257
|
+
const { initialPrompts, prompt } = splitInput(input);
|
|
1258
|
+
const temperature = input.temperature ?? this.#temperature;
|
|
1259
|
+
return {
|
|
1260
|
+
session: await api.create({
|
|
1261
|
+
...initialPrompts.length > 0 ? { initialPrompts } : {},
|
|
1262
|
+
...temperature !== void 0 ? { temperature } : {},
|
|
1263
|
+
...input.signal ? { signal: input.signal } : {}
|
|
1264
|
+
}),
|
|
1265
|
+
prompt
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
async complete(input) {
|
|
1269
|
+
const { session, prompt } = await this.#session(input);
|
|
1270
|
+
try {
|
|
1271
|
+
return { content: [{
|
|
1272
|
+
type: "text",
|
|
1273
|
+
text: await session.prompt(prompt, ...input.signal ? [{ signal: input.signal }] : [])
|
|
1274
|
+
}] };
|
|
1275
|
+
} finally {
|
|
1276
|
+
session.destroy?.();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
async *stream(input) {
|
|
1280
|
+
const { session, prompt } = await this.#session(input);
|
|
1281
|
+
if (!session.promptStreaming) {
|
|
1282
|
+
try {
|
|
1283
|
+
const text = await session.prompt(prompt, ...input.signal ? [{ signal: input.signal }] : []);
|
|
1284
|
+
yield {
|
|
1285
|
+
type: "text-delta",
|
|
1286
|
+
delta: text
|
|
1287
|
+
};
|
|
1288
|
+
yield {
|
|
1289
|
+
type: "done",
|
|
1290
|
+
content: text
|
|
1291
|
+
};
|
|
1292
|
+
} finally {
|
|
1293
|
+
session.destroy?.();
|
|
1294
|
+
}
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
let content = "";
|
|
1298
|
+
try {
|
|
1299
|
+
const reader = session.promptStreaming(prompt, ...input.signal ? [{ signal: input.signal }] : []).getReader();
|
|
1300
|
+
for (;;) {
|
|
1301
|
+
const { done, value } = await reader.read();
|
|
1302
|
+
if (done) break;
|
|
1303
|
+
if (value !== void 0 && value !== "") {
|
|
1304
|
+
content += value;
|
|
1305
|
+
yield {
|
|
1306
|
+
type: "text-delta",
|
|
1307
|
+
delta: value
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
yield {
|
|
1312
|
+
type: "done",
|
|
1313
|
+
content
|
|
1314
|
+
};
|
|
1315
|
+
} finally {
|
|
1316
|
+
session.destroy?.();
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async checkAvailability() {
|
|
1320
|
+
return (await probeChromeBuiltinAvailability()).available;
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
/**
|
|
1324
|
+
* 配置 → `LlmClient`。收在 SDK 里是为了让下游只跟 `RuntimeConfigStore` 打交道,
|
|
1325
|
+
* 不必自己维护「provider → client 类」的映射(设计 04 §5)。
|
|
1326
|
+
*/
|
|
1327
|
+
function createLlmClient(config) {
|
|
1328
|
+
if (config.provider === "chrome-builtin") return new ChromeBuiltinLlmClient();
|
|
1329
|
+
const common = {
|
|
1330
|
+
...config.baseUrl?.trim() ? { baseUrl: config.baseUrl.trim() } : {},
|
|
1331
|
+
...config.requestTimeoutMs !== void 0 ? { requestTimeoutMs: config.requestTimeoutMs } : {}
|
|
1332
|
+
};
|
|
1333
|
+
const apiKey = config.apiKey?.trim() ?? "";
|
|
1334
|
+
if (config.provider === "anthropic") return new AnthropicClient({
|
|
1335
|
+
...common,
|
|
1336
|
+
apiKey,
|
|
1337
|
+
model: config.model
|
|
1338
|
+
});
|
|
1339
|
+
if (config.provider === "google") return new GoogleGenAiClient({
|
|
1340
|
+
...common,
|
|
1341
|
+
apiKey,
|
|
1342
|
+
model: config.model
|
|
1343
|
+
});
|
|
1344
|
+
return new OpenAiCompatibleClient({
|
|
1345
|
+
...common,
|
|
1346
|
+
...apiKey !== "" ? { apiKey } : {},
|
|
1347
|
+
model: config.model
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
function recognitionCtor() {
|
|
1351
|
+
const scope = globalThis;
|
|
1352
|
+
return scope.SpeechRecognition ?? scope.webkitSpeechRecognition;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* 渲染期调用(FR-7.2):不可用时按钮置灰并说明原因,禁止呈现为可用后才失败。
|
|
1356
|
+
*
|
|
1357
|
+
* 顺序是刻意的——构造器都不存在时谈安全上下文没有意义;
|
|
1358
|
+
* 构造器在但站点是 http,识别一定会被浏览器拒绝,这条原因用户能自己解决。
|
|
1359
|
+
* @experimental
|
|
1360
|
+
*/
|
|
1361
|
+
function checkDictationAvailability() {
|
|
1362
|
+
if (recognitionCtor() === void 0) return {
|
|
1363
|
+
available: false,
|
|
1364
|
+
reason: "api-missing"
|
|
1365
|
+
};
|
|
1366
|
+
if (globalThis.isSecureContext === false) return {
|
|
1367
|
+
available: false,
|
|
1368
|
+
reason: "insecure-context"
|
|
1369
|
+
};
|
|
1370
|
+
return { available: true };
|
|
1371
|
+
}
|
|
1372
|
+
/** 浏览器的 error 码 → 稳定错误码;权限被拒要单独区分,UI 的降级提示不一样(FR-7.6) */
|
|
1373
|
+
function toError(code, message) {
|
|
1374
|
+
if (code === "not-allowed" || code === "service-not-allowed") return new WebSkillError("DICTATION_PERMISSION_DENIED", "Microphone access was denied. Voice input is unavailable until you allow it in the browser.", { reason: code });
|
|
1375
|
+
return new WebSkillError("DICTATION_FAILED", message ?? `Speech recognition failed: ${code}`, { reason: code });
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* 开始一次语音识别(FR-7.1)。
|
|
1379
|
+
*
|
|
1380
|
+
* **只能由用户手势调用**(FR-7.3)。设计上的保证是它不进工具列表:
|
|
1381
|
+
* agent 拿不到「开始录音」这个动作,因此不存在自主开麦的路径。
|
|
1382
|
+
* @experimental
|
|
1383
|
+
*/
|
|
1384
|
+
function startDictation(options) {
|
|
1385
|
+
const Ctor = recognitionCtor();
|
|
1386
|
+
if (Ctor === void 0) throw new WebSkillError("DICTATION_UNAVAILABLE", "Speech recognition is not available in this browser.");
|
|
1387
|
+
const recognition = new Ctor();
|
|
1388
|
+
recognition.lang = options.lang;
|
|
1389
|
+
recognition.continuous = true;
|
|
1390
|
+
recognition.interimResults = true;
|
|
1391
|
+
/** stop() 之后浏览器仍可能派发一轮回调;这里做一次闸门,保证「停了就不再收结果」 */
|
|
1392
|
+
let stopped = false;
|
|
1393
|
+
recognition.onresult = (event) => {
|
|
1394
|
+
if (stopped) return;
|
|
1395
|
+
let interim = "";
|
|
1396
|
+
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
|
1397
|
+
const result = event.results[index];
|
|
1398
|
+
if (result === void 0) continue;
|
|
1399
|
+
const text = result[0]?.transcript ?? "";
|
|
1400
|
+
if (text === "") continue;
|
|
1401
|
+
if (result.isFinal) options.onFinal(text);
|
|
1402
|
+
else interim += text;
|
|
1403
|
+
}
|
|
1404
|
+
if (interim !== "") options.onInterim(interim);
|
|
1405
|
+
};
|
|
1406
|
+
recognition.onerror = (event) => {
|
|
1407
|
+
if (stopped) return;
|
|
1408
|
+
if (event.error === "no-speech" || event.error === "aborted") return;
|
|
1409
|
+
options.onError(toError(event.error, event.message));
|
|
1410
|
+
};
|
|
1411
|
+
try {
|
|
1412
|
+
recognition.start();
|
|
1413
|
+
} catch (e) {
|
|
1414
|
+
throw new WebSkillError("DICTATION_UNAVAILABLE", `Speech recognition could not start: ${String(e)}`);
|
|
1415
|
+
}
|
|
1416
|
+
return { stop() {
|
|
1417
|
+
if (stopped) return;
|
|
1418
|
+
stopped = true;
|
|
1419
|
+
recognition.onresult = null;
|
|
1420
|
+
recognition.onerror = null;
|
|
1421
|
+
recognition.onend = null;
|
|
1422
|
+
recognition.stop();
|
|
1423
|
+
} };
|
|
1424
|
+
}
|
|
1425
|
+
/** 动图无法在不丢帧的前提下走单帧编码管线,超限直接拒绝(裁决 D-2) */
|
|
1426
|
+
const UNCOMPRESSABLE_MIME_TYPES = /* @__PURE__ */ new Set(["image/gif"]);
|
|
1427
|
+
const QUALITY_STEPS = [
|
|
1428
|
+
.9,
|
|
1429
|
+
.7,
|
|
1430
|
+
.5
|
|
1431
|
+
];
|
|
1432
|
+
const SCALE_STEP = .75;
|
|
1433
|
+
const MAX_ROUNDS = 5;
|
|
1434
|
+
/**
|
|
1435
|
+
* 把图片压到字节预算内(0.6.0 FR-11.3)。
|
|
1436
|
+
* 先逐级降质,仍超限则按长边逐步缩放,最多 5 轮;仍超限抛 `ATTACHMENT_TOO_LARGE`。
|
|
1437
|
+
*
|
|
1438
|
+
* 编码一律交给平台(`OffscreenCanvas.convertToBlob`),不手写编码器(AGENTS.md §6)。
|
|
1439
|
+
* 因此本函数只能在浏览器里用,落在 `@webskill/browser` 而不是 core/runtime(AC-G6)。
|
|
1440
|
+
*/
|
|
1441
|
+
async function compressImageToBudget(file, maxBytes) {
|
|
1442
|
+
const originalBytes = file.size;
|
|
1443
|
+
if (originalBytes <= maxBytes) return {
|
|
1444
|
+
blob: file,
|
|
1445
|
+
originalBytes,
|
|
1446
|
+
compressedBytes: originalBytes,
|
|
1447
|
+
scaled: false
|
|
1448
|
+
};
|
|
1449
|
+
if (UNCOMPRESSABLE_MIME_TYPES.has(file.type)) throw new WebSkillError("ATTACHMENT_TYPE_REJECTED", `GIF cannot be compressed without dropping animation; the file is ${formatBytes(originalBytes)} which exceeds the ${formatBytes(maxBytes)} limit`);
|
|
1450
|
+
if (typeof createImageBitmap !== "function" || typeof OffscreenCanvas !== "function") throw new WebSkillError("ATTACHMENT_TOO_LARGE", `Image is ${formatBytes(originalBytes)} which exceeds the ${formatBytes(maxBytes)} limit, and this browser cannot re-encode images`);
|
|
1451
|
+
const bitmap = await createImageBitmap(file);
|
|
1452
|
+
try {
|
|
1453
|
+
let width = bitmap.width;
|
|
1454
|
+
let height = bitmap.height;
|
|
1455
|
+
let scaled = false;
|
|
1456
|
+
let best;
|
|
1457
|
+
for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
|
1458
|
+
for (const quality of QUALITY_STEPS) {
|
|
1459
|
+
const blob = await encode(bitmap, width, height, quality);
|
|
1460
|
+
const candidate = {
|
|
1461
|
+
blob,
|
|
1462
|
+
originalBytes,
|
|
1463
|
+
compressedBytes: blob.size,
|
|
1464
|
+
scaled,
|
|
1465
|
+
quality
|
|
1466
|
+
};
|
|
1467
|
+
if (blob.size <= maxBytes) return candidate;
|
|
1468
|
+
if (!best || blob.size < best.compressedBytes) best = candidate;
|
|
1469
|
+
}
|
|
1470
|
+
width = Math.max(1, Math.round(width * SCALE_STEP));
|
|
1471
|
+
height = Math.max(1, Math.round(height * SCALE_STEP));
|
|
1472
|
+
scaled = true;
|
|
1473
|
+
}
|
|
1474
|
+
throw new WebSkillError("ATTACHMENT_TOO_LARGE", `Image is still ${formatBytes(best?.compressedBytes ?? originalBytes)} after compression, which exceeds the ${formatBytes(maxBytes)} limit`);
|
|
1475
|
+
} finally {
|
|
1476
|
+
bitmap.close();
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
async function encode(bitmap, width, height, quality) {
|
|
1480
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
1481
|
+
const ctx = canvas.getContext("2d");
|
|
1482
|
+
if (!ctx) throw new WebSkillError("ATTACHMENT_TOO_LARGE", "Failed to acquire a 2D canvas context for image compression");
|
|
1483
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
1484
|
+
return canvas.convertToBlob({
|
|
1485
|
+
type: "image/jpeg",
|
|
1486
|
+
quality
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
function formatBytes(bytes) {
|
|
1490
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
1491
|
+
}
|
|
1492
|
+
/** @experimental */
|
|
1493
|
+
function isCaptureFailure(result) {
|
|
1494
|
+
return "reason" in result;
|
|
1495
|
+
}
|
|
1496
|
+
const CAPTURABLE_TAGS = /* @__PURE__ */ new Set([
|
|
1497
|
+
"IMG",
|
|
1498
|
+
"CANVAS",
|
|
1499
|
+
"SVG"
|
|
1500
|
+
]);
|
|
1501
|
+
/** 只有这三种标签能取像;其余元素连取像入口都不进(FR-12.1,由 T-02-15 守门) @experimental */
|
|
1502
|
+
function isCapturableElement(element) {
|
|
1503
|
+
return CAPTURABLE_TAGS.has(element.tagName.toUpperCase());
|
|
1504
|
+
}
|
|
1505
|
+
function base64Of(bytes) {
|
|
1506
|
+
let binary = "";
|
|
1507
|
+
const chunk = 32768;
|
|
1508
|
+
for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
1509
|
+
return btoa(binary);
|
|
1510
|
+
}
|
|
1511
|
+
function splitDataUrl(dataUrl) {
|
|
1512
|
+
const comma = dataUrl.indexOf(",");
|
|
1513
|
+
if (!dataUrl.startsWith("data:") || comma === -1) return void 0;
|
|
1514
|
+
const meta = dataUrl.slice(5, comma);
|
|
1515
|
+
if (!meta.endsWith(";base64")) return void 0;
|
|
1516
|
+
return {
|
|
1517
|
+
mimeType: meta.slice(0, -7),
|
|
1518
|
+
data: dataUrl.slice(comma + 1)
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
/** 压到预算内;压不下去时把失败原因原样上抛给调用方转成 `CaptureFailure` */
|
|
1522
|
+
async function toBudget(id, level, blob, maxBytes) {
|
|
1523
|
+
const compressed = await compressImageToBudget(blob, maxBytes);
|
|
1524
|
+
const buffer = await compressed.blob.arrayBuffer();
|
|
1525
|
+
return {
|
|
1526
|
+
id,
|
|
1527
|
+
mimeType: compressed.blob.type === "" ? "application/octet-stream" : compressed.blob.type,
|
|
1528
|
+
data: base64Of(new Uint8Array(buffer)),
|
|
1529
|
+
level,
|
|
1530
|
+
originalBytes: compressed.originalBytes,
|
|
1531
|
+
bytes: compressed.compressedBytes
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
/** L1:`<img>` 取原图字节。跨域且无 CORS 头时 fetch 失败,不改用 canvas 重绘(同样会被污染) */
|
|
1535
|
+
async function captureImg(element, id, maxBytes) {
|
|
1536
|
+
const src = element.currentSrc !== "" ? element.currentSrc : element.src;
|
|
1537
|
+
if (src === "") throw new Error("the <img> element has no resolved source");
|
|
1538
|
+
const inline = splitDataUrl(src);
|
|
1539
|
+
if (inline) {
|
|
1540
|
+
const bytes = Uint8Array.from(atob(inline.data), (char) => char.charCodeAt(0));
|
|
1541
|
+
return toBudget(id, "src", new Blob([bytes], { type: inline.mimeType }), maxBytes);
|
|
1542
|
+
}
|
|
1543
|
+
const response = await fetch(src, { mode: "cors" });
|
|
1544
|
+
if (response.type === "opaque") throw new Error("the response is opaque, so its bytes cannot be read");
|
|
1545
|
+
if (!response.ok) throw new Error(`fetching the source returned HTTP ${response.status}`);
|
|
1546
|
+
return toBudget(id, "src", await response.blob(), maxBytes);
|
|
1547
|
+
}
|
|
1548
|
+
/** L2:`<canvas>` 读回。画布被跨域内容污染时 `toDataURL` 抛 SecurityError,不再兜底(裁决 D-3) */
|
|
1549
|
+
async function captureCanvas(element, id, maxBytes) {
|
|
1550
|
+
const parsed = splitDataUrl(element.toDataURL("image/png"));
|
|
1551
|
+
if (parsed === void 0) throw new Error("the canvas produced an unreadable data URL");
|
|
1552
|
+
const bytes = Uint8Array.from(atob(parsed.data), (char) => char.charCodeAt(0));
|
|
1553
|
+
return toBudget(id, "canvas", new Blob([bytes], { type: parsed.mimeType }), maxBytes);
|
|
1554
|
+
}
|
|
1555
|
+
/** L2:内联 `<svg>` 序列化。SVG 是文本,不进压缩管线——重编码成位图反而丢信息 */
|
|
1556
|
+
function captureSvg(element, id, maxBytes) {
|
|
1557
|
+
const markup = new XMLSerializer().serializeToString(element);
|
|
1558
|
+
const data = base64Of(new TextEncoder().encode(markup));
|
|
1559
|
+
const bytes = data.length;
|
|
1560
|
+
if (bytes > maxBytes) throw new Error(`the serialized SVG is ${bytes} bytes, which exceeds the budget`);
|
|
1561
|
+
return {
|
|
1562
|
+
id,
|
|
1563
|
+
mimeType: "image/svg+xml",
|
|
1564
|
+
data,
|
|
1565
|
+
level: "canvas",
|
|
1566
|
+
originalBytes: bytes,
|
|
1567
|
+
bytes
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* 取一个元素的图像(0.6.0 FR-12.1)。
|
|
1572
|
+
*
|
|
1573
|
+
* 按元素类型分派,不做无意义的尝试:`<img>` 只走 L1,`<canvas>` / `<svg>` 只走 L2,
|
|
1574
|
+
* 两者互不兜底。失败返回 `CaptureFailure` 而不是抛错——一张图抓不到不该让整次感知失败。
|
|
1575
|
+
* @experimental
|
|
1576
|
+
*/
|
|
1577
|
+
async function captureElementImage(element, options) {
|
|
1578
|
+
const { id, maxBytes } = options;
|
|
1579
|
+
const tag = element.tagName.toUpperCase();
|
|
1580
|
+
if (tag === "IMG") try {
|
|
1581
|
+
return await captureImg(element, id, maxBytes);
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
return {
|
|
1584
|
+
id,
|
|
1585
|
+
reason: `L1 could not read the image source: ${describeError(e)}`,
|
|
1586
|
+
triedLevels: ["src"]
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
if (tag === "CANVAS") try {
|
|
1590
|
+
return await captureCanvas(element, id, maxBytes);
|
|
1591
|
+
} catch (e) {
|
|
1592
|
+
return {
|
|
1593
|
+
id,
|
|
1594
|
+
reason: `L2 could not read the canvas: ${describeError(e)}`,
|
|
1595
|
+
triedLevels: ["canvas"]
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
if (tag === "SVG") try {
|
|
1599
|
+
return captureSvg(element, id, maxBytes);
|
|
1600
|
+
} catch (e) {
|
|
1601
|
+
return {
|
|
1602
|
+
id,
|
|
1603
|
+
reason: `L2 could not serialize the SVG: ${describeError(e)}`,
|
|
1604
|
+
triedLevels: ["canvas"]
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
return {
|
|
1608
|
+
id,
|
|
1609
|
+
reason: `<${element.tagName.toLowerCase()}> is not a capturable element`,
|
|
1610
|
+
triedLevels: []
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
function describeError(e) {
|
|
1614
|
+
if (e instanceof DOMException && e.name === "SecurityError") return "the canvas is tainted by cross-origin data";
|
|
1615
|
+
if (e instanceof TypeError) return "the request was blocked, most likely by CORS";
|
|
1616
|
+
return e instanceof Error ? e.message : String(e);
|
|
1617
|
+
}
|
|
1618
|
+
/** 值可能是凭据的输入类型:即便宿主忘了写进 exclude 也不读值 */
|
|
1619
|
+
const SECRET_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "hidden"]);
|
|
1620
|
+
/** tag → 可访问性角色的最小映射;命中不了就退到 generic,不猜 */
|
|
1621
|
+
const ROLE_BY_TAG = {
|
|
1622
|
+
A: "link",
|
|
1623
|
+
BUTTON: "button",
|
|
1624
|
+
CANVAS: "img",
|
|
1625
|
+
H1: "heading",
|
|
1626
|
+
H2: "heading",
|
|
1627
|
+
H3: "heading",
|
|
1628
|
+
H4: "heading",
|
|
1629
|
+
H5: "heading",
|
|
1630
|
+
H6: "heading",
|
|
1631
|
+
IMG: "img",
|
|
1632
|
+
LI: "listitem",
|
|
1633
|
+
NAV: "navigation",
|
|
1634
|
+
OL: "list",
|
|
1635
|
+
P: "paragraph",
|
|
1636
|
+
SELECT: "combobox",
|
|
1637
|
+
SVG: "img",
|
|
1638
|
+
TABLE: "table",
|
|
1639
|
+
TD: "cell",
|
|
1640
|
+
TEXTAREA: "textbox",
|
|
1641
|
+
TH: "columnheader",
|
|
1642
|
+
TR: "row",
|
|
1643
|
+
UL: "list"
|
|
1644
|
+
};
|
|
1645
|
+
const INPUT_TYPE_ROLE = {
|
|
1646
|
+
button: "button",
|
|
1647
|
+
checkbox: "checkbox",
|
|
1648
|
+
radio: "radio",
|
|
1649
|
+
submit: "button"
|
|
1650
|
+
};
|
|
1651
|
+
/** 名称与值都截断:一个超长文本节点不该把上下文吃光 */
|
|
1652
|
+
const MAX_TEXT = 200;
|
|
1653
|
+
const clip = (text) => {
|
|
1654
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
1655
|
+
return normalized.length > MAX_TEXT ? `${normalized.slice(0, MAX_TEXT)}…` : normalized;
|
|
1656
|
+
};
|
|
1657
|
+
function roleOf(element) {
|
|
1658
|
+
const explicit = element.getAttribute("role");
|
|
1659
|
+
if (explicit !== null && explicit.trim() !== "") return explicit.trim();
|
|
1660
|
+
if (element.tagName === "INPUT") {
|
|
1661
|
+
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
1662
|
+
return INPUT_TYPE_ROLE[type] ?? "textbox";
|
|
1663
|
+
}
|
|
1664
|
+
return ROLE_BY_TAG[element.tagName.toUpperCase()] ?? "generic";
|
|
1665
|
+
}
|
|
1666
|
+
/** 自身直系文本(不含后代元素的文本):后代会各自成为节点,重复没有意义 */
|
|
1667
|
+
function ownText(element) {
|
|
1668
|
+
let text = "";
|
|
1669
|
+
for (const node of element.childNodes) if (node.nodeType === 3) text += node.nodeValue ?? "";
|
|
1670
|
+
return clip(text);
|
|
1671
|
+
}
|
|
1672
|
+
function nameOf(element, doc) {
|
|
1673
|
+
const label = element.getAttribute("aria-label");
|
|
1674
|
+
if (label !== null && label.trim() !== "") return clip(label);
|
|
1675
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
1676
|
+
if (labelledBy !== null) {
|
|
1677
|
+
const parts = labelledBy.split(/\s+/).map((id) => doc.getElementById(id)?.textContent ?? "").filter((part) => part.trim() !== "");
|
|
1678
|
+
if (parts.length > 0) return clip(parts.join(" "));
|
|
1679
|
+
}
|
|
1680
|
+
if (element.tagName === "IMG") {
|
|
1681
|
+
const alt = element.getAttribute("alt");
|
|
1682
|
+
if (alt !== null && alt.trim() !== "") return clip(alt);
|
|
1683
|
+
}
|
|
1684
|
+
const own = ownText(element);
|
|
1685
|
+
return own === "" ? void 0 : own;
|
|
1686
|
+
}
|
|
1687
|
+
function valueOf(element) {
|
|
1688
|
+
if (element.tagName === "INPUT") {
|
|
1689
|
+
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
1690
|
+
if (SECRET_INPUT_TYPES.has(type)) return void 0;
|
|
1691
|
+
const value = element.value;
|
|
1692
|
+
return value === "" ? void 0 : clip(value);
|
|
1693
|
+
}
|
|
1694
|
+
if (element.tagName === "TEXTAREA" || element.tagName === "SELECT") {
|
|
1695
|
+
const value = element.value;
|
|
1696
|
+
return value === "" ? void 0 : clip(value);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* 隐藏节点不进结果:屏幕上看不见的东西,用户没有机会发现它被读走了。
|
|
1701
|
+
* jsdom 里 `getComputedStyle` 可用但布局是假的,因此只看显式声明,不看尺寸。
|
|
1702
|
+
*/
|
|
1703
|
+
function hidden(element, view) {
|
|
1704
|
+
if (element.hasAttribute("hidden")) return true;
|
|
1705
|
+
if (element.getAttribute("aria-hidden") === "true") return true;
|
|
1706
|
+
const inlineStyle = element.getAttribute("style") ?? "";
|
|
1707
|
+
if (/display\s*:\s*none|visibility\s*:\s*hidden/i.test(inlineStyle)) return true;
|
|
1708
|
+
if (view !== null) {
|
|
1709
|
+
const computed = view.getComputedStyle(element);
|
|
1710
|
+
if (computed.display === "none" || computed.visibility === "hidden") return true;
|
|
1711
|
+
}
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
/** script / style 的正文是代码,不是页面内容 */
|
|
1715
|
+
const SKIPPED_TAGS = /* @__PURE__ */ new Set([
|
|
1716
|
+
"SCRIPT",
|
|
1717
|
+
"STYLE",
|
|
1718
|
+
"NOSCRIPT",
|
|
1719
|
+
"TEMPLATE"
|
|
1720
|
+
]);
|
|
1721
|
+
function describe(element, excluded, doc, view, targets) {
|
|
1722
|
+
if (excluded.has(element)) return void 0;
|
|
1723
|
+
if (SKIPPED_TAGS.has(element.tagName)) return void 0;
|
|
1724
|
+
if (hidden(element, view)) return void 0;
|
|
1725
|
+
const children = [];
|
|
1726
|
+
for (const child of element.children) {
|
|
1727
|
+
const node = describe(child, excluded, doc, view, targets);
|
|
1728
|
+
if (node !== void 0) children.push(node);
|
|
1729
|
+
}
|
|
1730
|
+
const name = nameOf(element, doc);
|
|
1731
|
+
const value = valueOf(element);
|
|
1732
|
+
const role = roleOf(element);
|
|
1733
|
+
if (role === "generic" && name === void 0 && value === void 0 && children.length === 0) return void 0;
|
|
1734
|
+
const node = {
|
|
1735
|
+
role,
|
|
1736
|
+
...name !== void 0 ? { name } : {},
|
|
1737
|
+
...value !== void 0 ? { value } : {},
|
|
1738
|
+
...children.length > 0 ? { children } : {}
|
|
1739
|
+
};
|
|
1740
|
+
if (targets !== void 0 && isCapturableElement(element)) targets.push({
|
|
1741
|
+
element,
|
|
1742
|
+
node
|
|
1743
|
+
});
|
|
1744
|
+
return node;
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* DOM 遍历实现(设计 09 §2 的「实现层」)。产出只有角色 / 名称 / 值,
|
|
1748
|
+
* **不含任何 HTML 标记、class、data 属性或注释**(FR-10.4)。
|
|
1749
|
+
*
|
|
1750
|
+
* 判定顺序是先 `include` 取子树再 `exclude` 剪枝:`exclude` 命中的元素连同
|
|
1751
|
+
* 其整棵后代一起消失,而不是只去掉那一个节点。
|
|
1752
|
+
* @experimental
|
|
1753
|
+
*/
|
|
1754
|
+
function createDomPerceptionReader(options = {}) {
|
|
1755
|
+
function collect(scope, targets) {
|
|
1756
|
+
const doc = options.document ?? globalThis.document;
|
|
1757
|
+
if (doc === void 0 || scope.include.length === 0) return [];
|
|
1758
|
+
const view = doc.defaultView;
|
|
1759
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
1760
|
+
for (const selector of scope.exclude ?? []) for (const element of doc.querySelectorAll(selector)) excluded.add(element);
|
|
1761
|
+
const roots = [];
|
|
1762
|
+
for (const selector of scope.include) for (const element of doc.querySelectorAll(selector)) if (!roots.includes(element)) roots.push(element);
|
|
1763
|
+
const nodes = [];
|
|
1764
|
+
for (const root of roots) {
|
|
1765
|
+
const node = describe(root, excluded, doc, view, targets);
|
|
1766
|
+
if (node !== void 0) nodes.push(node);
|
|
1767
|
+
}
|
|
1768
|
+
return nodes;
|
|
1769
|
+
}
|
|
1770
|
+
async function readWithImages(scope, capture) {
|
|
1771
|
+
const targets = [];
|
|
1772
|
+
const nodes = collect(scope, targets);
|
|
1773
|
+
const selected = targets.slice(0, capture.maxImages);
|
|
1774
|
+
const images = [];
|
|
1775
|
+
let imageFailures = 0;
|
|
1776
|
+
for (const [index, target] of selected.entries()) {
|
|
1777
|
+
const result = await captureElementImage(target.element, {
|
|
1778
|
+
id: `img-${index + 1}`,
|
|
1779
|
+
maxBytes: capture.maxImageBytes
|
|
1780
|
+
});
|
|
1781
|
+
if (isCaptureFailure(result)) {
|
|
1782
|
+
target.node.imageNote = result.reason;
|
|
1783
|
+
imageFailures += 1;
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
target.node.imageId = result.id;
|
|
1787
|
+
images.push({
|
|
1788
|
+
id: result.id,
|
|
1789
|
+
mimeType: result.mimeType,
|
|
1790
|
+
data: result.data,
|
|
1791
|
+
level: result.level
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
for (const target of targets.slice(capture.maxImages)) target.node.imageNote = "Not captured: the per-message image limit was reached";
|
|
1795
|
+
return {
|
|
1796
|
+
nodes,
|
|
1797
|
+
images,
|
|
1798
|
+
imagesOmitted: targets.length - selected.length,
|
|
1799
|
+
imageFailures
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
function read(scope, capture) {
|
|
1803
|
+
if (capture === void 0) return collect(scope, void 0);
|
|
1804
|
+
if (!capture.images || capture.maxImages <= 0) return Promise.resolve({ nodes: collect(scope, void 0) });
|
|
1805
|
+
return readWithImages(scope, capture);
|
|
1806
|
+
}
|
|
1807
|
+
return { read };
|
|
1808
|
+
}
|
|
1126
1809
|
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
1127
1810
|
const isNonEmptyString = (v) => typeof v === "string" && v !== "";
|
|
1128
1811
|
/** Worker 侧入站校验:非法消息返回 undefined(忽略) */
|
|
@@ -1636,6 +2319,173 @@ var WorkerRuntimeClient = class {
|
|
|
1636
2319
|
}
|
|
1637
2320
|
}
|
|
1638
2321
|
};
|
|
2322
|
+
/**
|
|
2323
|
+
* 静态加密的 MemoryStore 包装(FR-19.5)。落盘形态:
|
|
2324
|
+
* `{ v: 1, iv: base64, ct: base64 }`;每次写入生成新的 12 字节随机 IV
|
|
2325
|
+
* ——AES-GCM 下 IV 重用会同时毁掉机密性与完整性,不能复用。
|
|
2326
|
+
*
|
|
2327
|
+
* 算法与密钥都交给 Web Crypto,本文件不实现任何密码学原语。
|
|
2328
|
+
* @experimental
|
|
2329
|
+
*/
|
|
2330
|
+
const ENVELOPE_VERSION = 1;
|
|
2331
|
+
const toBase64 = (bytes) => {
|
|
2332
|
+
let binary = "";
|
|
2333
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2334
|
+
return btoa(binary);
|
|
2335
|
+
};
|
|
2336
|
+
const allocate = (length) => new Uint8Array(new ArrayBuffer(length));
|
|
2337
|
+
const fromBase64 = (text) => {
|
|
2338
|
+
const binary = atob(text);
|
|
2339
|
+
const bytes = allocate(binary.length);
|
|
2340
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
2341
|
+
return bytes;
|
|
2342
|
+
};
|
|
2343
|
+
const encodeUtf8 = (text) => {
|
|
2344
|
+
const bytes = allocate(text.length * 3);
|
|
2345
|
+
const { written } = new TextEncoder().encodeInto(text, bytes);
|
|
2346
|
+
return bytes.subarray(0, written);
|
|
2347
|
+
};
|
|
2348
|
+
const isEnvelope = (value) => {
|
|
2349
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2350
|
+
const record = value;
|
|
2351
|
+
return record["v"] === ENVELOPE_VERSION && typeof record["iv"] === "string" && typeof record["ct"] === "string";
|
|
2352
|
+
};
|
|
2353
|
+
/**
|
|
2354
|
+
* 某条已读出的记忆值是否是密文信封。观测方(如 console)据此决定要不要去取密钥:
|
|
2355
|
+
* 加密关着的时候不该为了读一条明文而凭空建出一把密钥。
|
|
2356
|
+
* @experimental
|
|
2357
|
+
*/
|
|
2358
|
+
function isEncryptedMemoryValue(value) {
|
|
2359
|
+
return isEnvelope(value);
|
|
2360
|
+
}
|
|
2361
|
+
function requireSubtle() {
|
|
2362
|
+
const subtle = globalThis.crypto?.subtle;
|
|
2363
|
+
if (subtle === void 0) throw new WebSkillError("PROFILE_KEY_UNAVAILABLE", "Web Crypto is unavailable, so encrypted memory cannot be read or written. Serve the page over HTTPS or disable encrypted storage.");
|
|
2364
|
+
return subtle;
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* 用 AES-GCM 包装一个 MemoryStore:值加密后再交给 `inner`,读取时解密。
|
|
2368
|
+
* 密钥不可用(未解锁 / 无 Web Crypto)时**拒绝读写并抛结构化错误**,
|
|
2369
|
+
* 不静默降级成明文——那会让「已加密」的承诺失效而用户无从察觉。
|
|
2370
|
+
*
|
|
2371
|
+
* `scopes` 限定加密范围。默认全加密;宿主通常只加密存放个人数据的
|
|
2372
|
+
* `user:` 作用域,把运行观测(会话参数历史、技能统计)留作明文供 console 读取。
|
|
2373
|
+
* @experimental
|
|
2374
|
+
*/
|
|
2375
|
+
function createEncryptedMemoryStore(inner, key, options = {}) {
|
|
2376
|
+
const covers = options.scopes ?? (() => true);
|
|
2377
|
+
const requireKey = () => {
|
|
2378
|
+
if (key === void 0) throw new WebSkillError("PROFILE_KEY_UNAVAILABLE", "The encryption key for stored user data is unavailable, so the store refused to read or write.");
|
|
2379
|
+
return key;
|
|
2380
|
+
};
|
|
2381
|
+
const decrypt = async (raw) => {
|
|
2382
|
+
if (raw === void 0 || raw === null) return raw;
|
|
2383
|
+
if (!isEnvelope(raw)) return raw;
|
|
2384
|
+
const plain = await requireSubtle().decrypt({
|
|
2385
|
+
name: "AES-GCM",
|
|
2386
|
+
iv: fromBase64(raw.iv)
|
|
2387
|
+
}, requireKey(), fromBase64(raw.ct));
|
|
2388
|
+
return JSON.parse(new TextDecoder().decode(plain));
|
|
2389
|
+
};
|
|
2390
|
+
const store = {
|
|
2391
|
+
async get(scope, storeKey) {
|
|
2392
|
+
const raw = await inner.get(scope, storeKey);
|
|
2393
|
+
return covers(scope) ? decrypt(raw) : raw;
|
|
2394
|
+
},
|
|
2395
|
+
async set(scope, storeKey, value) {
|
|
2396
|
+
if (!covers(scope)) {
|
|
2397
|
+
await inner.set(scope, storeKey, value);
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
const subtle = requireSubtle();
|
|
2401
|
+
const iv = crypto.getRandomValues(allocate(12));
|
|
2402
|
+
const ct = await subtle.encrypt({
|
|
2403
|
+
name: "AES-GCM",
|
|
2404
|
+
iv
|
|
2405
|
+
}, requireKey(), encodeUtf8(JSON.stringify(value ?? null)));
|
|
2406
|
+
const envelope = {
|
|
2407
|
+
v: ENVELOPE_VERSION,
|
|
2408
|
+
iv: toBase64(iv),
|
|
2409
|
+
ct: toBase64(new Uint8Array(ct))
|
|
2410
|
+
};
|
|
2411
|
+
await inner.set(scope, storeKey, envelope);
|
|
2412
|
+
},
|
|
2413
|
+
async delete(scope, storeKey) {
|
|
2414
|
+
await inner.delete(scope, storeKey);
|
|
2415
|
+
},
|
|
2416
|
+
async list(scope) {
|
|
2417
|
+
const entries = await inner.list(scope);
|
|
2418
|
+
if (!covers(scope)) return entries;
|
|
2419
|
+
return Promise.all(entries.map(async (entry) => ({
|
|
2420
|
+
key: entry.key,
|
|
2421
|
+
value: await decrypt(entry.value)
|
|
2422
|
+
})));
|
|
2423
|
+
},
|
|
2424
|
+
async clear(scope) {
|
|
2425
|
+
await inner.clear(scope);
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
if (inner.transaction) store.transaction = async (scope, fn) => inner.transaction(scope, async (raw) => fn(createEncryptedMemoryStore(raw, key, options)));
|
|
2429
|
+
return store;
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* 生成一把不可导出的 AES-GCM 256 密钥(FR-19.5)。不可导出意味着页面脚本
|
|
2433
|
+
* 拿不到原始字节,只能通过 Web Crypto 使用它;宿主负责持久化这个句柄(如 IndexedDB)。
|
|
2434
|
+
* @experimental
|
|
2435
|
+
*/
|
|
2436
|
+
async function generateMemoryEncryptionKey() {
|
|
2437
|
+
return requireSubtle().generateKey({
|
|
2438
|
+
name: "AES-GCM",
|
|
2439
|
+
length: 256
|
|
2440
|
+
}, false, ["encrypt", "decrypt"]);
|
|
2441
|
+
}
|
|
2442
|
+
const KEY_DB_NAME = "webskill-keys";
|
|
2443
|
+
const KEY_STORE_NAME = "keys";
|
|
2444
|
+
function request(req) {
|
|
2445
|
+
return new Promise((resolve, reject) => {
|
|
2446
|
+
req.onsuccess = () => resolve(req.result);
|
|
2447
|
+
req.onerror = () => reject(req.error ?? /* @__PURE__ */ new Error("IndexedDB request failed"));
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
function openDb() {
|
|
2451
|
+
const indexedDb = globalThis.indexedDB;
|
|
2452
|
+
if (indexedDb === void 0) throw new WebSkillError("PROFILE_KEY_UNAVAILABLE", "IndexedDB is unavailable, so the encryption key for stored user data cannot be persisted.");
|
|
2453
|
+
return new Promise((resolve, reject) => {
|
|
2454
|
+
const open = indexedDb.open(KEY_DB_NAME, 1);
|
|
2455
|
+
open.onupgradeneeded = () => {
|
|
2456
|
+
if (!open.result.objectStoreNames.contains(KEY_STORE_NAME)) open.result.createObjectStore(KEY_STORE_NAME);
|
|
2457
|
+
};
|
|
2458
|
+
open.onsuccess = () => resolve(open.result);
|
|
2459
|
+
open.onerror = () => reject(open.error ?? /* @__PURE__ */ new Error("IndexedDB open failed"));
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* 取(必要时生成)本地数据加密密钥并持久化在 IndexedDB(FR-19.5)。
|
|
2464
|
+
* 存的是 `CryptoKey` 句柄本身而不是密钥字节:结构化克隆能存它,
|
|
2465
|
+
* 而它是 non-extractable 的,页面脚本与扩展都读不出原始字节。
|
|
2466
|
+
* @experimental
|
|
2467
|
+
*/
|
|
2468
|
+
async function openMemoryEncryptionKey(name = "user-profile") {
|
|
2469
|
+
const db = await openDb();
|
|
2470
|
+
try {
|
|
2471
|
+
const existing = await request(db.transaction(KEY_STORE_NAME, "readonly").objectStore(KEY_STORE_NAME).get(name));
|
|
2472
|
+
if (existing instanceof CryptoKey) return existing;
|
|
2473
|
+
const created = await generateMemoryEncryptionKey();
|
|
2474
|
+
await request(db.transaction(KEY_STORE_NAME, "readwrite").objectStore(KEY_STORE_NAME).put(created, name));
|
|
2475
|
+
return created;
|
|
2476
|
+
} finally {
|
|
2477
|
+
db.close();
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
/** 删除本地数据加密密钥;清除画像与记录时一并调用,避免留下解不开的旧密文 @experimental */
|
|
2481
|
+
async function deleteMemoryEncryptionKey(name = "user-profile") {
|
|
2482
|
+
const db = await openDb();
|
|
2483
|
+
try {
|
|
2484
|
+
await request(db.transaction(KEY_STORE_NAME, "readwrite").objectStore(KEY_STORE_NAME).delete(name));
|
|
2485
|
+
} finally {
|
|
2486
|
+
db.close();
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
1639
2489
|
|
|
1640
2490
|
//#endregion
|
|
1641
|
-
export { BrowserSkillManager, BrowserWorkerScriptExecutor, IframeWorkerLike, OpfsProvider, TsTranspiler, WORKER_BOOTSTRAP_SOURCE, WorkerRuntimeClient, WorkerUiBridge, bridgeError, createIframeWorker, extractZipWeb, installWebSkillNavigator, isOpfsAvailable, parseBridgeRequest, parseMainMessage, parseWorkerEvent, sha256HexWeb, startWorkerRuntimeHost };
|
|
2491
|
+
export { BrowserSkillManager, BrowserWorkerScriptExecutor, ChromeBuiltinLlmClient, IframeWorkerLike, OpfsProvider, TsTranspiler, WORKER_BOOTSTRAP_SOURCE, WorkerRuntimeClient, WorkerUiBridge, bridgeError, captureElementImage, checkDictationAvailability, compressImageToBudget, createDomPerceptionReader, createEncryptedMemoryStore, createIframeWorker, createLlmClient, deleteMemoryEncryptionKey, extractZipWeb, generateMemoryEncryptionKey, installWebSkillNavigator, isCapturableElement, isCaptureFailure, isEncryptedMemoryValue, isOpfsAvailable, openMemoryEncryptionKey, parseBridgeRequest, parseMainMessage, parseWorkerEvent, probeChromeBuiltinAvailability, sha256HexWeb, startDictation, startWorkerRuntimeHost };
|