From 9bc55121401ee97585ea7d93f9117ffbf2844377 Mon Sep 17 00:00:00 2001 From: QAyong Date: Wed, 26 Aug 2026 15:23:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(portrait-clearance):=20=E8=82=96=E5=83=8F?= =?UTF-8?q?=E6=8E=92=E6=9F=A5=20-=20=E5=A2=9E=E5=8A=A0=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=B8=8E=E6=9C=AC=E6=9C=BA=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- canvas-agent/README.md | 6 + canvas-agent/bun.lock | 203 ++- canvas-agent/package.json | 9 +- canvas-agent/src/http-server.ts | 4 +- canvas-agent/src/local-runtime-contract.ts | 8 +- canvas-agent/src/local-runtime-host.ts | 9 +- canvas-agent/src/local-runtime-security.ts | 4 +- canvas-agent/src/local-runtime-session.ts | 4 +- canvas-agent/src/local-runtime.ts | 23 +- .../src/modules/portrait-clearance-http.ts | 319 +++++ .../src/portrait-clearance/baidu-search.ts | 76 + .../src/portrait-clearance/contracts.ts | 272 ++++ canvas-agent/src/portrait-clearance/dedup.ts | 94 ++ .../src/portrait-clearance/face-engine.ts | 299 ++++ .../src/portrait-clearance/image-metrics.ts | 270 ++++ .../src/portrait-clearance/model-store.ts | 182 +++ .../src/portrait-clearance/reports.ts | 511 +++++++ .../src/portrait-clearance/risk-rules.ts | 98 ++ .../portrait-clearance/safe-image-download.ts | 113 ++ .../src/portrait-clearance/task-runner.ts | 238 ++++ .../src/portrait-clearance/task-store.ts | 676 +++++++++ canvas-agent/test/local-runtime-http.test.ts | 31 + canvas-agent/test/portrait-clearance.test.ts | 387 ++++++ docs/content/docs/progress/pending-test.mdx | 15 + .../portrait-clearance-canvas-plugin.mdx | 1223 +++++++++++++++++ docs/index.md | 2 + .../components/canvas/canvas-context-menu.tsx | 3 + .../canvas/canvas-node-action-context.ts | 2 + .../components/canvas/canvas-node-content.tsx | 5 +- web/src/components/canvas/canvas-node.tsx | 8 +- web/src/components/canvas/canvas-toolbar.tsx | 6 +- .../canvas/nodes/portrait-clearance-node.tsx | 117 ++ .../portrait-clearance-icon.tsx | 26 + .../portrait-clearance-modal.tsx | 486 +++++++ web/src/components/layout/app-providers.tsx | 7 + web/src/lib/canvas/canvas-node-copy.ts | 9 + .../canvas/node-registry/node-definition.ts | 2 + .../definitions/add-node-menu-tools.tsx | 11 + .../canvas/tool-registry/tool-definition.ts | 2 + web/src/lib/plugins/builtin/index.ts | 1 + .../lib/plugins/builtin/portrait-clearance.ts | 40 + web/src/lib/portrait-clearance/contracts.ts | 244 ++++ .../lib/portrait-clearance/input-bindings.ts | 76 + web/src/lib/portrait-clearance/vision.ts | 132 ++ web/src/pages/canvas/project.tsx | 76 +- .../canvas/use-canvas-node-operations.ts | 11 +- .../use-portrait-clearance-coordinator.ts | 162 +++ web/src/pages/plugins/index.tsx | 1 + web/src/pages/plugins/plugin-documentation.ts | 1 + web/src/services/local-runtime.ts | 7 +- .../services/portrait-clearance-runtime.ts | 297 ++++ web/src/types/canvas.ts | 3 + .../portrait-clearance-input-bindings.test.ts | 32 + ...portrait-clearance-model-selection.test.ts | 49 + 54 files changed, 6832 insertions(+), 60 deletions(-) create mode 100644 canvas-agent/src/modules/portrait-clearance-http.ts create mode 100644 canvas-agent/src/portrait-clearance/baidu-search.ts create mode 100644 canvas-agent/src/portrait-clearance/contracts.ts create mode 100644 canvas-agent/src/portrait-clearance/dedup.ts create mode 100644 canvas-agent/src/portrait-clearance/face-engine.ts create mode 100644 canvas-agent/src/portrait-clearance/image-metrics.ts create mode 100644 canvas-agent/src/portrait-clearance/model-store.ts create mode 100644 canvas-agent/src/portrait-clearance/reports.ts create mode 100644 canvas-agent/src/portrait-clearance/risk-rules.ts create mode 100644 canvas-agent/src/portrait-clearance/safe-image-download.ts create mode 100644 canvas-agent/src/portrait-clearance/task-runner.ts create mode 100644 canvas-agent/src/portrait-clearance/task-store.ts create mode 100644 canvas-agent/test/portrait-clearance.test.ts create mode 100644 docs/design/portrait-clearance-canvas-plugin.mdx create mode 100644 web/src/components/canvas/nodes/portrait-clearance-node.tsx create mode 100644 web/src/components/canvas/portrait-clearance/portrait-clearance-icon.tsx create mode 100644 web/src/components/canvas/portrait-clearance/portrait-clearance-modal.tsx create mode 100644 web/src/lib/plugins/builtin/portrait-clearance.ts create mode 100644 web/src/lib/portrait-clearance/contracts.ts create mode 100644 web/src/lib/portrait-clearance/input-bindings.ts create mode 100644 web/src/lib/portrait-clearance/vision.ts create mode 100644 web/src/pages/canvas/use-portrait-clearance-coordinator.ts create mode 100644 web/src/services/portrait-clearance-runtime.ts create mode 100644 web/test/portrait-clearance-input-bindings.test.ts create mode 100644 web/test/portrait-clearance-model-selection.test.ts diff --git a/canvas-agent/README.md b/canvas-agent/README.md index 893b492b3..bcdf72100 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -70,6 +70,12 @@ Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接 - 外部程序直接切换 Dreamina CLI 账号无法被本应用实时观测;只能在下一次 CLI 状态或命令边界重新校验,因此本机任务运行期间请不要在其他程序中换号。 - 官方 CLI 的 argv 可能被同一 OS 用户通过进程列表看到,其中可能包含 prompt、receipt 或本地路径;这是官方 CLI 的进程边界,本应用不承诺对同机用户隐藏这些参数。 +## 肖像可识别性本机引擎 + +Canvas Agent 内置 `portrait-clearance` Local Runtime 模块。它只接收签名的画布请求,不读取项目 API Key;图片、embedding、候选和报告保存在 Agent 的配置目录,不进入画布 JSON。 + +本机模型不会随 npm 包发布。用户必须在肖像排查工作台中显式安装并校验 `buffalo_l` 所需的 `det_10g.onnx` 与 `w600k_r50.onnx`,安装过程保留现有可用模型并拒绝校验失败的临时文件。缺少模型时,模块返回 `portrait_model_missing`,不会伪造低风险结果。 + ## 发布 `canvas-agent` 使用自己的 `package.json` 版本号,不跟仓库根目录 `VERSION` 绑定。发布包名为 `@ddcat666/open-ai-canvas-agent`。 diff --git a/canvas-agent/bun.lock b/canvas-agent/bun.lock index e23f0daf8..90ffd13d1 100644 --- a/canvas-agent/bun.lock +++ b/canvas-agent/bun.lock @@ -7,8 +7,12 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@openai/codex": "^0.149.1", + "docx": "^9.5.1", "express": "^5.2.1", "json-canonicalize": "2.0.0", + "onnxruntime-node": "^1.22.0", + "playwright-core": "^1.55.0", + "sharp": "^0.34.3", "zod": "^3.25.76", }, "devDependencies": { @@ -19,10 +23,15 @@ }, }, }, + "trustedDependencies": [ + "onnxruntime-node", + ], "overrides": { "zod": "3.25.76", }, "packages": { + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -77,21 +86,71 @@ "@hono/node-server": ["@hono/node-server@1.19.14", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "zod": "^3.25 || ^4.0", "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@img/colour": ["@img/colour@1.1.0", "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@openai/codex": ["@openai/codex@0.149.1", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.149.1-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.149.1-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.149.1-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.149.1-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.149.1-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.149.1-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-6q5pbcpFbJbqOpkubSDBwXmktQ55aD8eUzGzBF1zASob2DjwhBKDSNGtdZKalfrNJUdTDTPDMmzCXEXs5tMBYA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - "@openai/codex-darwin-arm64": ["@openai/codex@0.149.1-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6X84kTCbnTgPIJ2EdcPsrvwS0Wxsqpa+bCswGmRf4BjhcQ5nPMnBC6yCAaCMj+vrbXQHj+L6sa9FaR4QkmA1qw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - "@openai/codex-darwin-x64": ["@openai/codex@0.149.1-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-MfLBQLfcElJL9tvj6y45qVHHMGSXCPnQOixuD3/Zq0g1BW/eFizkrGLdn48cFpc+l8cK+gt5nYG5pQYwVs6g4A=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - "@openai/codex-linux-arm64": ["@openai/codex@0.149.1-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-OqxUfZ1TVvHd18zHPKK/8ZRlpk8Vy11mg5CMHaLxNWldTbwVImDKtSLWT+m8m4NM5Sz4PbjtZMrVT/RfpBW/mQ=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - "@openai/codex-linux-x64": ["@openai/codex@0.149.1-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-Of5fGYgr7tAMsyj6vhXb4/RM/UoA3Zq8BLegUBDC09UNy1XTLGYP/2XD+UX8z3qh0NDwxYdCjFIWdDNijKZggQ=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - "@openai/codex-win32-arm64": ["@openai/codex@0.149.1-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-5K0DmOKGK9Bos627p8sK8ATHjovPK0sDyT6h9Cb+4v+5CW5SGw1HLgjGxoLfJ8g3cg6mtg/pRCXXo2L/j71UVA=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - "@openai/codex-win32-x64": ["@openai/codex@0.149.1-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-G3QXGAg7nyyhqOeooAMUekBCeHd8a1QByhKcVAFyzNBaI06t6Ft7nsF+1SzFS0spuIdU4YyMi5YD26ukADBQUQ=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + + "@openai/codex": ["@openai/codex@0.149.1", "https://registry.npmmirror.com/@openai/codex/-/codex-0.149.1.tgz", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.149.1-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.149.1-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.149.1-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.149.1-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.149.1-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.149.1-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-6q5pbcpFbJbqOpkubSDBwXmktQ55aD8eUzGzBF1zASob2DjwhBKDSNGtdZKalfrNJUdTDTPDMmzCXEXs5tMBYA=="], + + "@openai/codex-darwin-arm64": ["@openai/codex@0.149.1-darwin-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.149.1-darwin-arm64.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-6X84kTCbnTgPIJ2EdcPsrvwS0Wxsqpa+bCswGmRf4BjhcQ5nPMnBC6yCAaCMj+vrbXQHj+L6sa9FaR4QkmA1qw=="], + + "@openai/codex-darwin-x64": ["@openai/codex@0.149.1-darwin-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.149.1-darwin-x64.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-MfLBQLfcElJL9tvj6y45qVHHMGSXCPnQOixuD3/Zq0g1BW/eFizkrGLdn48cFpc+l8cK+gt5nYG5pQYwVs6g4A=="], + + "@openai/codex-linux-arm64": ["@openai/codex@0.149.1-linux-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.149.1-linux-arm64.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-OqxUfZ1TVvHd18zHPKK/8ZRlpk8Vy11mg5CMHaLxNWldTbwVImDKtSLWT+m8m4NM5Sz4PbjtZMrVT/RfpBW/mQ=="], + + "@openai/codex-linux-x64": ["@openai/codex@0.149.1-linux-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.149.1-linux-x64.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Of5fGYgr7tAMsyj6vhXb4/RM/UoA3Zq8BLegUBDC09UNy1XTLGYP/2XD+UX8z3qh0NDwxYdCjFIWdDNijKZggQ=="], + + "@openai/codex-win32-arm64": ["@openai/codex@0.149.1-win32-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.149.1-win32-arm64.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-5K0DmOKGK9Bos627p8sK8ATHjovPK0sDyT6h9Cb+4v+5CW5SGw1HLgjGxoLfJ8g3cg6mtg/pRCXXo2L/j71UVA=="], + + "@openai/codex-win32-x64": ["@openai/codex@0.149.1-win32-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.149.1-win32-x64.tgz", { "os": "win32", "cpu": "x64" }, "sha512-G3QXGAg7nyyhqOeooAMUekBCeHd8a1QByhKcVAFyzNBaI06t6Ft7nsF+1SzFS0spuIdU4YyMi5YD26ukADBQUQ=="], "@types/body-parser": ["@types/body-parser@1.19.6", "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], @@ -103,7 +162,7 @@ "@types/http-errors": ["@types/http-errors@2.0.5", "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], - "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "@types/node": ["@types/node@22.20.1", "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], "@types/qs": ["@types/qs@6.15.1", "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], @@ -113,48 +172,50 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], - "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], - "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], - "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], - "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], - "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], - "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], - "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], - "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], - "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], - "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], - "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], - "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], - "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], - "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], - "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "https://registry.npmmirror.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], "accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "adm-zip": ["adm-zip@0.6.0", "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.6.0.tgz", {}, "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg=="], + "ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -175,14 +236,24 @@ "cookie-signature": ["cookie-signature@1.2.2", "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-util-is": ["core-util-is@1.0.3", "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "define-data-property": ["define-data-property@1.1.4", "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "docx": ["docx@9.7.1", "https://registry.npmmirror.com/docx/-/docx-9.7.1.tgz", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg=="], + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -199,6 +270,8 @@ "escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -227,10 +300,18 @@ "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "global-agent": ["global-agent@4.1.3", "https://registry.npmmirror.com/global-agent/-/global-agent-4.1.3.tgz", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="], + + "globalthis": ["globalthis@1.0.4", "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hash.js": ["hash.js@1.1.7", "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], + "hasown": ["hasown@2.0.4", "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hono": ["hono@4.12.25", "https://registry.npmmirror.com/hono/-/hono-4.12.25.tgz", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], @@ -239,6 +320,8 @@ "iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ip-address": ["ip-address@10.2.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -247,16 +330,24 @@ "is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "isarray": ["isarray@1.0.0", "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "json-canonicalize": ["json-canonicalize@2.0.0", "", {}, "sha512-yyrnK/mEm6Na3ChbJUWueXdapueW0p380RUyTW87XGb1ww8l8hU0pRrGC3vSWHe9CxrbPHX2fGUOZpNiHR0IIg=="], + "json-canonicalize": ["json-canonicalize@2.0.0", "https://registry.npmmirror.com/json-canonicalize/-/json-canonicalize-2.0.0.tgz", {}, "sha512-yyrnK/mEm6Na3ChbJUWueXdapueW0p380RUyTW87XGb1ww8l8hU0pRrGC3vSWHe9CxrbPHX2fGUOZpNiHR0IIg=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jszip": ["jszip@3.10.1", "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "lie": ["lie@3.3.0", "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "matcher": ["matcher@4.0.0", "https://registry.npmmirror.com/matcher/-/matcher-4.0.0.tgz", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -267,18 +358,30 @@ "mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@5.1.16", "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + "negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "on-finished": ["on-finished@2.4.1", "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onnxruntime-common": ["onnxruntime-common@1.29.0", "https://registry.npmmirror.com/onnxruntime-common/-/onnxruntime-common-1.29.0.tgz", {}, "sha512-/F63/e2VJoaVXGGNu6S5QH7jivBThGO95OzAVXXQ8hTta/b1QxI8udHa6cI3+3mAb5WWIIaMMwfZw01oivjJ1g=="], + + "onnxruntime-node": ["onnxruntime-node@1.29.0", "https://registry.npmmirror.com/onnxruntime-node/-/onnxruntime-node-1.29.0.tgz", { "dependencies": { "adm-zip": "^0.6.0", "global-agent": "^4.1.3", "onnxruntime-common": "1.29.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-WjiVVB72riILz8HbYvxvmjKyE/WmkYoSfKY++axo5jAR609HQg8MwiG/HhShpTcJfmmAdzxxmB+MMST3A+SiPA=="], + + "pako": ["pako@1.0.11", "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -287,6 +390,10 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright-core": ["playwright-core@1.62.1", "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.15.2", "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], @@ -295,18 +402,32 @@ "raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.1", "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "semver": ["semver@7.8.5", "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@8.1.0", "https://registry.npmmirror.com/serialize-error/-/serialize-error-8.1.0.tgz", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], + "serve-static": ["serve-static@2.2.1", "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "setimmediate": ["setimmediate@1.0.5", "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -321,25 +442,37 @@ "statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="], + "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.23.1", "https://registry.npmmirror.com/tsx/-/tsx-4.23.1.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="], + + "type-fest": ["type-fest@0.20.2", "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], "type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "typescript": ["typescript@7.0.2", "https://registry.npmmirror.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "xml": ["xml@1.0.1", "https://registry.npmmirror.com/xml/-/xml-1.0.1.tgz", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], + + "xml-js": ["xml-js@1.6.11", "https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + + "zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -353,6 +486,10 @@ "@types/serve-static/@types/node": ["@types/node@22.19.21", "https://registry.npmmirror.com/@types/node/-/node-22.19.21.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], + "docx/@types/node": ["@types/node@25.9.5", "https://registry.npmmirror.com/@types/node/-/node-25.9.5.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], + "type-is/content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "docx/@types/node/undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], } } diff --git a/canvas-agent/package.json b/canvas-agent/package.json index 537916f82..0073cc7f1 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -21,8 +21,12 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@openai/codex": "^0.149.1", + "docx": "^9.5.1", "express": "^5.2.1", "json-canonicalize": "2.0.0", + "onnxruntime-node": "^1.22.0", + "playwright-core": "^1.55.0", + "sharp": "^0.34.3", "zod": "^3.25.76" }, "devDependencies": { @@ -46,5 +50,8 @@ "license": "MIT", "publishConfig": { "access": "public" - } + }, + "trustedDependencies": [ + "onnxruntime-node" + ] } diff --git a/canvas-agent/src/http-server.ts b/canvas-agent/src/http-server.ts index 631286773..fc9e81e4c 100644 --- a/canvas-agent/src/http-server.ts +++ b/canvas-agent/src/http-server.ts @@ -1,5 +1,5 @@ import { CanvasSession } from "./canvas-session.js"; -import type { LocalRuntimeConfig } from "./config.js"; +import { CONFIG_DIR, type LocalRuntimeConfig } from "./config.js"; import { createLocalRuntimeApp } from "./local-runtime.js"; import { startLocalRuntime } from "./local-runtime-host.js"; import { LocalRuntimeSessionManager } from "./local-runtime-session.js"; @@ -11,6 +11,7 @@ import { createDreaminaHttpModule, type DreaminaHttpModuleOptions, } from "./modules/dreamina-http.js"; +import { createPortraitClearanceHttpModule } from "./modules/portrait-clearance-http.js"; export type CanvasAgentHttpDependencies = Pick; @@ -44,6 +45,7 @@ export function createHttpApp( modules: [ createCanvasAgentHttpModule(config, session), createDreaminaHttpModule({ ownerId: config.ownerId!, ...dependencies }), + createPortraitClearanceHttpModule({ ownerId: config.ownerId!, configDir: CONFIG_DIR }), ], legacyMasterToken: config.token, legacyOrigins: config.origins ?? [], diff --git a/canvas-agent/src/local-runtime-contract.ts b/canvas-agent/src/local-runtime-contract.ts index fde140c69..c17521b32 100644 --- a/canvas-agent/src/local-runtime-contract.ts +++ b/canvas-agent/src/local-runtime-contract.ts @@ -6,7 +6,7 @@ export const LOCAL_RUNTIME_ENDPOINT = "http://127.0.0.1:17371"; export const LOCAL_RUNTIME_ID = "framefield-local-runtime"; export const LOCAL_RUNTIME_API_VERSION = 2; -export type LocalRuntimeModuleId = "canvas-agent" | "dreamina"; +export type LocalRuntimeModuleId = "canvas-agent" | "dreamina" | "portrait-clearance"; export type LocalRuntimeScope = | "runtime:status" | "runtime:revoke" @@ -16,7 +16,11 @@ export type LocalRuntimeScope = | "dreamina:logout" | "dreamina:run" | "dreamina:models" - | "dreamina:generate"; + | "dreamina:generate" + | "portrait:status" + | "portrait:model" + | "portrait:run" + | "portrait:read"; export type DreaminaModelOperation = | "text-to-image" diff --git a/canvas-agent/src/local-runtime-host.ts b/canvas-agent/src/local-runtime-host.ts index 69930528e..d4930592a 100644 --- a/canvas-agent/src/local-runtime-host.ts +++ b/canvas-agent/src/local-runtime-host.ts @@ -12,9 +12,10 @@ import { type LocalRuntimeConfig, } from "./config.js"; import { createLocalRuntimeApp, type LocalRuntimeModule } from "./local-runtime.js"; -import { LocalRuntimeSessionManager } from "./local-runtime-session.js"; +import { LOCAL_RUNTIME_DEFAULT_SCOPES, LocalRuntimeSessionManager } from "./local-runtime-session.js"; import { createCanvasAgentHttpModule } from "./modules/canvas-agent-http.js"; import { createDreaminaHttpModule } from "./modules/dreamina-http.js"; +import { createPortraitClearanceHttpModule } from "./modules/portrait-clearance-http.js"; export type StartLocalRuntimeOptions = { config?: LocalRuntimeConfig; @@ -35,6 +36,7 @@ export function createDefaultLocalRuntimeModules(config: LocalRuntimeConfig): Lo ...Object.values(config.canvases ?? {}).map((canvas) => canvas.workspacePath), ], }), + createPortraitClearanceHttpModule({ ownerId: ensureRuntimeOwnerId(config), configDir: CONFIG_DIR }), ]; } @@ -54,10 +56,15 @@ export function startLocalRuntime(options: StartLocalRuntimeOptions = {}) { : `http://127.0.0.1:${requestedPort}`; const authority = requestedPort === 0 ? "127.0.0.1:0" : `127.0.0.1:${requestedPort}`; const modules = [...(options.modules ?? createDefaultLocalRuntimeModules(config))]; + const scopes = [...new Set([ + ...LOCAL_RUNTIME_DEFAULT_SCOPES, + ...modules.flatMap((module) => module.descriptor.scopes), + ])]; const sessions = new LocalRuntimeSessionManager({ endpoint, trustedOrigins: config.trustedWebOrigins, registrations: config.browserRegistrations, + scopes, persistRegistrations: () => persistConfig(config), onSessionRevoked: (sessionId) => { for (const module of modules) module.onRuntimeSessionRevoked?.(sessionId); diff --git a/canvas-agent/src/local-runtime-security.ts b/canvas-agent/src/local-runtime-security.ts index 1e5164fbd..3be6e9ed8 100644 --- a/canvas-agent/src/local-runtime-security.ts +++ b/canvas-agent/src/local-runtime-security.ts @@ -129,7 +129,9 @@ export function signedRuntimeGuard( proof, scope, }); - res.locals.runtimeSession = session; + // Keep the public session contract free of origin while exposing the + // already verified origin to module handlers that scope local data. + res.locals.runtimeSession = { ...session, origin }; next(); } catch (error) { next(error); diff --git a/canvas-agent/src/local-runtime-session.ts b/canvas-agent/src/local-runtime-session.ts index 55097e85e..db2ebdbc5 100644 --- a/canvas-agent/src/local-runtime-session.ts +++ b/canvas-agent/src/local-runtime-session.ts @@ -14,7 +14,7 @@ const REQUEST_CLOCK_SKEW_MS = 30_000; const MAX_SESSION_NONCES = 2_048; const MAX_PENDING_CHALLENGES = 64; -const DEFAULT_SCOPES: readonly LocalRuntimeScope[] = [ +export const LOCAL_RUNTIME_DEFAULT_SCOPES: readonly LocalRuntimeScope[] = [ "runtime:status", "runtime:revoke", "canvas:connect", @@ -122,7 +122,7 @@ export class LocalRuntimeSessionManager { this.registrations = options.registrations; this.persistRegistrations = options.persistRegistrations ?? (() => undefined); this.now = options.now ?? Date.now; - this.scopes = options.scopes ?? DEFAULT_SCOPES; + this.scopes = options.scopes ?? LOCAL_RUNTIME_DEFAULT_SCOPES; this.onSessionRevoked = options.onSessionRevoked; this.timers = options.timers ?? { setTimeout(callback, delayMs) { diff --git a/canvas-agent/src/local-runtime.ts b/canvas-agent/src/local-runtime.ts index 644e982de..61e893e34 100644 --- a/canvas-agent/src/local-runtime.ts +++ b/canvas-agent/src/local-runtime.ts @@ -185,7 +185,7 @@ function validateModules(modules: readonly LocalRuntimeModule[]) { const ids = new Set(); const routes = new Set(); for (const module of modules) { - if (module.descriptor.id !== "canvas-agent" && module.descriptor.id !== "dreamina") { + if (module.descriptor.id !== "canvas-agent" && module.descriptor.id !== "dreamina" && module.descriptor.id !== "portrait-clearance") { throw new Error(`Unsupported Local Runtime module id: ${module.descriptor.id}`); } if (ids.has(module.descriptor.id)) throw new Error(`Duplicate Local Runtime module id: ${module.descriptor.id}`); @@ -220,13 +220,18 @@ function corsPolicies(modules: readonly LocalRuntimeModule[], legacyOrigins: rea ]); for (const module of modules) { for (const item of module.routes) { + const previous = policies.get(item.path); + const legacyHeaders = item.legacy + ? item.method === "POST" ? ["content-type", "x-canvas-agent-token"] : ["x-canvas-agent-token"] + : []; policies.set(item.path, { - methods: [item.method], - headers: protectedCorsHeaders(item.method, item.lastEventId), - trustedOrigin: true, - ...(item.legacy ? { - legacyOrigins, - legacyHeaders: item.method === "POST" ? ["content-type", "x-canvas-agent-token"] : ["x-canvas-agent-token"], + methods: uniqueStrings([...(previous?.methods || []), item.method]), + headers: uniqueStrings([...(previous?.headers || []), ...protectedCorsHeaders(item.method, item.lastEventId)]), + ...(previous?.publicInfo ? { publicInfo: true } : {}), + ...(previous?.trustedOrigin || item.legacy || !previous ? { trustedOrigin: true } : {}), + ...(previous?.legacyOrigins || item.legacy ? { + legacyOrigins: previous?.legacyOrigins || legacyOrigins, + legacyHeaders: uniqueStrings([...(previous?.legacyHeaders || []), ...legacyHeaders]), } : {}), }); } @@ -234,6 +239,10 @@ function corsPolicies(modules: readonly LocalRuntimeModule[], legacyOrigins: rea return policies; } +function uniqueStrings(values: readonly string[]) { + return [...new Set(values)]; +} + function route(handler: (req: Request, res: Response) => void | Promise): RequestHandler { return (req, res, next) => void Promise.resolve(handler(req, res)).catch(next); } diff --git a/canvas-agent/src/modules/portrait-clearance-http.ts b/canvas-agent/src/modules/portrait-clearance-http.ts new file mode 100644 index 000000000..bc39b9a29 --- /dev/null +++ b/canvas-agent/src/modules/portrait-clearance-http.ts @@ -0,0 +1,319 @@ +import path from "node:path"; + +import type { Request, RequestHandler, Response } from "express"; +import { ZodError } from "zod"; + +import { CONFIG_DIR } from "../config.js"; +import type { LocalRuntimeModule } from "../local-runtime.js"; +import { createPortraitClearanceTaskRequestSchema, PORTRAIT_ERROR_MESSAGES } from "../portrait-clearance/contracts.js"; +import { PortraitFaceEngine } from "../portrait-clearance/face-engine.js"; +import { detectPortraitBrowser } from "../portrait-clearance/baidu-search.js"; +import { installPortraitModels, portraitModelStatus } from "../portrait-clearance/model-store.js"; +import { PortraitTaskRunner } from "../portrait-clearance/task-runner.js"; +import { PortraitTaskStore, PortraitTaskStoreError } from "../portrait-clearance/task-store.js"; + +export type PortraitClearanceHttpModuleOptions = { + ownerId: string; + configDir?: string; + fetch?: typeof fetch; +}; + +export function createPortraitClearanceHttpModule(options: PortraitClearanceHttpModuleOptions = { ownerId: "runtime-owner", configDir: CONFIG_DIR }): LocalRuntimeModule { + const configDir = options.configDir ?? CONFIG_DIR; + const moduleRoot = path.join(configDir, "portrait-clearance"); + const store = new PortraitTaskStore(moduleRoot, options.ownerId); + const faceEngine = new PortraitFaceEngine({ modelRoot: moduleRoot }); + const runner = new PortraitTaskRunner(store, faceEngine); + + const withOwner = (response: Response) => { + const session = response.locals.runtimeSession as { keyId?: unknown; origin?: unknown } | undefined; + if (typeof session?.keyId !== "string" || typeof session.origin !== "string") throw new Error("portrait_runtime_session_missing"); + return { keyId: session.keyId, origin: session.origin }; + }; + const status = async (owner: { keyId: string; origin: string }) => { + const models = await portraitModelStatus(moduleRoot); + const { root: _root, ...publicModels } = models; + const browser = await detectPortraitBrowser(); + return { models: publicModels, browser: { available: browser.available, ...(browser.browserName ? { browserName: browser.browserName } : {}), ...(browser.available ? {} : { reason: "未找到系统 Chrome、Edge 或 Chromium" }) }, tasks: await store.counts(owner) }; + }; + + const module: LocalRuntimeModule = { + descriptor: { + id: "portrait-clearance", + displayName: "肖像可识别性本机引擎", + apiVersion: 1, + scopes: ["portrait:status", "portrait:model", "portrait:run", "portrait:read"], + }, + routes: [ + { + method: "GET", + path: "/portrait-clearance/status", + scope: "portrait:status", + handler: asyncRoute(async (_request, response) => { + const current = await status(withOwner(response)); + response.json({ ok: true, module: "portrait-clearance", apiVersion: 1, ready: current.models.ready, ...current }); + }), + }, + { + method: "POST", + path: "/portrait-clearance/model/install", + scope: "portrait:model", + handler: asyncRoute(async (_request, response) => { + assertEmptyBody(_request); + const result = await installPortraitModels(moduleRoot, { fetch: options.fetch }); + response.json({ ok: true, result: { ...result, root: undefined } }); + }), + }, + { + method: "POST", + path: "/portrait-clearance/tasks", + scope: "portrait:run", + handler: asyncRoute(async (request, response) => { + const body = parseBody(request); + const parsed = createPortraitClearanceTaskRequestSchema.safeParse(body); + if (!parsed.success) return sendError(response, "portrait_input_invalid", 400); + const owner = withOwner(response); + const created = await store.create(parsed.data, owner); + runner.start(created.record); + response.status(created.created ? 201 : 200).json({ ok: true, task: publicTask(created.record) }); + }), + }, + { + method: "GET", + path: "/portrait-clearance/tasks", + scope: "portrait:read", + queryKeys: ["projectId", "nodeId", "ownerScopeHash", "limit", "cursor"], + handler: asyncRoute(async (request, response) => { + const owner = withOwner(response); + const query = request.query as Record; + const limit = parseLimit(query.limit); + const result = await store.list({ limit, ...optionalQuery(query, "projectId"), ...optionalQuery(query, "nodeId"), ...optionalQuery(query, "ownerScopeHash"), ...optionalQuery(query, "cursor") }, owner); + for (const task of result.tasks) if (task.status === "queued" || task.status === "running") { + const record = await store.get(task.taskId, owner); + runner.start(record); + } + response.json({ ok: true, ...result }); + }), + }, + { + method: "GET", + path: "/portrait-clearance/tasks/:taskId", + scope: "portrait:read", + handler: asyncRoute(async (request, response) => { + const task = await store.get(requiredParam(request, "taskId"), withOwner(response)); + response.json({ ok: true, task: publicTask(task) }); + }), + }, + { + method: "GET", + path: "/portrait-clearance/tasks/:taskId/events", + scope: "portrait:read", + lastEventId: true, + handler: asyncRoute(async (request, response) => { + const owner = withOwner(response); + const taskId = requiredParam(request, "taskId"); + response.status(200).setHeader("content-type", "text/event-stream").setHeader("cache-control", "no-store").setHeader("connection", "keep-alive"); + let cursor = request.headers["last-event-id"] as string | undefined; + let closed = false; + request.on("close", () => { closed = true; }); + const deadline = Date.now() + 60_000; + while (!closed && Date.now() < deadline) { + const events = await store.events(taskId, owner, cursor); + for (const event of events) { + response.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + cursor = event.id; + } + const current = await store.get(taskId, owner); + if (current.status === "completed" || current.status === "partial" || current.status === "failed" || current.status === "cancelled") break; + await delay(500); + } + if (!closed) response.end(); + }), + }, + { + method: "POST", + path: "/portrait-clearance/tasks/:taskId/cancel", + scope: "portrait:run", + handler: asyncRoute(async (request, response) => { + assertEmptyBody(request); + const task = await store.requestCancel(requiredParam(request, "taskId"), withOwner(response)); + response.json({ ok: true, task: publicTask(task) }); + }), + }, + { + method: "POST", + path: "/portrait-clearance/tasks/:taskId/retry", + scope: "portrait:run", + handler: asyncRoute(async (request, response) => { + assertEmptyBody(request); + const owner = withOwner(response); + const current = await store.get(requiredParam(request, "taskId"), owner); + if (current.status !== "failed" && current.status !== "cancelled") return sendError(response, "portrait_retry_not_allowed", 409); + const task = await store.prepareRetry(current.taskId, owner); + runner.start(task); + response.json({ ok: true, task: publicTask(task) }); + }), + }, + { + method: "POST", + path: "/portrait-clearance/tasks/:taskId/delete", + scope: "portrait:run", + handler: asyncRoute(async (request, response) => { + assertEmptyBody(request); + const result = await store.delete(requiredParam(request, "taskId"), withOwner(response)); + response.json({ ok: true, result }); + }), + }, + { + method: "POST", + path: "/portrait-clearance/tasks/:taskId/model-jobs/claim", + scope: "portrait:run", + handler: asyncRoute(async (request, response) => { + assertEmptyBody(request); + const taskId = requiredParam(request, "taskId"); + const owner = withOwner(response); + const task = await store.get(taskId, owner); + if (task.status !== "waiting_model") { + response.json({ ok: true, job: null }); + return; + } + await store.ensureModelJobs(taskId, owner); + const job = await store.claimModelJob(taskId, owner); + response.json({ ok: true, job: job ? publicJob(job) : null }); + }), + }, + { + method: "POST", + path: "/portrait-clearance/tasks/:taskId/model-jobs/:jobId/complete", + scope: "portrait:run", + handler: asyncRoute(async (request, response) => { + const result = await store.completeModelJob(requiredParam(request, "taskId"), withOwner(response), requiredParam(request, "jobId"), parseBody(request)); + response.json({ ok: true, task: publicTask(result.record), job: publicJob(result.job) }); + }), + }, + { + method: "POST", + path: "/portrait-clearance/tasks/:taskId/model-jobs/:jobId/fail", + scope: "portrait:run", + handler: asyncRoute(async (request, response) => { + const result = await store.failModelJob(requiredParam(request, "taskId"), withOwner(response), requiredParam(request, "jobId"), parseBody(request)); + response.json({ ok: true, task: publicTask(result.record), job: publicJob(result.job) }); + }), + }, + { + method: "GET", + path: "/portrait-clearance/tasks/:taskId/images/:imageId", + scope: "portrait:read", + handler: asyncRoute(async (request, response) => { + const taskId = requiredParam(request, "taskId"); + const imageId = requiredParam(request, "imageId"); + const file = await store.readImageArtifact(taskId, withOwner(response), imageId); + response.type(file.mimeType).send(file.bytes); + }), + }, + { + method: "GET", + path: "/portrait-clearance/tasks/:taskId/artifacts/:artifactId", + scope: "portrait:read", + handler: asyncRoute(async (request, response) => { + const task = await store.get(requiredParam(request, "taskId"), withOwner(response)); + const artifactId = requiredParam(request, "artifactId"); + if (artifactId === "clearance-result.json") { + if (!task.resultRelativePath) return sendError(response, "portrait_artifact_not_found", 404); + const result = await store.readResult(task.taskId, withOwner(response)); + if (!result) return sendError(response, "portrait_artifact_not_found", 404); + response.type("application/json").send(JSON.stringify(result)); + return; + } + if (!["clearance-report.md", "clearance-report.html", "clearance-report.docx"].includes(artifactId)) return sendError(response, "portrait_artifact_not_found", 404); + const report = await store.readReport(task.taskId, withOwner(response), artifactId); + response.type(report.mimeType).send(report.bytes); + }), + }, + ], + start: async () => { + for (const record of await store.recoverableRecords()) runner.start(record); + }, + publicHealth: () => ({ portraitClearance: "available" }), + }; + return module; +} + +function asyncRoute(action: (request: Request, response: Response) => Promise): RequestHandler { + return (request, response, next) => { + void action(request, response).catch((error) => { + if (response.headersSent) return next(error); + if (error instanceof PortraitTaskStoreError) return sendError(response, error.code, error.status); + if (error instanceof ZodError) return sendError(response, "portrait_input_invalid", 400); + return sendError(response, "portrait_runtime_unavailable", 503); + }); + }; +} + +function parseBody(request: Request) { + if (!Buffer.isBuffer(request.body) || request.body.length === 0) return {}; + try { + const value = JSON.parse(request.body.toString("utf8")) as unknown; + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; + } catch { + return {}; + } +} + +function assertEmptyBody(request: Request) { + const body = parseBody(request); + if (Object.keys(body).length) throw new PortraitTaskStoreError("portrait_input_invalid", "请求字段无效", 400); +} + +function requiredParam(request: Request, name: string) { + const value = request.params[name]; + if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,160}$/.test(value)) throw new PortraitTaskStoreError("portrait_task_not_found", "任务不存在", 404); + return value; +} + +function parseLimit(value: unknown) { + if (value === undefined) return 30; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 60) throw new PortraitTaskStoreError("portrait_input_invalid", "分页参数无效", 400); + return parsed; +} + +function optionalQuery(query: Record, key: string) { + const value = query[key]; + return typeof value === "string" && value ? { [key]: value } : {}; +} + +function publicTask(task: { taskId: string; projectId: string; nodeId: string; mode: string; analysisMode: string; modelRef?: string; status: string; stage: string; progress: number; processedCandidates: number; totalCandidates?: number; errorCode?: string; errorMessage?: string; createdAt: string; updatedAt: string; completedAt?: string; detailsAvailable: boolean }) { + return { + taskId: task.taskId, + projectId: task.projectId, + nodeId: task.nodeId, + mode: task.mode, + analysisMode: task.analysisMode, + ...(task.modelRef ? { modelRef: task.modelRef } : {}), + status: task.status, + stage: task.stage, + progress: task.progress, + processedCandidates: task.processedCandidates, + ...(task.totalCandidates === undefined ? {} : { totalCandidates: task.totalCandidates }), + ...(task.errorCode ? { errorCode: task.errorCode } : {}), + ...(task.errorMessage ? { errorMessage: task.errorMessage } : {}), + createdAt: task.createdAt, + updatedAt: task.updatedAt, + ...(task.completedAt ? { completedAt: task.completedAt } : {}), + detailsAvailable: task.detailsAvailable, + }; +} + +function publicJob(job: { jobId: string; taskId: string; pairId: string; queryImageId: string; comparisonImageId: string; status: string; attempt: number; leaseToken?: string; leaseExpiresAt?: string; errorCode?: string; errorMessage?: string }) { + const { leaseToken, leaseExpiresAt, ...safe } = job; + return { ...safe, ...(leaseToken ? { leaseToken } : {}), ...(leaseExpiresAt ? { leaseExpiresAt } : {}) }; +} + +function sendError(response: Response, code: string, status: number) { + response.status(status).json({ ok: false, code, message: PORTRAIT_ERROR_MESSAGES[code] || "肖像排查本机任务失败" }); +} + +function delay(milliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/canvas-agent/src/portrait-clearance/baidu-search.ts b/canvas-agent/src/portrait-clearance/baidu-search.ts new file mode 100644 index 000000000..dcd300a0b --- /dev/null +++ b/canvas-agent/src/portrait-clearance/baidu-search.ts @@ -0,0 +1,76 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { chromium } from "playwright-core"; + +export type PortraitSearchCandidate = { originalRank: number; title: string; imageUrl: string; sourcePageUrl?: string; sourceDomain?: string }; + +export class PortraitBaiduSearchError extends Error { + constructor(readonly code: "portrait_search_captcha" | "portrait_search_layout_changed" | "portrait_search_browser_unavailable", message: string) { + super(message); + this.name = "PortraitBaiduSearchError"; + } +} + +export type PortraitBrowserStatus = { available: boolean; executablePath?: string; browserName?: string }; + +export async function detectPortraitBrowser(): Promise { + for (const candidate of browserCandidates()) { + try { await fs.access(candidate); return { available: true, executablePath: candidate, browserName: path.basename(candidate) }; } catch { /* try next */ } + } + return { available: false }; +} + +export async function searchBaiduByImage(queryPath: string, options: { maxCandidates: number; scrolls: number; visible: boolean; signal?: AbortSignal } ): Promise { + const browser = await detectPortraitBrowser(); + if (!browser.available || !browser.executablePath) throw new PortraitBaiduSearchError("portrait_search_browser_unavailable", "未找到系统 Chrome、Edge 或 Chromium"); + const context = await chromium.launch({ executablePath: browser.executablePath, headless: !options.visible }); + try { + const page = await context.newPage({ acceptDownloads: false }); + await page.goto("https://graph.baidu.com/pcpage/index?tpl_from=pc", { waitUntil: "domcontentloaded", timeout: 30_000 }); + if (options.signal?.aborted) throw abortError(); + const upload = page.locator('input[type="file"]').first(); + if (await upload.count() === 0) throw new PortraitBaiduSearchError("portrait_search_layout_changed", "百度识图上传入口发生变化"); + await upload.setInputFiles(queryPath); + await page.waitForTimeout(2_000); + for (let index = 0; index < options.scrolls; index += 1) { + if (options.signal?.aborted) throw abortError(); + await page.mouse.wheel(0, 1_500); + await page.waitForTimeout(600); + } + const candidates = await page.locator("img").evaluateAll((images) => images.map((image) => { + const element = image as HTMLImageElement; + const parent = element.closest("a"); + return { title: element.alt || element.title || "百度识图候选", imageUrl: element.currentSrc || element.src, sourcePageUrl: parent?.href || undefined }; + }).filter((item) => item.imageUrl && /^https?:/i.test(item.imageUrl))); + if (!candidates.length) { + const text = await page.locator("body").innerText().catch(() => ""); + if (/验证码|安全验证|异常流量/i.test(text)) throw new PortraitBaiduSearchError("portrait_search_captcha", "百度识图需要验证码或安全验证"); + throw new PortraitBaiduSearchError("portrait_search_layout_changed", "未能从百度识图结果页提取候选图片"); + } + return candidates.slice(0, options.maxCandidates).map((candidate, index) => ({ ...candidate, originalRank: index + 1, ...(candidate.sourcePageUrl ? { sourceDomain: safeDomain(candidate.sourcePageUrl) } : {}) })); + } finally { + await context.close(); + } +} + +function browserCandidates() { + const programFiles = process.env.ProgramFiles || "C:\\Program Files"; + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + return [ + path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"), + path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), + path.join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"), + path.join(programFiles, "Chromium", "Application", "chrome.exe"), + ]; +} + +function safeDomain(value: string) { + try { return new URL(value).hostname; } catch { return undefined; } +} + +function abortError() { + return Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); +} diff --git a/canvas-agent/src/portrait-clearance/contracts.ts b/canvas-agent/src/portrait-clearance/contracts.ts new file mode 100644 index 000000000..c55d22167 --- /dev/null +++ b/canvas-agent/src/portrait-clearance/contracts.ts @@ -0,0 +1,272 @@ +import { z } from "zod"; + +export const PORTRAIT_CLEARANCE_SCHEMA_VERSION = 1 as const; +export const PORTRAIT_CLEARANCE_MODULE_ID = "portrait-clearance" as const; + +export const portraitRiskLevelSchema = z.enum(["high", "medium", "low_to_medium", "low", "unable_to_determine"]); +export type PortraitRiskLevel = z.infer; + +export const portraitClearanceModeSchema = z.enum(["direct-compare", "network-search"]); +export type PortraitClearanceMode = z.infer; + +export const portraitClearanceAnalysisModeSchema = z.enum(["local-plus-vision", "local-only"]); +export type PortraitClearanceAnalysisMode = z.infer; + +export const portraitClearanceDedupModeSchema = z.enum(["phash", "arcface"]); +export type PortraitClearanceDedupMode = z.infer; + +export const portraitClearanceInputRoleSchema = z.enum(["query", "reference", "candidate"]); +export type PortraitClearanceInputRole = z.infer; + +export const portraitClearanceTaskStatusSchema = z.enum(["queued", "running", "waiting_model", "partial", "completed", "failed", "cancelled"]); +export type PortraitClearanceTaskStatus = z.infer; + +export const portraitClearanceTaskStageSchema = z.enum([ + "validating-inputs", + "checking-model-resources", + "preparing-query", + "searching", + "downloading-candidates", + "deduplicating", + "local-comparing", + "waiting-for-model", + "model-comparing", + "building-report", + "done", +]); +export type PortraitClearanceTaskStage = z.infer; + +export const portraitClearanceSettingsSchema = z.object({ + maxCandidates: z.number().int().min(1).max(60), + searchScrolls: z.number().int().min(0).max(20), + dedupMode: portraitClearanceDedupModeSchema, + modelConcurrency: z.number().int().min(1).max(10), + showBrowserForDebug: z.boolean(), +}).strict(); +export type PortraitClearanceSettings = z.infer; + +export const portraitClearanceInputSchema = z.object({ + nodeId: z.string().min(1).max(160), + role: portraitClearanceInputRoleSchema, + fileName: z.string().min(1).max(240), + mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]), + dataUrl: z.string().regex(/^data:image\/(?:jpeg|png|webp);base64,[A-Za-z0-9+/=_-]+$/), +}).strict(); +export type PortraitClearanceInput = z.infer; + +export const createPortraitClearanceTaskRequestSchema = z.object({ + schemaVersion: z.literal(PORTRAIT_CLEARANCE_SCHEMA_VERSION), + clientOperationId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$/), + ownerScopeHash: z.string().regex(/^[a-f0-9]{64}$/), + projectId: z.string().min(1).max(160), + nodeId: z.string().min(1).max(160), + mode: portraitClearanceModeSchema, + analysisMode: portraitClearanceAnalysisModeSchema, + modelRef: z.string().min(1).max(240).optional(), + inputs: z.array(portraitClearanceInputSchema).min(1).max(61), + settings: portraitClearanceSettingsSchema, +}).strict(); +export type CreatePortraitClearanceTaskRequest = z.infer; + +export const portraitImageQualitySchema = z.object({ + width: z.number().int().positive(), + height: z.number().int().positive(), + sharpness: z.number().finite().nonnegative(), + brightness: z.number().finite().min(0).max(255), + contrast: z.number().finite().nonnegative(), + grade: z.enum(["good", "usable", "poor"]), +}).strict(); +export type PortraitImageQuality = z.infer; + +export const portraitFaceSchema = z.object({ + bbox: z.tuple([z.number().finite(), z.number().finite(), z.number().finite(), z.number().finite()]), + detScore: z.number().finite().min(0).max(1), + areaRatio: z.number().finite().min(0).max(1), +}).strict(); + +export const portraitLocalPrecheckSchema = z.object({ + qualityA: portraitImageQualitySchema, + qualityB: portraitImageQualitySchema, + facesA: z.number().int().nonnegative(), + facesB: z.number().int().nonnegative(), + selectedFaceA: portraitFaceSchema.optional(), + selectedFaceB: portraitFaceSchema.optional(), + faceSimilarity: z.number().finite().min(-1).max(1).optional(), + ssim: z.number().finite().min(-1).max(1), + colorHistogramCorrelation: z.number().finite().min(-1).max(1), + canExtractEmbedding: z.boolean(), + reliabilityIssues: z.array(z.string().min(1).max(500)).max(32), +}).strict(); +export type PortraitLocalPrecheck = z.infer; + +export const portraitFeatureKeySchema = z.enum(["face_shape", "facial_layout", "eyes_brows", "nose_mouth", "hair_hairline", "distinctive_features"]); +export type PortraitFeatureKey = z.infer; + +const portraitFeatureComparisonSchema = z.object({ + similarity: z.enum(["high", "medium", "low", "none"]), + note: z.string().max(1_000), +}).strict(); + +export const portraitVisionComparisonSchema = z.object({ + imageAType: z.enum(["realistic", "stylized"]), + imageBType: z.enum(["realistic", "stylized"]), + analysisPath: z.enum(["A", "B"]), + status: z.enum(["success", "unable_to_determine"]), + riskLevel: portraitRiskLevelSchema, + overallSimilarity: z.number().finite().min(0).max(1), + featureComparison: z.object({ + face_shape: portraitFeatureComparisonSchema, + facial_layout: portraitFeatureComparisonSchema, + eyes_brows: portraitFeatureComparisonSchema, + nose_mouth: portraitFeatureComparisonSchema, + hair_hairline: portraitFeatureComparisonSchema, + distinctive_features: portraitFeatureComparisonSchema, + }).strict(), + basis: z.array(z.string().min(1).max(1_000)).max(16), + limitations: z.array(z.string().min(1).max(1_000)).max(16), + modificationSuggestions: z.array(z.string().min(1).max(1_000)).max(16), + insightfaceFusionNote: z.string().max(1_000), + manualReviewRecommended: z.boolean(), +}).strict(); +export type PortraitVisionComparison = z.infer; + +export const portraitPairResultSchema = z.object({ + id: z.string().min(1).max(160), + queryImageId: z.string().min(1).max(160), + comparisonImageId: z.string().min(1).max(160), + candidateId: z.string().min(1).max(160).optional(), + source: z.enum(["connected-reference", "connected-candidate", "baidu"]), + status: z.enum(["success", "partial", "failed"]), + riskLevel: portraitRiskLevelSchema, + overallSimilarity: z.number().finite().min(0).max(1).optional(), + analysisPath: z.enum(["A", "B", "unable"]), + localPrecheck: portraitLocalPrecheckSchema, + visionComparison: portraitVisionComparisonSchema.optional(), + basis: z.array(z.string().min(1).max(1_000)).max(32), + limitations: z.array(z.string().min(1).max(1_000)).max(32), + error: z.object({ code: z.string().min(1).max(120), message: z.string().min(1).max(1_000), retryable: z.boolean() }).strict().optional(), +}).strict(); +export type PortraitPairResult = z.infer; + +export const portraitCandidateSchema = z.object({ + id: z.string().min(1).max(160), + originalRank: z.number().int().nonnegative(), + title: z.string().max(500), + imageArtifactId: z.string().min(1).max(160), + imageUrl: z.string().url().max(4_000).optional(), + sourcePageUrl: z.string().url().max(4_000).optional(), + sourceDomain: z.string().max(255).optional(), + source: z.enum(["connected", "baidu"]), + byteSize: z.number().int().nonnegative(), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + dedupGroupId: z.string().min(1).max(160).optional(), + resultId: z.string().min(1).max(160).optional(), +}).strict(); +export type PortraitCandidate = z.infer; + +export const portraitClearanceResultSchema = z.object({ + schemaVersion: z.literal(PORTRAIT_CLEARANCE_SCHEMA_VERSION), + taskId: z.string().min(1).max(160), + mode: portraitClearanceModeSchema, + queryImageId: z.string().min(1).max(160), + highestRisk: portraitRiskLevelSchema, + riskCounts: z.record(portraitRiskLevelSchema, z.number().int().nonnegative()), + candidateCount: z.number().int().nonnegative(), + comparedCount: z.number().int().nonnegative(), + candidates: z.array(portraitCandidateSchema).max(60), + pairs: z.array(portraitPairResultSchema).max(60), + limitations: z.array(z.string().min(1).max(1_000)).max(32), + createdAt: z.string().datetime(), + completedAt: z.string().datetime().optional(), +}).strict(); +export type PortraitClearanceResult = z.infer; + +export const portraitModelJobStatusSchema = z.enum(["pending", "leased", "completed", "failed"]); +export type PortraitModelJobStatus = z.infer; + +export const portraitModelJobSchema = z.object({ + jobId: z.string().regex(/^portrait-job-[a-f0-9]{16,80}$/), + taskId: z.string().min(1).max(160), + pairId: z.string().min(1).max(160), + queryImageId: z.string().min(1).max(160), + comparisonImageId: z.string().min(1).max(160), + status: portraitModelJobStatusSchema, + attempt: z.number().int().nonnegative(), + leaseToken: z.string().regex(/^[A-Za-z0-9_-]{16,160}$/).optional(), + leaseExpiresAt: z.string().datetime().optional(), + errorCode: z.string().min(1).max(120).optional(), + errorMessage: z.string().min(1).max(1_000).optional(), +}).strict(); +export type PortraitModelJob = z.infer; + +export const portraitModelJobCompleteRequestSchema = z.object({ + attempt: z.number().int().nonnegative(), + leaseToken: z.string().regex(/^[A-Za-z0-9_-]{16,160}$/), + visionComparison: portraitVisionComparisonSchema, +}).strict(); +export type PortraitModelJobCompleteRequest = z.infer; + +export const portraitModelJobFailRequestSchema = z.object({ + attempt: z.number().int().nonnegative(), + leaseToken: z.string().regex(/^[A-Za-z0-9_-]{16,160}$/), + errorCode: z.string().min(1).max(120), + errorMessage: z.string().min(1).max(1_000), + retryable: z.boolean(), +}).strict(); +export type PortraitModelJobFailRequest = z.infer; + +export const portraitTaskSummarySchema = z.object({ + taskId: z.string().min(1).max(160), + clientOperationId: z.string().min(1).max(160), + ownerScopeHash: z.string().regex(/^[a-f0-9]{64}$/), + projectId: z.string().min(1).max(160), + nodeId: z.string().min(1).max(160), + mode: portraitClearanceModeSchema, + analysisMode: portraitClearanceAnalysisModeSchema, + modelRef: z.string().min(1).max(240).optional(), + status: portraitClearanceTaskStatusSchema, + stage: portraitClearanceTaskStageSchema, + progress: z.number().finite().min(0).max(1), + processedCandidates: z.number().int().nonnegative(), + totalCandidates: z.number().int().nonnegative().optional(), + errorCode: z.string().min(1).max(120).optional(), + errorMessage: z.string().min(1).max(1_000).optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().optional(), + detailsAvailable: z.boolean(), +}).strict(); +export type PortraitTaskSummary = z.infer; + +export const PORTRAIT_ERROR_MESSAGES: Record = { + portrait_plugin_disabled: "请先在插件中心启用肖像排查", + portrait_runtime_unavailable: "肖像排查本机引擎不可用", + portrait_module_unavailable: "当前 Canvas Agent 不包含肖像排查模块", + portrait_model_missing: "请先安装并校验本地肖像模型", + portrait_input_missing: "请连接所需的图片节点", + portrait_input_invalid: "输入图片损坏、过大或格式不受支持", + portrait_multiple_faces: "图片包含多张人脸,请先裁剪到单一目标后重试", + portrait_search_captcha: "搜索服务需要验证码,请使用可见浏览器重试或连接手动候选", + portrait_candidate_download_blocked: "候选图片下载被安全策略阻止", + portrait_task_cancelled: "任务已停止,已完成阶段仍可查看", + portrait_model_job_not_found: "视觉模型作业不存在", + portrait_model_job_conflict: "视觉模型作业已失效,请重新打开任务", + portrait_artifact_not_found: "排查报告不存在", +}; + +export function riskFromFaceSimilarity(similarity: number | undefined): PortraitRiskLevel { + if (similarity === undefined || !Number.isFinite(similarity)) return "unable_to_determine"; + if (similarity >= 0.65) return "high"; + if (similarity >= 0.5) return "medium"; + if (similarity >= 0.35) return "low_to_medium"; + return "low"; +} + +export function riskScore(level: PortraitRiskLevel) { + return ({ high: 1, medium: 0.66, low_to_medium: 0.5, low: 0.2, unable_to_determine: 0 } as const)[level]; +} + +export function highestRisk(levels: readonly PortraitRiskLevel[]): PortraitRiskLevel { + return levels.reduce((highest, current) => riskScore(current) > riskScore(highest) ? current : highest, "unable_to_determine"); +} diff --git a/canvas-agent/src/portrait-clearance/dedup.ts b/canvas-agent/src/portrait-clearance/dedup.ts new file mode 100644 index 000000000..a8084d427 --- /dev/null +++ b/canvas-agent/src/portrait-clearance/dedup.ts @@ -0,0 +1,94 @@ +import { cosineSimilarity, hammingDistance } from "./image-metrics.js"; +import type { PortraitClearanceDedupMode } from "./contracts.js"; + +export type PortraitDedupCandidate = { + id: string; + byteHash: string; + phash: string; + byteSize: number; + pixelArea: number; + embedding?: ArrayLike; +}; + +export type PortraitDedupGroup = { + id: string; + keptId: string; + removedIds: string[]; + reason: "byte-hash" | "phash" | "phash-and-arcface"; +}; + +export type PortraitDedupResult = { + kept: PortraitDedupCandidate[]; + groups: PortraitDedupGroup[]; + inputCount: number; + outputCount: number; + byteDeduplicatedCount: number; + visualDeduplicatedCount: number; + phashDistance: number; + arcfaceThreshold?: number; +}; + +export function deduplicatePortraitCandidates(candidates: readonly PortraitDedupCandidate[], mode: PortraitClearanceDedupMode): PortraitDedupResult { + const groups: PortraitDedupGroup[] = []; + const removed = new Set(); + const kept = new Map(); + const hashOwner = new Map(); + let byteDeduplicatedCount = 0; + let visualDeduplicatedCount = 0; + + for (const candidate of candidates) { + const previous = hashOwner.get(candidate.byteHash); + if (previous) { + const winner = preferredCandidate(previous, candidate); + const loser = winner.id === previous.id ? candidate : previous; + kept.set(winner.id, winner); + removed.add(loser.id); + hashOwner.set(candidate.byteHash, winner); + byteDeduplicatedCount += 1; + addGroup(groups, winner, loser, "byte-hash"); + continue; + } + hashOwner.set(candidate.byteHash, candidate); + kept.set(candidate.id, candidate); + } + + const active = [...kept.values()].filter((candidate) => !removed.has(candidate.id)); + for (let index = 0; index < active.length; index += 1) { + const left = active[index]!; + if (removed.has(left.id)) continue; + for (let nextIndex = index + 1; nextIndex < active.length; nextIndex += 1) { + const right = active[nextIndex]!; + if (removed.has(right.id) || hammingDistance(left.phash, right.phash) > 5) continue; + const similarity = left.embedding && right.embedding ? cosineSimilarity(left.embedding, right.embedding) : undefined; + if (mode === "arcface" && similarity !== undefined && similarity < 0.9) continue; + const winner = preferredCandidate(left, right); + const loser = winner.id === left.id ? right : left; + removed.add(loser.id); + kept.set(winner.id, winner); + visualDeduplicatedCount += 1; + addGroup(groups, winner, loser, mode === "arcface" && similarity !== undefined ? "phash-and-arcface" : "phash"); + } + } + + const output = candidates.filter((candidate) => !removed.has(candidate.id)); + return { + kept: output, + groups, + inputCount: candidates.length, + outputCount: output.length, + byteDeduplicatedCount, + visualDeduplicatedCount, + phashDistance: 5, + ...(mode === "arcface" ? { arcfaceThreshold: 0.9 } : {}), + }; +} + +function preferredCandidate(left: PortraitDedupCandidate, right: PortraitDedupCandidate) { + return left.pixelArea > right.pixelArea || (left.pixelArea === right.pixelArea && left.byteSize >= right.byteSize) ? left : right; +} + +function addGroup(groups: PortraitDedupGroup[], kept: PortraitDedupCandidate, removed: PortraitDedupCandidate, reason: PortraitDedupGroup["reason"]) { + const existing = groups.find((group) => group.keptId === kept.id && group.reason === reason); + if (existing) existing.removedIds.push(removed.id); + else groups.push({ id: `dedup-${groups.length + 1}`, keptId: kept.id, removedIds: [removed.id], reason }); +} diff --git a/canvas-agent/src/portrait-clearance/face-engine.ts b/canvas-agent/src/portrait-clearance/face-engine.ts new file mode 100644 index 000000000..4be6e09a6 --- /dev/null +++ b/canvas-agent/src/portrait-clearance/face-engine.ts @@ -0,0 +1,299 @@ +import fs from "node:fs/promises"; + +import * as ort from "onnxruntime-node"; + +import type { DecodedPortraitImage } from "./image-metrics.js"; +import { cosineSimilarity } from "./image-metrics.js"; +import { portraitModelDirectory, portraitModelStatus } from "./model-store.js"; + +export type PortraitFace = { + bbox: [number, number, number, number]; + detScore: number; + keypoints: Array<[number, number]>; + areaRatio: number; + embedding?: Float32Array; +}; + +export type PortraitFaceAnalysis = { + faces: PortraitFace[]; + selectedFace?: PortraitFace; + embedding?: Float32Array; +}; + +export class PortraitFaceEngineError extends Error { + constructor(readonly code: "portrait_model_missing" | "portrait_face_engine_failed", message: string) { + super(message); + this.name = "PortraitFaceEngineError"; + } +} + +type FaceEngineOptions = { + modelRoot: string; + detectorSize?: number; + threshold?: number; +}; + +type DetectorSession = { session: ort.InferenceSession; inputName: string }; +type EmbeddingSession = { session: ort.InferenceSession; inputName: string }; + +export class PortraitFaceEngine { + private detector?: Promise; + private embedding?: Promise; + private readonly detectorSize: number; + private readonly threshold: number; + + constructor(private readonly options: FaceEngineOptions) { + this.detectorSize = options.detectorSize ?? 640; + // Keep low-confidence detections available for crop/search and let the + // precheck mark them unreliable instead of discarding them before the + // report can explain why the result is inconclusive. + this.threshold = options.threshold ?? 0.4; + } + + async status() { + return portraitModelStatus(this.options.modelRoot); + } + + async analyze(image: DecodedPortraitImage): Promise { + const models = await this.status(); + if (!models.ready) throw new PortraitFaceEngineError("portrait_model_missing", "本地肖像 ONNX 模型尚未安装"); + try { + const faces = await this.detect(image); + for (const face of faces) face.embedding = await this.embed(image, face.keypoints); + const selectedFace = [...faces].sort((left, right) => right.detScore - left.detScore || right.areaRatio - left.areaRatio)[0]; + return { faces, selectedFace, embedding: selectedFace?.embedding }; + } catch (error) { + if (error instanceof PortraitFaceEngineError) throw error; + throw new PortraitFaceEngineError("portrait_face_engine_failed", "本地人脸模型执行失败"); + } + } + + async compare(left: DecodedPortraitImage, right: DecodedPortraitImage) { + const [a, b] = await Promise.all([this.analyze(left), this.analyze(right)]); + return { left: a, right: b, similarity: a.embedding && b.embedding ? cosineSimilarity(a.embedding, b.embedding) : undefined }; + } + + private async detect(image: DecodedPortraitImage) { + const session = await this.getDetectorSession(); + const input = await detectorTensor(image, this.detectorSize); + const output = await session.session.run({ [session.inputName]: input.tensor }); + const detections: PortraitFace[] = []; + const outputs = session.session.outputNames.map((name) => output[name]); + if (outputs.length < 9) throw new Error("portrait_detector_outputs_invalid"); + for (let level = 0; level < 3; level += 1) { + const scores = numericTensorData(outputs[level]!); + const boxes = numericTensorData(outputs[level + 3]!); + const keypoints = numericTensorData(outputs[level + 6]!); + const anchors = scores.length; + const gridWidth = Math.round(Math.sqrt(anchors / 2)); + const gridHeight = gridWidth; + if (gridWidth * gridHeight * 2 !== anchors || boxes.length !== anchors * 4 || keypoints.length !== anchors * 10) throw new Error("portrait_detector_shape_invalid"); + const stride = [8, 16, 32][level]!; + for (let index = 0; index < anchors; index += 1) { + const score = normalizedScore(Number(scores[index])); + if (score < this.threshold) continue; + const cell = Math.floor(index / 2); + const x = cell % gridWidth; + const y = Math.floor(cell / gridWidth); + const centerX = x * stride; + const centerY = y * stride; + const offset = index * 4; + const left = centerX - Number(boxes[offset]) * stride; + const top = centerY - Number(boxes[offset + 1]) * stride; + const right = centerX + Number(boxes[offset + 2]) * stride; + const bottom = centerY + Number(boxes[offset + 3]) * stride; + const pointOffset = index * 10; + const points: Array<[number, number]> = []; + for (let point = 0; point < 5; point += 1) points.push([centerX + Number(keypoints[pointOffset + point * 2]) * stride, centerY + Number(keypoints[pointOffset + point * 2 + 1]) * stride]); + detections.push({ + bbox: mapBoxToSource([left, top, right, bottom], image, input.scale, input.offsetX, input.offsetY), + detScore: score, + keypoints: points.map(([pointX, pointY]) => mapPointToSource(pointX, pointY, image, input.scale, input.offsetX, input.offsetY)), + areaRatio: 0, + }); + } + } + const selected = nonMaximumSuppression(detections, 0.4); + return selected.map((face) => ({ ...face, areaRatio: areaRatio(face.bbox, image.width, image.height) })); + } + + private async embed(image: DecodedPortraitImage, keypoints: Array<[number, number]>) { + const session = await this.getEmbeddingSession(); + const aligned = alignFace(image, keypoints); + const data = new Float32Array(1 * 3 * 112 * 112); + for (let index = 0; index < 112 * 112; index += 1) { + const source = index * 3; + data[index] = (aligned[source]! - 127.5) / 127.5; + data[112 * 112 + index] = (aligned[source + 1]! - 127.5) / 127.5; + data[2 * 112 * 112 + index] = (aligned[source + 2]! - 127.5) / 127.5; + } + const output = await session.session.run({ [session.inputName]: new ort.Tensor("float32", data, [1, 3, 112, 112]) }); + const embedding = new Float32Array(numericTensorData(output[session.session.outputNames[0]!])); + let norm = 0; + for (const value of embedding) norm += value * value; + norm = Math.sqrt(norm); + if (!norm) return embedding; + for (let index = 0; index < embedding.length; index += 1) embedding[index] = embedding[index]! / norm; + return embedding; + } + + private getDetectorSession() { + this.detector ??= this.loadDetector(); + return this.detector; + } + + private getEmbeddingSession() { + this.embedding ??= this.loadEmbedding(); + return this.embedding; + } + + private async loadDetector() { + const filePath = `${portraitModelDirectory(this.options.modelRoot)}/det_10g.onnx`; + await fs.access(filePath); + const session = await ort.InferenceSession.create(filePath, { executionProviders: ["cpu"] }); + return { session, inputName: session.inputNames[0]! }; + } + + private async loadEmbedding() { + const filePath = `${portraitModelDirectory(this.options.modelRoot)}/w600k_r50.onnx`; + await fs.access(filePath); + const session = await ort.InferenceSession.create(filePath, { executionProviders: ["cpu"] }); + return { session, inputName: session.inputNames[0]! }; + } +} + +function normalizedScore(value: number) { + if (!Number.isFinite(value)) return 0; + return value >= 0 && value <= 1 ? value : 1 / (1 + Math.exp(-value)); +} + +function numericTensorData(value: unknown) { + if (!value || typeof value !== "object" || !("data" in value)) throw new Error("portrait_tensor_invalid"); + const data = (value as { data: unknown }).data; + if (!data || typeof (data as ArrayLike).length !== "number") throw new Error("portrait_tensor_invalid"); + return data as ArrayLike; +} + +async function detectorTensor(image: DecodedPortraitImage, size: number) { + const source = await import("sharp"); + const resized = await source.default(image.rgb, { raw: { width: image.width, height: image.height, channels: 3 } }) + .resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0 } }) + .raw() + .toBuffer({ resolveWithObject: true }); + const scale = Math.min(size / image.width, size / image.height); + const offsetX = (size - image.width * scale) / 2; + const offsetY = (size - image.height * scale) / 2; + const data = new Float32Array(1 * 3 * size * size); + for (let index = 0; index < size * size; index += 1) { + const sourceIndex = index * 3; + data[index] = (resized.data[sourceIndex]! - 127.5) / 128; + data[size * size + index] = (resized.data[sourceIndex + 1]! - 127.5) / 128; + data[2 * size * size + index] = (resized.data[sourceIndex + 2]! - 127.5) / 128; + } + return { tensor: new ort.Tensor("float32", data, [1, 3, size, size]), scale, offsetX, offsetY }; +} + +function mapBoxToSource(box: [number, number, number, number], image: DecodedPortraitImage, scale: number, offsetX: number, offsetY: number): [number, number, number, number] { + return [ + clamp((box[0] - offsetX) / scale, 0, image.width), + clamp((box[1] - offsetY) / scale, 0, image.height), + clamp((box[2] - offsetX) / scale, 0, image.width), + clamp((box[3] - offsetY) / scale, 0, image.height), + ]; +} + +function mapPointToSource(x: number, y: number, image: DecodedPortraitImage, scale: number, offsetX: number, offsetY: number): [number, number] { + return [clamp((x - offsetX) / scale, 0, image.width), clamp((y - offsetY) / scale, 0, image.height)]; +} + +function nonMaximumSuppression(faces: PortraitFace[], threshold: number) { + const sorted = [...faces].sort((left, right) => right.detScore - left.detScore); + const kept: PortraitFace[] = []; + while (sorted.length) { + const current = sorted.shift()!; + kept.push(current); + for (let index = sorted.length - 1; index >= 0; index -= 1) if (intersectionOverUnion(current.bbox, sorted[index]!.bbox) > threshold) sorted.splice(index, 1); + } + return kept; +} + +function intersectionOverUnion(a: PortraitFace["bbox"], b: PortraitFace["bbox"]) { + const left = Math.max(a[0], b[0]); + const top = Math.max(a[1], b[1]); + const right = Math.min(a[2], b[2]); + const bottom = Math.min(a[3], b[3]); + const intersection = Math.max(0, right - left) * Math.max(0, bottom - top); + const areaA = Math.max(0, a[2] - a[0]) * Math.max(0, a[3] - a[1]); + const areaB = Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]); + return intersection / Math.max(1, areaA + areaB - intersection); +} + +function areaRatio(box: PortraitFace["bbox"], width: number, height: number) { + return Math.max(0, box[2] - box[0]) * Math.max(0, box[3] - box[1]) / Math.max(1, width * height); +} + +function alignFace(image: DecodedPortraitImage, keypoints: Array<[number, number]>) { + const target: Array<[number, number]> = [[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]]; + const transform = similarityTransform(keypoints, target); + const aligned = new Uint8Array(112 * 112 * 3); + for (let y = 0; y < 112; y += 1) { + for (let x = 0; x < 112; x += 1) { + const sourcePoint = inverseTransform(transform, x, y); + const targetIndex = (y * 112 + x) * 3; + sampleRgb(image, sourcePoint[0], sourcePoint[1], aligned, targetIndex); + } + } + return aligned; +} + +function similarityTransform(source: Array<[number, number]>, target: Array<[number, number]>) { + const sourceMean = meanPoint(source); + const targetMean = meanPoint(target); + let alpha = 0; + let beta = 0; + let denominator = 0; + for (let index = 0; index < source.length; index += 1) { + const sx = source[index]![0] - sourceMean[0]; + const sy = source[index]![1] - sourceMean[1]; + const tx = target[index]![0] - targetMean[0]; + const ty = target[index]![1] - targetMean[1]; + alpha += sx * tx + sy * ty; + beta += sx * ty - sy * tx; + denominator += sx * sx + sy * sy; + } + alpha /= denominator || 1; + beta /= denominator || 1; + return { + alpha, + beta, + tx: targetMean[0] - alpha * sourceMean[0] + beta * sourceMean[1], + ty: targetMean[1] - beta * sourceMean[0] - alpha * sourceMean[1], + }; +} + +function inverseTransform(transform: { alpha: number; beta: number; tx: number; ty: number }, x: number, y: number): [number, number] { + const dx = x - transform.tx; + const dy = y - transform.ty; + const denominator = transform.alpha * transform.alpha + transform.beta * transform.beta || 1; + return [ + (transform.alpha * dx + transform.beta * dy) / denominator, + (-transform.beta * dx + transform.alpha * dy) / denominator, + ]; +} + +function sampleRgb(image: DecodedPortraitImage, x: number, y: number, target: Uint8Array, offset: number) { + const x0 = clamp(Math.floor(x), 0, image.width - 1); + const y0 = clamp(Math.floor(y), 0, image.height - 1); + const index = (y0 * image.width + x0) * 3; + target[offset] = image.rgb[index]!; + target[offset + 1] = image.rgb[index + 1]!; + target[offset + 2] = image.rgb[index + 2]!; +} + +function meanPoint(points: Array<[number, number]>): [number, number] { + return [points.reduce((sum, point) => sum + point[0], 0) / points.length, points.reduce((sum, point) => sum + point[1], 0) / points.length]; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} diff --git a/canvas-agent/src/portrait-clearance/image-metrics.ts b/canvas-agent/src/portrait-clearance/image-metrics.ts new file mode 100644 index 000000000..dabec49b9 --- /dev/null +++ b/canvas-agent/src/portrait-clearance/image-metrics.ts @@ -0,0 +1,270 @@ +import crypto from "node:crypto"; + +import sharp from "sharp"; + +import type { PortraitImageQuality } from "./contracts.js"; + +export type DecodedPortraitImage = { + width: number; + height: number; + channels: 3; + rgb: Uint8Array; + gray: Float32Array; + byteHash: string; + phash: string; + quality: PortraitImageQuality; +}; + +export type GrayImage = { width: number; height: number; values: ArrayLike }; + +export function cosineSimilarity(a: ArrayLike, b: ArrayLike) { + if (a.length !== b.length || a.length === 0) return 0; + let dot = 0; + let normA = 0; + let normB = 0; + for (let index = 0; index < a.length; index += 1) { + const left = Number(a[index]); + const right = Number(b[index]); + if (!Number.isFinite(left) || !Number.isFinite(right)) return 0; + dot += left * right; + normA += left * left; + normB += right * right; + } + const denominator = Math.sqrt(normA * normB); + return denominator > 0 ? dot / denominator : 0; +} + +export function imageQuality(width: number, height: number, gray: ArrayLike): PortraitImageQuality { + if (gray.length !== width * height || width < 1 || height < 1) throw new Error("portrait_gray_buffer_invalid"); + let sum = 0; + for (let index = 0; index < gray.length; index += 1) sum += Number(gray[index]); + const brightness = sum / gray.length; + let variance = 0; + for (let index = 0; index < gray.length; index += 1) { + const delta = Number(gray[index]) - brightness; + variance += delta * delta; + } + const contrast = Math.sqrt(variance / gray.length); + const sharpness = laplacianVariance(width, height, gray); + const grade = sharpness >= 120 && brightness >= 55 && brightness <= 205 && contrast >= 35 + ? "good" + : sharpness >= 50 && brightness >= 35 && brightness <= 225 && contrast >= 20 + ? "usable" + : "poor"; + return { + width, + height, + sharpness: round(sharpness), + brightness: round(brightness), + contrast: round(contrast), + grade, + }; +} + +export function laplacianVariance(width: number, height: number, gray: ArrayLike) { + if (width < 3 || height < 3) return 0; + const responses: number[] = []; + for (let y = 1; y < height - 1; y += 1) { + for (let x = 1; x < width - 1; x += 1) { + const center = Number(gray[y * width + x]); + const response = 4 * center + - Number(gray[(y - 1) * width + x]) + - Number(gray[(y + 1) * width + x]) + - Number(gray[y * width + x - 1]) + - Number(gray[y * width + x + 1]); + responses.push(response); + } + } + return varianceOf(responses); +} + +export function structuralSimilarity(a: GrayImage, b: GrayImage) { + const side = 64; + const left = resizeGray(a, side, side); + const right = resizeGray(b, side, side); + const meanA = meanOf(left); + const meanB = meanOf(right); + const varianceA = varianceOf(left, meanA); + const varianceB = varianceOf(right, meanB); + let covariance = 0; + for (let index = 0; index < left.length; index += 1) covariance += (left[index]! - meanA) * (right[index]! - meanB); + covariance /= left.length; + const c1 = 6.5025; + const c2 = 58.5225; + const value = ((2 * meanA * meanB + c1) * (2 * covariance + c2)) + / ((meanA * meanA + meanB * meanB + c1) * (varianceA + varianceB + c2)); + return clamp(round(value, 4), -1, 1); +} + +export function colorHistogramCorrelation(a: Uint8Array, b: Uint8Array) { + if (a.length % 3 !== 0 || b.length % 3 !== 0 || a.length === 0 || b.length === 0) return 0; + const left = new Float64Array(64); + const right = new Float64Array(64); + for (let index = 0; index < a.length; index += 3) { + left[colorBin(a[index]!, a[index + 1]!, a[index + 2]!) ] += 1; + right[colorBin(b[index]!, b[index + 1]!, b[index + 2]!) ] += 1; + } + return clamp(round(pearson(left, right), 4), -1, 1); +} + +export function computePHash(gray: GrayImage) { + const size = 32; + const source = resizeGray(gray, size, size); + const coefficients = new Array(64); + for (let v = 0; v < 8; v += 1) { + for (let u = 0; u < 8; u += 1) coefficients[v * 8 + u] = dctCoefficient(source, size, u, v); + } + const median = medianOf(coefficients.slice(1)); + let bits = ""; + for (const coefficient of coefficients) bits += coefficient > median ? "1" : "0"; + return bits; +} + +export function hammingDistance(left: string, right: string) { + if (left.length !== right.length) return Number.POSITIVE_INFINITY; + let distance = 0; + for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) distance += 1; + return distance; +} + +export function sha256Hex(value: Uint8Array | Buffer) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +export async function decodePortraitImage(input: Uint8Array | Buffer): Promise { + const source = Buffer.from(input); + const decoded = await sharp(source, { failOn: "error" }) + .rotate() + .flatten({ background: { r: 0, g: 0, b: 0 } }) + .toColorspace("srgb") + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const rgb = new Uint8Array(decoded.data); + const gray = rgbToGray(rgb); + const width = decoded.info.width; + const height = decoded.info.height; + return { + width, + height, + channels: 3, + rgb, + gray, + byteHash: sha256Hex(source), + phash: computePHash({ width, height, values: gray }), + quality: imageQuality(width, height, gray), + }; +} + +export async function cropPortraitSearchImage(input: Uint8Array | Buffer, image: DecodedPortraitImage, bbox?: readonly [number, number, number, number]) { + if (!bbox) return Buffer.from(input); + const [left, top, right, bottom] = bbox; + const width = Math.max(1, right - left); + const height = Math.max(1, bottom - top); + const paddingX = width * 0.35; + const paddingY = height * 0.35; + const cropLeft = clamp(Math.floor(left - paddingX), 0, image.width - 1); + const cropTop = clamp(Math.floor(top - paddingY), 0, image.height - 1); + const cropRight = clamp(Math.ceil(right + paddingX), cropLeft + 1, image.width); + const cropBottom = clamp(Math.ceil(bottom + paddingY), cropTop + 1, image.height); + return sharp(Buffer.from(image.rgb), { raw: { width: image.width, height: image.height, channels: 3 } }) + .extract({ left: cropLeft, top: cropTop, width: cropRight - cropLeft, height: cropBottom - cropTop }) + .jpeg({ quality: 92, mozjpeg: true }) + .toBuffer(); +} + +function rgbToGray(rgb: Uint8Array) { + const gray = new Float32Array(rgb.length / 3); + for (let index = 0, pixel = 0; index < rgb.length; index += 3, pixel += 1) { + gray[pixel] = 0.114 * rgb[index + 2]! + 0.587 * rgb[index + 1]! + 0.299 * rgb[index]!; + } + return gray; +} + +function resizeGray(image: GrayImage, width: number, height: number) { + const result = new Float64Array(width * height); + for (let y = 0; y < height; y += 1) { + const sourceY = ((y + 0.5) * image.height / height) - 0.5; + const y0 = clamp(Math.floor(sourceY), 0, image.height - 1); + const y1 = clamp(y0 + 1, 0, image.height - 1); + const fy = clamp(sourceY - Math.floor(sourceY), 0, 1); + for (let x = 0; x < width; x += 1) { + const sourceX = ((x + 0.5) * image.width / width) - 0.5; + const x0 = clamp(Math.floor(sourceX), 0, image.width - 1); + const x1 = clamp(x0 + 1, 0, image.width - 1); + const fx = clamp(sourceX - Math.floor(sourceX), 0, 1); + const top = Number(image.values[y0 * image.width + x0]) * (1 - fx) + Number(image.values[y0 * image.width + x1]) * fx; + const bottom = Number(image.values[y1 * image.width + x0]) * (1 - fx) + Number(image.values[y1 * image.width + x1]) * fx; + result[y * width + x] = top * (1 - fy) + bottom * fy; + } + } + return result; +} + +function dctCoefficient(values: ArrayLike, size: number, u: number, v: number) { + let total = 0; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) total += Number(values[y * size + x]) * Math.cos(((2 * x + 1) * u * Math.PI) / (2 * size)) * Math.cos(((2 * y + 1) * v * Math.PI) / (2 * size)); + } + const scale = (u === 0 ? 1 / Math.sqrt(2) : 1) * (v === 0 ? 1 / Math.sqrt(2) : 1); + return (2 / size) * scale * total; +} + +function colorBin(r: number, g: number, b: number) { + return ((r >> 6) << 4) | ((g >> 6) << 2) | (b >> 6); +} + +function meanOf(values: ArrayLike) { + let total = 0; + for (let index = 0; index < values.length; index += 1) total += Number(values[index]); + return total / values.length; +} + +function varianceOf(values: ArrayLike, mean = meanOf(values)) { + let total = 0; + for (let index = 0; index < values.length; index += 1) { + const delta = Number(values[index]) - mean; + total += delta * delta; + } + return total / values.length; +} + +function pearson(a: ArrayLike, b: ArrayLike) { + const meanA = meanOf(a); + const meanB = meanOf(b); + let numerator = 0; + let denominatorA = 0; + let denominatorB = 0; + for (let index = 0; index < a.length; index += 1) { + const left = Number(a[index]) - meanA; + const right = Number(b[index]) - meanB; + numerator += left * right; + denominatorA += left * left; + denominatorB += right * right; + } + const denominator = Math.sqrt(denominatorA * denominatorB); + return denominator ? numerator / denominator : isConstant(a) && isConstant(b) ? 1 : 0; +} + +function isConstant(values: ArrayLike) { + if (!values.length) return false; + const first = Number(values[0]); + for (let index = 1; index < values.length; index += 1) if (Number(values[index]) !== first) return false; + return true; +} + +function medianOf(values: readonly number[]) { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2; +} + +function round(value: number, digits = 2) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/canvas-agent/src/portrait-clearance/model-store.ts b/canvas-agent/src/portrait-clearance/model-store.ts new file mode 100644 index 000000000..473a034ff --- /dev/null +++ b/canvas-agent/src/portrait-clearance/model-store.ts @@ -0,0 +1,182 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const MODEL_PACK = "buffalo_l"; +const MODEL_FILES = { + detector: { + fileName: "det_10g.onnx", + sha256: "5838f7fe053675b1c7a08b633df49e7af5495cee0493c7dcf6697200b85b5b91", + url: "https://modelscope.cn/models/deepghs/insightface/resolve/master/buffalo_l/det_10g.onnx", + }, + embedding: { + fileName: "w600k_r50.onnx", + sha256: "4c06341c33c2ca1f86781dab0e829f88ad5b64be9fba56e56bc9ebdefc619e43", + url: "https://modelscope.cn/models/deepghs/insightface/resolve/master/buffalo_l/w600k_r50.onnx", + }, +} as const; +const MODEL_DOWNLOAD_MAX_REDIRECTS = 3; +const verificationCache = new Map(); +const activeInstalls = new Map>>>(); + +export type PortraitModelStatus = { + modelPack: typeof MODEL_PACK; + root: string; + detector: { fileName: string; installed: boolean; sha256: string }; + embedding: { fileName: string; installed: boolean; sha256: string }; + ready: boolean; +}; + +export function portraitModelDirectory(root: string) { + return path.join(root, "models", MODEL_PACK); +} + +export async function portraitModelStatus(root: string): Promise { + const directory = portraitModelDirectory(root); + const detector = await installedFileStatus(directory, MODEL_FILES.detector); + const embedding = await installedFileStatus(directory, MODEL_FILES.embedding); + return { + modelPack: MODEL_PACK, + root: path.resolve(root), + detector, + embedding, + ready: detector.installed && embedding.installed, + }; +} + +export async function installPortraitModels(root: string, options: { signal?: AbortSignal; fetch?: typeof fetch } = {}) { + const key = path.resolve(root); + const active = activeInstalls.get(key); + if (active) return active; + const install = performPortraitModelInstall(root, options).finally(() => { + if (activeInstalls.get(key) === install) activeInstalls.delete(key); + }); + activeInstalls.set(key, install); + return install; +} + +async function performPortraitModelInstall(root: string, options: { signal?: AbortSignal; fetch?: typeof fetch } = {}) { + const fetchImpl = options.fetch ?? fetch; + const current = await portraitModelStatus(root); + if (current.ready) return { ...current, downloaded: false }; + + const parent = path.dirname(portraitModelDirectory(root)); + await fs.mkdir(parent, { recursive: true }); + const temporaryDirectory = await fs.mkdtemp(path.join(parent, `.install-${process.pid}-`)); + try { + for (const item of Object.values(MODEL_FILES)) { + if (options.signal?.aborted) throw abortError(); + const response = await fetchModelResponse(fetchImpl, item.url, options.signal); + if (!response.ok || !response.body) throw new Error(`portrait_model_download_failed:${response.status}`); + const filePath = path.join(temporaryDirectory, item.fileName); + const output = await fs.open(filePath, "wx"); + const digest = crypto.createHash("sha256"); + try { + const reader = response.body.getReader(); + let bytes = 0; + while (true) { + if (options.signal?.aborted) throw abortError(); + const chunk = await reader.read(); + if (chunk.done) break; + const value = Buffer.from(chunk.value); + bytes += value.byteLength; + if (bytes > 1_000_000_000) throw new Error("portrait_model_too_large"); + digest.update(value); + await output.write(value); + } + } finally { + await output.close(); + } + if (digest.digest("hex") !== item.sha256) throw new Error(`portrait_model_checksum_failed:${item.fileName}`); + } + + const targetDirectory = portraitModelDirectory(root); + const backupDirectory = `${targetDirectory}.previous-${Date.now()}`; + let movedOld = false; + try { + await fs.rename(targetDirectory, backupDirectory); + movedOld = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + try { + await fs.rename(temporaryDirectory, targetDirectory); + } catch (error) { + if (movedOld) await fs.rename(backupDirectory, targetDirectory).catch(() => undefined); + throw error; + } + if (movedOld) await fs.rm(backupDirectory, { recursive: true, force: true }); + return { ...(await portraitModelStatus(root)), downloaded: true }; + } catch (error) { + await fs.rm(temporaryDirectory, { recursive: true, force: true }); + throw error; + } +} + +async function fetchModelResponse(fetchImpl: typeof fetch, sourceUrl: string, signal?: AbortSignal) { + let currentUrl = new URL(sourceUrl); + for (let redirectCount = 0; redirectCount <= MODEL_DOWNLOAD_MAX_REDIRECTS; redirectCount += 1) { + assertAllowedModelDownloadUrl(currentUrl); + const response = await fetchImpl(currentUrl, { + signal, + redirect: "manual", + headers: { "user-agent": "open-ai-canvas-portrait-clearance/1.0" }, + }); + if (![301, 302, 303, 307, 308].includes(response.status)) return response; + const location = response.headers.get("location"); + await response.body?.cancel(); + if (!location || redirectCount === MODEL_DOWNLOAD_MAX_REDIRECTS) throw new Error("portrait_model_redirect_invalid"); + currentUrl = new URL(location, currentUrl); + } + throw new Error("portrait_model_redirect_invalid"); +} + +function assertAllowedModelDownloadUrl(url: URL) { + const hostname = url.hostname.toLowerCase(); + if (url.protocol !== "https:" || url.username || url.password || (hostname !== "modelscope.cn" && !hostname.endsWith(".modelscope.cn"))) { + throw new Error("portrait_model_redirect_invalid"); + } +} + +async function installedFileStatus(directory: string, item: (typeof MODEL_FILES)[keyof typeof MODEL_FILES]) { + const filePath = path.join(directory, item.fileName); + let installed = false; + try { + const stat = await fs.stat(filePath); + if (stat.isFile() && stat.size > 0) { + const cached = verificationCache.get(filePath); + if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) installed = cached.valid; + else { + installed = await sha256File(filePath) === item.sha256; + verificationCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, valid: installed }); + } + } + } catch { + installed = false; + } + return { fileName: item.fileName, installed, sha256: item.sha256 }; +} + +async function sha256File(filePath: string) { + const digest = crypto.createHash("sha256"); + const file = await fs.open(filePath, "r"); + try { + for await (const chunk of file.readableWebStream()) digest.update(Buffer.from(chunk)); + } finally { + await file.close(); + } + return digest.digest("hex"); +} + +function abortError() { + return Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); +} + +export function defaultPortraitModelRoot(configDir: string) { + return path.join(configDir, "portrait-clearance"); +} + +export function defaultPortraitConfigRoot() { + return path.join(os.homedir(), ".infinite-canvas", "portrait-clearance"); +} diff --git a/canvas-agent/src/portrait-clearance/reports.ts b/canvas-agent/src/portrait-clearance/reports.ts new file mode 100644 index 000000000..e24c64dab --- /dev/null +++ b/canvas-agent/src/portrait-clearance/reports.ts @@ -0,0 +1,511 @@ +import { + Document, + HeadingLevel, + ImageRun, + Packer, + Paragraph, + Table, + TableCell, + TableRow, + TextRun, + WidthType, +} from "docx"; +import sharp from "sharp"; + +import type { + PortraitClearanceResult, + PortraitFeatureKey, + PortraitImageQuality, + PortraitLocalPrecheck, + PortraitPairResult, + PortraitRiskLevel, + PortraitVisionComparison, +} from "./contracts.js"; + +const MAX_EMBEDDED_IMAGE_BYTES = 1_500_000; +const MAX_TOTAL_EMBEDDED_IMAGE_BYTES = 12 * 1024 * 1024; +const HTML_REPORT_VERSION = "2"; +const DISCLAIMER = "本结果仅表示当前检索范围内的可识别性排查,不是身份确认、法律结论或司法鉴定。"; + +const RISK_ORDER: PortraitRiskLevel[] = ["high", "medium", "low_to_medium", "low", "unable_to_determine"]; +const FEATURE_LABELS: Record = { + face_shape: "脸型与下颌线", + facial_layout: "五官整体布局", + eyes_brows: "眼型与眉形", + nose_mouth: "鼻型与嘴型", + hair_hairline: "发型与发际线", + distinctive_features: "标志性特征", +}; +const FEATURE_KEYS = Object.keys(FEATURE_LABELS) as PortraitFeatureKey[]; + +export type PortraitReportImage = { + id: string; + mimeType: "image/jpeg" | "image/png" | "image/webp"; + bytes: Uint8Array; +}; + +type ImageBudget = { + remaining: number; + embeddedIds: Set; +}; + +type ReportSummary = { + riskCounts: Record; + successCount: number; + partialCount: number; + failedCount: number; +}; + +export async function buildPortraitReports(result: PortraitClearanceResult, images: readonly PortraitReportImage[] = []) { + const markdown = buildMarkdown(result); + const html = buildHtml(result, await optimizeHtmlImages(images)); + const docx = await buildDocx(result, images); + return { markdown, html, docx }; +} + +function buildMarkdown(result: PortraitClearanceResult) { + const summary = summarizeResult(result); + const lines = [ + "# 肖像权可识别性撞脸排查报告", + "", + "## 一、检测结论", + "", + `查询图:${result.queryImageId}`, + `排查模式:${modeLabel(result.mode)}`, + `候选数:${result.candidateCount}`, + `已完成分析:${result.comparedCount}`, + `处理状态:已完成 ${summary.successCount} 项;部分完成 ${summary.partialCount} 项;失败/跳过 ${summary.failedCount} 项`, + `综合风险等级:**${riskLabel(result.highestRisk)}**`, + `创建时间:${result.createdAt}`, + `完成时间:${result.completedAt || "尚未完成全部视觉模型分析"}`, + "", + `> ${DISCLAIMER}`, + "", + "风险分布:", + ...RISK_ORDER.filter((risk) => summary.riskCounts[risk] > 0).map((risk) => `- ${riskLabel(risk)}:${summary.riskCounts[risk]} 个`), + "", + `**综合结论:** ${verdict(result.highestRisk)}`, + ]; + + const unableReasons = collectUnableReasons(result); + if (unableReasons.length) lines.push("", "无法可靠判断的主要原因:", ...unableReasons.map((item) => `- ${markdownInline(item)}`)); + + lines.push("", "## 二、详细比对结果", ""); + lines.push("| # | 比对图 | 来源 | 状态 | 分析路径 | 风险等级 | 整体相似度 | 本地指标说明 | 主要依据 |"); + lines.push("|---:|---|---|---|---|---|---:|---|---|"); + for (const pair of orderedPairs(result)) { + const candidate = candidateForPair(result, pair); + const basis = visionBasis(pair).slice(0, 2).join(";") || "无"; + lines.push([ + candidate?.originalRank ?? "-", + markdownInline(candidate?.title || pair.comparisonImageId), + sourceLabel(pair.source), + statusLabel(pair.status), + analysisPathLabel(pair.analysisPath), + riskLabel(pair.riskLevel), + pair.overallSimilarity === undefined ? "-" : pair.overallSimilarity.toFixed(2), + markdownInline(localMetricSummary(pair)), + markdownInline(basis).slice(0, 120), + ].join(" | ")); + } + + for (const pair of orderedPairs(result)) { + const candidate = candidateForPair(result, pair); + const vision = pair.visionComparison; + lines.push("", `### #${candidate?.originalRank ?? "-"} ${markdownInline(candidate?.title || pair.comparisonImageId)} — ${riskLabel(pair.riskLevel)}`, ""); + lines.push(`- 来源:${sourceLabel(pair.source)}`); + lines.push(`- 状态:${statusLabel(pair.status)}`); + lines.push(`- 分析路径:${analysisPathLabel(pair.analysisPath)}`); + if (pair.overallSimilarity !== undefined) lines.push(`- 整体相似度:${pair.overallSimilarity.toFixed(4)}`); + if (candidate?.sourceDomain) lines.push(`- 来源域名:${markdownInline(candidate.sourceDomain)}`); + if (candidate?.sourcePageUrl && safeUrl(candidate.sourcePageUrl)) lines.push(`- 来源页面:[打开来源页面](${safeUrl(candidate.sourcePageUrl)})`); + if (pair.error) lines.push(`- 处理说明:${markdownInline(pair.error.message)}(${markdownInline(pair.error.code)})`); + + lines.push("", "#### 本地预检", "", ...localMetricLines(pair.localPrecheck, pair.analysisPath).map((item) => `- ${markdownInline(item)}`)); + if (vision) { + lines.push("", "#### 多模态面部特征分析", ""); + lines.push(`- 图像类型:A=${imageTypeLabel(vision.imageAType)};B=${imageTypeLabel(vision.imageBType)}`); + lines.push(`- 模型状态:${vision.status === "success" ? "完成" : "无法可靠判断"}`); + lines.push(`- 模型风险:${riskLabel(vision.riskLevel)}`); + lines.push(`- 人工复核:${vision.manualReviewRecommended ? "建议" : "未特别标记"}`); + lines.push("", "| 特征维度 | 相似度 | 分析说明 |", "|---|---|---|"); + for (const key of FEATURE_KEYS) { + const feature = vision.featureComparison[key]; + lines.push(`| ${FEATURE_LABELS[key]} | ${featureSimilarityLabel(feature.similarity)} | ${markdownInline(feature.note)} |`); + } + if (vision.insightfaceFusionNote) lines.push("", `- InsightFace 融合说明:${markdownInline(vision.insightfaceFusionNote)}`); + if (vision.basis.length) lines.push("", "主要依据:", ...vision.basis.map((item) => `- ${markdownInline(item)}`)); + if (vision.modificationSuggestions.length) lines.push("", "修改建议:", ...vision.modificationSuggestions.map((item) => `- ${markdownInline(item)}`)); + if (vision.limitations.length) lines.push("", "模型局限:", ...vision.limitations.map((item) => `- ${markdownInline(item)}`)); + } else { + lines.push("", "主要依据:", ...pair.basis.map((item) => `- ${markdownInline(item)}`)); + } + if (pair.limitations.length) lines.push("", "本项限制:", ...pair.limitations.map((item) => `- ${markdownInline(item)}`)); + } + + lines.push("", "## 三、限制说明", "", ...globalLimitations(result).map((item) => `- ${markdownInline(item)}`)); + return lines.join("\n"); +} + +function buildHtml(result: PortraitClearanceResult, images: readonly PortraitReportImage[]) { + const imageMap = new Map(images.map((image) => [image.id, image])); + const budget: ImageBudget = { remaining: MAX_TOTAL_EMBEDDED_IMAGE_BYTES, embeddedIds: new Set() }; + const queryImage = imageMap.get(result.queryImageId); + const queryImageHtml = imageFrame(queryImage, "查询图", budget, result.queryImageId); + const summary = summarizeResult(result); + const riskBadges = RISK_ORDER.filter((risk) => summary.riskCounts[risk] > 0) + .map((risk) => `${riskLabel(risk)}:${summary.riskCounts[risk]} 个`) + .join(""); + const unableReasons = collectUnableReasons(result); + const unableReasonsHtml = unableReasons.length + ? `
无法可靠判断的主要原因:${htmlList(unableReasons)}
` + : ""; + + const pairHtml = orderedPairs(result).map((pair) => { + const candidate = candidateForPair(result, pair); + const title = candidate?.title || pair.comparisonImageId; + const candidateImage = imageMap.get(pair.comparisonImageId); + const vision = pair.visionComparison; + const limitations = unique([...pair.limitations, ...(vision?.limitations || [])]); + const sourceLink = candidate?.sourcePageUrl && safeUrl(candidate.sourcePageUrl) + ? `打开来源页面` + : ""; + const targetWarning = pair.localPrecheck.facesA > 1 || pair.localPrecheck.facesB > 1 + ? `
目标对象提示:检测到多张人脸,当前结果无法确认具体比对目标。建议先裁剪到单一目标人脸,或明确指定目标人物后重新检测。
` + : ""; + const errorHtml = pair.error + ? `
处理说明:${escapeHtml(pair.error.message)}${escapeHtml(pair.error.code)}
` + : ""; + const visionHtml = vision ? `
+

多模态面部特征分析

+
图像类型:A-${escapeHtml(imageTypeLabel(vision.imageAType))} / B-${escapeHtml(imageTypeLabel(vision.imageBType))}模型状态:${escapeHtml(vision.status === "success" ? "完成" : "无法可靠判断")}模型风险:${escapeHtml(riskLabel(vision.riskLevel))}人工复核:${vision.manualReviewRecommended ? "建议" : "未特别标记"}
+ ${featureTableHtml(vision)} + ${vision.insightfaceFusionNote ? `
InsightFace 融合说明:${escapeHtml(vision.insightfaceFusionNote)}
` : ""} + ${vision.basis.length ? `
主要依据${htmlList(vision.basis)}
` : ""} + ${vision.modificationSuggestions.length ? `
修改建议${htmlList(vision.modificationSuggestions)}
` : ""} +
` : `
主要依据${htmlList(pair.basis, "无")}
`; + + return `
+
#${escapeHtml(String(candidate?.originalRank ?? "-"))} — ${escapeHtml(title.slice(0, 100))}${escapeHtml(riskLabel(pair.riskLevel))}${escapeHtml(statusLabel(pair.status))}
+
${queryImageHtml}
查询图
${imageFrame(candidateImage, title, budget, pair.comparisonImageId)}
候选图:${escapeHtml(title)}
+
来源:${escapeHtml(sourceLabel(pair.source))}分析路径:${escapeHtml(analysisPathLabel(pair.analysisPath))}整体相似度:${pair.overallSimilarity === undefined ? "-" : pair.overallSimilarity.toFixed(4)}本地预检:${escapeHtml(localMetricSummary(pair))}
+ ${targetWarning}${errorHtml} +

本地预检明细

${precheckTableHtml(pair.localPrecheck, pair.analysisPath)}
+ ${visionHtml} + ${limitations.length ? `
限制与人工复核${htmlList(limitations)}
` : ""} + ${sourceLink ? `` : ""} +
`; + }).join(""); + + const metaRows = [ + ["任务", result.taskId], + ["排查模式", modeLabel(result.mode)], + ["候选数", String(result.candidateCount)], + ["已完成分析", String(result.comparedCount)], + ["处理状态", `完成 ${summary.successCount} / 部分 ${summary.partialCount} / 失败或跳过 ${summary.failedCount}`], + ["创建时间", result.createdAt], + ["完成时间", result.completedAt || "尚未完成全部视觉模型分析"], + ]; + return `肖像权可识别性撞脸排查报告
+

肖像权可识别性撞脸排查报告

本地预检 + 项目视觉模型双路径分析 · 结果经本机结构校验

+
${escapeHtml(DISCLAIMER)}
+
${queryImageHtml}
查询图

综合风险等级:${escapeHtml(riskLabel(result.highestRisk))}

${riskBadges}
${escapeHtml(verdict(result.highestRisk))}
${unableReasonsHtml}
+

检测概览

${metaRows.map(([key, value]) => `
${escapeHtml(key)}
${escapeHtml(value)}
`).join("")}
+

详细比对结果

${pairHtml || "

当前没有可交付的比对结果。

"}
+

限制说明

${htmlList(globalLimitations(result))}
+
`; +} + +async function buildDocx(result: PortraitClearanceResult, images: readonly PortraitReportImage[]) { + const imageMap = new Map(images.map((image) => [image.id, image])); + const summary = summarizeResult(result); + const children: Array = [ + new Paragraph({ text: "肖像权可识别性撞脸排查报告", heading: HeadingLevel.TITLE }), + new Paragraph({ children: [new TextRun({ text: DISCLAIMER, bold: true })] }), + reportTable([ + ["任务", result.taskId], + ["排查模式", modeLabel(result.mode)], + ["候选数", String(result.candidateCount)], + ["已完成分析", String(result.comparedCount)], + ["处理状态", `完成 ${summary.successCount} 项;部分完成 ${summary.partialCount} 项;失败/跳过 ${summary.failedCount} 项`], + ["创建时间", result.createdAt], + ["完成时间", result.completedAt || "尚未完成全部视觉模型分析"], + ]), + new Paragraph({ text: "一、检测结论", heading: HeadingLevel.HEADING_1 }), + new Paragraph({ children: [new TextRun({ text: "综合风险等级:", bold: true }), new TextRun({ text: riskLabel(result.highestRisk), bold: true, color: riskColor(result.highestRisk) })] }), + new Paragraph(`风险分布:${RISK_ORDER.filter((risk) => summary.riskCounts[risk] > 0).map((risk) => `${riskLabel(risk)} ${summary.riskCounts[risk]} 个`).join(";") || "无"}`), + new Paragraph(verdict(result.highestRisk)), + ...collectUnableReasons(result).map((item) => new Paragraph({ text: `无法判断原因:${item}`, bullet: { level: 0 } })), + new Paragraph({ text: "查询图", heading: HeadingLevel.HEADING_1 }), + ]; + const budget: ImageBudget = { remaining: MAX_TOTAL_EMBEDDED_IMAGE_BYTES, embeddedIds: new Set() }; + const queryImage = imageMap.get(result.queryImageId); + if (queryImage) children.push(await docxImageParagraph(queryImage, "查询图", budget)); + + children.push(new Paragraph({ text: "二、详细比对结果", heading: HeadingLevel.HEADING_1 })); + for (const pair of orderedPairs(result)) { + const candidate = candidateForPair(result, pair); + const title = candidate?.title || pair.comparisonImageId; + const candidateImage = imageMap.get(pair.comparisonImageId); + const vision = pair.visionComparison; + const limitations = unique([...pair.limitations, ...(vision?.limitations || [])]); + children.push(new Paragraph({ text: `#${candidate?.originalRank ?? "-"} ${title} — ${riskLabel(pair.riskLevel)}`, heading: HeadingLevel.HEADING_2 })); + if (candidateImage) children.push(await docxImageParagraph(candidateImage, "候选图", budget)); + children.push(reportTable([ + ["来源", sourceLabel(pair.source)], + ["状态", statusLabel(pair.status)], + ["分析路径", analysisPathLabel(pair.analysisPath)], + ["整体相似度", pair.overallSimilarity === undefined ? "-" : pair.overallSimilarity.toFixed(4)], + ["本地指标", localMetricSummary(pair)], + ])); + if (pair.error) children.push(new Paragraph({ children: [new TextRun({ text: `处理说明:${pair.error.message}(${pair.error.code})`, bold: true })] })); + children.push(new Paragraph({ text: "本地预检明细", heading: HeadingLevel.HEADING_3 }), reportTable(localMetricRows(pair.localPrecheck, pair.analysisPath))); + if (pair.localPrecheck.facesA > 1 || pair.localPrecheck.facesB > 1) children.push(new Paragraph("目标对象提示:检测到多张人脸,当前结果无法确认具体比对目标。建议先裁剪到单一目标人脸后重新检测。")); + if (vision) { + children.push(new Paragraph({ text: "多模态面部特征分析", heading: HeadingLevel.HEADING_3 })); + children.push(reportTable([ + ["图像类型", `A-${imageTypeLabel(vision.imageAType)} / B-${imageTypeLabel(vision.imageBType)}`], + ["模型状态", vision.status === "success" ? "完成" : "无法可靠判断"], + ["模型风险", riskLabel(vision.riskLevel)], + ["人工复核", vision.manualReviewRecommended ? "建议" : "未特别标记"], + ])); + children.push(featureTableDocx(vision)); + if (vision.insightfaceFusionNote) children.push(new Paragraph(`InsightFace 融合说明:${vision.insightfaceFusionNote}`)); + if (vision.basis.length) children.push(new Paragraph({ text: "主要依据", heading: HeadingLevel.HEADING_4 }), ...vision.basis.map((item) => new Paragraph({ text: item, bullet: { level: 0 } }))); + if (vision.modificationSuggestions.length) children.push(new Paragraph({ text: "修改建议", heading: HeadingLevel.HEADING_4 }), ...vision.modificationSuggestions.map((item) => new Paragraph({ text: item, bullet: { level: 0 } }))); + } else { + children.push(new Paragraph({ text: "主要依据", heading: HeadingLevel.HEADING_3 }), ...pair.basis.map((item) => new Paragraph({ text: item, bullet: { level: 0 } }))); + } + if (limitations.length) children.push(new Paragraph({ text: "限制与人工复核", heading: HeadingLevel.HEADING_3 }), ...limitations.map((item) => new Paragraph({ text: item, bullet: { level: 0 } }))); + if (candidate?.sourcePageUrl && safeUrl(candidate.sourcePageUrl)) children.push(new Paragraph(`来源页面:${safeUrl(candidate.sourcePageUrl)}`)); + } + + children.push(new Paragraph({ text: "三、限制说明", heading: HeadingLevel.HEADING_1 }), ...globalLimitations(result).map((item) => new Paragraph({ text: item, bullet: { level: 0 } }))); + const document = new Document({ sections: [{ properties: { page: { size: { width: 11906, height: 16838 } } }, children }] }); + return new Uint8Array(await Packer.toBuffer(document)); +} + +function summarizeResult(result: PortraitClearanceResult): ReportSummary { + const riskCounts = Object.fromEntries(RISK_ORDER.map((risk) => [risk, result.riskCounts[risk] ?? 0])) as Record; + return { + riskCounts, + successCount: result.pairs.filter((pair) => pair.status === "success").length, + partialCount: result.pairs.filter((pair) => pair.status === "partial").length, + failedCount: result.pairs.filter((pair) => pair.status === "failed").length, + }; +} + +function orderedPairs(result: PortraitClearanceResult) { + return [...result.pairs].sort((left, right) => riskRank(right.riskLevel) - riskRank(left.riskLevel) || (right.overallSimilarity ?? 0) - (left.overallSimilarity ?? 0) || (candidateForPair(result, left)?.originalRank ?? 0) - (candidateForPair(result, right)?.originalRank ?? 0)); +} + +function candidateForPair(result: PortraitClearanceResult, pair: PortraitPairResult) { + return result.candidates.find((candidate) => candidate.resultId === pair.id || candidate.id === pair.candidateId); +} + +function localMetricRows(precheck: PortraitLocalPrecheck, analysisPath: PortraitPairResult["analysisPath"]): Array<[string, string]> { + const rows: Array<[string, string]> = [ + ["人脸数", `A=${precheck.facesA} / B=${precheck.facesB}`], + ["画质等级", `A-${qualityLabel(precheck.qualityA.grade)}(${precheck.qualityA.width}×${precheck.qualityA.height}) / B-${qualityLabel(precheck.qualityB.grade)}(${precheck.qualityB.width}×${precheck.qualityB.height})`], + ["SSIM", precheck.ssim.toFixed(4)], + ["颜色直方图相关性", precheck.colorHistogramCorrelation.toFixed(4)], + ["可提取人脸 embedding", precheck.canExtractEmbedding ? "是" : "否"], + ]; + if (precheck.faceSimilarity !== undefined && analysisPath !== "B") rows.splice(2, 0, ["InsightFace 余弦相似度", precheck.faceSimilarity.toFixed(4)]); + if (analysisPath === "B") rows.splice(2, 0, ["本地 embedding 说明", "风格化路径不采用本地人脸 embedding 作为主要判断依据"]); + return rows; +} + +function localMetricLines(precheck: PortraitLocalPrecheck, analysisPath: PortraitPairResult["analysisPath"]) { + return localMetricRows(precheck, analysisPath).map(([key, value]) => `${key}:${value}`).concat(precheck.reliabilityIssues.length ? [`可靠性问题:${precheck.reliabilityIssues.join(";")}`] : []); +} + +function localMetricSummary(pair: PortraitPairResult) { + const precheck = pair.localPrecheck; + if (precheck.facesA > 1 || precheck.facesB > 1) return "检测到多张人脸,需人工指定目标"; + if (pair.analysisPath === "B") return "风格化路径,不采用本地 embedding"; + if (precheck.faceSimilarity !== undefined) return `InsightFace 余弦相似度 ${precheck.faceSimilarity.toFixed(4)}`; + return "未提取到可比对的人脸特征"; +} + +function precheckTableHtml(precheck: PortraitLocalPrecheck, analysisPath: PortraitPairResult["analysisPath"]) { + return definitionTableHtml(localMetricRows(precheck, analysisPath).concat(precheck.reliabilityIssues.length ? [["可靠性问题", precheck.reliabilityIssues.join(";")]] : [])); +} + +function featureTableHtml(vision: PortraitVisionComparison) { + const rows = FEATURE_KEYS.map((key) => { + const feature = vision.featureComparison[key]; + return `${escapeHtml(FEATURE_LABELS[key])}${escapeHtml(featureSimilarityLabel(feature.similarity))}${escapeHtml(feature.note || "-")}`; + }).join(""); + return `${rows}
特征维度相似度分析说明
`; +} + +function featureTableDocx(vision: PortraitVisionComparison) { + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ children: [new TableCell({ children: [new Paragraph("特征维度")] }), new TableCell({ children: [new Paragraph("相似度")] }), new TableCell({ children: [new Paragraph("分析说明")] })] }), + ...FEATURE_KEYS.map((key) => { + const feature = vision.featureComparison[key]; + return new TableRow({ children: [new TableCell({ children: [new Paragraph(FEATURE_LABELS[key])] }), new TableCell({ children: [new Paragraph(featureSimilarityLabel(feature.similarity))] }), new TableCell({ children: [new Paragraph(feature.note || "-")] })] }); + }), + ], + }); +} + +function reportTable(rows: Array<[string, string]>) { + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: rows.map(([label, value]) => new TableRow({ children: [new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: label, bold: true })] })] }), new TableCell({ children: [new Paragraph(value)] })] })), + }); +} + +async function docxImageParagraph(image: PortraitReportImage, alt: string, budget: ImageBudget) { + const bytes = await docxImageBytes(image, budget); + if (!bytes) return new Paragraph(`(${alt}未内嵌:图片过大或报告容量已达到上限)`); + return new Paragraph({ children: [new ImageRun({ data: bytes, transformation: { width: 320, height: 240 }, type: image.mimeType === "image/jpeg" ? "jpg" : "png" }), new TextRun({ text: `\n${alt}`, break: 1 })] }); +} + +async function docxImageBytes(image: PortraitReportImage, budget: ImageBudget) { + if (image.bytes.byteLength > MAX_EMBEDDED_IMAGE_BYTES || image.bytes.byteLength > budget.remaining) return undefined; + if (budget.embeddedIds.has(image.id)) return undefined; + budget.remaining -= image.bytes.byteLength; + budget.embeddedIds.add(image.id); + return image.mimeType === "image/webp" ? await sharp(image.bytes).png().toBuffer() : Buffer.from(image.bytes); +} + +function imageFrame(image: PortraitReportImage | undefined, alt: string, budget: ImageBudget, id: string) { + if (!image) return `
${escapeHtml(alt)}
图片未随报告内嵌
`; + if (image.bytes.byteLength > MAX_EMBEDDED_IMAGE_BYTES || (!budget.embeddedIds.has(id) && image.bytes.byteLength > budget.remaining)) return `
${escapeHtml(alt)}
图片过大,未内嵌
`; + if (!budget.embeddedIds.has(id)) { + budget.remaining -= image.bytes.byteLength; + budget.embeddedIds.add(id); + } + return `${escapeHtml(alt)}`; +} + +async function optimizeHtmlImages(images: readonly PortraitReportImage[]) { + return Promise.all(images.map(async (image) => { + try { + const source = sharp(image.bytes).rotate().flatten({ background: "#ffffff" }); + for (const width of [1280, 1024, 768, 640]) { + for (const quality of [78, 66, 54]) { + const bytes = await source.clone().resize({ width, height: width, fit: "inside", withoutEnlargement: true }).jpeg({ quality, progressive: true }).toBuffer(); + if (bytes.byteLength <= MAX_EMBEDDED_IMAGE_BYTES) return { ...image, mimeType: "image/jpeg" as const, bytes }; + } + } + const bytes = await source.clone().resize({ width: 512, height: 512, fit: "inside", withoutEnlargement: true }).jpeg({ quality: 42, progressive: true }).toBuffer(); + return { ...image, mimeType: "image/jpeg" as const, bytes }; + } catch { + return image; + } + })); +} + +function definitionTableHtml(rows: Array<[string, string]>) { + return `
${rows.map(([key, value]) => `
${escapeHtml(key)}
${escapeHtml(value)}
`).join("")}
`; +} + +function htmlList(items: string[], emptyText = "无") { + return `
    ${(items.length ? items : [emptyText]).map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
`; +} + +function globalLimitations(result: PortraitClearanceResult) { + const limitations = [...result.limitations, "比对结果可能受图片质量、角度、遮挡、风格化程度和候选图来源影响。"]; + if (result.pairs.some((pair) => pair.analysisPath === "B")) limitations.push("风格化图像场景不采用本地人脸 embedding 作为主要判断依据。", "风格化图像的结果应结合视觉特征和人工复核,不应单独作为身份结论。"); + if (result.pairs.some((pair) => pair.localPrecheck.facesA > 1 || pair.localPrecheck.facesB > 1)) limitations.push("多张人脸场景需要先裁剪到单一目标人脸,或明确指定目标人物后重新检测。"); + if (result.pairs.some((pair) => pair.status === "failed")) limitations.push("部分候选未完成比对,已在对应条目中标记为失败/跳过;一个候选失败不会代表其他候选的结果。"); + limitations.push("本结果不能替代司法鉴定、律师意见或法院判断。"); + return unique(limitations); +} + +function collectUnableReasons(result: PortraitClearanceResult, limit = 6) { + const reasons: string[] = []; + for (const pair of result.pairs) { + if (pair.riskLevel !== "unable_to_determine") continue; + for (const item of [...pair.localPrecheck.reliabilityIssues, ...pair.limitations, ...(pair.visionComparison?.limitations || []), ...(pair.error ? [pair.error.message] : []), ...pair.basis]) { + if (item && !reasons.includes(item)) reasons.push(item); + if (reasons.length >= limit) return reasons; + } + } + return reasons; +} + +function visionBasis(pair: PortraitPairResult) { + return pair.visionComparison?.basis.length ? pair.visionComparison.basis : pair.basis; +} + +function verdict(level: PortraitRiskLevel) { + return ({ + high: "存在较高撞脸风险,建议人工复核。本结果不能替代司法鉴定、律师意见或法院判断。", + medium: "存在一定撞脸风险,建议人工复核。本结果不能替代司法鉴定、律师意见或法院判断。", + low_to_medium: "在比对范围内存在局部相似,公开传播或商业使用前建议人工确认。", + low: "在当前比对范围内未发现高相似候选对象。但这不代表不存在其他肖像权风险。", + unable_to_determine: "部分或全部比对对象无法可靠判断,建议人工复核。", + } as Record)[level]; +} + +function modeLabel(mode: PortraitClearanceResult["mode"]) { + return mode === "direct-compare" ? "直接比对" : "网络排查"; +} + +function sourceLabel(source: PortraitPairResult["source"]) { + return ({ "connected-reference": "已连接参考图", "connected-candidate": "已连接候选图", baidu: "百度识图候选" } as Record)[source]; +} + +function statusLabel(status: PortraitPairResult["status"]) { + return ({ success: "完成", partial: "部分完成", failed: "失败/跳过" } as Record)[status]; +} + +function analysisPathLabel(path: PortraitPairResult["analysisPath"]) { + return path === "A" ? "Path A(写实图/本地特征融合)" : path === "B" ? "Path B(风格化视觉分析)" : "无法形成可靠路径"; +} + +function imageTypeLabel(value: PortraitVisionComparison["imageAType"]) { + return value === "realistic" ? "写实" : "风格化"; +} + +function qualityLabel(value: PortraitImageQuality["grade"]) { + return ({ good: "良好", usable: "可用", poor: "较差" } as Record)[value]; +} + +function featureSimilarityLabel(value: PortraitVisionComparison["featureComparison"][PortraitFeatureKey]["similarity"]) { + return ({ high: "高度相似", medium: "中等相似", low: "低度相似", none: "无明显相似" } as Record)[value]; +} + +function riskLabel(value: PortraitRiskLevel) { + return ({ high: "高", medium: "中", low_to_medium: "中低", low: "低", unable_to_determine: "无法判断" } as Record)[value]; +} + +function riskRank(value: PortraitRiskLevel) { + return RISK_ORDER.length - RISK_ORDER.indexOf(value); +} + +function riskColor(value: PortraitRiskLevel) { + return ({ high: "C62828", medium: "B26A00", low_to_medium: "9A7800", low: "2E7D32", unable_to_determine: "78716C" } as Record)[value]; +} + +function unique(values: string[]) { + return [...new Set(values.filter(Boolean))]; +} + +function markdownInline(value: string) { + return value.replace(/[|\r\n]/g, " ").replace(/`/g, "'"); +} + +function safeUrl(value: string) { + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!); +} + +function htmlCss() { + return `:root{--text:#20242a;--muted:#626b75;--border:#d9dee5;--panel:#fff;--surface:#f5f7f9;--high:#c62828;--high-bg:#fff1f0;--medium:#b26a00;--medium-bg:#fff7e6;--lowmid:#9a7800;--lowmid-bg:#fffbe6;--low:#2e7d32;--low-bg:#f0f9f1;--unable:#78716c;--unable-bg:#f5f5f4}*{box-sizing:border-box}body{margin:0;background:#f3f5f7;color:var(--text);font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;line-height:1.6}.container{max-width:1120px;margin:0 auto;padding:32px 22px 56px}.report-header{margin-bottom:18px}.report-header h1{margin:0 0 4px;font-size:30px;letter-spacing:.01em}.report-header p{margin:0;color:var(--muted);font-size:14px}.notice{padding:12px 16px;margin:16px 0;background:#fffbe6;border:1px solid #ffe58f;border-left:4px solid #d39b00;border-radius:8px;color:#6b5a16}.summary-card{display:flex;gap:24px;padding:20px;background:var(--panel);border:1px solid var(--border);border-radius:12px;box-shadow:0 3px 12px #1f29370b}.query-img{width:230px;flex:0 0 230px}.query-img img,.img-box img{display:block;width:100%;height:270px;object-fit:contain;background:var(--surface);border:1px solid var(--border);border-radius:8px}.query-img .label,.img-label{text-align:center;color:var(--muted);font-size:12px;margin-top:5px}.image-placeholder{display:grid;place-items:center;align-content:center;min-height:180px;padding:20px;text-align:center;color:var(--muted);background:var(--surface);border:1px dashed var(--border);border-radius:8px}.image-placeholder small{font-size:11px}.summary-info{min-width:0;flex:1}.summary-info h2{margin:2px 0 12px;font-size:21px}.badge,.status-badge,.feature-badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:700;white-space:nowrap}.badge.high,.feature-badge.high{background:var(--high-bg);color:var(--high)}.badge.medium,.feature-badge.medium{background:var(--medium-bg);color:var(--medium)}.badge.low_to_medium,.feature-badge.low{background:var(--lowmid-bg);color:var(--lowmid)}.badge.low{background:var(--low-bg);color:var(--low)}.badge.unable_to_determine,.feature-badge.none{background:var(--unable-bg);color:var(--unable)}.risk-dist{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.verdict{padding:12px 14px;border-radius:8px;background:var(--surface);color:#444}.verdict.high,.verdict.medium{background:var(--high-bg);color:#6b1d1d}.verdict.low_to_medium{background:var(--lowmid-bg);color:#665400}.verdict.low{background:var(--low-bg);color:#245b29}.verdict-reasons{margin-top:12px;color:var(--muted);font-size:13px}.meta,.definition-table{display:grid;grid-template-columns:170px 1fr;gap:0;border:1px solid var(--border);border-radius:8px;overflow:hidden}.meta dt,.meta dd,.definition-table dt,.definition-table dd{margin:0;padding:8px 11px;border-bottom:1px solid var(--border);font-size:13px}.meta dt,.definition-table dt{font-weight:700;color:var(--muted);background:var(--surface)}.meta dd,.definition-table dd{background:#fff}.meta dt:last-of-type,.meta dd:last-of-type,.definition-table dt:last-of-type,.definition-table dd:last-of-type{border-bottom:0}.section{margin-top:24px;padding:22px;background:var(--panel);border:1px solid var(--border);border-radius:12px}.section>h2{margin:0 0 14px;font-size:20px}.comp-card{margin-top:18px;padding:18px;border:1px solid var(--border);border-radius:10px;background:#fff}.comp-card.is-failed{border-color:#e2c08b;background:#fffdf6}.comp-header{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px}.comp-title{font-size:17px;font-weight:750;margin-right:auto}.status-badge.success{background:#edf7ee;color:#2e7d32}.status-badge.partial{background:#fff7e6;color:#9a6200}.status-badge.failed{background:#fff1f0;color:#b42318}.comp-images{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:14px}.detail-grid{display:flex;flex-wrap:wrap;gap:8px 18px;color:var(--muted);font-size:13px}.detail-grid strong{color:var(--text)}.target-warning,.error-note,.fusion-note,.suggestion-note,.limitation-note{margin-top:12px;padding:10px 13px;border-radius:7px;font-size:13px}.target-warning{background:#fff7ed;border:1px solid #fed7aa;border-left:3px solid #f97316;color:#9a3412}.error-note{background:#fff1f0;border:1px solid #ffccc7;border-left:3px solid #c62828;color:#8b1e1e}.error-code{margin-left:8px;color:var(--muted);font-family:ui-monospace,monospace}.precheck-block,.vision-block{margin-top:16px}.precheck-block h4,.vision-block h4{margin:0 0 8px;font-size:15px}.feature-table{width:100%;margin-top:12px;border-collapse:collapse;font-size:13px}.feature-table th,.feature-table td{padding:8px 10px;border:1px solid var(--border);vertical-align:top;text-align:left}.feature-table th{background:var(--surface)}.feature-table th:nth-child(1){width:22%}.feature-table th:nth-child(2){width:18%}.subsection{margin-top:13px;color:var(--muted);font-size:13px}.subsection strong{display:block;color:var(--text);margin-bottom:4px}.subsection ul,.verdict-reasons ul{margin:4px 0 0;padding-left:20px}.fusion-note{background:var(--surface);color:var(--muted)}.suggestion-note{background:#f0f7ff;border-left:3px solid #5790c9}.limitation-note{background:#fafafa;border-left:3px solid #a5adb5}.source-link{margin-top:13px;font-size:13px}.source-link a{color:#2864a5}.section>ul{margin:0;padding-left:20px;color:var(--muted);font-size:13px}@media(max-width:700px){.container{padding:20px 12px 36px}.summary-card{display:block}.query-img{width:190px}.comp-images{grid-template-columns:1fr}.meta,.definition-table{grid-template-columns:125px 1fr}}@media print{body{background:#fff}.container{max-width:none;padding:0}.section,.summary-card,.comp-card{box-shadow:none;break-inside:avoid}.notice{break-inside:avoid}}`; +} diff --git a/canvas-agent/src/portrait-clearance/risk-rules.ts b/canvas-agent/src/portrait-clearance/risk-rules.ts new file mode 100644 index 000000000..3dcc9a469 --- /dev/null +++ b/canvas-agent/src/portrait-clearance/risk-rules.ts @@ -0,0 +1,98 @@ +import { highestRisk, riskFromFaceSimilarity, type PortraitClearanceResult, type PortraitImageQuality, type PortraitLocalPrecheck, type PortraitRiskLevel, type PortraitVisionComparison } from "./contracts.js"; +import { colorHistogramCorrelation, structuralSimilarity, type DecodedPortraitImage } from "./image-metrics.js"; +import type { PortraitFaceAnalysis } from "./face-engine.js"; + +export function buildLocalPrecheck( + imageA: DecodedPortraitImage, + imageB: DecodedPortraitImage, + facesA?: PortraitFaceAnalysis, + facesB?: PortraitFaceAnalysis, + extraIssues: string[] = [], +): PortraitLocalPrecheck { + const reliabilityIssues = [...extraIssues]; + const selectedFaceA = toFaceContract(facesA?.selectedFace); + const selectedFaceB = toFaceContract(facesB?.selectedFace); + if (imageA.quality.grade === "poor") reliabilityIssues.push("图片 A 质量过低,模糊、过暗、过曝或对比度不足会导致人脸特征不可靠。"); + if (imageB.quality.grade === "poor") reliabilityIssues.push("图片 B 质量过低,模糊、过暗、过曝或对比度不足会导致人脸特征不可靠。"); + if ((facesA?.faces.length ?? 0) > 1) reliabilityIssues.push("图片 A 检测到多张人脸,未指定目标对象,无法可靠判断。"); + if ((facesB?.faces.length ?? 0) > 1) reliabilityIssues.push("图片 B 检测到多张人脸,未指定目标对象,无法可靠判断。"); + if (facesA?.selectedFace && facesA.selectedFace.detScore < 0.65) reliabilityIssues.push(`图片 A 人脸检测置信度较低(${facesA.selectedFace.detScore.toFixed(4)}),特征提取不稳定。`); + if (facesB?.selectedFace && facesB.selectedFace.detScore < 0.65) reliabilityIssues.push(`图片 B 人脸检测置信度较低(${facesB.selectedFace.detScore.toFixed(4)}),特征提取不稳定。`); + if (facesA?.selectedFace && facesA.selectedFace.areaRatio < 0.02) reliabilityIssues.push(`图片 A 人脸区域占比过小(${facesA.selectedFace.areaRatio.toFixed(4)}),本地特征比对不可靠。`); + if (facesB?.selectedFace && facesB.selectedFace.areaRatio < 0.02) reliabilityIssues.push(`图片 B 人脸区域占比过小(${facesB.selectedFace.areaRatio.toFixed(4)}),本地特征比对不可靠。`); + return { + qualityA: imageA.quality, + qualityB: imageB.quality, + facesA: facesA?.faces.length ?? 0, + facesB: facesB?.faces.length ?? 0, + ...(selectedFaceA ? { selectedFaceA } : {}), + ...(selectedFaceB ? { selectedFaceB } : {}), + ...(facesA?.embedding && facesB?.embedding ? { faceSimilarity: round(cosine(facesA.embedding, facesB.embedding), 4) } : {}), + ssim: structuralSimilarity({ width: imageA.width, height: imageA.height, values: imageA.gray }, { width: imageB.width, height: imageB.height, values: imageB.gray }), + colorHistogramCorrelation: colorHistogramCorrelation(imageA.rgb, imageB.rgb), + canExtractEmbedding: Boolean(facesA?.embedding && facesB?.embedding), + reliabilityIssues: unique(reliabilityIssues), + }; +} + +export function localRisk(precheck: PortraitLocalPrecheck): PortraitRiskLevel { + if (!precheck.canExtractEmbedding || precheck.reliabilityIssues.length > 0 || precheck.facesA !== 1 || precheck.facesB !== 1) return "unable_to_determine"; + return riskFromFaceSimilarity(precheck.faceSimilarity); +} + +export function applyStylizedRiskFloor(comparison: PortraitVisionComparison): PortraitVisionComparison { + if (comparison.analysisPath !== "B" || comparison.featureComparison.distinctive_features.similarity !== "high") return comparison; + const risk = riskAtLeast(comparison.riskLevel, "medium"); + if (risk === comparison.riskLevel) return comparison; + return { + ...comparison, + riskLevel: risk, + basis: [...comparison.basis, "风格化路径中标志性特征被判断为高度相似,综合风险下限提升至中风险,建议人工复核。"], + manualReviewRecommended: true, + }; +} + +export function summarizeRisk(pairs: Array<{ riskLevel: PortraitRiskLevel }>) { + const riskCounts: Partial> = {}; + for (const pair of pairs) riskCounts[pair.riskLevel] = (riskCounts[pair.riskLevel] ?? 0) + 1; + return { highestRisk: highestRisk(pairs.map((pair) => pair.riskLevel)), riskCounts }; +} + +export function buildReliabilityIssues(quality: PortraitImageQuality, faces: number) { + const issues: string[] = []; + if (quality.grade === "poor") issues.push("图片质量过低,无法可靠提取肖像特征。"); + if (faces === 0) issues.push("未检测到人脸。"); + if (faces > 1) issues.push("检测到多张人脸,未指定目标对象。"); + return issues; +} + +function toFaceContract(face: PortraitFaceAnalysis["selectedFace"]) { + if (!face) return undefined; + return { bbox: face.bbox, detScore: face.detScore, areaRatio: face.areaRatio } as const; +} + +function cosine(a: ArrayLike, b: ArrayLike) { + let dot = 0; + let left = 0; + let right = 0; + for (let index = 0; index < a.length; index += 1) { + dot += Number(a[index]) * Number(b[index]); + left += Number(a[index]) ** 2; + right += Number(b[index]) ** 2; + } + return dot / Math.max(Number.EPSILON, Math.sqrt(left * right)); +} + +function riskAtLeast(current: PortraitRiskLevel, floor: PortraitRiskLevel) { + const order: PortraitRiskLevel[] = ["unable_to_determine", "low", "low_to_medium", "medium", "high"]; + return order[Math.max(order.indexOf(current), order.indexOf(floor))]!; +} + +function unique(values: string[]) { + return [...new Set(values)]; +} + +function round(value: number, digits: number) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} diff --git a/canvas-agent/src/portrait-clearance/safe-image-download.ts b/canvas-agent/src/portrait-clearance/safe-image-download.ts new file mode 100644 index 000000000..5416761ca --- /dev/null +++ b/canvas-agent/src/portrait-clearance/safe-image-download.ts @@ -0,0 +1,113 @@ +import dns from "node:dns/promises"; + +import sharp from "sharp"; + +import { decodePortraitImage, type DecodedPortraitImage } from "./image-metrics.js"; + +const MAX_IMAGE_BYTES = 12 * 1024 * 1024; +const MAX_REDIRECTS = 3; + +export class PortraitCandidateDownloadError extends Error { + constructor(readonly code: "portrait_candidate_download_blocked" | "portrait_candidate_invalid", message: string) { + super(message); + this.name = "PortraitCandidateDownloadError"; + } +} + +export async function downloadPortraitCandidate(url: string, options: { signal?: AbortSignal; fetch?: typeof fetch } = {}) { + const fetchImpl = options.fetch ?? fetch; + let current = validatePublicUrl(url); + for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) { + try { + await assertPublicImageHost(current); + } catch (error) { + if (error instanceof PortraitCandidateDownloadError) throw error; + throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选来源无法通过网络安全校验"); + } + const response = await fetchImpl(current, { + method: "GET", + redirect: "manual", + signal: options.signal, + headers: { accept: "image/jpeg,image/png,image/webp", "user-agent": "open-ai-canvas-portrait-clearance/1.0" }, + }); + if (response.status >= 300 && response.status < 400) { + if (redirect === MAX_REDIRECTS) throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选图片重定向次数过多"); + const location = response.headers.get("location"); + if (!location) throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选图片重定向地址缺失"); + current = validatePublicUrl(new URL(location, current).toString()); + continue; + } + if (!response.ok || !response.body) throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选图片无法下载"); + const contentType = (response.headers.get("content-type") || "").split(";", 1)[0].toLowerCase(); + if (!(["image/jpeg", "image/png", "image/webp"] as string[]).includes(contentType)) throw new PortraitCandidateDownloadError("portrait_candidate_invalid", "候选响应不是受支持的图片类型"); + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_IMAGE_BYTES)) throw new PortraitCandidateDownloadError("portrait_candidate_invalid", "候选图片超过大小限制"); + const bytes = await readBounded(response.body, MAX_IMAGE_BYTES, options.signal); + let decoded: DecodedPortraitImage; + try { decoded = await decodePortraitImage(bytes); } catch { throw new PortraitCandidateDownloadError("portrait_candidate_invalid", "候选图片无法解码"); } + const metadata = await sharp(bytes).metadata(); + if (metadata.pages && metadata.pages > 1) throw new PortraitCandidateDownloadError("portrait_candidate_invalid", "不接受多帧候选图片"); + return { url: current, contentType: contentType as "image/jpeg" | "image/png" | "image/webp", bytes, decoded }; + } + throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选图片下载失败"); +} + +export function validatePublicUrl(value: string) { + let url: URL; + try { url = new URL(value); } catch { throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选来源地址无效"); } + if (!(["http:", "https:"] as string[]).includes(url.protocol) || url.username || url.password || url.port && !["80", "443"].includes(url.port)) throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选来源协议或地址不受支持"); + return url.toString(); +} + +async function readBounded(stream: ReadableStream, limit: number, signal?: AbortSignal) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + if (signal?.aborted) throw abortError(); + const item = await reader.read(); + if (item.done) break; + total += item.value.byteLength; + if (total > limit) throw new PortraitCandidateDownloadError("portrait_candidate_invalid", "候选图片超过大小限制"); + chunks.push(item.value); + } + } finally { + await reader.cancel().catch(() => undefined); + } + const bytes = Buffer.allocUnsafe(total); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return bytes; +} + +async function isPublicAddress(hostname: string) { + const records = await dns.lookup(hostname, { all: true, verbatim: true }); + if (!records.length) return false; + return records.every((record) => isPublicIp(record.address)); +} + +function isPublicIp(value: string) { + if (value.includes(":")) { + const normalized = value.toLowerCase(); + const mappedIpv4 = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + if (mappedIpv4) return isPublicIp(mappedIpv4); + return normalized !== "::" && normalized !== "::1" && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && !normalized.startsWith("2001:db8:"); + } + const parts = value.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; + const [a, b] = parts; + return a !== 10 && a !== 127 && !(a === 169 && b === 254) && !(a === 172 && b >= 16 && b <= 31) && !(a === 192 && b === 168) && !(a === 100 && b >= 64 && b <= 127) && !(a === 198 && (b === 18 || b === 19)) && !(a === 0); +} + +function abortError() { + return Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); +} + +// Keep the DNS guard in the same module as URL validation so future callers cannot +// accidentally add a redirect path that bypasses the private-network check. +export async function assertPublicImageHost(value: string) { + const url = new URL(validatePublicUrl(value)); + if (url.hostname === "localhost" || !(await isPublicAddress(url.hostname))) throw new PortraitCandidateDownloadError("portrait_candidate_download_blocked", "候选来源解析到本机或私有网络地址"); + return url; +} diff --git a/canvas-agent/src/portrait-clearance/task-runner.ts b/canvas-agent/src/portrait-clearance/task-runner.ts new file mode 100644 index 000000000..168aa302b --- /dev/null +++ b/canvas-agent/src/portrait-clearance/task-runner.ts @@ -0,0 +1,238 @@ +import { isTerminal, type PortraitTaskRecord, type PortraitTaskStore } from "./task-store.js"; +import { portraitClearanceResultSchema, type PortraitCandidate, type PortraitClearanceResult, type PortraitPairResult } from "./contracts.js"; +import { cropPortraitSearchImage, decodePortraitImage, type DecodedPortraitImage } from "./image-metrics.js"; +import { deduplicatePortraitCandidates, type PortraitDedupCandidate } from "./dedup.js"; +import { PortraitFaceEngine, PortraitFaceEngineError, type PortraitFaceAnalysis } from "./face-engine.js"; +import { searchBaiduByImage, PortraitBaiduSearchError, type PortraitSearchCandidate } from "./baidu-search.js"; +import { downloadPortraitCandidate, PortraitCandidateDownloadError } from "./safe-image-download.js"; +import { buildLocalPrecheck, localRisk, summarizeRisk } from "./risk-rules.js"; +import { buildPortraitReports } from "./reports.js"; + +const MAX_CANDIDATE_BYTES = 120 * 1024 * 1024; + +type RuntimeCandidate = { + id: string; + artifactId: string; + originalRank: number; + title: string; + source: "connected" | "baidu"; + mimeType: "image/jpeg" | "image/png" | "image/webp"; + imageUrl?: string; + sourcePageUrl?: string; + sourceDomain?: string; + bytes: Uint8Array; + decoded: DecodedPortraitImage; + faces?: PortraitFaceAnalysis; +}; + +export class PortraitTaskRunner { + constructor(private readonly store: PortraitTaskStore, private readonly faceEngine: PortraitFaceEngine) {} + + start(record: PortraitTaskRecord) { + this.store.start(record.taskId, (current, signal) => this.run(current, signal)); + } + + async recover(owner: { keyId: string; origin: string }) { + const page = await this.store.list({ limit: 60 }, owner); + for (const summary of page.tasks) if (summary.status === "queued" || summary.status === "running") this.start(await this.store.get(summary.taskId, owner)); + } + + private async run(initial: PortraitTaskRecord, signal: AbortSignal) { + const owner = { keyId: initial.keyId, origin: initial.origin }; + try { + if (signal.aborted || initial.cancelRequested || initial.status === "cancelled") return; + if (initial.mode === "direct-compare") await this.runDirect(initial, owner, signal); + else await this.runNetwork(initial, owner, signal); + } catch (error) { + if (isAbort(error) || signal.aborted) return; + const current = await this.store.get(initial.taskId, owner).catch(() => undefined); + if (!current || current.cancelRequested || current.status === "cancelled") return; + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + console.error(`[portrait-clearance] task ${initial.taskId} failed: ${detail}`); + const code = error instanceof PortraitFaceEngineError ? error.code : error instanceof PortraitCandidateDownloadError ? error.code : "portrait_face_engine_failed"; + await fail(this.store, current, owner, code, publicErrorMessage(code)); + } + } + + private async runDirect(initial: PortraitTaskRecord, owner: { keyId: string; origin: string }, signal: AbortSignal) { + const query = initial.inputs.find((input) => input.role === "query"); + const reference = initial.inputs.find((input) => input.role === "reference"); + if (!query || !reference) return fail(this.store, initial, owner, "portrait_input_missing", "直接比对需要一张查询图和一张参考图"); + let record = await this.store.update(initial.taskId, owner, { status: "running", stage: "checking-model-resources", progress: 0.08 }); + if (!(await this.faceEngine.status()).ready) return fail(this.store, record, owner, "portrait_model_missing", "请先安装并校验本地 det_10g.onnx 与 w600k_r50.onnx"); + record = await this.store.update(record.taskId, owner, { stage: "preparing-query", progress: 0.16 }); + let queryImage: DecodedPortraitImage; + let referenceImage: DecodedPortraitImage; + try { + queryImage = await this.readInputImage(record, owner, query.id); + referenceImage = await this.readInputImage(record, owner, reference.id); + } catch { + return fail(this.store, record, owner, "portrait_input_invalid", "输入图片损坏、过大或格式不受支持"); + } + record = await this.store.update(record.taskId, owner, { stage: "local-comparing", progress: 0.35, processedCandidates: 0, totalCandidates: 1 }); + const [queryFaces, referenceFaces] = await Promise.all([this.faceEngine.analyze(queryImage), this.faceEngine.analyze(referenceImage)]); + if (await this.cancelled(record, owner, signal)) return; + const localPrecheck = buildLocalPrecheck(queryImage, referenceImage, queryFaces, referenceFaces); + const pair: PortraitPairResult = { + id: "pair-1", + queryImageId: query.id, + comparisonImageId: reference.id, + source: "connected-reference", + status: record.analysisMode === "local-only" ? "success" : "partial", + riskLevel: localRisk(localPrecheck), + ...(localPrecheck.faceSimilarity === undefined ? {} : { overallSimilarity: reportSimilarity(localPrecheck.faceSimilarity) }), + analysisPath: localPrecheck.canExtractEmbedding && localPrecheck.reliabilityIssues.length === 0 ? "A" : "unable", + localPrecheck, + basis: localPrecheck.faceSimilarity === undefined ? ["未能提取双方可比对的人脸特征,无法仅凭本地辅助指标得出风险等级。"] : [`本地 ArcFace 余弦相似度:${localPrecheck.faceSimilarity.toFixed(4)}。`], + limitations: record.analysisMode === "local-only" ? ["仅本地模式不调用项目视觉模型;风格化图像或可靠性异常必须人工复核。"] : ["本地预检已完成,等待页面级协调器调用项目视觉模型。"], + }; + return this.persistResult(record, owner, query.id, [pair], [{ id: reference.id, artifactId: reference.id, originalRank: 0, title: reference.fileName, source: "connected", mimeType: reference.mimeType, bytes: await this.readInputBytes(record, owner, reference.id), decoded: referenceImage }], []); + } + + private async runNetwork(initial: PortraitTaskRecord, owner: { keyId: string; origin: string }, signal: AbortSignal) { + const query = initial.inputs.find((input) => input.role === "query"); + if (!query) return fail(this.store, initial, owner, "portrait_input_missing", "网络排查需要一张查询图"); + let record = await this.store.update(initial.taskId, owner, { status: "running", stage: "checking-model-resources", progress: 0.06 }); + if (!(await this.faceEngine.status()).ready) return fail(this.store, record, owner, "portrait_model_missing", "请先安装并校验本地 det_10g.onnx 与 w600k_r50.onnx"); + record = await this.store.update(record.taskId, owner, { stage: "preparing-query", progress: 0.14 }); + let queryBytes: Uint8Array; + let queryImage: DecodedPortraitImage; + try { + queryBytes = await this.readInputBytes(record, owner, query.id); + queryImage = await decodePortraitImage(queryBytes); + } catch { + return fail(this.store, record, owner, "portrait_input_invalid", "查询图片损坏、过大或格式不受支持"); + } + const queryFaces = await this.faceEngine.analyze(queryImage); + const searchImage = await cropPortraitSearchImage(queryBytes, queryImage, queryFaces.selectedFace?.bbox); + const searchPath = await this.store.writeModelInput(record.taskId, owner, "query-search.jpg", searchImage); + const limitations: string[] = []; + if (!queryFaces.selectedFace) limitations.push("查询图未检测到可用人脸,网络搜索使用原图,结果可能不完整。"); + const manualCandidates = await this.readManualCandidates(record, owner); + let searchCandidates: PortraitSearchCandidate[] = []; + try { + record = await this.store.update(record.taskId, owner, { stage: "searching", progress: 0.22 }); + searchCandidates = await searchBaiduByImage(searchPath, { maxCandidates: record.settings.maxCandidates, scrolls: record.settings.searchScrolls, visible: record.settings.showBrowserForDebug }); + } catch (error) { + if (error instanceof PortraitBaiduSearchError) { + limitations.push(error.message); + if (!manualCandidates.length) return fail(this.store, record, owner, error.code, error.message); + } else throw error; + } + if (await this.cancelled(record, owner, signal)) return; + record = await this.store.update(record.taskId, owner, { stage: "downloading-candidates", progress: 0.3, totalCandidates: Math.min(record.settings.maxCandidates, manualCandidates.length + searchCandidates.length) }); + const candidates = [...manualCandidates]; + let candidateBytes = candidates.reduce((total, candidate) => total + candidate.bytes.byteLength, 0); + for (const [index, item] of searchCandidates.entries()) { + if (candidates.length >= record.settings.maxCandidates || await this.cancelled(record, owner, signal)) break; + try { + const downloaded = await downloadPortraitCandidate(item.imageUrl); + if (candidateBytes + downloaded.bytes.byteLength > MAX_CANDIDATE_BYTES) { + limitations.push("候选图片累计大小达到本机 120MB 限制,后续候选未继续保存。"); + break; + } + const id = `candidate-${index + 1}`; + await this.store.writeCandidate(record.taskId, owner, id, extensionForMime(downloaded.contentType), downloaded.bytes); + candidates.push({ id, artifactId: id, originalRank: item.originalRank, title: item.title, source: "baidu", mimeType: downloaded.contentType, imageUrl: downloaded.url, sourcePageUrl: item.sourcePageUrl, sourceDomain: item.sourceDomain, bytes: downloaded.bytes, decoded: downloaded.decoded }); + candidateBytes += downloaded.bytes.byteLength; + } catch (error) { + limitations.push(error instanceof Error ? error.message : "候选图片下载失败"); + } + } + record = await this.store.update(record.taskId, owner, { stage: "deduplicating", progress: 0.45, totalCandidates: candidates.length }); + const dedupInputs: PortraitDedupCandidate[] = []; + for (const candidate of candidates) { + if (record.settings.dedupMode === "arcface") { + try { candidate.faces = await this.faceEngine.analyze(candidate.decoded); } catch { limitations.push(`候选 ${candidate.id} 无法提取本地人脸特征,继续使用 pHash 去重。`); } + } + dedupInputs.push({ id: candidate.id, byteHash: candidate.decoded.byteHash, phash: candidate.decoded.phash, byteSize: candidate.bytes.byteLength, pixelArea: candidate.decoded.width * candidate.decoded.height, embedding: candidate.faces?.embedding }); + } + const deduped = deduplicatePortraitCandidates(dedupInputs, record.settings.dedupMode); + const keptIds = new Set(deduped.kept.map((candidate) => candidate.id)); + const keptCandidates = candidates.filter((candidate) => keptIds.has(candidate.id)); + record = await this.store.update(record.taskId, owner, { stage: "local-comparing", progress: 0.52, totalCandidates: keptCandidates.length }); + const pairs: PortraitPairResult[] = []; + for (const [index, candidate] of keptCandidates.entries()) { + if (await this.cancelled(record, owner, signal)) return; + let faces = candidate.faces; + try { faces ||= await this.faceEngine.analyze(candidate.decoded); } catch (error) { + const localPrecheck = buildLocalPrecheck(queryImage, candidate.decoded, queryFaces, undefined, [error instanceof Error ? error.message : "候选人脸特征提取失败"]); + pairs.push(failedPair(index, query.id, candidate, localPrecheck, "portrait_face_engine_failed", "候选人脸特征提取失败")); + continue; + } + const localPrecheck = buildLocalPrecheck(queryImage, candidate.decoded, queryFaces, faces); + const riskLevel = localRisk(localPrecheck); + pairs.push({ id: `pair-${index + 1}`, queryImageId: query.id, comparisonImageId: candidate.artifactId, candidateId: candidate.id, source: candidate.source === "baidu" ? "baidu" : "connected-candidate", status: record.analysisMode === "local-only" ? "success" : "partial", riskLevel, ...(localPrecheck.faceSimilarity === undefined ? {} : { overallSimilarity: reportSimilarity(localPrecheck.faceSimilarity) }), analysisPath: localPrecheck.canExtractEmbedding && localPrecheck.reliabilityIssues.length === 0 ? "A" : "unable", localPrecheck, basis: localPrecheck.faceSimilarity === undefined ? ["未能提取双方可比对的人脸特征,无法仅凭本地辅助指标得出风险等级。"] : [`本地 ArcFace 余弦相似度:${localPrecheck.faceSimilarity.toFixed(4)}。`], limitations: ["本机结果不确认私人身份,也不构成法律结论。"] }); + await this.store.update(record.taskId, owner, { progress: 0.52 + ((index + 1) / Math.max(1, keptCandidates.length)) * 0.38, processedCandidates: index + 1 }); + } + return this.persistResult(record, owner, query.id, pairs, keptCandidates, limitations); + } + + private async persistResult(record: PortraitTaskRecord, owner: { keyId: string; origin: string }, queryImageId: string, pairs: PortraitPairResult[], candidates: RuntimeCandidate[], limitations: string[]) { + const summary = summarizeRisk(pairs); + const completedAt = new Date().toISOString(); + const candidateResults: PortraitCandidate[] = candidates.map((candidate, index) => { + const pair = pairs.find((item) => item.candidateId === candidate.id) || pairs[index]; + return { id: candidate.id, originalRank: candidate.originalRank || index + 1, title: candidate.title, imageArtifactId: candidate.artifactId, ...(candidate.imageUrl ? { imageUrl: candidate.imageUrl } : {}), ...(candidate.sourcePageUrl ? { sourcePageUrl: candidate.sourcePageUrl } : {}), ...(candidate.sourceDomain ? { sourceDomain: candidate.sourceDomain } : {}), source: candidate.source, byteSize: candidate.bytes.byteLength, width: candidate.decoded.width, height: candidate.decoded.height, ...(pair?.id ? { resultId: pair.id } : {}) }; + }); + const hasFailure = pairs.some((pair) => pair.status === "failed"); + const result: PortraitClearanceResult = portraitClearanceResultSchema.parse({ schemaVersion: 1, taskId: record.taskId, mode: record.mode, queryImageId, highestRisk: summary.highestRisk, riskCounts: summary.riskCounts, candidateCount: candidates.length, comparedCount: pairs.length, candidates: candidateResults, pairs, limitations: ["本结果仅表示当前检索范围内的可识别性排查,不是身份确认、法律结论或司法鉴定。", ...limitations], createdAt: record.createdAt, ...(record.analysisMode === "local-only" ? { completedAt } : {}) }); + await this.store.writeResult(record.taskId, owner, result); + const reportImages = [{ id: queryImageId, bytes: await this.readInputBytes(record, owner, queryImageId), mimeType: record.inputs.find((input) => input.id === queryImageId)?.mimeType || "image/jpeg" as const }, ...candidates.map((candidate) => ({ id: candidate.artifactId, bytes: candidate.bytes, mimeType: candidate.mimeType }))]; + const reports = await buildPortraitReports(result, reportImages); + await this.store.writeReports(record.taskId, owner, reports); + if (record.analysisMode === "local-only") return this.store.update(record.taskId, owner, { status: hasFailure || !pairs.length ? "partial" : "completed", stage: "done", progress: 1, processedCandidates: pairs.length, completedAt }); + if (!pairs.length) return this.store.update(record.taskId, owner, { status: "partial", stage: "done", progress: 1, processedCandidates: 0, completedAt }); + await this.store.ensureModelJobs(record.taskId, owner); + return this.store.update(record.taskId, owner, { status: "waiting_model", stage: "waiting-for-model", progress: 0.92, processedCandidates: pairs.length, detailsAvailable: true }); + } + + private async readManualCandidates(record: PortraitTaskRecord, owner: { keyId: string; origin: string }): Promise { + const result: RuntimeCandidate[] = []; + for (const [index, input] of record.inputs.filter((item) => item.role === "candidate").entries()) { + try { + const bytes = await this.readInputBytes(record, owner, input.id); + result.push({ id: input.id, artifactId: input.id, originalRank: index + 1, title: input.fileName, source: "connected", mimeType: input.mimeType, bytes, decoded: await decodePortraitImage(bytes) }); + } catch { + // Invalid manual candidates are recorded as a partial limitation by the caller's final report. + } + } + return result; + } + + private async readInputBytes(record: PortraitTaskRecord, owner: { keyId: string; origin: string }, inputId: string) { + return (await this.store.readInput(record.taskId, owner, inputId)).bytes; + } + + private async readInputImage(record: PortraitTaskRecord, owner: { keyId: string; origin: string }, inputId: string) { + return decodePortraitImage(await this.readInputBytes(record, owner, inputId)); + } + + private async cancelled(record: PortraitTaskRecord, owner: { keyId: string; origin: string }, signal: AbortSignal) { + return signal.aborted || (await this.store.get(record.taskId, owner)).cancelRequested === true; + } +} + +function failedPair(index: number, queryId: string, candidate: RuntimeCandidate, localPrecheck: ReturnType, code: string, message: string): PortraitPairResult { + return { id: `pair-${index + 1}`, queryImageId: queryId, comparisonImageId: candidate.artifactId, candidateId: candidate.id, source: candidate.source === "baidu" ? "baidu" : "connected-candidate", status: "failed", riskLevel: "unable_to_determine", analysisPath: "unable", localPrecheck, basis: [message], limitations: ["该候选未完成本地比对。"], error: { code, message, retryable: true } }; +} + +async function fail(store: PortraitTaskStore, record: PortraitTaskRecord, owner: { keyId: string; origin: string }, code: string, message: string) { + await store.update(record.taskId, owner, { status: "failed", stage: "done", progress: 1, errorCode: code, errorMessage: message, completedAt: new Date().toISOString() }); +} + +function extensionForMime(mime: string) { + return mime === "image/png" ? "png" as const : mime === "image/webp" ? "webp" as const : "jpg" as const; +} + +function reportSimilarity(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function publicErrorMessage(code: string) { + return ({ portrait_model_missing: "请先安装并校验本地肖像模型", portrait_input_invalid: "输入图片损坏、过大或格式不受支持", portrait_candidate_download_blocked: "候选图片下载被安全策略阻止", portrait_candidate_invalid: "候选图片无效或无法解码", portrait_search_browser_unavailable: "未找到系统 Chrome、Edge 或 Chromium", portrait_search_layout_changed: "百度识图页面结构发生变化", portrait_search_captcha: "百度识图需要验证码或安全验证" } as Record)[code] || "本地肖像检测失败"; +} + +function isAbort(error: unknown) { + return Boolean(error && typeof error === "object" && "name" in error && (error as { name?: unknown }).name === "AbortError"); +} diff --git a/canvas-agent/src/portrait-clearance/task-store.ts b/canvas-agent/src/portrait-clearance/task-store.ts new file mode 100644 index 000000000..e305a4fed --- /dev/null +++ b/canvas-agent/src/portrait-clearance/task-store.ts @@ -0,0 +1,676 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { createPortraitClearanceTaskRequestSchema, portraitClearanceInputSchema, portraitClearanceResultSchema, portraitModelJobCompleteRequestSchema, portraitModelJobFailRequestSchema, portraitModelJobSchema, portraitTaskSummarySchema, type CreatePortraitClearanceTaskRequest, type PortraitClearanceInputRole, type PortraitClearanceTaskStatus, type PortraitModelJob, type PortraitTaskSummary } from "./contracts.js"; +import { applyStylizedRiskFloor, summarizeRisk } from "./risk-rules.js"; +import { buildPortraitReports } from "./reports.js"; + +// Base64 expands binary input by roughly 4/3; keep the decoded total below +// 20MB so the signed JSON request remains within Local Runtime's 30MB limit. +const MAX_INPUT_BYTES = 20 * 1024 * 1024; +const MODEL_JOB_LEASE_MS = 180_000; +const MAX_MODEL_JOB_ATTEMPTS = 3; + +export type PortraitTaskInputFile = { + id: string; + nodeId: string; + role: PortraitClearanceInputRole; + fileName: string; + mimeType: "image/jpeg" | "image/png" | "image/webp"; + relativePath: string; +}; + +export type PortraitTaskRecord = PortraitTaskSummary & { + schemaVersion: 1; + ownerId: string; + keyId: string; + origin: string; + ownerScopeHash: string; + settings: CreatePortraitClearanceTaskRequest["settings"]; + inputs: PortraitTaskInputFile[]; + resultRelativePath?: string; + reportRelativePaths?: Partial>; + modelJobs?: PortraitModelJob[]; + cancelRequested?: boolean; +}; + +export type PortraitTaskEvent = { + id: string; + taskId: string; + type: "state" | "candidate" | "error"; + at: string; + summary: PortraitTaskSummary; +}; + +export class PortraitTaskStoreError extends Error { + constructor(readonly code: "portrait_task_not_found" | "portrait_task_forbidden" | "portrait_idempotency_conflict" | "portrait_input_invalid" | "portrait_task_delete_failed" | "portrait_model_job_not_found" | "portrait_model_job_conflict" | "portrait_artifact_not_found", message: string, readonly status = 400) { + super(message); + this.name = "PortraitTaskStoreError"; + } +} + +export class PortraitTaskStore { + private readonly active = new Map>(); + private readonly activeControllers = new Map(); + private readonly taskLocks = new Map>(); + private readonly deleting = new Set(); + + constructor(private readonly root: string, private readonly runtimeOwnerId: string) {} + + async create(request: CreatePortraitClearanceTaskRequest, owner: { keyId: string; origin: string }) { + const parsed = createPortraitClearanceTaskRequestSchema.parse(request); + const queryCount = parsed.inputs.filter((input) => input.role === "query").length; + const referenceCount = parsed.inputs.filter((input) => input.role === "reference").length; + if (parsed.mode === "direct-compare" && (queryCount !== 1 || referenceCount !== 1)) throw new PortraitTaskStoreError("portrait_input_invalid", "直接比对需要唯一的查询图和参考图", 400); + if (parsed.mode === "network-search" && queryCount !== 1) throw new PortraitTaskStoreError("portrait_input_invalid", "网络排查需要唯一的查询图", 400); + const existing = await this.findByOperation(parsed, owner); + if (existing) return { record: existing, created: false }; + const existingOperation = (await this.listAll()).find((item) => item.clientOperationId === parsed.clientOperationId); + if (existingOperation) throw new PortraitTaskStoreError("portrait_idempotency_conflict", "同一幂等键已用于另一项肖像排查任务", 409); + const taskId = `portrait-${crypto.randomBytes(16).toString("hex")}`; + const taskRoot = this.taskRoot(taskId); + await fs.mkdir(path.join(taskRoot, "inputs"), { recursive: true }); + let inputFiles: PortraitTaskInputFile[]; + try { + inputFiles = await persistInputs(taskRoot, parsed.inputs); + } catch (error) { + await fs.rm(taskRoot, { recursive: true, force: true }); + throw error; + } + const now = new Date().toISOString(); + const record: PortraitTaskRecord = { + schemaVersion: 1, + taskId, + clientOperationId: parsed.clientOperationId, + ownerId: this.runtimeOwnerId, + keyId: owner.keyId, + origin: owner.origin, + ownerScopeHash: parsed.ownerScopeHash, + projectId: parsed.projectId, + nodeId: parsed.nodeId, + mode: parsed.mode, + analysisMode: parsed.analysisMode, + ...(parsed.modelRef ? { modelRef: parsed.modelRef } : {}), + status: "queued", + stage: "validating-inputs", + progress: 0, + processedCandidates: 0, + totalCandidates: parsed.mode === "direct-compare" ? 1 : undefined, + createdAt: now, + updatedAt: now, + detailsAvailable: false, + settings: parsed.settings, + inputs: inputFiles, + }; + await this.writeRecord(record); + await this.appendEvent(record, "state"); + return { record, created: true }; + } + + async get(taskId: string, owner: { keyId: string; origin: string }) { + const record = await this.readRecord(taskId); + this.assertOwner(record, owner); + return record; + } + + async list(query: { projectId?: string; nodeId?: string; ownerScopeHash?: string; limit: number; cursor?: string }, owner: { keyId: string; origin: string }) { + const records = (await this.listAll()) + .filter((record) => record.keyId === owner.keyId && record.origin === owner.origin) + .filter((record) => !query.ownerScopeHash || record.ownerScopeHash === query.ownerScopeHash) + .filter((record) => !query.projectId || record.projectId === query.projectId) + .filter((record) => !query.nodeId || record.nodeId === query.nodeId) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.taskId.localeCompare(left.taskId)); + const after = query.cursor ? records.findIndex((record) => record.taskId === query.cursor) + 1 : 0; + const selected = records.slice(Math.max(0, after), Math.min(records.length, Math.max(0, after) + query.limit)); + return { tasks: selected.map(toSummary), ...(selected.length && selected.at(-1)!.taskId !== records.at(-1)?.taskId ? { nextCursor: selected.at(-1)!.taskId } : {}) }; + } + + async counts(owner: { keyId: string; origin: string }) { + const records = (await this.listAll()).filter((record) => record.keyId === owner.keyId && record.origin === owner.origin); + const recoverable = records.filter((record) => record.status === "queued" || record.status === "running" || record.status === "waiting_model"); + return { active: records.filter((record) => this.isRunning(record.taskId) || record.status === "waiting_model").length, recoverable: recoverable.length }; + } + + async recoverableRecords() { + return (await this.listAll()).filter((record) => record.status === "queued" || record.status === "running"); + } + + async update(taskId: string, owner: { keyId: string; origin: string }, patch: Partial>) { + return this.withTaskLock(taskId, () => this.updateUnlocked(taskId, owner, patch)); + } + + private async updateUnlocked(taskId: string, owner: { keyId: string; origin: string }, patch: Partial>, allowCancelledTransition = false) { + if (this.deleting.has(taskId)) throw new PortraitTaskStoreError("portrait_task_not_found", "任务正在删除", 404); + const record = await this.get(taskId, owner); + if (!allowCancelledTransition && record.status === "cancelled" && patch.status && patch.status !== "cancelled") return record; + const next: PortraitTaskRecord = { ...record, ...patch, updatedAt: new Date().toISOString() }; + await this.writeRecord(next); + await this.appendEvent(next, patch.errorCode ? "error" : "state"); + return next; + } + + async prepareRetry(taskId: string, owner: { keyId: string; origin: string }) { + this.activeControllers.get(taskId)?.abort(); + await this.active.get(taskId)?.catch(() => undefined); + return this.withTaskLock(taskId, async () => { + const record = await this.get(taskId, owner); + for (const directory of ["results", "reports", "candidates", "model-inputs"]) { + await fs.rm(path.join(this.taskRoot(record.taskId), directory), { recursive: true, force: true }); + } + return this.updateUnlocked(taskId, owner, { + status: "queued", + stage: "validating-inputs", + progress: 0, + processedCandidates: 0, + errorCode: undefined, + errorMessage: undefined, + completedAt: undefined, + detailsAvailable: false, + resultRelativePath: undefined, + reportRelativePaths: undefined, + modelJobs: undefined, + cancelRequested: false, + }, true); + }); + } + + async writeResult(taskId: string, owner: { keyId: string; origin: string }, result: unknown) { + const record = await this.get(taskId, owner); + const parsed = portraitClearanceResultSchema.parse(result); + const resultRelativePath = "results/clearance-result.json"; + const resultPath = path.join(this.taskRoot(record.taskId), resultRelativePath); + await atomicWrite(resultPath, JSON.stringify(parsed, null, 2)); + return this.update(taskId, owner, { resultRelativePath, detailsAvailable: true }); + } + + async readResult(taskId: string, owner: { keyId: string; origin: string }) { + return this.withTaskLock(taskId, async () => { + const record = await this.get(taskId, owner); + if (!record.resultRelativePath) return undefined; + try { + const value = await this.readStoredResult(record); + return this.syncFailedModelPairs(record, owner, value); + } catch { + return undefined; + } + }); + } + + private async readStoredResult(record: PortraitTaskRecord) { + if (!record.resultRelativePath) return undefined; + return JSON.parse(await fs.readFile(path.join(this.taskRoot(record.taskId), record.resultRelativePath), "utf8")) as unknown; + } + + private async syncFailedModelPairs(record: PortraitTaskRecord, owner: { keyId: string; origin: string }, value: unknown) { + const parsed = portraitClearanceResultSchema.safeParse(value); + if (!parsed.success || !record.modelJobs?.some((job) => job.status === "failed" && job.errorMessage)) return value; + let changed = false; + const pairs = parsed.data.pairs.map((pair) => { + const job = record.modelJobs?.find((candidate) => candidate.status === "failed" && candidate.pairId === pair.id && candidate.errorMessage); + if (!job || pair.status === "failed" && pair.error?.code === job.errorCode) return pair; + changed = true; + return { ...pair, status: "failed" as const, riskLevel: "unable_to_determine" as const, analysisPath: "unable" as const, overallSimilarity: undefined, visionComparison: undefined, basis: [...pair.basis, job.errorMessage!].slice(0, 32), limitations: [...new Set([...pair.limitations, "该候选未完成视觉模型比对,已从本批次跳过。"])].slice(0, 32), error: { code: job.errorCode || "portrait_model_job_failed", message: job.errorMessage!, retryable: false } }; + }); + if (!changed) return parsed.data; + const riskSummary = summarizeRisk(pairs); + const next = portraitClearanceResultSchema.parse({ ...parsed.data, highestRisk: riskSummary.highestRisk, riskCounts: riskSummary.riskCounts, comparedCount: pairs.filter((pair) => pair.status !== "failed").length, pairs }); + await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(next, null, 2)); + if (record.status === "partial" || record.status === "completed") { + await this.refreshReports(record.taskId, owner, next).catch(() => undefined); + } + return next; + } + + async writeReports(taskId: string, owner: { keyId: string; origin: string }, reports: { markdown: string; html: string; docx: Uint8Array }) { + return this.withTaskLock(taskId, () => this.writeReportsUnlocked(taskId, owner, reports)); + } + + private async writeReportsUnlocked(taskId: string, owner: { keyId: string; origin: string }, reports: { markdown: string; html: string; docx: Uint8Array }) { + const record = await this.get(taskId, owner); + const reportDirectory = path.join(this.taskRoot(record.taskId), "reports"); + await fs.mkdir(reportDirectory, { recursive: true }); + await atomicWrite(path.join(reportDirectory, "clearance-report.md"), reports.markdown); + await atomicWrite(path.join(reportDirectory, "clearance-report.html"), reports.html); + await atomicWriteBytes(path.join(reportDirectory, "clearance-report.docx"), reports.docx); + const reportRelativePaths = { md: "reports/clearance-report.md", html: "reports/clearance-report.html", docx: "reports/clearance-report.docx" } as const; + return this.updateUnlocked(taskId, owner, { reportRelativePaths }); + } + + async readReport(taskId: string, owner: { keyId: string; origin: string }, artifactId: string) { + const record = await this.get(taskId, owner); + const relativePath = artifactId === "clearance-report.md" ? record.reportRelativePaths?.md : artifactId === "clearance-report.html" ? record.reportRelativePaths?.html : artifactId === "clearance-report.docx" ? record.reportRelativePaths?.docx : undefined; + if (!relativePath) throw new PortraitTaskStoreError("portrait_artifact_not_found", "报告不存在", 404); + const mimeType = artifactId.endsWith(".md") ? "text/markdown; charset=utf-8" : artifactId.endsWith(".html") ? "text/html; charset=utf-8" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + const reportPath = path.join(this.taskRoot(record.taskId), relativePath); + if (artifactId === "clearance-report.html") { + const existing = await fs.readFile(reportPath, "utf8").catch(() => ""); + if (!existing.includes('data-report-version="2"')) { + await this.refreshReports(taskId, owner, portraitClearanceResultSchema.parse(await this.readResult(taskId, owner))); + } + } + return { mimeType, bytes: await fs.readFile(reportPath) }; + } + + async ensureModelJobs(taskId: string, owner: { keyId: string; origin: string }) { + return this.withTaskLock(taskId, () => this.ensureModelJobsUnlocked(taskId, owner)); + } + + private async ensureModelJobsUnlocked(taskId: string, owner: { keyId: string; origin: string }) { + const record = await this.get(taskId, owner); + if (record.modelJobs?.length) return record.modelJobs; + const result = portraitClearanceResultSchema.parse(await this.readStoredResult(record)); + const modelJobs = result.pairs.map((pair) => portraitModelJobSchema.parse({ + jobId: `portrait-job-${crypto.randomBytes(16).toString("hex")}`, + taskId: record.taskId, + pairId: pair.id, + queryImageId: pair.queryImageId, + comparisonImageId: pair.comparisonImageId, + status: "pending", + attempt: 0, + })); + const next = { ...record, modelJobs, updatedAt: new Date().toISOString() }; + await this.writeRecord(next); + await this.appendEvent(next, "state"); + return modelJobs; + } + + async claimModelJob(taskId: string, owner: { keyId: string; origin: string }): Promise { + return this.withTaskLock(taskId, () => this.claimModelJobUnlocked(taskId, owner)); + } + + private async claimModelJobUnlocked(taskId: string, owner: { keyId: string; origin: string }): Promise { + const record = await this.get(taskId, owner); + const modelJobs = record.modelJobs || []; + const now = Date.now(); + let changed = false; + const exhausted = modelJobs.find((job) => job.status === "leased" && job.attempt > MAX_MODEL_JOB_ATTEMPTS && job.leaseToken && job.leaseExpiresAt && Date.parse(job.leaseExpiresAt) > now); + if (exhausted?.leaseToken) { + exhausted.status = "failed"; + exhausted.errorCode = "portrait_model_attempts_exhausted"; + exhausted.errorMessage = "该候选视觉模型多次未完成,已跳过此候选"; + exhausted.leaseToken = undefined; + exhausted.leaseExpiresAt = undefined; + changed = true; + } + for (const job of modelJobs) { + if (job.status === "leased" && (!job.leaseExpiresAt || Date.parse(job.leaseExpiresAt) <= now)) { + job.status = "pending"; + job.leaseToken = undefined; + job.leaseExpiresAt = undefined; + changed = true; + } + } + const job = modelJobs.find((candidate) => candidate.status === "pending"); + if (!job) { + if (changed) await this.saveModelJobs(record, modelJobs); + return undefined; + } + job.status = "leased"; + job.attempt += 1; + job.leaseToken = crypto.randomBytes(24).toString("base64url"); + job.leaseExpiresAt = new Date(now + MODEL_JOB_LEASE_MS).toISOString(); + await this.saveModelJobs(record, modelJobs); + return job; + } + + async completeModelJob(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) { + return this.withTaskLock(taskId, () => this.completeModelJobUnlocked(taskId, owner, jobId, request)); + } + + private async completeModelJobUnlocked(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) { + const parsed = portraitModelJobCompleteRequestSchema.parse(request); + const record = await this.get(taskId, owner); + if (record.status === "cancelled") throw new PortraitTaskStoreError("portrait_model_job_conflict", "任务已停止,不能继续提交视觉模型结果", 409); + const jobs = record.modelJobs || []; + const job = jobs.find((candidate) => candidate.jobId === jobId); + if (!job) throw new PortraitTaskStoreError("portrait_model_job_not_found", "模型作业不存在", 404); + if (job.status === "completed") return { record, result: await this.readStoredResult(record), job }; + assertLeasedJob(job, parsed.attempt, parsed.leaseToken); + const result = portraitClearanceResultSchema.parse(await this.readStoredResult(record)); + const pair = result.pairs.find((candidate) => candidate.id === job.pairId); + if (!pair) throw new PortraitTaskStoreError("portrait_model_job_conflict", "模型作业对应的比对结果不存在", 409); + const vision = applyStylizedRiskFloor(parsed.visionComparison); + const nextPair = { ...pair, status: vision.status === "success" ? "success" as const : "partial" as const, riskLevel: vision.riskLevel, overallSimilarity: vision.overallSimilarity, analysisPath: vision.analysisPath, visionComparison: vision, basis: [...pair.basis, ...vision.basis].slice(0, 32), limitations: [...pair.limitations, ...vision.limitations].slice(0, 32) }; + const nextPairs = result.pairs.map((candidate) => candidate.id === pair.id ? nextPair : candidate); + const riskSummary = summarizeRisk(nextPairs); + const nextResult = portraitClearanceResultSchema.parse({ ...result, highestRisk: riskSummary.highestRisk, riskCounts: riskSummary.riskCounts, pairs: nextPairs, completedAt: undefined }); + await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(nextResult, null, 2)); + job.status = "completed"; + job.leaseToken = undefined; + job.leaseExpiresAt = undefined; + await this.saveModelJobs(record, jobs); + const complete = jobs.every((candidate) => candidate.status === "completed" || candidate.status === "failed"); + const completedAt = complete ? new Date().toISOString() : undefined; + const finalResult = completedAt ? portraitClearanceResultSchema.parse({ ...nextResult, completedAt }) : nextResult; + if (completedAt) { + await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(finalResult, null, 2)); + await this.refreshReports(taskId, owner, finalResult); + } + const updated = await this.updateUnlocked(taskId, owner, complete ? { status: jobs.some((candidate) => candidate.status === "failed") || finalResult.pairs.some((candidate) => candidate.status !== "success") ? "partial" : "completed", stage: "done", progress: 1, processedCandidates: finalResult.pairs.length, completedAt } : { processedCandidates: jobs.filter((candidate) => candidate.status === "completed").length, progress: 0.92 + (jobs.filter((candidate) => candidate.status === "completed").length / Math.max(1, jobs.length)) * 0.08, stage: "model-comparing", status: "waiting_model" }); + return { record: updated, result: finalResult, job }; + } + + async failModelJob(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) { + return this.withTaskLock(taskId, () => this.failModelJobUnlocked(taskId, owner, jobId, request)); + } + + private async failModelJobUnlocked(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) { + const parsed = portraitModelJobFailRequestSchema.parse(request); + const record = await this.get(taskId, owner); + if (record.status === "cancelled") throw new PortraitTaskStoreError("portrait_model_job_conflict", "任务已停止,不能继续提交视觉模型结果", 409); + const jobs = record.modelJobs || []; + const job = jobs.find((candidate) => candidate.jobId === jobId); + if (!job) throw new PortraitTaskStoreError("portrait_model_job_not_found", "模型作业不存在", 404); + if (job.status === "failed") return { record, job }; + assertLeasedJob(job, parsed.attempt, parsed.leaseToken); + let result = portraitClearanceResultSchema.parse(await this.readStoredResult(record)); + if (parsed.retryable && job.attempt < 3) { + job.status = "pending"; + job.leaseToken = undefined; + job.leaseExpiresAt = undefined; + } else { + job.status = "failed"; + job.errorCode = parsed.errorCode; + job.errorMessage = parsed.errorMessage; + job.leaseToken = undefined; + job.leaseExpiresAt = undefined; + const nextPairs = result.pairs.map((pair) => { + if (pair.id !== job.pairId) return pair; + return { ...pair, status: "failed" as const, riskLevel: "unable_to_determine" as const, analysisPath: "unable" as const, basis: [...pair.basis, parsed.errorMessage].slice(0, 32), limitations: [...pair.limitations, "该候选未完成视觉模型比对,已从本批次跳过。"].slice(0, 32), error: { code: parsed.errorCode, message: parsed.errorMessage, retryable: false } }; + }); + const riskSummary = summarizeRisk(nextPairs); + result = portraitClearanceResultSchema.parse({ ...result, highestRisk: riskSummary.highestRisk, riskCounts: riskSummary.riskCounts, pairs: nextPairs, completedAt: undefined }); + await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(result, null, 2)); + } + await this.saveModelJobs(record, jobs); + const complete = jobs.every((candidate) => candidate.status === "completed" || candidate.status === "failed"); + if (complete) { + const modelLimitations = jobs.filter((candidate) => candidate.status === "failed" && candidate.errorMessage).map((candidate) => `视觉模型作业未完成:${candidate.errorMessage}`).slice(0, 16); + const finalResult = portraitClearanceResultSchema.parse({ + ...result, + limitations: [...result.limitations, ...modelLimitations].slice(0, 32), + completedAt: new Date().toISOString(), + }); + await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(finalResult, null, 2)); + await this.refreshReports(taskId, owner, finalResult); + } + const updated = complete ? await this.updateUnlocked(taskId, owner, { status: "partial", stage: "done", progress: 1, completedAt: new Date().toISOString() }) : await this.updateUnlocked(taskId, owner, { status: "waiting_model", stage: "waiting-for-model" }); + return { record: updated, job }; + } + + async readInput(taskId: string, owner: { keyId: string; origin: string }, inputId: string) { + const record = await this.get(taskId, owner); + const input = record.inputs.find((candidate) => candidate.id === inputId); + if (!input) throw new PortraitTaskStoreError("portrait_input_invalid", "任务输入不存在", 404); + return { input, bytes: await fs.readFile(path.join(this.taskRoot(record.taskId), input.relativePath)) }; + } + + async writeCandidate(taskId: string, owner: { keyId: string; origin: string }, artifactId: string, extension: "jpg" | "png" | "webp", bytes: Uint8Array) { + const record = await this.get(taskId, owner); + if (!/^candidate-[A-Za-z0-9._:-]{1,120}$/.test(artifactId)) throw new PortraitTaskStoreError("portrait_input_invalid", "候选文件标识无效", 400); + const relativePath = path.join("candidates", `${artifactId}.${extension}`); + const filePath = path.join(this.taskRoot(record.taskId), relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, bytes, { flag: "wx" }); + return { relativePath }; + } + + async readImageArtifact(taskId: string, owner: { keyId: string; origin: string }, artifactId: string) { + const record = await this.get(taskId, owner); + const input = record.inputs.find((candidate) => candidate.id === artifactId); + if (input) return { mimeType: input.mimeType, bytes: await fs.readFile(path.join(this.taskRoot(record.taskId), input.relativePath)) }; + if (!/^candidate-[A-Za-z0-9._:-]{1,120}$/.test(artifactId)) throw new PortraitTaskStoreError("portrait_input_invalid", "图片资源不存在", 404); + const directory = path.join(this.taskRoot(record.taskId), "candidates"); + const entries = await fs.readdir(directory).catch(() => [] as string[]); + const fileName = entries.find((entry) => entry.startsWith(`${artifactId}.`)); + if (!fileName) throw new PortraitTaskStoreError("portrait_input_invalid", "图片资源不存在", 404); + const extension = path.extname(fileName).slice(1); + const mimeType = extension === "jpg" ? "image/jpeg" : extension === "png" ? "image/png" : "image/webp"; + return { mimeType, bytes: await fs.readFile(path.join(directory, fileName)) }; + } + + async requestCancel(taskId: string, owner: { keyId: string; origin: string }) { + return this.withTaskLock(taskId, async () => { + const record = await this.get(taskId, owner); + if (isTerminal(record.status)) return record; + const next = await this.updateUnlocked(taskId, owner, { cancelRequested: true, status: "cancelled", stage: "done", errorCode: "portrait_task_cancelled", errorMessage: "任务已停止", completedAt: new Date().toISOString() }); + this.activeControllers.get(taskId)?.abort(); + return next; + }); + } + + async delete(taskId: string, owner: { keyId: string; origin: string }) { + await this.withTaskLock(taskId, async () => { + const record = await this.get(taskId, owner); + if (!isTerminal(record.status)) { + await this.updateUnlocked(taskId, owner, { cancelRequested: true, status: "cancelled", stage: "done", errorCode: "portrait_task_cancelled", errorMessage: "任务已停止", completedAt: new Date().toISOString() }); + } + this.deleting.add(taskId); + this.activeControllers.get(taskId)?.abort(); + }); + try { + await this.active.get(taskId)?.catch(() => undefined); + return await this.withTaskLock(taskId, async () => { + const record = await this.get(taskId, owner); + await fs.rm(this.taskRoot(record.taskId), { recursive: true, force: false }).catch(() => { throw new PortraitTaskStoreError("portrait_task_delete_failed", "删除本地排查数据失败", 500); }); + return { deleted: true as const }; + }); + } finally { + this.active.delete(taskId); + this.activeControllers.delete(taskId); + this.deleting.delete(taskId); + } + } + + async events(taskId: string, owner: { keyId: string; origin: string }, afterId?: string) { + const record = await this.get(taskId, owner); + const eventPath = path.join(this.taskRoot(record.taskId), "events.ndjson"); + let lines: string[] = []; + try { lines = (await fs.readFile(eventPath, "utf8")).split("\n").filter(Boolean); } catch { return []; } + const events = lines.map((line) => JSON.parse(line) as PortraitTaskEvent); + return afterId ? events.filter((event) => event.id > afterId) : events; + } + + async inputPath(taskId: string, owner: { keyId: string; origin: string }, inputId: string) { + const record = await this.get(taskId, owner); + const input = record.inputs.find((candidate) => candidate.id === inputId); + if (!input) throw new PortraitTaskStoreError("portrait_input_invalid", "任务输入不存在", 404); + return path.join(this.taskRoot(taskId), input.relativePath); + } + + async writeModelInput(taskId: string, owner: { keyId: string; origin: string }, name: "query-search.jpg", bytes: Uint8Array) { + const record = await this.get(taskId, owner); + const relativePath = path.join("model-inputs", name); + const filePath = path.join(this.taskRoot(record.taskId), relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await atomicWriteBytes(filePath, bytes); + return filePath; + } + + start(taskId: string, runner: (record: PortraitTaskRecord, signal: AbortSignal) => Promise) { + if (this.active.has(taskId) || this.deleting.has(taskId)) return; + const controller = new AbortController(); + const promise = this.readRecord(taskId).then((record) => runner(record, controller.signal)).finally(() => { + if (this.active.get(taskId) === promise) this.active.delete(taskId); + if (this.activeControllers.get(taskId) === controller) this.activeControllers.delete(taskId); + }); + this.active.set(taskId, promise); + this.activeControllers.set(taskId, controller); + void promise.catch(() => undefined); + } + + isRunning(taskId: string) { + return this.active.has(taskId); + } + + private async findByOperation(request: CreatePortraitClearanceTaskRequest, owner: { keyId: string; origin: string }) { + const record = (await this.listAll()).find((candidate) => candidate.clientOperationId === request.clientOperationId); + if (!record) return undefined; + if (record.keyId !== owner.keyId || record.origin !== owner.origin || record.ownerScopeHash !== request.ownerScopeHash || record.projectId !== request.projectId || record.nodeId !== request.nodeId) throw new PortraitTaskStoreError("portrait_idempotency_conflict", "肖像排查幂等键不匹配", 409); + return record; + } + + private async listAll() { + await fs.mkdir(this.tasksRoot(), { recursive: true }); + const entries = await fs.readdir(this.tasksRoot(), { withFileTypes: true }); + const records: PortraitTaskRecord[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + try { records.push(await this.readRecord(entry.name)); } catch { /* ignore torn/foreign directories */ } + } + return records; + } + + private async withTaskLock(taskId: string, action: () => Promise): Promise { + const previous = this.taskLocks.get(taskId) || Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const queued = previous.then(() => gate); + this.taskLocks.set(taskId, queued); + await previous; + try { + return await action(); + } finally { + release(); + if (this.taskLocks.get(taskId) === queued) this.taskLocks.delete(taskId); + } + } + + private async readRecord(taskId: string) { + if (!/^[a-z0-9-]{16,80}$/.test(taskId)) throw new PortraitTaskStoreError("portrait_task_not_found", "任务不存在", 404); + try { + const value = JSON.parse(await fs.readFile(path.join(this.taskRoot(taskId), "task.json"), "utf8")) as PortraitTaskRecord; + toSummary(value); + if (value.schemaVersion !== 1 || value.ownerId !== this.runtimeOwnerId || !Array.isArray(value.inputs)) throw new Error(); + return value; + } catch (error) { + if (error instanceof PortraitTaskStoreError) throw error; + throw new PortraitTaskStoreError("portrait_task_not_found", "任务不存在", 404); + } + } + + private assertOwner(record: PortraitTaskRecord, owner: { keyId: string; origin: string }) { + if (record.ownerId !== this.runtimeOwnerId || record.keyId !== owner.keyId || record.origin !== owner.origin) throw new PortraitTaskStoreError("portrait_task_forbidden", "无权访问该本地排查任务", 403); + } + + private tasksRoot() { return path.join(this.root, "tasks"); } + private taskRoot(taskId: string) { return path.join(this.tasksRoot(), taskId); } + + private async writeRecord(record: PortraitTaskRecord) { + await atomicWrite(path.join(this.taskRoot(record.taskId), "task.json"), JSON.stringify(record, null, 2)); + } + + private async appendEvent(record: PortraitTaskRecord, type: PortraitTaskEvent["type"]) { + const event: PortraitTaskEvent = { id: `${record.updatedAt}:${crypto.randomBytes(4).toString("hex")}`, taskId: record.taskId, type, at: record.updatedAt, summary: toSummary(record) }; + await fs.appendFile(path.join(this.taskRoot(record.taskId), "events.ndjson"), `${JSON.stringify(event)}\n`, "utf8"); + } + + private async saveModelJobs(record: PortraitTaskRecord, modelJobs: PortraitModelJob[]) { + const next = { ...record, modelJobs: modelJobs.map((job) => portraitModelJobSchema.parse(job)), updatedAt: new Date().toISOString() }; + await this.writeRecord(next); + await this.appendEvent(next, "state"); + } + + private async refreshReports(taskId: string, owner: { keyId: string; origin: string }, result: ReturnType) { + const imageIds = new Set([result.queryImageId, ...result.candidates.map((candidate) => candidate.imageArtifactId)]); + const images = []; + for (const imageId of imageIds) { + try { + const image = await this.readImageArtifact(taskId, owner, imageId); + images.push({ id: imageId, mimeType: image.mimeType as "image/jpeg" | "image/png" | "image/webp", bytes: image.bytes }); + } catch { + // A report remains useful when a deleted or unavailable candidate image cannot be embedded. + } + } + await this.writeReportsUnlocked(taskId, owner, await buildPortraitReports(result, images)); + } +} + +export function toSummary(record: PortraitTaskRecord): PortraitTaskSummary { + return portraitTaskSummarySchema.parse({ + taskId: record.taskId, + clientOperationId: record.clientOperationId, + ownerScopeHash: record.ownerScopeHash, + projectId: record.projectId, + nodeId: record.nodeId, + mode: record.mode, + analysisMode: record.analysisMode, + ...(record.modelRef ? { modelRef: record.modelRef } : {}), + status: record.status, + stage: record.stage, + progress: record.progress, + processedCandidates: record.processedCandidates, + ...(record.totalCandidates === undefined ? {} : { totalCandidates: record.totalCandidates }), + ...(record.errorCode ? { errorCode: record.errorCode } : {}), + ...(record.errorMessage ? { errorMessage: record.errorMessage } : {}), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ...(record.completedAt ? { completedAt: record.completedAt } : {}), + detailsAvailable: record.detailsAvailable, + }); +} + +export function isTerminal(status: PortraitClearanceTaskStatus) { + return status === "partial" || status === "completed" || status === "failed" || status === "cancelled"; +} + +export function decodePortraitDataUrl(value: string, expectedMime: PortraitTaskInputFile["mimeType"]) { + const match = /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=_-]+)$/.exec(value); + if (!match || match[1] !== expectedMime) throw new PortraitTaskStoreError("portrait_input_invalid", "输入图片格式无效", 400); + const data = Buffer.from(match[2]!, "base64"); + if (!data.byteLength || data.byteLength > MAX_INPUT_BYTES) throw new PortraitTaskStoreError("portrait_input_invalid", "输入图片超过本机大小限制", 413); + return data; +} + +async function persistInputs(taskRoot: string, inputs: CreatePortraitClearanceTaskRequest["inputs"]): Promise { + let totalBytes = 0; + const files: PortraitTaskInputFile[] = []; + for (const [index, input] of inputs.entries()) { + const parsed = portraitClearanceInputSchema.parse(input); + const bytes = decodePortraitDataUrl(parsed.dataUrl, parsed.mimeType); + totalBytes += bytes.byteLength; + if (totalBytes > MAX_INPUT_BYTES) throw new PortraitTaskStoreError("portrait_input_invalid", "任务输入总大小超过本机限制", 413); + const id = `input-${index + 1}`; + const extension = parsed.mimeType === "image/jpeg" ? "jpg" : parsed.mimeType.slice("image/".length); + const relativePath = path.join("inputs", `${id}.${extension}`); + await fs.writeFile(path.join(taskRoot, relativePath), bytes, { flag: "wx" }); + files.push({ id, nodeId: parsed.nodeId, role: parsed.role, fileName: parsed.fileName, mimeType: parsed.mimeType, relativePath }); + } + return files; +} + +async function atomicWrite(filePath: string, contents: string) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`; + await fs.writeFile(temporary, contents, { encoding: "utf8", flag: "wx" }); + await replaceFile(temporary, filePath); +} + +async function atomicWriteBytes(filePath: string, contents: Uint8Array) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`; + await fs.writeFile(temporary, contents, { flag: "wx" }); + await replaceFile(temporary, filePath); +} + +async function replaceFile(temporary: string, target: string) { + try { + await fs.rename(temporary, target); + return; + } catch (renameError) { + // Windows can reject replacing a file that is briefly held by an + // antivirus/indexer. Copying the complete temp file keeps the write + // recoverable without deleting the previous durable record first. + try { + await fs.copyFile(temporary, target); + } catch { + throw renameError; + } + await fs.rm(temporary, { force: true }).catch(() => undefined); + } +} + +function assertLeasedJob(job: PortraitModelJob, attempt: number, leaseToken: string) { + if (job.status !== "leased" || job.attempt !== attempt || job.leaseToken !== leaseToken || !job.leaseExpiresAt || Date.parse(job.leaseExpiresAt) <= Date.now()) throw new PortraitTaskStoreError("portrait_model_job_conflict", "模型作业租约已失效,请重新领取", 409); +} diff --git a/canvas-agent/test/local-runtime-http.test.ts b/canvas-agent/test/local-runtime-http.test.ts index 97d58ce0e..78598e65e 100644 --- a/canvas-agent/test/local-runtime-http.test.ts +++ b/canvas-agent/test/local-runtime-http.test.ts @@ -77,6 +77,25 @@ test("runtime info has exact Host and route-scoped CORS/PNA without exposing mod }); }); +test("route-scoped CORS keeps every method registered on the same path", async () => { + await withRuntime(async ({ server }) => { + const preflight = await request(server, { + method: "OPTIONS", + path: "/dreamina/tasks", + headers: { + Host: authority, + Origin: origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type,x-framefield-runtime-session,x-framefield-runtime-timestamp,x-framefield-runtime-nonce,x-framefield-runtime-proof", + }, + }); + assert.equal(preflight.status, 204); + assert.equal(preflight.headers["access-control-allow-methods"], "GET,POST"); + assert.equal(preflight.headers["access-control-allow-origin"], origin); + assert.equal(preflight.headers["access-control-allow-headers"], "x-framefield-runtime-session,x-framefield-runtime-timestamp,x-framefield-runtime-nonce,x-framefield-runtime-proof,content-type"); + }); +}); + test("only an exact trusted Origin can silently establish a signed browser session", async () => { await withRuntime(async ({ server }) => { const key = browserKey(); @@ -480,6 +499,18 @@ async function withRuntime( res.json({ ok: true }); }, }, + { + method: "GET", + path: "/dreamina/tasks", + scope: "dreamina:status", + handler: (_req, res) => res.json({ ok: true }), + }, + { + method: "POST", + path: "/dreamina/tasks", + scope: "dreamina:generate", + handler: (_req, res) => res.json({ ok: true }), + }, { method: "POST", path: "/dreamina/generate/effects/claim", diff --git a/canvas-agent/test/portrait-clearance.test.ts b/canvas-agent/test/portrait-clearance.test.ts new file mode 100644 index 000000000..e49d7b539 --- /dev/null +++ b/canvas-agent/test/portrait-clearance.test.ts @@ -0,0 +1,387 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import sharp from "sharp"; + +import { createPortraitClearanceTaskRequestSchema, portraitClearanceSettingsSchema, riskFromFaceSimilarity } from "../src/portrait-clearance/contracts.js"; +import { deduplicatePortraitCandidates } from "../src/portrait-clearance/dedup.js"; +import { computePHash, cosineSimilarity, hammingDistance, imageQuality, structuralSimilarity } from "../src/portrait-clearance/image-metrics.js"; +import { buildPortraitReports } from "../src/portrait-clearance/reports.js"; +import { downloadPortraitCandidate, validatePublicUrl } from "../src/portrait-clearance/safe-image-download.js"; +import { PortraitTaskStore } from "../src/portrait-clearance/task-store.js"; +import { installPortraitModels } from "../src/portrait-clearance/model-store.js"; + +test("portrait risk thresholds and cosine similarity are deterministic", () => { + assert.equal(riskFromFaceSimilarity(undefined), "unable_to_determine"); + assert.equal(riskFromFaceSimilarity(0.65), "high"); + assert.equal(riskFromFaceSimilarity(0.5), "medium"); + assert.equal(riskFromFaceSimilarity(0.35), "low_to_medium"); + assert.equal(riskFromFaceSimilarity(0.34), "low"); + assert.equal(cosineSimilarity([1, 0], [1, 0]), 1); + assert.equal(cosineSimilarity([1, 0], [0, 1]), 0); +}); + +test("portrait model concurrency accepts ten and rejects values above the hard limit", () => { + const settings = { + maxCandidates: 30, + searchScrolls: 5, + dedupMode: "phash" as const, + modelConcurrency: 10, + showBrowserForDebug: false, + }; + assert.equal(portraitClearanceSettingsSchema.parse(settings).modelConcurrency, 10); + assert.throws(() => portraitClearanceSettingsSchema.parse({ ...settings, modelConcurrency: 11 })); +}); + +test("quality boundaries, SSIM and pHash remain finite for small fixtures", () => { + const flat = new Float32Array(16 * 16).fill(128); + const contrast = new Float32Array(16 * 16); + for (let index = 0; index < contrast.length; index += 1) contrast[index] = index % 2 ? 0 : 255; + assert.equal(imageQuality(16, 16, flat).grade, "poor"); + assert.equal(imageQuality(16, 16, contrast).grade, "good"); + const first = { width: 16, height: 16, values: contrast }; + const second = { width: 16, height: 16, values: contrast.slice() }; + const hash = computePHash(first); + assert.equal(hash.length, 64); + assert.equal(hammingDistance(hash, computePHash(second)), 0); + assert.equal(structuralSimilarity(first, second), 1); +}); + +test("dedup uses byte hash first and pHash/ArcFace as a conservative second condition", () => { + const result = deduplicatePortraitCandidates([ + { id: "a", byteHash: "same", phash: "0".repeat(64), byteSize: 100, pixelArea: 100 }, + { id: "b", byteHash: "same", phash: "1".repeat(64), byteSize: 90, pixelArea: 200 }, + { id: "c", byteHash: "c", phash: "0".repeat(63) + "1", byteSize: 100, pixelArea: 100, embedding: [1, 0] }, + { id: "d", byteHash: "d", phash: "0".repeat(63) + "1", byteSize: 100, pixelArea: 100, embedding: [0, 1] }, + ], "arcface"); + assert.deepEqual(result.kept.map((item) => item.id), ["b", "c", "d"]); + assert.equal(result.byteDeduplicatedCount, 1); + assert.equal(result.visualDeduplicatedCount, 0); +}); + +test("task store persists input bytes without returning data URLs and keeps idempotency owner scoped", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "portrait-clearance-store-")); + const store = new PortraitTaskStore(root, "runtime-owner"); + const owner = { keyId: "key-1", origin: "http://127.0.0.1:3000" }; + const input = "data:image/png;base64,iVBORw0KGgo="; + const request = createPortraitClearanceTaskRequestSchema.parse({ + schemaVersion: 1, + clientOperationId: "portrait-store-0001", + ownerScopeHash: "a".repeat(64), + projectId: "project-1", + nodeId: "node-1", + mode: "direct-compare", + analysisMode: "local-only", + inputs: [ + { nodeId: "query", role: "query", fileName: "query.png", mimeType: "image/png", dataUrl: input }, + { nodeId: "reference", role: "reference", fileName: "reference.png", mimeType: "image/png", dataUrl: input }, + ], + settings: { maxCandidates: 30, searchScrolls: 5, dedupMode: "phash", modelConcurrency: 2, showBrowserForDebug: false }, + }); + try { + const first = await store.create(request, owner); + const retry = await store.create(request, owner); + assert.equal(first.created, true); + assert.equal(retry.created, false); + assert.equal(retry.record.taskId, first.record.taskId); + assert.equal("dataUrl" in retry.record, false); + const persisted = await store.readInput(first.record.taskId, owner, "input-1"); + assert.deepEqual(persisted.bytes, Buffer.from("89504e470d0a1a0a", "hex")); + await assert.rejects(() => store.get(first.record.taskId, { keyId: "other", origin: owner.origin }), { code: "portrait_task_forbidden" }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("candidate URL validation blocks private destinations before fetching", async () => { + assert.throws(() => validatePublicUrl("file:///C:/secret.png"), { code: "portrait_candidate_download_blocked" }); + assert.throws(() => validatePublicUrl("https://user:pass@example.com/a.png"), { code: "portrait_candidate_download_blocked" }); + await assert.rejects(() => downloadPortraitCandidate("http://127.0.0.1/image.png", { fetch: async () => { throw new Error("must not fetch"); } }), { code: "portrait_candidate_download_blocked" }); +}); + +test("reports escape untrusted text and stay script-free", async () => { + const result = { + schemaVersion: 1 as const, + taskId: "portrait-report-test", + mode: "direct-compare" as const, + queryImageId: "input-1", + highestRisk: "unable_to_determine" as const, + riskCounts: { unable_to_determine: 1 }, + candidateCount: 1, + comparedCount: 1, + candidates: [{ id: "candidate-1", originalRank: 1, title: "", imageArtifactId: "input-2", source: "connected" as const, byteSize: 8, resultId: "pair-1" }], + pairs: [{ id: "pair-1", queryImageId: "input-1", comparisonImageId: "input-2", source: "connected-reference" as const, status: "success" as const, riskLevel: "unable_to_determine" as const, analysisPath: "unable" as const, localPrecheck: { qualityA: { width: 1, height: 1, sharpness: 0, brightness: 0, contrast: 0, grade: "poor" as const }, qualityB: { width: 1, height: 1, sharpness: 0, brightness: 0, contrast: 0, grade: "poor" as const }, facesA: 0, facesB: 0, ssim: 0, colorHistogramCorrelation: 0, canExtractEmbedding: false, reliabilityIssues: [""] }, basis: [""], limitations: [] }], + limitations: ["", "本结果不能替代司法鉴定"], + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }; + const reports = await buildPortraitReports(result); + assert.equal(reports.html.includes("