From 6ec9778d452d8e95ac36a277574bf5b837677171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EF=BD=9E?= <1617530133@qq.com> Date: Sat, 7 Nov 2026 10:40:07 +0800 Subject: [PATCH] chore: initial commit Laimiao agent v2 service: agent orchestration, sessions, billing, gateway client, skills catalog, auth, config, plus tests. --- .dockerignore | 12 + .env.example | 53 + .gitignore | 5 + .nvmrc | 1 + Dockerfile | 29 + docker-compose.yml | 39 + package-lock.json | 4486 +++++++++++++++++++++++++++++++++ package.json | 38 + scripts/migrate-contexts.ts | 45 + scripts/migrate-sessions.ts | 73 + src/agent/confirm_tools.ts | 321 +++ src/agent/confirmations.ts | 107 + src/agent/document_tools.ts | 525 ++++ src/agent/prompt.ts | 128 + src/agent/service.ts | 817 ++++++ src/agent/tools.ts | 666 +++++ src/auth/jwt.ts | 105 + src/billing/ledger.ts | 179 ++ src/billing/pricing.ts | 72 + src/config.ts | 307 +++ src/gateway/client.ts | 330 +++ src/index.ts | 99 + src/logger.ts | 39 + src/server.ts | 393 +++ src/sessions/context_store.ts | 103 + src/sessions/mysql_store.ts | 363 +++ src/sessions/store.ts | 198 ++ src/skills/catalog.ts | 242 ++ src/skills/direct.ts | 43 + src/skills/params.ts | 91 + src/skills/repository.ts | 168 ++ src/types.ts | 84 + src/utils/net.ts | 300 +++ src/utils/text.ts | 27 + tests/auth.test.ts | 44 + tests/batch.test.ts | 203 ++ tests/catalog.test.ts | 111 + tests/config.test.ts | 93 + tests/confirm_tools.test.ts | 115 + tests/confirmations.test.ts | 94 + tests/gateway.test.ts | 218 ++ tests/helpers.ts | 92 + tests/ledger.test.ts | 89 + tests/net.test.ts | 135 + tests/pricing.test.ts | 74 + tests/prompt.test.ts | 36 + tests/server.test.ts | 216 ++ tests/sessions.test.ts | 61 + tests/text.test.ts | 27 + tests/tools.test.ts | 200 ++ tsconfig.build.json | 10 + tsconfig.json | 24 + vitest.config.ts | 10 + 53 files changed, 12340 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/migrate-contexts.ts create mode 100644 scripts/migrate-sessions.ts create mode 100644 src/agent/confirm_tools.ts create mode 100644 src/agent/confirmations.ts create mode 100644 src/agent/document_tools.ts create mode 100644 src/agent/prompt.ts create mode 100644 src/agent/service.ts create mode 100644 src/agent/tools.ts create mode 100644 src/auth/jwt.ts create mode 100644 src/billing/ledger.ts create mode 100644 src/billing/pricing.ts create mode 100644 src/config.ts create mode 100644 src/gateway/client.ts create mode 100644 src/index.ts create mode 100644 src/logger.ts create mode 100644 src/server.ts create mode 100644 src/sessions/context_store.ts create mode 100644 src/sessions/mysql_store.ts create mode 100644 src/sessions/store.ts create mode 100644 src/skills/catalog.ts create mode 100644 src/skills/direct.ts create mode 100644 src/skills/params.ts create mode 100644 src/skills/repository.ts create mode 100644 src/types.ts create mode 100644 src/utils/net.ts create mode 100644 src/utils/text.ts create mode 100644 tests/auth.test.ts create mode 100644 tests/batch.test.ts create mode 100644 tests/catalog.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/confirm_tools.test.ts create mode 100644 tests/confirmations.test.ts create mode 100644 tests/gateway.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/ledger.test.ts create mode 100644 tests/net.test.ts create mode 100644 tests/pricing.test.ts create mode 100644 tests/prompt.test.ts create mode 100644 tests/server.test.ts create mode 100644 tests/sessions.test.ts create mode 100644 tests/text.test.ts create mode 100644 tests/tools.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82a3012 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +data +.env +.env.* +!.env.example +.git +.gitignore +tests +*.log +.vscode +.idea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f6e1c22 --- /dev/null +++ b/.env.example @@ -0,0 +1,53 @@ +NODE_ENV=development +HOST=0.0.0.0 +PORT=8100 +CORS_ORIGIN=* +APP_ROOT= +DATA_DIR=data +LOG_LEVEL=info +FILE_FETCH_ALLOWED_HOSTS= + +BILLING_MODE=live +BILLING_CURRENCY=credits +DEFAULT_SKILL_PRICE=10 +DEFAULT_SKILL_MODEL=gpt-image-2 +DEFAULT_SKILL_SIZE=1024x1024 + +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD= +MYSQL_DATABASE=laimiaodb +MYSQL_CONNECTION_LIMIT=10 +MYSQL_CONNECT_TIMEOUT_MS=10000 + +GATEWAY_CN_URL=https://cn.gateway.laimiaoai.com +GATEWAY_GLOBAL_URL=https://gateway.laimiaoai.com +GATEWAY_FORCE_URL= +GATEWAY_MOCK=false +GATEWAY_REQUEST_TIMEOUT_MS=180000 +GATEWAY_POLL_INTERVAL_MS=2000 +GATEWAY_POLL_TIMEOUT_MS=900000 + +LAIMIAO_LLM_PROVIDER=laimiao +LAIMIAO_LLM_BASE_URL= +LAIMIAO_LLM_API_KEY= +LAIMIAO_LLM_MODEL=deepseek-chat +LAIMIAO_LLM_CONTEXT_WINDOW=131072 +LAIMIAO_LLM_MAX_TOKENS=8192 +LAIMIAO_LLM_REASONING=false +LAIMIAO_LLM_THINKING=low + +JWT_KEY= +JWT_ALGORITHM=HS256 + +# 工具确认(通过 SSE 返回前端) +TOOL_CONFIRMATION_ENABLED=true +TOOL_CONFIRMATION_TOOLS=generate_image +TOOL_CONFIRMATION_TIMEOUT_MS=300000 + +# NODE_ENV=production 时启动会强制校验: +# JWT_KEY 必须非空,否则拒绝启动 +# CORS_ORIGIN 不能是 *,必须是逗号分隔的显式来源(如 https://app.example.com) +# FILE_FETCH_ALLOWED_HOSTS 必须非空(文档/参考图只允许从这些域名下载) +# FILE_FETCH_ALLOWED_HOSTS 示例:cdn.example.com,*.oss.example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddc0ba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +data/ +.env +*.log diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9518c2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +RUN npm run build + +FROM node:22-alpine AS runtime +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=8100 \ + DATA_DIR=/app/data \ + NPM_CONFIG_UPDATE_NOTIFIER=false +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json ./ +RUN mkdir -p /app/data && chown -R node:node /app +USER node +EXPOSE 8100 +CMD ["node", "dist/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f67b221 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + agent: + build: . + image: laimiao-agent:latest + init: true + restart: unless-stopped + env_file: + - .env + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: "8100" + DATA_DIR: /app/data + ports: + - "127.0.0.1:8100:8100" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m + volumes: + - agent-data:/app/data + user: "1000:1000" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 256 + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:8100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + agent-data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..da599c1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4486 @@ +{ + "name": "laimiao-skill-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "laimiao-skill-agent", + "version": "0.1.0", + "dependencies": { + "@earendil-works/pi-ai": "^1.4.0", + "@earendil-works/pi-coding-agent": "^1.4.0", + "@fastify/cors": "^11.0.1", + "dotenv": "^17.2.3", + "fastify": "^5.6.1", + "mammoth": "^1.13.0", + "mysql2": "^3.24.6", + "pdfjs-dist": "^5.6.205", + "typebox": "^1.1.24", + "unpdf": "^1.7.0", + "word-extractor": "^1.0.4", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.6.tgz", + "integrity": "sha512-ykF/MiYoM15dgZwF/WLgjJXVbAK2wU6vjPigeEnOHwyUJ0rdlraO5uekecKNVPkvOnz+oju/5zljViQs1nI1FQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.37.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.77.tgz", + "integrity": "sha512-CN6dQCDo32a8n2gvm2HBeqGV5BNI9noG96nTj5bGO2HWtt0Rucu/A8pcnJIqkzILOHSuilnCk0Do8/7h6SOXhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.79.tgz", + "integrity": "sha512-Netsu0nnmvP2HiN1ZFQBeUu+JehQIuubqz+j2kHDgeSE4HDzniCpIZ7RzoWABq+LTcbCi9W03hywifUagoVIWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz", + "integrity": "sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.22.tgz", + "integrity": "sha512-x7/5h58cqOoZR0RWUZC7v/cJOADQR42iSIQkvU+00HVHR4jh8ZpqEgRPGf2zei59iH5qP40lL3bkq49PuWl71g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/credential-provider-env": "^3.972.77", + "@aws-sdk/credential-provider-http": "^3.972.79", + "@aws-sdk/credential-provider-login": "^3.972.84", + "@aws-sdk/credential-provider-process": "^3.972.77", + "@aws-sdk/credential-provider-sso": "^3.973.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.83", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.84.tgz", + "integrity": "sha512-G9Ty1uIV0laEvEp2KiMbEhsuBAjgaD3KYrwVjAM5VUxpcIuPBqQI5riA7/qzrnSgYiAKQbubbgkwcqCICKCIxg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.89", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.89.tgz", + "integrity": "sha512-O8xj7/sanrnXfFHu/LUujVBiiwn5mknYenU7xt1w6+yvhR1QoDZWXLFh1gLITaFVPtb1jWgEirC+kFNw+//tyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.77", + "@aws-sdk/credential-provider-http": "^3.972.79", + "@aws-sdk/credential-provider-ini": "^3.973.22", + "@aws-sdk/credential-provider-process": "^3.972.77", + "@aws-sdk/credential-provider-sso": "^3.973.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.83", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.77.tgz", + "integrity": "sha512-sWA8Jm+ASzkTxoqLizHWRbt8Gj3yZCmZ0hYdvg0uIxuLzWseYcfrDFzaSnkRWlEWSJV38rAWFimdrN0J4NR7Pg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.21.tgz", + "integrity": "sha512-KV2LM8fbYUhDzGEBnPwb+ISNfNlhmnpLS+y6A6VsJpg1EI9aUJ0+dRTxh2GrGdneFH0T4TiuboHxz8i6+M38JA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/token-providers": "3.1157.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1157.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1157.0.tgz", + "integrity": "sha512-AeAzcwWRP2uANO2TjFpKjRdvOCzgfIu6YQl9Ceibd6E0gnq5dfOzujFnq2aeqgbLg4wRtXbUZE4gekCCPrz/OQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.83.tgz", + "integrity": "sha512-iekYilmo2Re51fC7gMkEfZ7jFDXwehSCmXjDESaYQsdgYhsLQFG7rTYr3GBvRIyj+Fkda2NxbZX7hAZDaFD0JQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.39.tgz", + "integrity": "sha512-V+AvQ+KFWApgm9134igy78cgoCEAFfV8K3KmlVi511vjkRWAgvy0tGUePrZsER+9EvrmUWEsrA2NBQspiGVBMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.34.tgz", + "integrity": "sha512-0IUoHM9N75uMaf70Tj2l+qmOSNw8HaDCsoghPRnewMaaPJjdSxzFaCpPBFDmSWuSlc0gBgv4CsvvvPGoBAD4Bg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.59.tgz", + "integrity": "sha512-lpLmEPrmRSG1ev+LqxkOn1JfVGclnS9b5hvGaSoBgS0JxW2u3QXsJuu+KTHvhdFrRohsqzl70cuiHhyUHFSjYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.51.tgz", + "integrity": "sha512-DJK4iGIHWU8Jh8ODMvqFYcX9yGBMXV4tSZAvqlHF5EDd8hbmF3eFO9KIWOnA7wcQjPzEkyMMW1/FgZ9RU8oimQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.49", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz", + "integrity": "sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz", + "integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz", + "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz", + "integrity": "sha512-PoKY84SKUwnt7S5QBZaGUqfNO1kjP3KHpHUA9QpFTj4cOjaVWMYIcBPJ6FDRCdE8Zen9OSECQUd0uUfVgtSmgA==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "1.4.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core/node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz", + "integrity": "sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "1.4.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz", + "integrity": "sha512-23CNAXpS9DuLzPMyYuvrXVVio46x4YhffCgCqaWmF7F5H8PooqjrQvX4ucF6M0BhUd+x2tV6r1J0dv8cCME4Cg==", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.4.0.tgz", + "integrity": "sha512-Z41LpZ3yYHSC/UUps6Mr39UMFPwH8elhJBG9DY1P7kuzsE5qSK/6eMj2EHzO5evJRQWLbKSqUMw+4nCNtIyiQg==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", + "@silvia-odwyer/photon-node": "0.3.4", + "brace-expansion": "5.0.12", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "minimatch": "10.2.6", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.10.2", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-mcp": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.4.0.tgz", + "integrity": "sha512-92aZmmGsUpHHknYuKXZHRINQfahntw5hU9If0I0ZM49GojOaFHpkl5/GF3/aatqpt7v7GjZbBfpfSygGKW+pnw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz", + "integrity": "sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.4.0.tgz", + "integrity": "sha512-4obPOjgOD7g4mGSlngIvxyujC1UPdmyI9zdVthbr8GufacFEX+Ce0+scPgHg++k2xj5+QjESM2d5TxvIuzCVmg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11", + "marked-cjk-friendly": "0.1.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.7.tgz", + "integrity": "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.1.tgz", + "integrity": "sha512-gZXNzd6FLHzOIfvoK4+Az3Wko2V6Y0MIsLxpyo4AEYl7a9q1JBPwPaxLea4nkr++8FCiJiPpYYo6E02uZW0dqw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.4.tgz", + "integrity": "sha512-kc8GQq5gX9RRhw0rov3DMrN8wS8yRlxJRdDzsYwwdBV/wazuVgc/1K7CKL90tlvu3RlHan6Y0R0aE3nJtBz2eQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.1.tgz", + "integrity": "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.1.tgz", + "integrity": "sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.1.2.tgz", + "integrity": "sha512-89WiRG+hW3VQSfCwg4Hb8b43PeyEX15K5+lEfsZ4EUth/FyOjS/lv8HsVuswUNXWiSnxMGNouE/fq/0w3VumHQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.2.tgz", + "integrity": "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.2.tgz", + "integrity": "sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.37.0.tgz", + "integrity": "sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.1.tgz", + "integrity": "sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.15.0.tgz", + "integrity": "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.2.tgz", + "integrity": "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg==", + "license": "BSD-2-Clause" + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.2.0.tgz", + "integrity": "sha512-q18imgm9fEdQVfvJbRJALE9VP/A5HGme0+NUnWcmFYM9rbQbLkZZk20FyIp1LQ8gDOauQKwqMc52YozqFaQyOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fast-uri": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.3.tgz", + "integrity": "sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.7", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.7.tgz", + "integrity": "sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.1.tgz", + "integrity": "sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/find-my-way": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.10.0.tgz", + "integrity": "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", + "integrity": "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/light-my-request": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", + "integrity": "sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "@fastify/deepmerge": "^3.1.0", + "cookie": "^2.0.0", + "process-warning": "^5.0.0", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mammoth": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.13.0.tgz", + "integrity": "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-cjk-friendly": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/marked-cjk-friendly/-/marked-cjk-friendly-0.1.2.tgz", + "integrity": "sha512-GUj8HjiEbur9yhgUo52FBgKizggF1QtT6gVdrYdWW6ZB4lVVjgS3EyyXPyfyRD0V4ghGRPlO7MO80rgSWbm0eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "marked": ">=15.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz", + "integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.96", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "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" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.1.tgz", + "integrity": "sha512-Z/nyvABwcRnRD2mMP7Kz6G1dFZZqX1mNErOnxF42OCQKE07b9k9hK9SQxHL3LFEZUdzQaLM4dId1E/E6Vm4WhA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz", + "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.2.1.tgz", + "integrity": "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.4.3.tgz", + "integrity": "sha512-u3nyf7ZciAv1GOijVpI0TDt9At5wmEi7lSHSlRrnjuThv1DcB7fgGBqqrz+fGZ54jAfTpOOX0JXwxSIg3pnxQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unpdf": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.7.0.tgz", + "integrity": "sha512-MiDhbougTETOvbw/x3hnNr18jzFFrlSitevfO/BHInvtUx68R3Fke9A36GLQfN1LpG1NiJZaV4oPvjioPk9vKQ==", + "license": "MIT", + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-extractor": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/word-extractor/-/word-extractor-1.0.4.tgz", + "integrity": "sha512-PyAGZQ2gjnVA5kcZAOAxoYciCMaAvu0dbVlw/zxHphhy+3be8cDeYKHJPO8iedIM3Sx0arA/ugKTJyXhZNgo6g==", + "license": "MIT", + "dependencies": { + "saxes": "^5.0.1", + "yauzl": "^2.10.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ecddbe --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "laimiao-skill-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "来秒AI 技能付费 Agent:pi agent SDK + skill catalog + per-image billing", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@earendil-works/pi-ai": "^1.4.0", + "@earendil-works/pi-coding-agent": "^1.4.0", + "@fastify/cors": "^11.0.1", + "dotenv": "^17.2.3", + "fastify": "^5.6.1", + "mammoth": "^1.13.0", + "mysql2": "^3.24.6", + "pdfjs-dist": "^5.6.205", + "typebox": "^1.1.24", + "unpdf": "^1.7.0", + "word-extractor": "^1.0.4", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/scripts/migrate-contexts.ts b/scripts/migrate-contexts.ts new file mode 100644 index 0000000..ba8c494 --- /dev/null +++ b/scripts/migrate-contexts.ts @@ -0,0 +1,45 @@ +import "dotenv/config"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { loadConfig } from "../src/config.js"; +import { createLogger } from "../src/logger.js"; +import { MySqlSessionContextStore } from "../src/sessions/context_store.js"; + +const config = loadConfig(); +const logger = createLogger(config.logLevel); +const dryRun = process.argv.includes("--dry-run"); + +async function main(): Promise { + const store = new MySqlSessionContextStore(config.mysql, logger); + await store.init(); + + let migrated = 0; + let skipped = 0; + if (!existsSync(config.piSessionsDir)) { + console.log("no pi session dir, nothing to migrate"); + await store.close(); + return; + } + + for (const file of readdirSync(config.piSessionsDir)) { + if (!file.endsWith(".jsonl")) continue; + const match = file.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/); + const sessionId = match?.[1] ?? file.replace(/^.*?_/, "").replace(/\.jsonl$/, ""); + if (!sessionId) { + skipped += 1; + continue; + } + const content = readFileSync(join(config.piSessionsDir, file)); + if (!dryRun) await store.save(sessionId, content); + migrated += 1; + } + + console.log(`${dryRun ? "[dry-run] " : ""}migrated contexts=${migrated} skipped=${skipped}`); + await store.close(); +} + +main().catch((error) => { + console.error("context migration failed:", error); + process.exit(1); +}); diff --git a/scripts/migrate-sessions.ts b/scripts/migrate-sessions.ts new file mode 100644 index 0000000..306279f --- /dev/null +++ b/scripts/migrate-sessions.ts @@ -0,0 +1,73 @@ +import "dotenv/config"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { loadConfig } from "../src/config.js"; +import { createLogger } from "../src/logger.js"; +import { MySqlSessionStore } from "../src/sessions/mysql_store.js"; +import type { SessionMessage, SessionMeta } from "../src/sessions/store.js"; + +interface SessionIndexFile { + sessions?: Record; +} + +const config = loadConfig(); +const logger = createLogger(config.logLevel); +const dryRun = process.argv.includes("--dry-run"); + +async function main(): Promise { + const store = new MySqlSessionStore(config.mysql, logger); + await store.init(); + + let sessionCount = 0; + let messageCount = 0; + let skipped = 0; + + if (existsSync(config.sessionsFile)) { + const parsed = JSON.parse(readFileSync(config.sessionsFile, "utf8")) as SessionIndexFile; + const sessions = Object.values(parsed.sessions ?? {}); + for (const meta of sessions) { + if (!meta.session_id) { + skipped += 1; + continue; + } + if (!dryRun) await store.upsertSession(meta); + sessionCount += 1; + } + } + + if (existsSync(config.messagesDir)) { + for (const file of readdirSync(config.messagesDir)) { + if (!file.endsWith(".jsonl")) continue; + const sessionId = file.slice(0, -".jsonl".length); + const lines = readFileSync(join(config.messagesDir, file), "utf8").split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + let message: SessionMessage; + try { + message = JSON.parse(trimmed) as SessionMessage; + } catch { + skipped += 1; + continue; + } + if (!message.id || !message.session_id) { + message.id = message.id || `${sessionId}-${messageCount}`; + message.session_id = message.session_id || sessionId; + } + if (!dryRun) await store.insertMessage(message); + messageCount += 1; + } + } + } + + console.log( + `${dryRun ? "[dry-run] " : ""}migrated sessions=${sessionCount} messages=${messageCount} skipped=${skipped}`, + ); + await store.close(); +} + +main().catch((error) => { + console.error("migration failed:", error); + process.exit(1); +}); diff --git a/src/agent/confirm_tools.ts b/src/agent/confirm_tools.ts new file mode 100644 index 0000000..95b48e2 --- /dev/null +++ b/src/agent/confirm_tools.ts @@ -0,0 +1,321 @@ +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +import type { Logger } from "../logger.js"; +import type { SkillCatalog } from "../skills/catalog.js"; +import type { ToolConfirmationManager } from "./confirmations.js"; +import type { AgentStreamEvent } from "./service.js"; + +interface ConfirmToolDeps { + catalog: SkillCatalog; + confirmations: ToolConfirmationManager; + sessionId: string; + logger: Logger; + emit: (event: AgentStreamEvent) => void; + hasStream: boolean; + onTravelStyleConfirmed?: (style: string) => void; +} + +interface TravelStyleOption { + id: string; + label: string; + desc: string; +} + +interface UserConfirmOption { + id: string; + label: string; + desc?: string; +} + +const DEFAULT_TRAVEL_STYLES: TravelStyleOption[] = [ + { id: "travel-general", label: "通用版", desc: "多景点有机融合主视觉,适用于绝大多数线路" }, + { id: "travel-simple", label: "简约版", desc: "单一核心主形象、大量留白、清爽高级感" }, + { id: "travel-illustration", label: "插画版", desc: "插画化主视觉,艺术化表达旅行,目的地识别不丢失" }, +]; + +type ConfirmResult = { + content: Array<{ type: "text"; text: string }>; + details: { ok: boolean; approved?: boolean; content?: string; error?: string }; + isError?: boolean; +}; + +export function createConfirmTools(deps: ConfirmToolDeps) { + const confirmContent = defineTool({ + name: "confirm_travel_content", + label: "确认行程内容", + description: + "向用户确认旅行线路摘要内容,等待用户确认或补充后返回最终结果。在 read_document 读取文档并整理出线路摘要后必须调用本工具:它会暂停流程,把摘要呈现给用户确认。用户可确认、修正或补充内容;工具返回用户确认结果后才可继续后续步骤。参数 summary 为线路摘要文本。返回 JSON: {approved, content},content 为用户最终确认/修改后的内容。", + parameters: Type.Object({ + summary: Type.String({ description: "线路摘要文本,可直接把 read_document 返回的摘要原样传入" }), + }), + execute: async ( + toolCallId: string, + params: { summary: string }, + signal: AbortSignal | undefined, + ): Promise => { + if (!deps.hasStream) { + return errorResult("该工具需要以 stream:true 调用才能等待用户确认。"); + } + const summary = String(params.summary || "").trim(); + if (!summary) { + return errorResult("summary 不能为空。"); + } + const decision = await deps.confirmations.request( + { + sessionId: deps.sessionId, + toolCallId, + toolName: "confirm_travel_content", + confirmType: "travel_content_confirm", + payload: { summary }, + args: { summary }, + }, + (request) => { + deps.emit({ + type: "content_confirmation_required", + confirmation_id: request.toolCallId, + session_id: request.sessionId, + tool_call_id: request.toolCallId, + confirm_type: "travel_content_confirm", + payload: request.payload, + expires_at: request.expiresAt, + }); + }, + signal, + ); + const content = typeof decision.content === "string" ? decision.content.trim() : ""; + const approved = decision.approved; + deps.emit({ + type: "content_confirmation_resolved", + confirmation_id: toolCallId, + tool_call_id: toolCallId, + confirm_type: "travel_content_confirm", + approved, + content, + reason: decision.reason, + }); + deps.logger.info( + { sessionId: deps.sessionId, toolCallId, approved, contentChars: content.length }, + "travel content confirmation resolved", + ); + return jsonResult({ approved, content: approved ? content : "" }); + }, + }); + + const confirmStyle = defineTool({ + name: "confirm_travel_style", + label: "选择旅游风格", + description: + "向用户列出当前可用的旅游风格/技能,等待用户选择后返回所选风格 id。内容确认后调用本工具会暂停流程,把技能库中全部可用旅游风格展示给用户。用户选定后必须使用返回的 content 作为 generate_image 的 skill_id,并先调用 read_skill 读取该风格规范。返回 JSON: {approved, content},content 为选中的风格 id。", + parameters: Type.Object({}), + execute: async ( + toolCallId: string, + _params: Record, + signal: AbortSignal | undefined, + ): Promise => { + if (!deps.hasStream) { + return errorResult("该工具需要以 stream:true 调用才能等待用户选择。"); + } + const styles = loadTravelStyles(deps.catalog); + const decision = await deps.confirmations.request( + { + sessionId: deps.sessionId, + toolCallId, + toolName: "confirm_travel_style", + confirmType: "travel_style_confirm", + payload: { styles }, + args: { styles }, + }, + (request) => { + deps.emit({ + type: "content_confirmation_required", + confirmation_id: request.toolCallId, + session_id: request.sessionId, + tool_call_id: request.toolCallId, + confirm_type: "travel_style_confirm", + payload: request.payload, + expires_at: request.expiresAt, + }); + }, + signal, + ); + const requested = typeof decision.content === "string" ? decision.content.trim() : ""; + const selected = styles.find((style) => style.id === requested); + const approved = decision.approved && Boolean(selected); + const content = selected?.id ?? ""; + deps.emit({ + type: "content_confirmation_resolved", + confirmation_id: toolCallId, + tool_call_id: toolCallId, + confirm_type: "travel_style_confirm", + approved, + content, + reason: decision.reason, + }); + if (approved && content) { + deps.onTravelStyleConfirmed?.(content); + } + deps.logger.info( + { sessionId: deps.sessionId, toolCallId, approved, style: content }, + "travel style confirmation resolved", + ); + return jsonResult({ approved, content }); + }, + }); + + const confirmUser = defineTool({ + name: "confirm_user", + label: "请求用户确认", + description: + "当技能缺少必填资料、需要用户确认关键信息、补充/修改文本或从选项中选择时调用。该工具会暂停流程,把内容展示给用户,等待用户确认、编辑或选择后返回。不要用文字直接提问后假设用户已回复。支持三种模式:confirm(确认/取消)、edit(编辑文本后提交)、select(从选项中选择);提供 options 时自动按 select 处理,edit 模式不要提供 options;edit 模式下把已知内容放入 default_value,缺失项只保留字段名并留空(例如「活动时间:」),不要写「请补充」「待填写」等需要用户删除后再输入的占位文字,也不得编造价格、团期、资质等事实信息。返回 JSON: {approved, content};confirm 模式 content 为空,edit 模式 content 为用户提交的文本,select 模式 content 为选中的选项 id。", + parameters: Type.Object({ + message: Type.String({ description: "展示给用户的确认内容或问题,必须使用简体中文" }), + title: Type.Optional(Type.String({ description: "卡片标题,默认“请确认”,必须使用简体中文" })), + mode: Type.Optional( + Type.Union([Type.Literal("confirm"), Type.Literal("edit"), Type.Literal("select")], { + description: "confirm=确认/取消,edit=编辑文本,select=选择选项;提供 options 时自动使用 select", + }), + ), + options: Type.Optional( + Type.Array( + Type.Union([ + Type.String(), + Type.Object({ + id: Type.String(), + label: Type.String(), + desc: Type.Optional(Type.String()), + }), + ]), + { description: "select 模式下的选项,支持字符串或 {id,label,desc};label/desc 必须使用简体中文" }, + ), + ), + default_value: Type.Optional(Type.String({ description: "edit 模式下的默认文本" })), + }), + execute: async ( + toolCallId: string, + params: { + message: string; + title?: string; + mode?: "confirm" | "edit" | "select"; + options?: Array; + default_value?: string; + }, + signal: AbortSignal | undefined, + ): Promise => { + if (!deps.hasStream) { + return errorResult("该工具需要以 stream:true 调用才能等待用户确认。"); + } + const message = String(params.message || "").trim(); + if (!message) { + return errorResult("message 不能为空。"); + } + const options = normalizeUserOptions(params.options); + const mode = options.length > 0 ? "select" : params.mode || "confirm"; + if (mode === "select" && options.length === 0) { + return errorResult("select 模式需要提供 options。"); + } + const payload = { + title: (params.title || "").trim() || "请确认", + message, + mode, + options, + default_value: params.default_value ?? "", + }; + const decision = await deps.confirmations.request( + { + sessionId: deps.sessionId, + toolCallId, + toolName: "confirm_user", + confirmType: "user_confirm", + payload, + args: payload, + }, + (request) => { + deps.emit({ + type: "content_confirmation_required", + confirmation_id: request.toolCallId, + session_id: request.sessionId, + tool_call_id: request.toolCallId, + confirm_type: "user_confirm", + payload: request.payload, + expires_at: request.expiresAt, + }); + }, + signal, + ); + let approved = decision.approved; + let content = typeof decision.content === "string" ? decision.content.trim() : ""; + if (mode === "confirm") { + content = ""; + } else if (mode === "select") { + if (!options.some((option) => option.id === content)) { + approved = false; + content = ""; + } + } + deps.emit({ + type: "content_confirmation_resolved", + confirmation_id: toolCallId, + tool_call_id: toolCallId, + confirm_type: "user_confirm", + approved, + content, + reason: decision.reason, + }); + deps.logger.info( + { sessionId: deps.sessionId, toolCallId, approved, mode, contentChars: content.length }, + "user confirmation resolved", + ); + return jsonResult({ approved, content }); + }, + }); + + return [confirmContent, confirmStyle, confirmUser]; +} + +function normalizeUserOptions(value: unknown): UserConfirmOption[] { + if (!Array.isArray(value)) return []; + const out: UserConfirmOption[] = []; + for (const item of value) { + if (typeof item === "string") { + const id = item.trim(); + if (id) out.push({ id, label: id }); + continue; + } + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (!id) continue; + const label = typeof record.label === "string" && record.label.trim() ? record.label.trim() : id; + const desc = typeof record.desc === "string" ? record.desc.trim() : ""; + out.push(desc ? { id, label, desc } : { id, label }); + } + return out; +} + +function loadTravelStyles(catalog: SkillCatalog): TravelStyleOption[] { + const styles = catalog + .list() + .filter((skill) => skill.manifest.industry === "旅游" && skill.manifest.enabled) + .map((skill) => ({ + id: skill.key, + label: skill.displayName, + desc: skill.description, + })); + return styles.length > 0 ? styles : DEFAULT_TRAVEL_STYLES; +} + +function jsonResult(value: { approved: boolean; content: string }): ConfirmResult { + return { + content: [{ type: "text", text: JSON.stringify(value) }], + details: { ok: true, approved: value.approved, content: value.content }, + }; +} + +function errorResult(text: string): ConfirmResult { + return { + content: [{ type: "text", text }], + details: { ok: false, error: text }, + isError: true, + }; +} diff --git a/src/agent/confirmations.ts b/src/agent/confirmations.ts new file mode 100644 index 0000000..4459971 --- /dev/null +++ b/src/agent/confirmations.ts @@ -0,0 +1,107 @@ +import type { Quote } from "../types.js"; + +export interface ToolConfirmationRequest { + sessionId: string; + toolCallId: string; + toolName: string; + args: unknown; + quote?: Quote; + confirmType?: string; + payload?: unknown; + expiresAt: number; +} + +export interface ToolConfirmationDecision { + approved: boolean; + content?: string; + reason?: string; +} + +interface PendingConfirmation { + resolve: (decision: ToolConfirmationDecision) => void; + timer: NodeJS.Timeout; + cleanup: () => void; +} + +function keyOf(sessionId: string, toolCallId: string): string { + return `${sessionId}:${toolCallId}`; +} + +export class ToolConfirmationManager { + private readonly pending = new Map(); + + constructor(private readonly timeoutMs: number) {} + + request( + input: Omit, + onRequest: (request: ToolConfirmationRequest) => void, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) { + return Promise.resolve({ approved: false, reason: "请求已中止" }); + } + + const key = keyOf(input.sessionId, input.toolCallId); + if (this.pending.has(key)) { + return Promise.resolve({ approved: false, reason: "该工具调用已在等待确认" }); + } + + const request: ToolConfirmationRequest = { + ...input, + expiresAt: Date.now() + this.timeoutMs, + }; + + return new Promise((resolve) => { + let settled = false; + const finish = (decision: ToolConfirmationDecision): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + this.pending.delete(key); + resolve(decision); + }; + const onAbort = (): void => finish({ approved: false, reason: "请求已中止" }); + const timer = setTimeout( + () => finish({ approved: false, reason: "等待用户确认超时" }), + this.timeoutMs, + ); + timer.unref?.(); + + this.pending.set(key, { + resolve: finish, + timer, + cleanup: () => finish({ approved: false, reason: "会话已结束" }), + }); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + onRequest(request); + } catch (error) { + finish({ approved: false, reason: error instanceof Error ? error.message : String(error) }); + } + }); + } + + resolve( + sessionId: string, + toolCallId: string, + decision: boolean | ToolConfirmationDecision, + reason?: string, + ): boolean { + const pending = this.pending.get(keyOf(sessionId, toolCallId)); + if (!pending) return false; + pending.resolve(typeof decision === "boolean" ? { approved: decision, reason } : decision); + return true; + } + + clearSession(sessionId: string): void { + const prefix = `${sessionId}:`; + for (const [key, pending] of this.pending) { + if (key.startsWith(prefix)) pending.cleanup(); + } + } + + count(): number { + return this.pending.size; + } +} diff --git a/src/agent/document_tools.ts b/src/agent/document_tools.ts new file mode 100644 index 0000000..75dd747 --- /dev/null +++ b/src/agent/document_tools.ts @@ -0,0 +1,525 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +import { defineTool } from "@earendil-works/pi-coding-agent"; +import mammoth from "mammoth"; +import { Type } from "typebox"; +import { definePDFJSModule, extractText, getDocumentProxy } from "unpdf"; + +import type { AppConfig } from "../config.js"; +import type { Logger } from "../logger.js"; +import { safeFetch } from "../utils/net.js"; + +const require = createRequire(import.meta.url); +const WordExtractor = require("word-extractor") as new () => { + extract(input: Buffer): Promise<{ getBody(): string }>; +}; +const PDFJS_ROOT = dirname(require.resolve("pdfjs-dist/package.json")); +const PDFJS_CMAPS = `${join(PDFJS_ROOT, "cmaps")}/`; +const PDFJS_STANDARD_FONTS = `${join(PDFJS_ROOT, "standard_fonts")}/`; +let pdfjsReady: Promise | null = null; + +function ensurePdfjs(): Promise { + pdfjsReady ??= definePDFJSModule(async () => import("pdfjs-dist/legacy/build/pdf.mjs") as never); + return pdfjsReady; +} + +const MAX_DOCUMENT_BYTES = 30 * 1024 * 1024; +const MAX_TEXT_CHARS = 60_000; +const MAX_OUTPUT_CHARS = 8_000; + +const DOC_EXTS = new Set(["doc", "docx", "pdf"]); +const TEXT_EXTS = new Set(["txt", "md", "csv", "json", "html", "htm", "xml", "log", "yaml", "yml"]); +const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "gif", "bmp", "heic", "heif"]); + +const OCR_SYSTEM_PROMPT = "你是一个文档 OCR 助手。请提取图片中的所有文字内容,逐行输出,保留原始顺序与排版,不要遗漏数字、价格、日期,不要添加解释。"; +const OCR_USER_PROMPT = "请提取这张图片中的全部文字:"; + +const ELEMENTS_PROMPT = `你是一位旅游海报策划专家。请从下面提供的旅游行程文档文本中, +全面、充分提炼出制作旅游海报所需的关键要素,尽量多提取文档中的有效信息,并以 JSON 对象输出。 + +要求输出的字段: +1. title: 行程标题(如"山西+陕西双飞7日游") +2. subtitle: 副标题(提炼天数/交通/目的地定位的一句话,如"双飞直抵 · 7天6晚 · 漫游晋陕";无则 null) +3. departure: 出发地(如"深圳";未标注填 null) +4. destination: 目的地(如"山西、陕西") +5. transport: 出行方式,从以下类别中选择一个:"飞机"/"高铁"/"火车"/"巴士" + (如文档未注明交通方式,填 null,不要自行猜测) +6. duration: 游玩天数(如"7天6晚") +7. dates: 出发团期列表(array of strings,如 ["04-12", "04-26"];未标注填空数组) +8. price: 参考价格对象,包含字段: + - adult: 成人价(如 "4999") + - older_child: 大童价 + - younger_child: 小童价 + - elder: 长者价 + 文档未出现的价格档位对应字段填 null +9. attractions: 游玩景点列表(array of strings,尽量完整,含沿途小景点) +10. scenic_features: 核心景点特色列表(array of objects,取 3-6 个重点景点),每项字段: + - name: 景点名称 + - feature: 该景点一句话特色/看点(如"壶口瀑布——黄河一壶收的磅礴瀑布") +11. highlights: 行程亮点/特色列表(array of strings,提炼 3-6 个, + 如"壶口瀑布观涛""夜游大唐不夜城") +12. itinerary: 每日行程列表(array of objects),每项字段: + - day: 第几天(如 "D1") + - route: 当天核心路线(如"深圳→运城→关帝庙") + - meals: 当天餐饮安排(如"含中晚餐";无则 "") + - hotel: 当晚住宿(如"运城四钻酒店";无则 "") + 文档没有每日行程时填空数组 +13. hotel: 住宿标准(如"全程精选四钻酒店";未标注填 null) +14. meals: 餐饮标准(如"含5早9正,升级特色餐";未标注填 null) +15. badges: 服务标签列表(array of strings,如 ["纯玩无购物", "品质出行"]) +16. included: 费用包含列表(array of strings,如 ["往返大交通", "行程住宿", "门票"]) +17. excluded: 费用不含/自费项目列表(array of strings,如 ["景区内小交通", "个人消费"];无则空数组) +18. gifts: 赠送项目列表(array of strings,如 ["旅游意外险", "当地特产伴手礼"];无则空数组) +19. experience: 特色体验/活动列表(array of strings,如 ["观赏《黄河之水天上来》演出", + "亲子互动体验"];无则空数组) +20. recommendation: 推荐理由/适合人群(一句话,如"亲子出游、长者康养、摄影爱好者的 + 首选线路";无则 null) +21. slogan: 特色需求标语(基于行程实际卖点提炼的营销短句,如"双飞直抵 · 不虚此行") + +注意:只提取文档中实际出现的信息,未出现的内容对应字段填 null 或空数组, +不要编造。请严格按 JSON 对象输出,不要输出任何额外文字或 Markdown 代码块标记。 + +行程文档文本如下:`; + +interface DocumentToolDeps { + config: AppConfig; + logger: Logger; +} + +interface DownloadedFile { + buffer: Buffer; + contentType: string; +} + +export function createDocumentTools(deps: DocumentToolDeps) { + const readDocument = defineTool({ + name: "read_document", + label: "读取旅游文档", + description: + "读取用户上传的行程文档,返回由线路要素拼成的摘要段落(纯文本,可直接修改)。当用户上传了旅行线路文件(doc/docx/Word、pdf、txt、图片文档)作为海报素材,需要了解线路信息时使用。返回一段用户友好的中文摘要(线路/天数/出发地目的地/出行/团期/价格/景点/标语),可直接呈现给用户确认并允许直接修改。", + parameters: Type.Object({ + file_url: Type.String({ description: "用户上传文件的 URL(CDN 地址)" }), + }), + execute: async ( + _toolCallId: string, + params: { file_url: string }, + ): Promise<{ + content: Array<{ type: "text"; text: string }>; + details: { ok: boolean; kind?: string; chars?: number; error?: string }; + isError?: boolean; + }> => { + const fileUrl = String(params.file_url || "").trim(); + if (!/^https?:\/\//i.test(fileUrl)) { + return errorResult("file_url 必须是 http/https 地址"); + } + try { + const loaded = await loadDocumentText(fileUrl, deps.config); + if (loaded.kind === "doc" || loaded.kind === "docx" || loaded.kind === "pdf") { + if (!loaded.text.trim()) { + return errorResult("未从文档中提取到文本内容。"); + } + const summary = await summarizeTravelDocument(loaded.text, deps.config, deps.logger); + const output = (summary || loaded.text).slice(0, MAX_OUTPUT_CHARS); + return okResult(output, loaded.kind, output.length); + } + if (loaded.kind === "txt" || loaded.kind === "image") { + const text = loaded.text.slice(0, MAX_OUTPUT_CHARS).trim(); + return okResult(text || "未从文档中提取到文本内容。", loaded.kind, text.length); + } + return errorResult(`暂不支持该文件类型(${loaded.kind || "未知"})。支持: doc/docx/pdf/txt/md/csv/json/html/xml/图片。`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.logger.warn({ error: message, fileUrl }, "read_document failed"); + return errorResult(`文档读取失败: ${message}`); + } + }, + }); + + const readDocumentText = defineTool({ + name: "read_document_text", + label: "读取文档文本", + description: + "读取用户上传的远程文档并返回提取后的纯文本,不做行业化总结。支持 doc/docx/pdf/txt/md/csv/json/html/xml 及图片 OCR。适合阅读合同、资料、说明书等通用文档;旅游行程文档请优先使用 read_document。长文档可通过 offset/limit 分页读取。", + parameters: Type.Object({ + file_url: Type.String({ description: "用户上传文件的 URL(CDN 地址)" }), + offset: Type.Optional(Type.Integer({ minimum: 0, description: "从第几个字符开始读取,默认 0" })), + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 20000, description: "本次最多返回多少字符,默认 8000" }), + ), + }), + execute: async ( + _toolCallId: string, + params: { file_url: string; offset?: number; limit?: number }, + ): Promise<{ + content: Array<{ type: "text"; text: string }>; + details: { + ok: boolean; + kind?: string; + total_chars?: number; + offset?: number; + limit?: number; + has_more?: boolean; + error?: string; + }; + isError?: boolean; + }> => { + const fileUrl = String(params.file_url || "").trim(); + if (!/^https?:\/\//i.test(fileUrl)) { + return errorResult("file_url 必须是 http/https 地址"); + } + try { + const loaded = await loadDocumentText(fileUrl, deps.config); + const fullText = loaded.text.trim(); + if (!fullText) { + return errorResult("未从文档中提取到文本内容。"); + } + const offset = Math.max(0, Math.floor(Number(params.offset) || 0)); + const limit = Math.min(Math.max(1, Math.floor(Number(params.limit) || 8000)), 20000); + if (offset >= fullText.length) { + return errorResult(`offset ${offset} 已超过文档长度(${fullText.length} 字符)。`); + } + const chunk = fullText.slice(offset, offset + limit); + const end = offset + chunk.length; + const hasMore = end < fullText.length; + let output = chunk; + if (hasMore) { + output += `\n\n[文档共 ${fullText.length} 字符,当前显示 ${offset + 1}-${end}。继续阅读请传 offset=${end}]`; + } + return { + content: [{ type: "text" as const, text: output }], + details: { + ok: true, + kind: loaded.kind, + total_chars: fullText.length, + offset, + limit, + has_more: hasMore, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.logger.warn({ error: message, fileUrl }, "read_document_text failed"); + return errorResult(`文档读取失败: ${message}`); + } + }, + }); + + return [readDocument, readDocumentText]; +} + +interface LoadedDocument { + kind: string; + text: string; +} + +async function loadDocumentText(fileUrl: string, config: AppConfig): Promise { + const downloaded = await downloadFile(fileUrl, config); + const kind = resolveKind(fileUrl, downloaded.contentType); + if (kind === "doc" || kind === "docx" || kind === "pdf") { + return { kind, text: await extractDocumentText(downloaded.buffer, kind) }; + } + if (kind === "txt") { + return { kind, text: downloaded.buffer.toString("utf8") }; + } + if (kind === "image") { + return { kind, text: await ocrImage(downloaded, config) }; + } + throw new Error(`暂不支持该文件类型(${kind || "未知"})。支持: doc/docx/pdf/txt/md/csv/json/html/xml/图片。`); +} + +function okResult(text: string, kind: string, chars: number) { + return { + content: [{ type: "text" as const, text }], + details: { ok: true, kind, chars }, + }; +} + +function errorResult(text: string) { + return { + content: [{ type: "text" as const, text }], + details: { ok: false, error: text }, + isError: true, + }; +} + +async function downloadFile(url: string, config: AppConfig): Promise { + const response = await safeFetch( + url, + { + signal: AbortSignal.timeout(120_000), + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + }, + }, + { allowedHosts: config.fileFetchAllowedHosts }, + ); + if (!response.ok) { + throw new Error(`文件下载失败 HTTP ${response.status}`); + } + const declaredBytes = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declaredBytes) && declaredBytes > MAX_DOCUMENT_BYTES) { + throw new Error("文件过大(超过 30MB)"); + } + const contentType = ((response.headers.get("content-type") || "").split(";")[0] ?? "").trim().toLowerCase(); + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength === 0) { + throw new Error("文件内容为空"); + } + if (buffer.byteLength > MAX_DOCUMENT_BYTES) { + throw new Error("文件过大(超过 30MB)"); + } + return { buffer, contentType }; +} + +function resolveKind(url: string, contentType: string): string { + const suffix = suffixOf(url); + if (DOC_EXTS.has(suffix)) return suffix; + if (TEXT_EXTS.has(suffix)) return "txt"; + if (IMAGE_EXTS.has(suffix)) return "image"; + if (contentType === "application/pdf") return "pdf"; + if (contentType === "application/msword") return "doc"; + if (contentType.includes("wordprocessingml.document")) return "docx"; + if (contentType.startsWith("text/")) return "txt"; + if (contentType.startsWith("image/")) return "image"; + return suffix; +} + +function suffixOf(url: string): string { + try { + const pathname = new URL(url).pathname; + const index = pathname.lastIndexOf("."); + return index >= 0 ? pathname.slice(index + 1).toLowerCase() : ""; + } catch { + return ""; + } +} + +async function extractDocumentText(buffer: Buffer, kind: string): Promise { + if (kind === "docx") { + try { + const result = await mammoth.extractRawText({ buffer }); + return normalizeLines(result.value); + } catch (error) { + return normalizeLines(await extractWithWordExtractor(buffer)); + } + } + if (kind === "doc") { + return normalizeLines(await extractWithWordExtractor(buffer)); + } + if (kind === "pdf") { + await ensurePdfjs(); + const pdf = await getDocumentProxy(new Uint8Array(buffer), { + cMapUrl: PDFJS_CMAPS, + cMapPacked: true, + standardFontDataUrl: PDFJS_STANDARD_FONTS, + }); + const result = await extractText(pdf, { mergePages: true }); + return normalizeLines(Array.isArray(result.text) ? result.text.join("\n") : result.text); + } + return normalizeLines(buffer.toString("utf8")); +} + +async function extractWithWordExtractor(buffer: Buffer): Promise { + const extractor = new WordExtractor(); + const document = await extractor.extract(buffer); + return document.getBody(); +} + +function normalizeLines(text: string): string { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); +} + +async function summarizeTravelDocument(text: string, config: AppConfig, logger: Logger): Promise { + const source = text.slice(0, MAX_TEXT_CHARS); + try { + const content = await callChat(config, { + temperature: 0.1, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: "你是一个严谨的结构化信息提取器,只输出合法 JSON。" }, + { role: "user", content: `${ELEMENTS_PROMPT}\n---\n${source}\n---` }, + ], + }); + const elements = parseJsonObject(content); + if (!elements) return ""; + return friendlySummary(elements); + } catch (error) { + logger.warn({ error }, "travel document element extraction failed"); + return ""; + } +} + +async function ocrImage(downloaded: DownloadedFile, config: AppConfig): Promise { + const mimeType = downloaded.contentType.startsWith("image/") ? downloaded.contentType : "image/png"; + const dataUrl = `data:${mimeType};base64,${downloaded.buffer.toString("base64")}`; + return callChat(config, { + temperature: 0.1, + messages: [ + { role: "system", content: OCR_SYSTEM_PROMPT }, + { + role: "user", + content: [ + { type: "text", text: OCR_USER_PROMPT }, + { type: "image_url", image_url: { url: dataUrl } }, + ], + }, + ], + }); +} + +async function callChat(config: AppConfig, body: Record): Promise { + const baseUrl = config.llm.baseUrl.replace(/\/+$/, ""); + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.llm.apiKey}`, + }, + body: JSON.stringify({ model: config.llm.modelId, ...body }), + signal: AbortSignal.timeout(120_000), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`模型调用失败 HTTP ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`); + } + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string | null } }>; + }; + const content = data.choices?.[0]?.message?.content; + if (!content || !content.trim()) { + throw new Error("模型返回为空"); + } + return content; +} + +function parseJsonObject(content: string): Record | null { + const cleaned = content + .trim() + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/, ""); + try { + const parsed = JSON.parse(cleaned) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function friendlySummary(elements: Record): string { + const lines: string[] = []; + const title = asText(elements.title); + if (title) lines.push(`线路:${title}`); + + const departure = asText(elements.departure); + const destination = asText(elements.destination); + const duration = asText(elements.duration); + const transport = asText(elements.transport); + const segments: string[] = []; + if (departure) segments.push(`从${departure}出发`); + if (destination) segments.push(`前往${destination}`); + if (duration) segments.push(duration); + if (transport) segments.push(`${transport}往返`); + if (segments.length) lines.push(segments.join(",")); + + const dates = asTextList(elements.dates); + if (dates.length) lines.push(`团期:${dates.join("、")}`); + + const price = asRecord(elements.price); + const priceParts: string[] = []; + for (const [key, label] of [ + ["adult", "成人"], + ["older_child", "大童"], + ["younger_child", "小童"], + ["elder", "长者"], + ] as const) { + const value = asText(price[key]); + if (value) priceParts.push(`${label} ${value}元/人`); + } + if (priceParts.length) lines.push(`价格:${priceParts.join(",")}`); + + const attractions = asTextList(elements.attractions); + if (attractions.length) lines.push(`主要景点:${attractions.join("、")}`); + + const scenicFeatures = asTextList(elements.scenic_features); + if (scenicFeatures.length) lines.push(`景点特色:${scenicFeatures.join(";")}`); + + const subtitle = asText(elements.subtitle); + if (subtitle) lines.push(`副标题:${subtitle}`); + + const highlights = asTextList(elements.highlights); + if (highlights.length) lines.push(`行程亮点:${highlights.join("、")}`); + + const itinerary = Array.isArray(elements.itinerary) ? elements.itinerary : []; + const itineraryParts: string[] = []; + for (const item of itinerary) { + const record = asRecord(item); + const day = asText(record.day); + const route = asText(record.route); + const segment = `${day} ${route}`.trim(); + if (segment) itineraryParts.push(segment); + } + if (itineraryParts.length) lines.push(`行程:${itineraryParts.join("|")}`); + + const hotel = asText(elements.hotel); + if (hotel) lines.push(`住宿:${hotel}`); + + const meals = asText(elements.meals); + if (meals) lines.push(`餐饮:${meals}`); + + const badges = asTextList(elements.badges); + if (badges.length) lines.push(`服务:${badges.join("、")}`); + + const included = asTextList(elements.included); + if (included.length) lines.push(`费用包含:${included.join("、")}`); + + const excluded = asTextList(elements.excluded); + if (excluded.length) lines.push(`费用不含:${excluded.join("、")}`); + + const gifts = asTextList(elements.gifts); + if (gifts.length) lines.push(`赠送:${gifts.join("、")}`); + + const experience = asTextList(elements.experience); + if (experience.length) lines.push(`特色体验:${experience.join("、")}`); + + const recommendation = asText(elements.recommendation); + if (recommendation) lines.push(`推荐:${recommendation}`); + + const slogan = asText(elements.slogan); + if (slogan) lines.push(`标语:${slogan}`); + + return lines.length ? lines.join("\n") : JSON.stringify(elements); +} + +function asText(value: unknown): string { + return value === null || value === undefined ? "" : String(value).trim(); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asTextList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (item && typeof item === "object" && !Array.isArray(item)) { + const record = item as Record; + const name = asText(record.name); + const feature = asText(record.feature); + if (name && feature) return `${name}——${feature}`; + return name || feature; + } + return asText(item); + }) + .filter(Boolean); +} diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts new file mode 100644 index 0000000..a7ed65c --- /dev/null +++ b/src/agent/prompt.ts @@ -0,0 +1,128 @@ +import { MAX_IMAGES_PER_REQUEST } from "../skills/params.js"; +import type { AppConfig } from "../config.js"; +import type { SkillCatalog } from "../skills/catalog.js"; +import type { SkillDefinition } from "../types.js"; + +export interface SystemPrompts { + systemPrompt: string; + appendSystemPrompt: string[]; +} + +export function buildSystemPrompts( + catalog: SkillCatalog, + config: AppConfig, + skill?: SkillDefinition, +): SystemPrompts { + if (!skill) { + return buildDirectPrompts(config); + } + + const systemPrompt = [ + "你是「来秒AI」的创作助手,通过技能(Skill)帮助用户生成商业级图片。", + "", + "工作流程:", + "1. 本次请求已经由前端用户选定技能,不要重新选择或切换技能。", + "2. 该技能的完整规范已附在本提示词中,必须严格按照其中的要求补全信息、构造提示词;如需重新读取可调用 read_skill。", + "3. 生成图片必须调用 generate_image,并传入本次选定的 skill_id;不要自己拼接网关请求。", + "4. 张数以技能正文规定为准:正文规定了固定张数(如「固定生成 8 张」)时,必须生成该数量的全部图片,不得拆成多轮或减少数量;正文只规定范围时,用户未说明则取范围下限,用户指定则按用户数量(不超过范围上限)。", + "4.1 生成套图(多张不同内容)时,必须调用 generate_images_batch,参数结构只有两部分:① style = 整套图唯一的总体风格;② images = 按交付顺序的每张内容(图1生成什么、图2生成什么、图3生成什么…)。工具会把每张以「style + 图N:内容」的提示词一次性全部并发提交给生图模型,整体耗时≈最慢的一张;严禁逐张调用 generate_image 等待上一张完成后再提交下一张。只有「同一提示词重复出多张」时才用 generate_image 的 n 参数。", + "4.2 技能正文里的「逐张生成/按顺序生成」只表示交付顺序与编号规则,不代表串行提交:仍然要一次性批量提交,返回后按编号展示。", + "4.3 风格统一:先从技能规范提炼整套图共用的总体风格(画风/摄影风格、色板与主色、光线氛围、质感材质、字体与排版规则、构图与留白),写入 style 只传一次;images 里每张只写该张生成什么(主体、场景、文案、版式差异),不要重复描述风格、色板或字体。整套图必须看起来像同一位设计师的同一套作品。", + "4.4 每张图片可以有独立尺寸:在 images 里按图分别写 size;未写的使用技能默认尺寸。", + "5. 用户提供参考图时,把图片 URL 放入 reference_images;若技能需要参考图但用户没给,先追问。", + "6. 生成后简短说明结果,不重复贴出全部提示词。", + "6.1 套图生成完成后,最终回复必须按提交顺序逐张展示:每张先写「图N:<该张内容/用途>」,紧接着下一行用 Markdown 图片 `![图N](该张图片URL)` 展示对应图片(N 与提交顺序一致)。不得把图片集中堆在末尾,也不要用表格或下载链接代替逐张展示。", + "生成参数(尺寸、张数、水印、最大张数)以技能正文中的规定为准;调用 generate_image 时必须显式传入正文规定的 size 与 n,正文未规定的使用默认值。", + "7. 如果用户消息中出现【用户上传了以下文档…】标记,必须先读取文档再处理:普通文档用 read_document_text 读取原文;旅游行程文档优先用 read_document 提取线路摘要。不要编造文档中没有的信息。", + "8. 生成前必须核对技能正文要求的输入资料(如必需的商品/实物图、目的地与行程、日期与价格、品牌与卖点、文案、规格尺寸、参考图/文档等)。缺少必填信息时,禁止编造、禁止用默认值或“合理推断”直接生成,必须调用 confirm_user 向用户询问并等待确认:文本类信息用 mode=edit(把已知信息整理成草案;缺失项只保留字段名并留空,例如「活动时间:」「活动地点:」,不要写「请补充」「待填写」等需要用户删除后再输入的占位文字;价格/团期/包含项/资质等事实性字段不得虚构);让用户从多个选项中挑选时必须用 mode=select 并传 options(不要用 mode=edit 代替选择);需要用户同意/授权时用 mode=confirm。", + "8.1 只有用户确认(approved=true)且必填信息齐全后,才能调用 generate_image / generate_images_batch;用户取消或未补齐时停止本次生成,并明确列出还缺少哪些资料。信息已齐全但需要用户过一遍关键内容时,用 mode=edit 让用户确认后再生成,不要用文字反问等待回复。", + ...(skill.manifest.industry === "旅游" + ? [ + "9. 如果用户上传了行程文档(消息中有【用户上传了以下文档…】标记),先读取文档并调用 confirm_travel_content 让用户确认或修改;用户确认后直接使用本次选定的 skill_id 调用 generate_image。如果用户只是用文字提供行程信息,也先用 confirm_travel_content 生成可编辑的线路摘要让用户确认,再生成。不要再询问或选择海报风格。", + "10. confirm_travel_content 返回的 content 是用户最终确认/修改后的内容,生成时必须严格以此为准。", + ] + : []), + "", + "沟通要求:使用简体中文,直接、专业、简洁。缺少关键信息时不要用文字反问等待用户回复,而是按上面的确认流程给出可编辑草案让用户修改确认。", + "所有面向用户的文字(包括确认卡片的标题、说明、选项文案、追问与结果总结)必须使用简体中文;只有生图提示词本身可以是英文。", + "不要在回复中提及积分、费用、单价或预估消耗;扣费由平台自动完成。", + "禁止在未调用 generate_image 的情况下声称图片已生成;如果工具返回用户取消、确认超时或被阻止,明确告知用户本次未生成图片。", + ].join("\n"); + + const pricing = [ + "## 本次技能", + `skill_id: ${skill.key}`, + `名称: ${skill.displayName}`, + `模型: ${skill.manifest.model}`, + `默认尺寸: ${skill.manifest.defaults.size ?? config.skillDefaults.size}`, + `单价: ${skill.manifest.price.per_image} ${skill.manifest.price.currency}/张`, + "", + "## 技能规范", + catalog.promptCatalog([skill]) || "(技能摘要为空)", + "", + skill.content || "(技能正文为空)", + "", + "## 计费说明", + `计费模式:${config.billing.mode};结算币种:${config.billing.currency};每张成功图片按该技能单价结算。`, + config.billing.mode === "live" + ? "当前按技能预设的每张图片积分实际扣费(生成成功后由平台自动扣除),不要在回复中提及具体积分数量或扣费过程。" + : config.billing.mode === "dry_run" + ? "当前为试点期:每次生成只记录用量,不实际扣减团队积分。回复中可提示“预计消耗”,不要声称已扣费。" + : "当前计费已关闭:不扣减费用,用量仍会记录用于估算与审计。", + "", + "## 生图通道", + "所有图片统一通过服务端配置的生图网关生成;无需向用户询问或切换模型。", + ].join("\n"); + + return { systemPrompt, appendSystemPrompt: [pricing] }; +} + +function buildDirectPrompts(config: AppConfig): SystemPrompts { + const systemPrompt = [ + "你是「来秒AI」的创作助手,帮助用户生成商业级图片。", + "", + "本次请求用户没有选择技能,因此:", + "0. 本次为「原样直连」模式:必须直接调用 generate_image 生成图片,prompt 必须逐字使用用户输入原文,禁止改写、扩写、翻译、精简或补充;不要反问、不要要求用户补充信息。", + "1. 普通直接生成不要读取技能规范;如果用户要生成旅游海报并通过 confirm_travel_style 选择了风格,则必须调用 read_skill 读取该风格规范。", + "2. 直接理解用户需求;仅在用户通过 confirm_travel_style 选择了风格时,才按该风格规范补充提示词。", + "3. 普通直接生成调用 generate_image 时省略 skill_id;如果用户通过 confirm_travel_style 选择了旅游风格,则必须把该风格 id 作为 skill_id。", + `4. 用户未说明张数时生成 1 张,最多 ${MAX_IMAGES_PER_REQUEST} 张。`, + "4.1 如果需要多张不同内容(套图),调用 generate_images_batch 一次性提交:给一个总体 style,再按顺序列出每张 images 内容,并发生成;不要逐张串行调用 generate_image。", + "5. 用户提供参考图时,把图片 URL 放入 reference_images。", + "6. 生成后简短说明结果。", + "7. 如果用户消息中出现【用户上传了以下文档…】标记,必须先读取文档再处理:普通文档用 read_document_text 读取原文;旅游行程文档优先用 read_document 提取线路摘要。不要编造文档中没有的信息。", + "8. 如果用户要生成旅游海报(提到旅游/行程/线路/海报等),先调用 confirm_travel_style 让用户选择风格;用户选择后,使用返回的风格 id 作为 skill_id,先调用 read_skill 读取该风格规范,再调用 generate_image。", + "9. 如果用户上传了旅游行程文档,先读取文档并调用 confirm_travel_content 确认内容,再进入风格选择;如果只是文字描述且信息足够,可直接进入风格选择。", + "10. 需要用户确认关键信息、补充/修改文本或做选择时,禁止用文字反问,必须调用 confirm_user:让用户从多个选项中挑选时用 mode=select 并传 options(不要用 mode=edit 代替选择);需要用户补充/修改文本时用 mode=edit 并把已知内容放入 default_value,缺失项只写字段名并留空(如「活动时间:」),不要写「请补充」「待填写」等需要用户删除的占位文字;仅需用户同意/拒绝时用 mode=confirm。普通直接生成(原样直连)无需确认,直接生图。", + "", + "沟通要求:使用简体中文,直接、专业、简洁。缺少关键信息时不要用文字反问等待用户回复,而是按上面的确认流程给出可编辑草案让用户修改确认。", + "所有面向用户的文字(包括确认卡片的标题、说明、选项文案、追问与结果总结)必须使用简体中文;只有生图提示词本身可以是英文。", + "不要在回复中提及积分、费用、单价或预估消耗;扣费由平台自动完成。", + "禁止在未调用 generate_image 的情况下声称图片已生成;如果工具返回用户取消、确认超时或被阻止,明确告知用户本次未生成图片。", + ].join("\n"); + + const pricing = [ + "## 直接生成模式", + "默认不加载技能规范;旅游海报通过 confirm_travel_style 选择风格后,加载所选风格规范并按该风格计费。", + `默认模型: ${config.skillDefaults.model}`, + `默认尺寸: ${config.skillDefaults.size}`, + `默认单价: ${config.billing.defaultSkillPrice} ${config.billing.currency}/张`, + "", + "## 计费说明", + `计费模式:${config.billing.mode};结算币种:${config.billing.currency};每张成功图片按所选风格(或默认价)结算。`, + config.billing.mode === "live" + ? "当前按平台默认单价实际扣费(生成成功后由平台自动扣除),不要在回复中提及具体积分数量或扣费过程。" + : config.billing.mode === "dry_run" + ? "当前为试点期:每次生成只记录用量,不实际扣减团队积分。回复中可提示“预计消耗”,不要声称已扣费。" + : "当前计费已关闭:不扣减费用,用量仍会记录用于估算与审计。", + "", + "普通直接生成调用 generate_image 时不要传 skill_id;通过 confirm_travel_style 选择了旅游风格后,必须传该风格 id。", + ].join("\n"); + + return { systemPrompt, appendSystemPrompt: [pricing] }; +} + +export function buildUserPrompt(message: string, referenceImages: string[] = []): string { + if (referenceImages.length === 0) return message; + return [message, "", "参考图 URL:", ...referenceImages.map((url, index) => `${index + 1}. ${url}`)].join("\n"); +} diff --git a/src/agent/service.ts b/src/agent/service.ts new file mode 100644 index 0000000..e1b8a29 --- /dev/null +++ b/src/agent/service.ts @@ -0,0 +1,817 @@ +import { stripUploadedDocuments } from "../utils/text.js"; +import { safeFetch } from "../utils/net.js"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + createAgentSession, + DefaultResourceLoader, + ModelRuntime, + SessionManager, + SettingsManager, + type AgentSessionEvent, +} from "@earendil-works/pi-coding-agent"; +import type { ImageContent, Model, ThinkingLevel } from "@earendil-works/pi-ai"; +import { ToolConfirmationManager, type ToolConfirmationDecision } from "./confirmations.js"; +import { buildSystemPrompts, buildUserPrompt } from "./prompt.js"; +import { createAgentTools } from "./tools.js"; +import { createDocumentTools } from "./document_tools.js"; +import { createConfirmTools } from "./confirm_tools.js"; +import { quoteSkill } from "../billing/pricing.js"; +import type { UsageLedger } from "../billing/ledger.js"; +import type { AppConfig } from "../config.js"; +import type { GatewayClient } from "../gateway/client.js"; +import type { Logger } from "../logger.js"; +import type { SessionStoreLike } from "../sessions/store.js"; +import type { SessionContextStore } from "../sessions/context_store.js"; +import type { SkillCatalog } from "../skills/catalog.js"; +import { directSkill } from "../skills/direct.js"; +import type { Identity, Quote, SkillDefinition } from "../types.js"; + +export class AgentError extends Error { + constructor( + message: string, + readonly statusCode = 400, + ) { + super(message); + this.name = "AgentError"; + } +} + +export class AgentConfigError extends AgentError { + constructor(message: string) { + super(message, 503); + this.name = "AgentConfigError"; + } +} + +export interface MediaItem { + type: "image" | "video"; + url: string; +} + +export interface RunUsage { + input: number; + output: number; + total: number; + cost: number; +} + +export type ContentConfirmationType = + | "travel_content_confirm" + | "travel_style_confirm" + | "user_confirm"; + +export type AgentStreamEvent = + | { type: "session"; session_id: string } + | { type: "text_delta"; text: string } + | { type: "thinking_delta"; text: string } + | { type: "tool_start"; tool_call_id: string; tool_name: string; args: unknown } + | { type: "tool_end"; tool_call_id: string; tool_name: string; ok: boolean; duration_ms: number } + | { type: "images"; images: string[]; quote: Quote } + | { + type: "tool_confirmation_required"; + confirmation_id: string; + session_id: string; + tool_call_id: string; + tool_name: string; + args: unknown; + quote?: Quote; + expires_at: number; + } + | { + type: "tool_confirmation_resolved"; + confirmation_id: string; + tool_call_id: string; + approved: boolean; + reason?: string; + } + | { + type: "content_confirmation_required"; + confirmation_id: string; + session_id: string; + tool_call_id: string; + confirm_type: ContentConfirmationType; + payload: unknown; + expires_at: number; + } + | { + type: "content_confirmation_resolved"; + confirmation_id: string; + tool_call_id: string; + confirm_type: ContentConfirmationType; + approved: boolean; + content?: string; + reason?: string; + } + | { + type: "done"; + session_id: string; + message_id: string; + answer: string; + media: MediaItem[]; + usage: RunUsage; + skill_key?: string; + } + | { type: "error"; message: string }; + +export interface RunInput { + identity: Identity; + sessionId: string; + message: string; + referenceImages?: string[]; + skillKey?: string; + industry?: string; + directPrompt?: boolean; + signal?: AbortSignal; + onEvent?: (event: AgentStreamEvent) => void; +} + +export interface RunResult { + sessionId: string; + messageId: string; + answer: string; + media: MediaItem[]; + usage: RunUsage; + skill_key?: string; +} + +export interface ConfirmToolInput { + identity: Identity; + sessionId: string; + toolCallId: string; + approved: boolean; + content?: string; + reason?: string; +} + +export class AgentService { + private runtime?: Promise; + private readonly locks = new Map>(); + private readonly confirmations: ToolConfirmationManager; + + constructor( + private readonly config: AppConfig, + private readonly catalog: SkillCatalog, + private readonly ledger: UsageLedger, + private readonly gateway: GatewayClient, + private readonly store: SessionStoreLike, + private readonly contextStore: SessionContextStore, + private readonly logger: Logger, + ) { + this.confirmations = new ToolConfirmationManager(config.toolConfirmation.timeoutMs); + mkdirSync(config.agentDir, { recursive: true }); + mkdirSync(config.workspaceDir, { recursive: true }); + } + + isLlmConfigured(): boolean { + return Boolean(this.config.llm.baseUrl && this.config.llm.apiKey); + } + + async run(input: RunInput): Promise { + if (!this.isLlmConfigured()) { + throw new AgentConfigError("LLM 未配置:请设置 LAIMIAO_LLM_BASE_URL 与 LAIMIAO_LLM_API_KEY 后重启服务"); + } + return this.withSessionLock(input.sessionId, () => this.execute(input)); + } + + async confirmTool(input: ConfirmToolInput): Promise<{ + ok: true; + tool_call_id: string; + approved: boolean; + content?: string; + }> { + const meta = await this.store.get(input.sessionId); + if (!meta) throw new AgentError(`session not found: ${input.sessionId}`, 404); + if (meta.uid !== input.identity.uid) throw new AgentError("session does not belong to the current user", 403); + if (input.identity.team_id && meta.team_id && input.identity.team_id !== meta.team_id) { + throw new AgentError("session does not belong to the current team", 403); + } + const decision = { approved: input.approved, content: input.content, reason: input.reason }; + if (!this.confirmations.resolve(input.sessionId, input.toolCallId, decision)) { + throw new AgentError("工具确认不存在或已过期,请重新发起请求", 409); + } + this.logger.info( + { + sessionId: input.sessionId, + toolCallId: input.toolCallId, + approved: input.approved, + contentChars: input.content?.length ?? 0, + }, + "tool confirmation resolved", + ); + return { + ok: true, + tool_call_id: input.toolCallId, + approved: input.approved, + content: input.content, + }; + } + + private async execute(input: RunInput): Promise { + const meta = await this.store.get(input.sessionId); + if (!meta) throw new AgentError(`session not found: ${input.sessionId}`, 404); + if (meta.uid !== input.identity.uid) throw new AgentError("session does not belong to the current user", 403); + if (input.identity.team_id && meta.team_id && input.identity.team_id !== meta.team_id) { + throw new AgentError("session does not belong to the current team", 403); + } + const identity: Identity = { ...input.identity, team_id: meta.team_id || input.identity.team_id }; + const emit = input.onEvent ?? (() => {}); + const media: MediaItem[] = []; + + if (input.skillKey) await this.catalog.refresh(); + const selectedSkill = input.skillKey ? this.catalog.get(input.skillKey) : undefined; + if (input.skillKey && !selectedSkill) { + throw new AgentError(`skill 不存在:${input.skillKey}`, 404); + } + const skill = selectedSkill ?? directSkill(this.config); + const industry = (input.industry || selectedSkill?.manifest.industry || "").trim(); + const isDirectTravel = !selectedSkill && industry === "旅游"; + let travelStyleConfirmed = false; + let travelStyleKey = ""; + const referenceImages = (input.referenceImages ?? []).filter((url) => /^https?:\/\//.test(url)).slice(0, 6); + + // 入库时去掉前端为 Agent 追加的「【用户上传了以下文档…】」指令块, + // 历史会话里展示的应是用户真正输入的内容(附件另行展示) + const userMessage = await this.store.append(input.sessionId, { + role: "user", + mode: "agent", + content: stripUploadedDocuments(input.message), + media: referenceImages.map((url) => ({ type: "image" as const, url })), + status: "ok", + }); + + const prompts = buildSystemPrompts(this.catalog, this.config, selectedSkill); + const isTravel = selectedSkill?.manifest.industry === "旅游"; + const travelSkillKeys = this.catalog + .list() + .filter((item) => item.manifest.industry === "旅游") + .map((item) => item.key); + const allowedSkillKeys = selectedSkill + ? [skill.key] + : [...new Set([...travelSkillKeys, skill.key])]; + const toolNames = selectedSkill + ? [ + "read_document", + "read_document_text", + "confirm_user", + ...(isTravel ? ["confirm_travel_content"] : []), + "list_skills", + "read_skill", + "generate_image", + "generate_images_batch", + ] + : [ + "read_document", + "read_document_text", + "confirm_user", + "confirm_travel_content", + "confirm_travel_style", + "read_skill", + "generate_image", + "generate_images_batch", + ]; + + const runtime = await this.getRuntime(); + const model = this.resolveModel(runtime); + const settingsManager = SettingsManager.create(this.config.workspaceDir, this.config.agentDir); + const toolConfirmationExtension = (pi: { + on: ( + event: "tool_call", + handler: (event: { + toolCallId: string; + toolName: string; + input: Record; + }) => Promise<{ block?: boolean; reason?: string } | undefined>, + ) => void; + }): void => { + pi.on("tool_call", async (event) => { + if ( + (event.toolName === "generate_image" || event.toolName === "generate_images_batch") && + isDirectTravel && + !travelStyleConfirmed + ) { + return { + block: true, + reason: "旅游海报需要先调用 confirm_travel_style 让用户选择风格,再生成图片。", + }; + } + if ( + !this.config.toolConfirmation.enabled || + !this.config.toolConfirmation.tools.includes(event.toolName) + ) { + return undefined; + } + const toolArgs = { ...event.input }; + if (event.toolName === "generate_image" && isDirectTravel && travelStyleKey) { + toolArgs.skill_id = travelStyleKey; + } + const decision = await this.requestToolConfirmation({ + sessionId: input.sessionId, + toolCallId: event.toolCallId, + toolName: event.toolName, + toolArgs, + skill, + emit, + hasStream: Boolean(input.onEvent), + signal: input.signal, + }); + emit({ + type: "tool_confirmation_resolved", + confirmation_id: event.toolCallId, + tool_call_id: event.toolCallId, + approved: decision.approved, + reason: decision.reason, + }); + if (decision.approved) { + emit({ + type: "tool_start", + tool_call_id: event.toolCallId, + tool_name: event.toolName, + args: sanitizeArgs(toolArgs), + }); + return undefined; + } + return { block: true, reason: decision.reason ?? "用户拒绝执行该工具" }; + }); + }; + const loader = new DefaultResourceLoader({ + cwd: this.config.workspaceDir, + agentDir: this.config.agentDir, + settingsManager, + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + noSkills: true, + extensionFactories: [toolConfirmationExtension], + systemPrompt: prompts.systemPrompt, + appendSystemPrompt: prompts.appendSystemPrompt, + }); + await loader.reload(); + + const piSession = await this.openPiSession(input.sessionId); + const sessionManager = piSession.manager; + const customTools = [ + ...createAgentTools({ + config: this.config, + catalog: this.catalog, + ledger: this.ledger, + gateway: this.gateway, + logger: this.logger, + identity, + sessionId: input.sessionId, + allowedSkillKeys, + directSkill: selectedSkill ? undefined : skill, + getForcedSkillKey: () => (isDirectTravel ? travelStyleKey || undefined : undefined), + rawPrompt: input.directPrompt ? input.message : undefined, + onImages: (urls, quote) => { + for (const url of urls) media.push({ type: "image", url }); + emit({ type: "images", images: urls, quote }); + }, + }), + ...createDocumentTools({ config: this.config, logger: this.logger }), + ...createConfirmTools({ + catalog: this.catalog, + confirmations: this.confirmations, + sessionId: input.sessionId, + logger: this.logger, + emit, + hasStream: Boolean(input.onEvent), + onTravelStyleConfirmed: (style) => { + travelStyleConfirmed = true; + travelStyleKey = style; + }, + }), + ]; + + const { session } = await createAgentSession({ + cwd: this.config.workspaceDir, + agentDir: this.config.agentDir, + modelRuntime: runtime, + model, + thinkingLevel: this.config.llm.thinkingLevel as ThinkingLevel, + tools: toolNames, + resourceLoader: loader, + sessionManager, + customTools, + settingsManager, + }); + + const unsubscribe = session.subscribe((event) => this.forwardEvent(event, emit)); + const onAbort = () => { + void session.abort(); + }; + input.signal?.addEventListener("abort", onAbort, { once: true }); + + let assistantMessageId = ""; + try { + const assistantPlaceholder = await this.store.append( + input.sessionId, + { + role: "assistant", + mode: "agent", + content: "", + media: [], + status: "loading", + }, + userMessage.add_time + 1, + ); + assistantMessageId = assistantPlaceholder.id; + const referenceImageContents = await loadReferenceImages( + referenceImages, + this.config.fileFetchAllowedHosts, + this.logger, + ); + await session.prompt( + buildUserPrompt(input.message, referenceImages), + referenceImageContents.length > 0 ? { images: referenceImageContents } : undefined, + ); + const output = collectAssistantOutput(session.messages as unknown[]); + const answer = output.text.trim() || session.getLastAssistantText()?.trim() || (media.length ? "已为你生成图片。" : ""); + const usage = extractUsage(session.messages as unknown[]); + + await this.store.update(input.sessionId, assistantMessageId, { + mode: media.length ? "image" : "agent", + content: answer, + thinking: output.thinking.trim() || undefined, + media, + status: "ok", + error: "", + }); + + const result: RunResult = { + sessionId: input.sessionId, + messageId: assistantMessageId, + answer, + media, + usage, + skill_key: selectedSkill?.key, + }; + emit({ + type: "done", + session_id: result.sessionId, + message_id: assistantMessageId, + answer, + media, + usage, + skill_key: selectedSkill?.key, + }); + return result; + } catch (error) { + const messageText = error instanceof Error ? error.message : String(error); + if (assistantMessageId) { + await this.store.update(input.sessionId, assistantMessageId, { + mode: media.length ? "image" : "agent", + content: messageText, + media, + status: "error", + error: messageText, + }); + } else { + await this.store.append(input.sessionId, { + role: "assistant", + mode: "agent", + content: messageText, + media, + status: "error", + error: messageText, + }); + } + emit({ type: "error", message: messageText }); + throw error; + } finally { + input.signal?.removeEventListener("abort", onAbort); + this.confirmations.clearSession(input.sessionId); + unsubscribe(); + session.dispose(); + await this.persistPiSession(input.sessionId, piSession); + } + } + + private async requestToolConfirmation(input: { + sessionId: string; + toolCallId: string; + toolName: string; + toolArgs: Record; + skill: SkillDefinition; + emit: (event: AgentStreamEvent) => void; + hasStream: boolean; + signal?: AbortSignal; + }): Promise { + const config = this.config.toolConfirmation; + if (!config.enabled || !config.tools.includes(input.toolName)) { + return { approved: true }; + } + if (!input.hasStream) { + return { + approved: false, + reason: "该工具需要用户确认,请使用 stream:true 发起请求并在确认后继续", + }; + } + + const requestedSkillId = + typeof input.toolArgs.skill_id === "string" ? input.toolArgs.skill_id : ""; + const requestedSkill = requestedSkillId ? this.catalog.get(requestedSkillId) : undefined; + const quote = this.quoteForConfirmation(input.toolName, input.toolArgs, requestedSkill ?? input.skill); + this.logger.info( + { sessionId: input.sessionId, toolCallId: input.toolCallId, toolName: input.toolName }, + "waiting for tool confirmation", + ); + return this.confirmations.request( + { + sessionId: input.sessionId, + toolCallId: input.toolCallId, + toolName: input.toolName, + args: input.toolArgs, + quote, + }, + (request) => { + input.emit({ + type: "tool_confirmation_required", + confirmation_id: request.toolCallId, + session_id: request.sessionId, + tool_call_id: request.toolCallId, + tool_name: request.toolName, + args: request.args, + quote: request.quote, + expires_at: request.expiresAt, + }); + }, + input.signal, + ); + } + + private quoteForConfirmation( + toolName: string, + args: Record, + skill: SkillDefinition, + ): Quote | undefined { + if (toolName !== "generate_image") return undefined; + const size = typeof args.size === "string" ? args.size : undefined; + const n = typeof args.n === "number" && Number.isInteger(args.n) ? args.n : undefined; + try { + const defaults = this.catalog.defaultsFor(skill); + return quoteSkill( + skill, + { size: defaults.size, quantity: defaults.n }, + { size, quantity: n }, + this.config.billing.mode, + ); + } catch { + return undefined; + } + } + + private forwardEvent(event: AgentSessionEvent, emit: (event: AgentStreamEvent) => void): void { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + emit({ type: "text_delta", text: event.assistantMessageEvent.delta }); + return; + } + if (event.type === "message_update" && event.assistantMessageEvent.type === "thinking_delta") { + emit({ type: "thinking_delta", text: event.assistantMessageEvent.delta }); + return; + } + if (event.type === "tool_execution_start") { + if (this.config.toolConfirmation.enabled && this.config.toolConfirmation.tools.includes(event.toolName)) { + return; + } + emit({ + type: "tool_start", + tool_call_id: event.toolCallId, + tool_name: event.toolName, + args: sanitizeArgs(event.args), + }); + return; + } + if (event.type === "tool_execution_end") { + const details = event.result?.details as { ok?: boolean } | undefined; + emit({ + type: "tool_end", + tool_call_id: event.toolCallId, + tool_name: event.toolName, + ok: details?.ok ?? !event.isError, + duration_ms: event.durationMs ?? 0, + }); + } + } + + private resolveModel(runtime: ModelRuntime): Model<"openai-completions"> { + const { providerId, modelId } = this.config.llm; + const model = runtime.getModel(providerId, modelId); + if (!model) { + const available = runtime + .getModels() + .map((item) => `${item.provider}/${item.id}`) + .join(", "); + throw new AgentConfigError( + `模型未找到:${providerId}/${modelId}。可用模型:${available || "无"}。请检查 LAIMIAO_LLM_* 环境变量`, + ); + } + return model as Model<"openai-completions">; + } + + private async getRuntime(): Promise { + if (!this.runtime) { + this.runtime = this.createRuntime().catch((error: unknown) => { + this.runtime = undefined; + throw error; + }); + } + return this.runtime; + } + + private async createRuntime(): Promise { + const { providerId, baseUrl, modelId, contextWindow, maxTokens, reasoning } = this.config.llm; + const payload = { + providers: { + [providerId]: { + baseUrl, + api: "openai-completions", + apiKey: "${LAIMIAO_LLM_API_KEY}", + compat: { thinkingFormat: "qwen", supportsReasoningEffort: false }, + models: [ + { + id: modelId, + name: modelId, + contextWindow, + maxTokens, + reasoning, + input: ["text", "image"], + }, + ], + }, + }, + }; + writeFileSync(this.config.modelsFile, JSON.stringify(payload, null, 2), "utf8"); + + try { + return await ModelRuntime.create({ modelsPath: this.config.modelsFile, allowModelNetwork: false }); + } catch (error) { + throw new AgentConfigError( + `模型运行时初始化失败:${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async openPiSession( + sessionId: string, + ): Promise<{ manager: SessionManager; tempFile: string | null }> { + if (this.config.sessionContext !== "mysql") { + mkdirSync(this.config.piSessionsDir, { recursive: true }); + const existing = SessionManager.findById(this.config.workspaceDir, sessionId, this.config.piSessionsDir); + const manager = existing + ? SessionManager.open(existing, this.config.piSessionsDir, this.config.workspaceDir) + : SessionManager.create(this.config.workspaceDir, this.config.piSessionsDir, { id: sessionId }); + return { manager, tempFile: null }; + } + + const tmpDir = this.config.contextTmpDir; + mkdirSync(tmpDir, { recursive: true }); + const blob = await this.contextStore.load(sessionId); + if (blob) { + const file = join(tmpDir, `${sessionId}.jsonl`); + writeFileSync(file, blob); + return { + manager: SessionManager.open(file, tmpDir, this.config.workspaceDir), + tempFile: file, + }; + } + const manager = SessionManager.create(this.config.workspaceDir, tmpDir, { id: sessionId }); + return { manager, tempFile: null }; + } + + private async persistPiSession( + sessionId: string, + piSession: { manager: SessionManager; tempFile: string | null }, + ): Promise { + if (this.config.sessionContext !== "mysql") return; + const file = piSession.manager.getSessionFile() ?? piSession.tempFile; + if (!file || !existsSync(file)) return; + try { + await this.contextStore.save(sessionId, readFileSync(file)); + } catch (error) { + this.logger.error( + { sessionId, error: error instanceof Error ? error.message : String(error) }, + "failed to persist pi session context", + ); + } finally { + try { + unlinkSync(file); + } catch (error) { + this.logger.warn( + { sessionId, error: error instanceof Error ? error.message : String(error) }, + "failed to remove temp pi session file", + ); + } + } + } + + private async withSessionLock(sessionId: string, work: () => Promise): Promise { + const previous = this.locks.get(sessionId) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolveGate) => { + release = resolveGate; + }); + const tail = previous.then(() => gate); + this.locks.set(sessionId, tail); + await previous; + try { + return await work(); + } finally { + release(); + if (this.locks.get(sessionId) === tail) this.locks.delete(sessionId); + } + } +} + + +const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024; +const MAX_REFERENCE_IMAGE_TOTAL_BYTES = 20 * 1024 * 1024; + +async function loadReferenceImages( + urls: string[], + allowedHosts: string[], + logger: Logger, +): Promise { + const images: ImageContent[] = []; + let totalBytes = 0; + for (const url of urls) { + try { + const response = await safeFetch( + url, + { signal: AbortSignal.timeout(15_000) }, + { allowedHosts }, + ); + if (!response.ok) { + logger.warn({ url, status: response.status }, "reference image fetch failed"); + continue; + } + const mimeType = ((response.headers.get("content-type") || "").split(";")[0] ?? "").trim().toLowerCase(); + if (!mimeType.startsWith("image/")) { + logger.warn({ url, mimeType }, "reference image has non-image content type"); + continue; + } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength === 0) { + logger.warn({ url }, "reference image is empty"); + continue; + } + if (buffer.byteLength > MAX_REFERENCE_IMAGE_BYTES || totalBytes + buffer.byteLength > MAX_REFERENCE_IMAGE_TOTAL_BYTES) { + logger.warn({ url, bytes: buffer.byteLength, totalBytes }, "reference image skipped: too large"); + continue; + } + totalBytes += buffer.byteLength; + images.push({ type: "image", data: buffer.toString("base64"), mimeType }); + } catch (error) { + logger.warn({ error, url }, "reference image download failed"); + } + } + return images; +} + +function sanitizeArgs(args: unknown): unknown { + if (!args || typeof args !== "object") return args; + const record = { ...(args as Record) }; + if (typeof record.prompt === "string" && record.prompt.length > 200) { + record.prompt = `${record.prompt.slice(0, 200)}…`; + } + return record; +} + +interface UsageMessage { + role: string; + usage?: { input: number; output: number; totalTokens: number; cost: { total: number } }; +} + +function collectAssistantOutput(messages: unknown[]): { text: string; thinking: string } { + const texts: string[] = []; + const thoughts: string[] = []; + for (const item of messages) { + if (!item || typeof item !== "object") continue; + const message = item as { role?: string; content?: unknown }; + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const raw of message.content) { + if (!raw || typeof raw !== "object") continue; + const block = raw as { type?: string; text?: string; thinking?: string }; + if (block.type === "text" && block.text) texts.push(block.text); + else if (block.type === "thinking" && block.thinking) thoughts.push(block.thinking); + } + } + return { text: texts.join("\n\n"), thinking: thoughts.join("\n\n") }; +} + +function extractUsage(messages: unknown[]): RunUsage { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] as UsageMessage | undefined; + if (!message) continue; + if (message.role === "assistant" && message.usage) { + return { + input: message.usage.input, + output: message.usage.output, + total: message.usage.totalTokens, + cost: message.usage.cost.total, + }; + } + } + return { input: 0, output: 0, total: 0, cost: 0 }; +} diff --git a/src/agent/tools.ts b/src/agent/tools.ts new file mode 100644 index 0000000..e489522 --- /dev/null +++ b/src/agent/tools.ts @@ -0,0 +1,666 @@ +import { MAX_IMAGES_PER_REQUEST } from "../skills/params.js"; +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { PricingError, quoteSkill, repriceForQuantity } from "../billing/pricing.js"; +import type { UsageLedger } from "../billing/ledger.js"; +import type { AppConfig } from "../config.js"; +import { GatewayError, type GatewayClient } from "../gateway/client.js"; +import type { Logger } from "../logger.js"; +import type { SkillCatalog } from "../skills/catalog.js"; +import type { Identity, Quote, SkillDefinition, UsageRecord } from "../types.js"; + +export interface AgentToolDeps { + config: AppConfig; + catalog: SkillCatalog; + ledger: UsageLedger; + gateway: GatewayClient; + logger: Logger; + identity: Identity; + sessionId: string; + allowedSkillKeys?: string[]; + directSkill?: SkillDefinition; + getForcedSkillKey?: () => string | undefined; + rawPrompt?: string; + onImages: (urls: string[], quote: Quote) => void; +} + +export interface GeneratedImageDetails { + ok: boolean; + quote?: Quote; + task_id?: string; + images?: string[]; + elapsed_ms?: number; + idempotent?: boolean; + error?: string; +} + +export interface BatchImageDetails { + ok: boolean; + quote?: Quote; + images?: string[]; + task_id?: string; + elapsed_ms?: number; + idempotent?: boolean; + error?: string; +} + +export interface QuoteDetails { + ok: boolean; + quote?: Quote; + error?: string; +} + +export function createAgentTools(deps: AgentToolDeps) { + const { catalog, ledger, gateway, config, logger, identity, sessionId, directSkill } = deps; + const allowed = deps.allowedSkillKeys ? new Set(deps.allowedSkillKeys) : undefined; + const isAllowed = (key: string): boolean => !allowed || allowed.has(key); + const resolveSkill = (skillId?: string): SkillDefinition | undefined => { + const effectiveKey = deps.getForcedSkillKey?.() || skillId; + return effectiveKey ? catalog.get(effectiveKey) : directSkill; + }; + + const listSkills = defineTool({ + name: "list_skills", + label: "列出技能", + description: "列出当前可用的生图技能,包含每个技能的用途、默认尺寸、模型与每张价格。", + parameters: Type.Object({}), + execute: async () => { + const skills = catalog.publicSkills().filter((skill) => isAllowed(skill.key)); + const text = skills + .map( + (skill) => + `${skill.key} | ${skill.display_name} | ${skill.price.per_image} ${skill.price.currency}/张 | ${skill.model} | ${skill.defaults.size}\n${skill.description}`, + ) + .join("\n\n"); + return { + content: [{ type: "text" as const, text: text || "当前没有可用技能。" }], + details: { skills }, + }; + }, + }); + + + const readSkill = defineTool({ + name: "read_skill", + label: "读取技能说明", + description: "读取指定技能的 SKILL.md 全文。构造提示词前必须先调用此工具,并严格遵循其中的规范。", + parameters: Type.Object({ + skill_id: Type.Optional(Type.String({ description: "技能 key;直接生成模式下省略" })), + }), + execute: async ( + _toolCallId: string, + params: { skill_id?: string }, + ): Promise<{ + content: Array<{ type: "text"; text: string }>; + details: { ok: boolean; error?: string }; + isError?: boolean; + }> => { + const skill = resolveSkill(params.skill_id); + if (!skill || !isAllowed(skill.key)) { + return { + content: [{ type: "text", text: unknownSkillMessage(catalog, params.skill_id, allowed, directSkill) }], + details: { ok: false, error: "unknown_skill" }, + isError: true, + }; + } + if (!skill.content) { + return { + content: [{ type: "text", text: "当前为直接生成模式,没有技能规范需要读取。" }], + details: { ok: true }, + }; + } + return { + content: [{ type: "text", text: skill.content }], + details: { ok: true }, + }; + }, + }); + + const generateTool = defineTool({ + name: "generate_image", + label: "生成图片", + description: directSkill + ? "直接生成图片,不需要 skill_id。每张成功图片按平台默认单价计费,返回图片 URL。" + : "按指定技能生成图片。必须传入本次选定的 skill_id。调用前应先阅读该技能的 SKILL.md 并遵守其提示词规范。每张成功图片按技能单价计费,返回图片 URL。", + parameters: Type.Object({ + skill_id: Type.Optional(Type.String({ description: "技能 key;直接生成模式下省略" })), + prompt: Type.String({ minLength: 2, maxLength: 6000, description: "最终生图提示词" }), + size: Type.Optional(Type.String({ description: "图片尺寸,缺省用技能默认值" })), + n: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_IMAGES_PER_REQUEST, description: "生成张数,缺省 1;按技能正文要求的张数一次生成" })), + reference_images: Type.Optional( + Type.Array(Type.String(), { maxItems: 6, description: "参考图 URL 列表,仅 img2img 类技能使用" }), + ), + watermark: Type.Optional(Type.Boolean({ description: "是否加水印,缺省不加" })), + }), + execute: async ( + toolCallId: string, + params: { + skill_id?: string; + prompt: string; + size?: string; + n?: number; + reference_images?: string[]; + watermark?: boolean; + }, + signal: AbortSignal, + ): Promise<{ + content: Array<{ type: "text"; text: string }>; + details: GeneratedImageDetails; + isError?: boolean; + }> => { + const previous = ledger.findSucceededToolCall(identity.uid, sessionId, toolCallId); + if (previous) { + const quote = quoteFromRecord(previous); + return { + content: [ + { + type: "text", + text: `该工具调用已生成过图片,不重复计费。\n${links(previous.image_urls)}`, + }, + ], + details: { + ok: true, + quote, + task_id: previous.gateway_task_id, + images: previous.image_urls, + elapsed_ms: 0, + idempotent: true, + }, + }; + } + + const skill = resolveSkill(params.skill_id); + if (!skill || !isAllowed(skill.key)) { + return { + content: [{ type: "text", text: unknownSkillMessage(catalog, params.skill_id, allowed, directSkill) }], + details: { ok: false, error: "unknown_skill" }, + isError: true, + }; + } + + const defaults = catalog.defaultsFor(skill); + let quote: Quote; + try { + quote = quoteSkill( + skill, + { size: defaults.size, quantity: defaults.n }, + { size: params.size, quantity: params.n }, + config.billing.mode, + ); + if (directSkill && skill.key === directSkill.key) { + const unit = Math.max(1, Math.ceil(quote.unit_credits * 1.3)); + quote = { ...quote, unit_credits: unit, total_credits: unit * quote.quantity }; + } + } catch (error) { + const message = error instanceof PricingError || error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text", text: `参数不合法:${message}` }], + details: { ok: false, error: message }, + isError: true, + }; + } + + const referenceImages = (params.reference_images ?? []).filter((url) => /^https?:\/\//.test(url)).slice(0, 6); + const finalPrompt = deps.rawPrompt && deps.rawPrompt.trim() ? deps.rawPrompt.trim() : params.prompt; + + // 生成前按技能单价(直接生成按技能最低标准)校验团队积分 + const isDirect = !!(directSkill && skill.key === directSkill.key); + const requiredCredits = isDirect ? lowestSkillPrice(catalog, config) : quote.total_credits; + const balance = await fetchTeamScore(config.teamApiUrl, identity.team_id); + if (balance !== null && balance < requiredCredits) { + return { + content: [ + { + type: "text", + text: `团队积分不足:本次生成需要 ${requiredCredits} 积分,当前剩余 ${balance} 积分,请充值后再试。`, + }, + ], + details: { ok: false, error: "insufficient_score" }, + isError: true, + }; + } + const record = ledger.begin({ + uid: identity.uid, + team_id: identity.team_id, + session_id: sessionId, + tool_call_id: toolCallId, + skill_id: skill.key, + skill_name: skill.displayName, + model: quote.model, + size: quote.size, + quantity: quote.quantity, + unit_credits: quote.unit_credits, + size_multiplier: quote.size_multiplier, + total_credits: quote.total_credits, + currency: quote.currency, + billing_mode: quote.billing_mode, + }); + + logger.info( + { + skill: skill.key, + model: quote.model, + size: quote.size, + quantity: quote.quantity, + total: quote.total_credits, + usageId: record.id, + }, + "image generation started", + ); + + try { + const result = await gateway.generateImage({ + uid: identity.uid, + team_id: identity.team_id, + model: quote.model, + prompt: finalPrompt, + size: quote.size, + n: quote.quantity, + images: referenceImages, + watermark: params.watermark ?? defaults.watermark, + metadata: { + skill_id: skill.key, + session_id: sessionId, + uid: identity.uid, + team_id: identity.team_id, + usage_id: record.id, + tool_call_id: toolCallId, + credits: quote.total_credits, + }, + signal, + }); + + if (result.urls.length === 0) { + throw new Error("网关没有返回图片 URL"); + } + + const billedQuote = repriceForQuantity(quote, result.urls.length); + ledger.settle(record.id, "succeeded", { + gateway_task_id: result.taskId, + image_urls: result.urls, + quantity: billedQuote.quantity, + total_credits: billedQuote.total_credits, + }); + deps.onImages(result.urls, billedQuote); + logger.info( + { usageId: record.id, taskId: result.taskId, images: result.urls.length }, + "image generation succeeded", + ); + + return { + content: [ + { + type: "text", + text: `生成完成:${billedQuote.quantity} 张,技能 ${skill.displayName},模型 ${billedQuote.model},尺寸 ${billedQuote.size}${billingSuffix(billedQuote)}。\n${links(result.urls)}`, + }, + ], + details: { + ok: true, + quote: billedQuote, + task_id: result.taskId, + images: result.urls, + elapsed_ms: result.elapsedMs, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ledger.settle(record.id, "failed", { error: message }); + logger.error( + { + usageId: record.id, + skill: skill.key, + error: message, + code: error instanceof GatewayError ? error.code : undefined, + status: error instanceof GatewayError ? error.status : undefined, + detail: error instanceof GatewayError ? error.detail : undefined, + }, + "image generation failed", + ); + return { + content: [ + { + type: "text", + text: `图片生成失败:${message}。可以调整提示词或稍后重试。本次不计费。`, + }, + ], + details: { ok: false, quote, error: message }, + isError: true, + }; + } + }, + }); + + interface BatchItemResult { + ok: boolean; + urls: string[]; + quote?: Quote; + error?: string; + } + + const generateBatchTool = defineTool({ + name: "generate_images_batch", + label: "批量生成套图", + description: + "一次提交整套图片:先给整套图唯一的总体风格 style,再按顺序列出 images(图1生成什么、图2生成什么…)。每张图独立提交给生图模型,所有任务并发执行(整体耗时≈最慢的一张),统一返回整套图片。生成套图、详情图、主图组时必须用本工具一次提交,禁止逐张调用 generate_image 等待上一张完成。", + parameters: Type.Object({ + skill_id: Type.Optional(Type.String({ description: "技能 key;直接生成模式下省略" })), + style: Type.Optional( + Type.String({ + maxLength: 2000, + description: + "整套图唯一的总体风格(画风/摄影风格、色板与主色、光线氛围、质感材质、字体排版、构图与留白规则等)。只传一次,会应用到每一张,保证整套风格一致", + }), + ), + images: Type.Array( + Type.Object({ + content: Type.String({ + minLength: 2, + maxLength: 3000, + description: "这一张生成什么(如「图1:头图,主视觉…」「图2:每日行程…」);只写该张独有内容与版式,不要重复风格", + }), + size: Type.Optional(Type.String({ description: "该张图片尺寸,缺省用技能默认值" })), + }), + { minItems: 1, maxItems: MAX_IMAGES_PER_REQUEST, description: "按交付顺序排列的图片内容列表(图1、图2、图3…)" }, + ), + reference_images: Type.Optional( + Type.Array(Type.String(), { maxItems: 6, description: "参考图 URL 列表,仅 img2img 类技能使用" }), + ), + watermark: Type.Optional(Type.Boolean({ description: "是否加水印,缺省不加" })), + }), + execute: async ( + toolCallId: string, + params: { + skill_id?: string; + style?: string; + images: Array<{ content: string; size?: string }>; + reference_images?: string[]; + watermark?: boolean; + }, + signal: AbortSignal, + ): Promise<{ + content: Array<{ type: "text"; text: string }>; + details: BatchImageDetails; + isError?: boolean; + }> => { + const skill = resolveSkill(params.skill_id); + if (!skill || !isAllowed(skill.key)) { + return { + content: [{ type: "text" as const, text: unknownSkillMessage(catalog, params.skill_id, allowed, directSkill) }], + details: { ok: false, error: "unknown_skill" }, + isError: true, + }; + } + + const images = (params.images ?? []).filter((item) => item && typeof item.content === "string" && item.content.trim()); + if (!images.length) { + return { + content: [{ type: "text" as const, text: "批量生成至少需要一张图片的内容说明。" }], + details: { ok: false, error: "empty_items" }, + isError: true, + }; + } + if (images.length > skill.manifest.max_images) { + return { + content: [ + { + type: "text" as const, + text: `本次提交 ${images.length} 张,超过技能「${skill.displayName}」单次上限 ${skill.manifest.max_images} 张,请减少数量后重试。`, + }, + ], + details: { ok: false, error: "too_many_items" }, + isError: true, + }; + } + + const isDirect = !!(directSkill && skill.key === directSkill.key); + const sharedStyle = (params.style ?? "").trim().slice(0, 2000); + if (!isDirect && images.length > 1 && !sharedStyle) { + return { + content: [ + { + type: "text" as const, + text: "生成套图时必须先提供整套图唯一的总体风格 style,再按顺序列出 images 的每张内容。", + }, + ], + details: { ok: false, error: "missing_style" }, + isError: true, + }; + } + + const defaults = catalog.defaultsFor(skill); + let itemQuotes: Quote[]; + try { + itemQuotes = images.map((item) => + quoteSkill( + skill, + { size: defaults.size, quantity: 1 }, + { size: item.size ?? defaults.size, quantity: 1 }, + config.billing.mode, + ), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `参数不合法:${message}` }], + details: { ok: false, error: message }, + isError: true, + }; + } + + const referenceImages = (params.reference_images ?? []).filter((url) => /^https?:\/\//.test(url)).slice(0, 6); + const composePrompt = (content: string, index: number): string => { + const raw = deps.rawPrompt && deps.rawPrompt.trim() ? deps.rawPrompt.trim() : `图${index + 1}:${content.trim()}`; + return sharedStyle ? `${sharedStyle}\n\n${raw}` : raw; + }; + const pendingItems = images.map((item, index) => ({ + index, + prompt: composePrompt(item.content, index), + quote: itemQuotes[index]!, + previous: ledger.findSucceededToolCall(identity.uid, sessionId, `${toolCallId}:${index}`), + })); + + const requiredCredits = isDirect + ? lowestSkillPrice(catalog, config) * pendingItems.filter((item) => !item.previous).length + : pendingItems + .filter((item) => !item.previous) + .reduce((sum, item) => sum + item.quote.total_credits, 0); + const balance = await fetchTeamScore(config.teamApiUrl, identity.team_id); + if (balance !== null && balance < requiredCredits) { + return { + content: [ + { + type: "text" as const, + text: `团队积分不足:本次批量生成需要 ${requiredCredits} 积分,当前剩余 ${balance} 积分,请充值后再试。`, + }, + ], + details: { ok: false, error: "insufficient_score" }, + isError: true, + }; + } + + logger.info( + { skill: skill.key, mode: "batch_concurrent", count: pendingItems.length, total: requiredCredits }, + "batch image generation started", + ); + + // 每张独立提交,全部并发执行:整体耗时≈最慢的一张,而不是逐张累加 + const results: BatchItemResult[] = await Promise.all( + pendingItems.map(async (item): Promise => { + if (item.previous) { + return { ok: true, urls: item.previous.image_urls, quote: quoteFromRecord(item.previous) }; + } + const record = ledger.begin({ + uid: identity.uid, + team_id: identity.team_id, + session_id: sessionId, + tool_call_id: `${toolCallId}:${item.index}`, + skill_id: skill.key, + skill_name: skill.displayName, + model: item.quote.model, + size: item.quote.size, + quantity: item.quote.quantity, + unit_credits: item.quote.unit_credits, + size_multiplier: item.quote.size_multiplier, + total_credits: item.quote.total_credits, + currency: item.quote.currency, + billing_mode: item.quote.billing_mode, + }); + try { + const result = await gateway.generateImage({ + uid: identity.uid, + team_id: identity.team_id, + model: item.quote.model, + prompt: item.prompt, + size: item.quote.size, + n: 1, + images: referenceImages, + watermark: params.watermark ?? defaults.watermark, + metadata: { + skill_id: skill.key, + session_id: sessionId, + uid: identity.uid, + team_id: identity.team_id, + usage_id: record.id, + tool_call_id: `${toolCallId}:${item.index}`, + batch_index: item.index, + credits: item.quote.total_credits, + }, + signal, + }); + if (!result.urls.length) throw new Error("网关没有返回图片 URL"); + const billed = repriceForQuantity(item.quote, result.urls.length); + ledger.settle(record.id, "succeeded", { + gateway_task_id: result.taskId, + image_urls: result.urls, + quantity: billed.quantity, + total_credits: billed.total_credits, + }); + return { ok: true, urls: result.urls, quote: billed }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ledger.settle(record.id, "failed", { error: message }); + logger.error( + { + usageId: record.id, + skill: skill.key, + batchIndex: item.index, + error: message, + code: error instanceof GatewayError ? error.code : undefined, + }, + "batch image generation item failed", + ); + return { ok: false, urls: [], quote: item.quote, error: message }; + } + }), + ); + + const urls = results.flatMap((result) => result.urls); + const failures = results + .map((result, index) => ({ result, index })) + .filter((entry) => !entry.result.ok); + const successQuotes = results.map((result) => result.quote).filter((quote): quote is Quote => Boolean(quote)); + + if (urls.length) { + const first = successQuotes[0] ?? itemQuotes[0]!; + const aggregate: Quote = { + ...first, + quantity: urls.length, + size_multiplier: 1, + total_credits: successQuotes.reduce((sum, quote) => sum + quote.total_credits, 0), + }; + deps.onImages(urls, aggregate); + logger.info( + { skill: skill.key, images: urls.length, failed: failures.length, total: aggregate.total_credits }, + "batch image generation finished", + ); + + const failureText = failures.length + ? `\n失败 ${failures.length} 张:` + + failures.map((entry) => `第 ${entry.index + 1} 张(${entry.result.error ?? "未知错误"})`).join(";") + : ""; + return { + content: [ + { + type: "text" as const, + text: `套图生成完成:成功 ${urls.length} 张${failureText}。\n${links(urls)}`, + }, + ], + details: { ok: true, quote: aggregate, images: urls, elapsed_ms: 0 }, + }; + } + + const firstError = failures[0]?.result.error ?? "全部生成失败"; + return { + content: [{ type: "text" as const, text: `套图生成失败:${firstError}。本次不计费。` }], + details: { ok: false, error: firstError }, + isError: true, + }; + }, + }); + + return [listSkills, readSkill, generateTool, generateBatchTool]; +} + +export function quoteFromRecord(record: UsageRecord): Quote { + return { + skill_id: record.skill_id, + skill_name: record.skill_name, + model: record.model, + size: record.size, + quantity: record.quantity, + unit_credits: record.unit_credits, + size_multiplier: record.size_multiplier, + total_credits: record.total_credits, + currency: record.currency, + billing_mode: record.billing_mode, + }; +} + +async function fetchTeamScore(teamApiUrl: string, teamId: string): Promise { + if (!teamApiUrl || !teamId) return null; + try { + const response = await fetch(`${teamApiUrl}/api/team/get_score?team_id=${encodeURIComponent(teamId)}`, { + signal: AbortSignal.timeout(4000), + }); + if (!response.ok) return null; + const text = (await response.text()).trim(); + const value = Number(text); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +function lowestSkillPrice(catalog: SkillCatalog, config: AppConfig): number { + const prices = catalog + .list() + .map((skill) => skill.manifest.price.per_image) + .filter((price) => Number.isFinite(price) && price > 0); + if (prices.length) return Math.min(...prices); + return Math.max(1, config.billing.defaultSkillPrice); +} + +function links(urls: string[]): string { + return urls.map((url, index) => `${index + 1}. ${url}`).join("\n"); +} + +function billingSuffix(quote: Quote): string { + if (quote.billing_mode === "live") return "(已按技能单价扣费)"; + if (quote.billing_mode === "dry_run") return `(试点期仅记账,不实际扣费,预计 ${quote.total_credits} ${quote.currency})`; + return ""; +} + +function unknownSkillMessage( + catalog: SkillCatalog, + skillId: string | undefined, + allowed?: Set, + directSkill?: SkillDefinition, +): string { + if (!skillId && directSkill) return "当前为直接生成模式,请省略 skill_id。"; + const available = catalog + .list() + .filter((skill) => !allowed || allowed.has(skill.key)) + .map((skill) => skill.key) + .join(", "); + return `未知技能 "${skillId ?? ""}"。可用技能:${available || "无"}。`; +} diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts new file mode 100644 index 0000000..f94f018 --- /dev/null +++ b/src/auth/jwt.ts @@ -0,0 +1,105 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { IncomingHttpHeaders } from "node:http"; +import type { AuthConfig } from "../config.js"; +import type { Identity } from "../types.js"; + +export class AuthError extends Error { + readonly statusCode = 401; + + constructor(message = "未登录或登录已过期") { + super(message); + this.name = "AuthError"; + } +} + +interface JwtPayload { + uid?: number | string; + nickname?: string; + avatar?: string; + mobile?: string; + exp?: number; + nbf?: number; +} + +export interface IdentityRequest { + headers: IncomingHttpHeaders; + teamId?: string; + uid?: string; +} + +export function resolveIdentity(auth: AuthConfig, request: IdentityRequest): Identity { + const authorization = firstHeader(request.headers.authorization); + + if (auth.jwtKey) { + if (!authorization) throw new AuthError(); + const token = authorization.replace(/^Bearer\s+/i, "").trim(); + const payload = verifyJwt(token, auth.jwtKey, auth.jwtAlgorithm); + const uid = payload.uid === undefined || payload.uid === null ? "" : String(payload.uid); + if (!uid) throw new AuthError("token 缺少 uid"); + return { + uid, + team_id: request.teamId ?? firstHeader(request.headers["x-team-id"]) ?? "", + nickname: payload.nickname, + avatar: payload.avatar, + mobile: payload.mobile, + }; + } + + const uid = request.uid ?? firstHeader(request.headers["x-uid"]) ?? "1"; + return { + uid, + team_id: request.teamId ?? firstHeader(request.headers["x-team-id"]) ?? "", + nickname: firstHeader(request.headers["x-nickname"]), + }; +} + +export function verifyJwt(token: string, secret: string, algorithm: string): JwtPayload { + const parts = token.split("."); + if (parts.length !== 3) throw new AuthError("token 格式错误"); + const [headerPart, payloadPart, signaturePart] = parts; + if (!headerPart || !payloadPart || !signaturePart) throw new AuthError("token 格式错误"); + + const header = decodeJson(headerPart) as { alg?: string }; + if (header.alg !== algorithm) { + throw new AuthError(`不支持的签名算法:${header.alg ?? "unknown"}`); + } + if (algorithm !== "HS256") { + throw new AuthError(`JWT_ALGORITHM 仅支持 HS256,当前为 ${algorithm}`); + } + + const expected = createHmac("sha256", secret).update(`${headerPart}.${payloadPart}`).digest("base64url"); + const providedBuffer = Buffer.from(signaturePart); + const expectedBuffer = Buffer.from(expected); + if (providedBuffer.length !== expectedBuffer.length || !timingSafeEqual(providedBuffer, expectedBuffer)) { + throw new AuthError("token 签名无效"); + } + + const payload = decodeJson(payloadPart) as JwtPayload; + const now = Math.floor(Date.now() / 1000); + if (!payload.exp || payload.exp < now) throw new AuthError("token 已过期"); + if (payload.nbf && payload.nbf > now + 60) throw new AuthError("token 尚未生效"); + + // team-api 签发的 uid 是不带引号的 19 位整数,JSON.parse 会按双精度截断 + // (1953296379736625152 会变成 1953296379736625200),必须从原始 JSON 文本里按字符串取回 + const rawUid = readRawUid(Buffer.from(payloadPart, "base64url").toString("utf8")); + if (rawUid) payload.uid = rawUid; + return payload; +} + +function readRawUid(payloadJson: string): string | undefined { + const match = payloadJson.match(/"uid"\s*:\s*"?([0-9]+)"?/); + return match ? match[1] : undefined; +} + +function decodeJson(part: string): unknown { + try { + return JSON.parse(Buffer.from(part, "base64url").toString("utf8")); + } catch { + throw new AuthError("token 解析失败"); + } +} + +function firstHeader(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value || undefined; +} diff --git a/src/billing/ledger.ts b/src/billing/ledger.ts new file mode 100644 index 0000000..74dd614 --- /dev/null +++ b/src/billing/ledger.ts @@ -0,0 +1,179 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { Logger } from "../logger.js"; +import type { LedgerStatus, UsageRecord } from "../types.js"; + +export interface BeginUsage + extends Omit {} + +export interface UsageFilter { + uid?: string; + team_id?: string; + session_id?: string; + skill_id?: string; + status?: LedgerStatus; + tool_call_id?: string; + limit?: number; +} + +export interface UsageSummary { + billing_mode: string; + currency: string; + totals: { + records: number; + images: number; + credits_succeeded: number; + credits_pending: number; + credits_failed: number; + }; + by_skill: Array<{ + skill_id: string; + skill_name: string; + records: number; + images: number; + credits: number; + }>; +} + +type SettlePatch = Partial< + Pick +>; + +export class UsageLedger { + private readonly records = new Map(); + private readonly succeededByToolCall = new Map(); + + constructor( + private readonly file: string, + private readonly logger: Logger, + ) { + mkdirSync(dirname(file), { recursive: true }); + this.load(); + } + + begin(input: BeginUsage): UsageRecord { + const record: UsageRecord = { + ...input, + id: randomUUID(), + ts: Math.floor(Date.now() / 1000), + gateway_task_id: "", + image_urls: [], + status: "pending", + }; + this.store(record); + return record; + } + + settle(id: string, status: Exclude, patch: SettlePatch = {}): UsageRecord | undefined { + const current = this.records.get(id); + if (!current) { + this.logger.warn({ id }, "settle called for unknown usage record"); + return undefined; + } + const next: UsageRecord = { + ...current, + ...patch, + status, + ts: Math.floor(Date.now() / 1000), + }; + this.store(next); + if (status === "succeeded") { + this.succeededByToolCall.set(toolKey(next.uid, next.session_id, next.tool_call_id), next.id); + } + return next; + } + + findSucceededToolCall(uid: string, sessionId: string, toolCallId: string): UsageRecord | undefined { + if (!toolCallId) return undefined; + const id = this.succeededByToolCall.get(toolKey(uid, sessionId, toolCallId)); + return id ? this.records.get(id) : undefined; + } + + list(filter: UsageFilter = {}): UsageRecord[] { + const result = [...this.records.values()].filter((record) => matches(record, filter)); + result.sort((a, b) => b.ts - a.ts || a.id.localeCompare(b.id)); + return filter.limit !== undefined ? result.slice(0, Math.max(0, filter.limit)) : result; + } + + summary(filter: UsageFilter = {}): UsageSummary { + const records = this.list({ ...filter, limit: undefined }); + const bySkill = new Map(); + const totals = { + records: records.length, + images: 0, + credits_succeeded: 0, + credits_pending: 0, + credits_failed: 0, + }; + + for (const record of records) { + const entry = + bySkill.get(record.skill_id) ?? + { + skill_id: record.skill_id, + skill_name: record.skill_name, + records: 0, + images: 0, + credits: 0, + }; + entry.records += 1; + + if (record.status === "succeeded") { + totals.images += record.quantity; + totals.credits_succeeded += record.total_credits; + entry.images += record.quantity; + entry.credits += record.total_credits; + } else if (record.status === "pending") { + totals.credits_pending += record.total_credits; + } else { + totals.credits_failed += record.total_credits; + } + bySkill.set(record.skill_id, entry); + } + + return { + billing_mode: records[0]?.billing_mode ?? "unknown", + currency: records[0]?.currency ?? "credits", + totals, + by_skill: [...bySkill.values()].sort((a, b) => b.credits - a.credits), + }; + } + + private store(record: UsageRecord): void { + this.records.set(record.id, record); + appendFileSync(this.file, `${JSON.stringify(record)}\n`, "utf8"); + } + + private load(): void { + if (!existsSync(this.file)) return; + for (const line of readFileSync(this.file, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed) as UsageRecord; + if (!record.id) continue; + this.records.set(record.id, record); + if (record.status === "succeeded" && record.tool_call_id) { + this.succeededByToolCall.set(toolKey(record.uid, record.session_id, record.tool_call_id), record.id); + } + } catch { + this.logger.warn({ line: trimmed.slice(0, 120) }, "skipping malformed usage ledger line"); + } + } + } +} + +function toolKey(uid: string, sessionId: string, toolCallId: string): string { + return `${uid}:${sessionId}:${toolCallId}`; +} + +function matches(record: UsageRecord, filter: UsageFilter): boolean { + if (filter.uid && record.uid !== filter.uid) return false; + if (filter.team_id && record.team_id !== filter.team_id) return false; + if (filter.session_id && record.session_id !== filter.session_id) return false; + if (filter.skill_id && record.skill_id !== filter.skill_id) return false; + if (filter.status && record.status !== filter.status) return false; + if (filter.tool_call_id && record.tool_call_id !== filter.tool_call_id) return false; + return true; +} diff --git a/src/billing/pricing.ts b/src/billing/pricing.ts new file mode 100644 index 0000000..150628d --- /dev/null +++ b/src/billing/pricing.ts @@ -0,0 +1,72 @@ +import type { BillingMode } from "../config.js"; +import type { Quote, SkillDefinition } from "../types.js"; + +export class PricingError extends Error { + constructor(message: string) { + super(message); + this.name = "PricingError"; + } +} + +const SIZE = /^(\d{2,5})x(\d{2,5})$/; + +export interface QuoteInput { + quantity?: number; + size?: string; +} + +export interface QuoteDefaults { + size: string; + quantity: number; +} + +export function quoteSkill( + skill: SkillDefinition, + defaults: QuoteDefaults, + input: QuoteInput, + billingMode: BillingMode, +): Quote { + const quantity = normalizeQuantity(skill, input.quantity ?? defaults.quantity); + const size = input.size ?? defaults.size; + if (!SIZE.test(size)) { + throw new PricingError(`size must look like 1024x1536, got "${size}"`); + } + + const multiplier = skill.manifest.price.size_multipliers[size] ?? 1; + return { + skill_id: skill.key, + skill_name: skill.displayName, + model: skill.manifest.model, + size, + quantity, + unit_credits: skill.manifest.price.per_image, + size_multiplier: multiplier, + total_credits: Math.round(skill.manifest.price.per_image * multiplier * quantity), + currency: skill.manifest.price.currency, + billing_mode: billingMode, + }; +} + +export function repriceForQuantity(quote: Quote, quantity: number): Quote { + if (!Number.isInteger(quantity) || quantity < 0) { + throw new PricingError(`quantity must be a non-negative integer, got ${quantity}`); + } + return { + ...quote, + quantity, + total_credits: Math.round(quote.unit_credits * quote.size_multiplier * quantity), + }; +} + +export function normalizeQuantity(skill: SkillDefinition, quantity: number): number { + if (!Number.isInteger(quantity)) { + throw new PricingError(`quantity must be an integer, got ${quantity}`); + } + if (quantity < 1) { + throw new PricingError("quantity must be at least 1"); + } + if (quantity > skill.manifest.max_images) { + throw new PricingError(`quantity exceeds max_images (${skill.manifest.max_images}) for skill ${skill.key}`); + } + return quantity; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..756e76c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,307 @@ +import { join, resolve } from "node:path"; +import { z } from "zod"; + +import { parseHostAllowlist } from "./utils/net.js"; + +export type BillingMode = "live" | "dry_run" | "off"; +export type NodeEnv = "development" | "test" | "production"; +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + +const boolish = z.preprocess((value) => { + if (typeof value === "boolean") return value; + if (typeof value === "string") return ["1", "true", "yes", "on"].includes(value.toLowerCase()); + return value; +}, z.boolean()); + +const intish = (fallback: number, min: number, max: number) => + z.preprocess( + (value) => (value === "" || value === undefined || value === null ? undefined : value), + z.coerce.number().int().min(min).max(max).default(fallback), + ); + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + HOST: z.string().default("0.0.0.0"), + PORT: intish(8100, 1, 65535), + CORS_ORIGIN: z.string().default("*"), + TEAM_API_URL: z.string().default("http://localhost:8000"), + SESSION_STORE: z.enum(["mysql", "file"]).default("mysql"), + SESSION_CONTEXT: z.enum(["mysql", "file"]).default("mysql"), + APP_ROOT: z.string().optional(), + DATA_DIR: z.string().default("data"), + FILE_FETCH_ALLOWED_HOSTS: z.string().default(""), + BILLING_MODE: z.enum(["live", "dry_run", "off"]).default("live"), + BILLING_CURRENCY: z.string().default("credits"), + DEFAULT_SKILL_PRICE: intish(10, 0, 1_000_000), + DEFAULT_SKILL_MODEL: z.string().default("gpt-image-2"), + DEFAULT_SKILL_SIZE: z.string().default("1024x1024"), + TOOL_CONFIRMATION_ENABLED: boolish.default(true), + TOOL_CONFIRMATION_TOOLS: z.string().default("generate_image"), + TOOL_CONFIRMATION_TIMEOUT_MS: intish(300_000, 1_000, 3_600_000), + MYSQL_HOST: z.string().default("127.0.0.1"), + MYSQL_PORT: intish(3306, 1, 65535), + MYSQL_USER: z.string().default("root"), + MYSQL_PASSWORD: z.string().default(""), + MYSQL_DATABASE: z.string().default("laimiaodb"), + MYSQL_CONNECTION_LIMIT: intish(10, 1, 100), + MYSQL_CONNECT_TIMEOUT_MS: intish(10_000, 1_000, 120_000), + GATEWAY_CN_URL: z.string().default("https://cn.gateway.laimiaoai.com"), + GATEWAY_GLOBAL_URL: z.string().default("https://gateway.laimiaoai.com"), + GATEWAY_FORCE_URL: z.string().optional(), + GATEWAY_MOCK: boolish.default(false), + GATEWAY_REQUEST_TIMEOUT_MS: intish(180_000, 1_000, 900_000), + GATEWAY_POLL_INTERVAL_MS: intish(2_000, 10, 60_000), + GATEWAY_POLL_TIMEOUT_MS: intish(900_000, 1_000, 3_600_000), + LAIMIAO_LLM_PROVIDER: z.string().default("laimiao"), + LAIMIAO_LLM_BASE_URL: z.string().default(""), + LAIMIAO_LLM_API_KEY: z.string().default(""), + LAIMIAO_LLM_MODEL: z.string().default("deepseek-chat"), + LAIMIAO_LLM_CONTEXT_WINDOW: intish(131_072, 1_024, 2_000_000), + LAIMIAO_LLM_MAX_TOKENS: intish(8_192, 128, 200_000), + LAIMIAO_LLM_REASONING: boolish.default(false), + LAIMIAO_LLM_THINKING: z + .enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]) + .default("low"), + JWT_KEY: z.string().optional(), + JWT_ALGORITHM: z.string().default("HS256"), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), +}); + +export interface LlmConfig { + providerId: string; + baseUrl: string; + apiKey: string; + modelId: string; + contextWindow: number; + maxTokens: number; + reasoning: boolean; + thinkingLevel: ThinkingLevel; +} + +export interface GatewayConfig { + cnUrl: string; + globalUrl: string; + forceUrl?: string; + mock: boolean; + requestTimeoutMs: number; + pollIntervalMs: number; + pollTimeoutMs: number; +} + +export interface ToolConfirmationConfig { + tools: string[]; + timeoutMs: number; +} + +export interface BillingConfig { + mode: BillingMode; + currency: string; + defaultSkillPrice: number; +} + +export interface MysqlConfig { + host: string; + port: number; + user: string; + password: string; + database: string; + connectionLimit: number; + connectTimeoutMs: number; +} + +export interface SkillDefaultsConfig { + model: string; + size: string; +} + +export interface ToolConfirmationConfig { + enabled: boolean; + tools: string[]; + timeoutMs: number; +} + +export interface AuthConfig { + jwtKey?: string; + jwtAlgorithm: string; +} + +export interface Paths { + rootDir: string; + dataDir: string; + agentDir: string; + workspaceDir: string; + piSessionsDir: string; + contextTmpDir: string; + sessionsFile: string; + messagesDir: string; + ledgerFile: string; + modelsFile: string; +} + +export interface AppConfig extends Paths { + nodeEnv: NodeEnv; + isProduction: boolean; + host: string; + port: number; + teamApiUrl: string; + fileFetchAllowedHosts: string[]; + sessionStore: "mysql" | "file"; + sessionContext: "mysql" | "file"; + corsOrigin: string; + logLevel: "debug" | "info" | "warn" | "error"; + llm: LlmConfig; + gateway: GatewayConfig; + toolConfirmation: ToolConfirmationConfig; + billing: BillingConfig; + skillDefaults: SkillDefaultsConfig; + mysql: MysqlConfig; + auth: AuthConfig; +} + +type RawEnv = Record; + +export function loadConfig(env: RawEnv = process.env): AppConfig { + const parsed = envSchema.safeParse(env); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new Error(`invalid environment: ${issue?.path.join(".") ?? "?"} ${issue?.message ?? ""}`.trim()); + } + const raw = parsed.data; + + const rootDir = resolve(raw.APP_ROOT || process.cwd()); + const dataDir = resolve(rootDir, raw.DATA_DIR); + const agentDir = join(dataDir, "pi"); + const paths: Paths = { + rootDir, + dataDir, + agentDir, + workspaceDir: join(dataDir, "workspace"), + piSessionsDir: join(agentDir, "sessions"), + contextTmpDir: join(dataDir, "context-tmp"), + sessionsFile: join(dataDir, "sessions.json"), + messagesDir: join(dataDir, "messages"), + ledgerFile: join(dataDir, "usage-ledger.jsonl"), + modelsFile: join(agentDir, "models.json"), + }; + + for (const [name, value] of [ + ["GATEWAY_CN_URL", raw.GATEWAY_CN_URL], + ["GATEWAY_GLOBAL_URL", raw.GATEWAY_GLOBAL_URL], + ["GATEWAY_FORCE_URL", raw.GATEWAY_FORCE_URL], + ["LAIMIAO_LLM_BASE_URL", raw.LAIMIAO_LLM_BASE_URL], + ["TEAM_API_URL", raw.TEAM_API_URL], + ] as const) { + if (value && !isHttpUrl(value)) throw new Error(`${name} must be an http(s) URL, got "${value}"`); + } + + const corsOrigins = parseCorsOrigins(raw.CORS_ORIGIN); + const fileFetchAllowedHosts = parseHostAllowlist(raw.FILE_FETCH_ALLOWED_HOSTS); + const isProduction = raw.NODE_ENV === "production"; + + if (isProduction) { + if (!raw.JWT_KEY) { + throw new Error("invalid environment: JWT_KEY is required when NODE_ENV=production"); + } + if (corsOrigins.length === 0 || corsOrigins.includes("*")) { + throw new Error( + "invalid environment: CORS_ORIGIN must list explicit origins (no wildcard) when NODE_ENV=production", + ); + } + for (const origin of corsOrigins) { + if (!isHttpOrigin(origin)) throw new Error(`invalid environment: CORS_ORIGIN entry "${origin}" is not an origin`); + } + if (fileFetchAllowedHosts.length === 0) { + throw new Error( + "invalid environment: FILE_FETCH_ALLOWED_HOSTS is required when NODE_ENV=production (comma separated CDN hosts, e.g. cdn.example.com,*.oss.example.com)", + ); + } + } + + return { + ...paths, + nodeEnv: raw.NODE_ENV, + isProduction, + host: raw.HOST, + port: raw.PORT, + teamApiUrl: raw.TEAM_API_URL.replace(/\/+$/, ""), + fileFetchAllowedHosts, + sessionStore: raw.SESSION_STORE, + sessionContext: raw.SESSION_CONTEXT, + corsOrigin: raw.CORS_ORIGIN, + logLevel: raw.LOG_LEVEL, + llm: { + providerId: raw.LAIMIAO_LLM_PROVIDER, + baseUrl: raw.LAIMIAO_LLM_BASE_URL, + apiKey: raw.LAIMIAO_LLM_API_KEY, + modelId: raw.LAIMIAO_LLM_MODEL, + contextWindow: raw.LAIMIAO_LLM_CONTEXT_WINDOW, + maxTokens: raw.LAIMIAO_LLM_MAX_TOKENS, + reasoning: raw.LAIMIAO_LLM_REASONING, + thinkingLevel: raw.LAIMIAO_LLM_THINKING, + }, + gateway: { + cnUrl: raw.GATEWAY_CN_URL, + globalUrl: raw.GATEWAY_GLOBAL_URL, + forceUrl: raw.GATEWAY_FORCE_URL || undefined, + mock: raw.GATEWAY_MOCK, + requestTimeoutMs: raw.GATEWAY_REQUEST_TIMEOUT_MS, + pollIntervalMs: raw.GATEWAY_POLL_INTERVAL_MS, + pollTimeoutMs: raw.GATEWAY_POLL_TIMEOUT_MS, + }, + billing: { + mode: raw.BILLING_MODE, + currency: raw.BILLING_CURRENCY, + defaultSkillPrice: raw.DEFAULT_SKILL_PRICE, + }, + skillDefaults: { + model: raw.DEFAULT_SKILL_MODEL, + size: raw.DEFAULT_SKILL_SIZE, + }, + toolConfirmation: { + enabled: raw.TOOL_CONFIRMATION_ENABLED, + tools: raw.TOOL_CONFIRMATION_TOOLS.split(",") + .map((tool) => tool.trim()) + .filter(Boolean), + timeoutMs: raw.TOOL_CONFIRMATION_TIMEOUT_MS, + }, + mysql: { + host: raw.MYSQL_HOST, + port: raw.MYSQL_PORT, + user: raw.MYSQL_USER, + password: raw.MYSQL_PASSWORD, + database: raw.MYSQL_DATABASE, + connectionLimit: raw.MYSQL_CONNECTION_LIMIT, + connectTimeoutMs: raw.MYSQL_CONNECT_TIMEOUT_MS, + }, + auth: { + jwtKey: raw.JWT_KEY || undefined, + jwtAlgorithm: raw.JWT_ALGORITHM, + }, + }; +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function parseCorsOrigins(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function isHttpOrigin(value: string): boolean { + if (value === "*") return false; + try { + const url = new URL(value); + return (url.protocol === "http:" || url.protocol === "https:") && url.pathname === "/" && !url.search && !url.hash; + } catch { + return false; + } +} diff --git a/src/gateway/client.ts b/src/gateway/client.ts new file mode 100644 index 0000000..bb9ebf3 --- /dev/null +++ b/src/gateway/client.ts @@ -0,0 +1,330 @@ +import { randomUUID } from "node:crypto"; +import type { GatewayConfig } from "../config.js"; +import type { Logger } from "../logger.js"; + +export class GatewayError extends Error { + constructor( + message: string, + readonly code: string, + readonly status?: number, + readonly detail?: unknown, + ) { + super(message); + this.name = "GatewayError"; + } +} + +export interface GenerateImageInput { + uid: string; + team_id: string; + model: string; + prompt: string; + size: string; + n: number; + images?: string[]; + watermark?: boolean; + metadata?: Record; + signal?: AbortSignal; +} + +export interface GenerateImageResult { + taskId: string; + urls: string[]; + status: string; + raw: unknown; + elapsedMs: number; +} + +const CN_PREFIXES = ["seedream", "seedance", "kling", "wan", "happyhorse"]; +const FAILURE_STATES = new Set(["failed", "error", "failure", "cancelled", "canceled"]); +const SUCCESS_STATES = new Set(["success", "completed", "succeeded", "done", "finished", "ok"]); +const IMAGE_KEYS = ["url", "urls", "images", "data", "results", "output", "drawing", "media_list", "videos"] as const; + +export class GatewayClient { + constructor( + private readonly config: GatewayConfig, + private readonly logger: Logger, + ) {} + + baseUrlFor(model: string): string { + if (this.config.forceUrl) return stripSlash(this.config.forceUrl); + const normalized = model.toLowerCase(); + const isCn = CN_PREFIXES.some((prefix) => normalized.startsWith(prefix)); + return stripSlash(isCn ? this.config.cnUrl : this.config.globalUrl); + } + + async generateImage(input: GenerateImageInput): Promise { + const startedAt = Date.now(); + if (this.config.mock) return this.mockGenerate(input, startedAt); + + const base = this.baseUrlFor(input.model); + const submit = await this.requestJson(`${base}/generate/v1/images`, { + method: "POST", + body: stringifyWithRawIds({ + uid: toRawInt(input.uid), + team_id: toRawInt(input.team_id), + model: input.model, + prompt: input.prompt, + size: normalizeSize(input.model, input.size), + n: input.n, + images: input.images?.length ? input.images : undefined, + watermark: input.watermark ?? false, + type: 0, + is_allowed: 1, + metadata: input.metadata, + }), + signal: input.signal, + }); + + const submitRecord = asRecord(submit); + const ret = submitRecord.ret; + if (ret !== undefined && ret !== null && Number(ret) !== 0) { + throw new GatewayError( + `gateway rejected the task: ${String(submitRecord.msg ?? ret)}`, + "GATEWAY_REJECTED", + 502, + submit, + ); + } + + const taskId = String(submitRecord.task_id ?? submitRecord.id ?? ""); + const inlineUrls = extractImageUrls(submitRecord); + if (inlineUrls.length > 0) { + return { + taskId, + urls: inlineUrls.slice(0, input.n), + status: "success", + raw: submit, + elapsedMs: Date.now() - startedAt, + }; + } + if (!taskId) { + throw new GatewayError("gateway did not return a task_id", "GATEWAY_BAD_RESPONSE", 502, submit); + } + + this.logger.info({ taskId, model: input.model, n: input.n }, "gateway task submitted"); + const polled = await this.poll(taskId, base, input.signal); + return { + taskId, + urls: polled.urls.slice(0, input.n), + status: polled.status, + raw: polled.raw, + elapsedMs: Date.now() - startedAt, + }; + } + + private async poll( + taskId: string, + base: string, + signal?: AbortSignal, + ): Promise<{ status: string; urls: string[]; raw: unknown }> { + const deadline = Date.now() + this.config.pollTimeoutMs; + const url = `${base}/generate/v1/images/${encodeURIComponent(taskId)}`; + let successWithoutUrls = 0; + + while (Date.now() < deadline) { + await sleep(this.config.pollIntervalMs, signal); + let data: unknown; + try { + data = await this.requestJson(url, { method: "GET", signal }); + } catch (error) { + if (signal?.aborted) { + throw new GatewayError("request aborted", "GATEWAY_ABORTED"); + } + if (error instanceof GatewayError) { + if (error.code === "GATEWAY_TASK_NOT_FOUND" || error.code === "GATEWAY_GENERATION_FAILED") { + throw error; + } + if ( + error.status && + error.status >= 400 && + error.status < 500 && + error.status !== 408 && + error.status !== 429 + ) { + throw error; + } + } + this.logger.warn( + { + taskId, + error: error instanceof Error ? error.message : String(error), + code: error instanceof GatewayError ? error.code : undefined, + }, + "gateway poll failed; retrying", + ); + continue; + } + const record = asRecord(data); + const status = String(record.status ?? "").toLowerCase(); + const urls = extractImageUrls(record); + + if (urls.length > 0) return { status: status || "success", urls, raw: data }; + if (status === "not_found") { + throw new GatewayError(`gateway task not found: ${taskId}`, "GATEWAY_TASK_NOT_FOUND", 404, data); + } + if (FAILURE_STATES.has(status)) { + throw new GatewayError( + String(record.error ?? record.msg ?? "image generation failed"), + "GATEWAY_GENERATION_FAILED", + 502, + data, + ); + } + if (SUCCESS_STATES.has(status)) { + successWithoutUrls += 1; + if (successWithoutUrls >= 5) { + throw new GatewayError("gateway finished without returning image urls", "GATEWAY_NO_IMAGES", 502, data); + } + this.logger.warn( + { taskId, status, attempts: successWithoutUrls }, + "gateway reported success without image urls; waiting for finalization", + ); + continue; + } + successWithoutUrls = 0; + } + + throw new GatewayError(`gateway task timed out: ${taskId}`, "GATEWAY_TIMEOUT", 504); + } + + private async requestJson( + url: string, + init: { method: string; body?: string; signal?: AbortSignal }, + ): Promise { + const timeoutSignal = AbortSignal.timeout(this.config.requestTimeoutMs); + const signals: AbortSignal[] = [timeoutSignal]; + if (init.signal) signals.push(init.signal); + + let response: Response; + try { + response = await fetch(url, { + method: init.method, + headers: { "content-type": "application/json" }, + body: init.body, + signal: AbortSignal.any(signals), + }); + } catch (error) { + if (init.signal?.aborted) { + throw new GatewayError("request aborted", "GATEWAY_ABORTED"); + } + if (timeoutSignal.aborted) { + throw new GatewayError("gateway request timed out", "GATEWAY_TIMEOUT", 504); + } + throw new GatewayError( + `gateway request failed: ${error instanceof Error ? error.message : String(error)}`, + "GATEWAY_NETWORK_ERROR", + ); + } + + const text = await response.text(); + let data: unknown = text; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + + if (!response.ok) { + throw new GatewayError(`gateway responded ${response.status}`, "GATEWAY_HTTP_ERROR", response.status, data); + } + return data; + } + + private async mockGenerate(input: GenerateImageInput, startedAt: number): Promise { + await sleep(Math.min(this.config.pollIntervalMs, 300), input.signal); + const taskId = `mock-${randomUUID()}`; + const urls = Array.from({ length: input.n }, (_, index) => { + const seed = encodeURIComponent(`${input.model}-${taskId}-${index + 1}`); + return `https://picsum.photos/seed/${seed}/${input.size.replace("x", "/")}`; + }); + this.logger.info({ taskId, model: input.model, n: input.n }, "mock image generated"); + return { + taskId, + urls, + status: "success", + raw: { mock: true }, + elapsedMs: Date.now() - startedAt, + }; + } +} + +export function extractImageUrls(record: Record): string[] { + const urls: string[] = []; + const seen = new Set(); + const add = (value: unknown): void => { + if (typeof value === "string" && /^https?:\/\//.test(value) && !seen.has(value)) { + seen.add(value); + urls.push(value); + } + }; + const addNode = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) addNode(item); + return; + } + if (value && typeof value === "object") { + const entry = value as Record; + add(entry.url ?? entry.image_url ?? entry.oss_url ?? entry.video_url); + for (const key of ["images", "output", "drawing", "media_list", "data", "results"]) { + if (key in entry) addNode(entry[key]); + } + return; + } + add(value); + }; + + for (const key of IMAGE_KEYS) { + if (key in record) addNode(record[key]); + } + return urls; +} + +export function normalizeSize(model: string, size: string): string { + if (!model.toLowerCase().startsWith("gpt-image")) return size; + const match = size.toLowerCase().match(/^(\d+)x(\d+)$/); + if (!match) return size; + const round = (value: number) => Math.max(16, Math.round(value / 16) * 16); + return `${round(Number(match[1]))}x${round(Number(match[2]))}`; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +function toRawInt(value: string): unknown { + const digits = String(value ?? "").replace(/[^0-9]/g, "") || "0"; + const rawJson = (JSON as unknown as { rawJSON?: (text: string) => unknown }).rawJSON; + if (typeof rawJson === "function") return rawJson(digits); + return `__raw_${digits}__`; +} + +function stringifyWithRawIds(body: Record): string { + return JSON.stringify(body).replace(/"__raw_([0-9]+)__"/g, "$1"); +} + +function stripSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new GatewayError("request aborted", "GATEWAY_ABORTED")); + return; + } + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = () => { + cleanup(); + reject(new GatewayError("request aborted", "GATEWAY_ABORTED")); + }; + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3334104 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,99 @@ +import "dotenv/config"; +import { AgentService } from "./agent/service.js"; +import { UsageLedger } from "./billing/ledger.js"; +import { loadConfig } from "./config.js"; +import { GatewayClient } from "./gateway/client.js"; +import { createLogger } from "./logger.js"; +import { buildServer } from "./server.js"; +import { SessionStore, type SessionStoreLike } from "./sessions/store.js"; +import { MySqlSessionStore } from "./sessions/mysql_store.js"; +import { MySqlSessionContextStore, NoopSessionContextStore, type SessionContextStore } from "./sessions/context_store.js"; +import { SkillCatalog } from "./skills/catalog.js"; +import { MySqlSkillRepository } from "./skills/repository.js"; + +const config = loadConfig(); +const logger = createLogger(config.logLevel); + +const skillRepository = new MySqlSkillRepository( + config.mysql, + { + size: config.skillDefaults.size, + price: config.billing.defaultSkillPrice, + currency: config.billing.currency, + }, + logger, +); +const catalog = await new SkillCatalog({ + repository: skillRepository, + defaultPrice: config.billing.defaultSkillPrice, + defaultModel: config.skillDefaults.model, + defaultSize: config.skillDefaults.size, + currency: config.billing.currency, + logger, +}).load(); + +const ledger = new UsageLedger(config.ledgerFile, logger); +async function createSessionStore(): Promise { + const fallback = new SessionStore(config.sessionsFile, config.messagesDir, logger); + if (config.sessionStore !== "mysql") return fallback; + try { + const store = new MySqlSessionStore(config.mysql, logger); + await store.init(); + return store; + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "mysql session store init failed, fallback to file store", + ); + return fallback; + } +} + +const store = await createSessionStore(); + +async function createContextStore(): Promise { + if (config.sessionContext !== "mysql") return new NoopSessionContextStore(); + const storeImpl = new MySqlSessionContextStore(config.mysql, logger); + await storeImpl.init(); + return storeImpl; +} + +const contextStore = await createContextStore(); +const gateway = new GatewayClient(config.gateway, logger); +const service = new AgentService(config, catalog, ledger, gateway, store, contextStore, logger); +const app = await buildServer({ config, logger, catalog, ledger, store, service }); + +for (const diagnostic of catalog.diagnostics()) { + logger.warn({ diagnostic }, "skill catalog diagnostic"); +} +if (!config.auth.jwtKey) { + logger.warn("JWT_KEY 未配置:开发模式,接受 x-uid / x-team-id 头"); +} +if (catalog.list().length === 0) { + logger.warn({ database: config.mysql.database }, "skills 表没有可用技能,请先向 skill_library 写入技能"); +} +if (!service.isLlmConfigured()) { + logger.warn("LAIMIAO_LLM_BASE_URL / LAIMIAO_LLM_API_KEY 未配置:对话接口将返回 503"); +} + +await app.listen({ host: config.host, port: config.port }); +logger.info( + { + port: config.port, + skills: catalog.list().map((skill) => skill.key), + skillSource: "mysql", + billing: config.billing.mode, + gatewayMock: config.gateway.mock, + }, + "laimiao-skill-agent started", +); + +const shutdown = async (signal: string): Promise => { + logger.info({ signal }, "shutting down"); + await app.close(); + await skillRepository.close(); + process.exit(0); +}; + +process.on("SIGINT", () => void shutdown("SIGINT")); +process.on("SIGTERM", () => void shutdown("SIGTERM")); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..7024ca1 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,39 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const ORDER: Record = { debug: 10, info: 20, warn: 30, error: 40 }; + +export interface Logger { + debug(fields: unknown, message?: string): void; + info(fields: unknown, message?: string): void; + warn(fields: unknown, message?: string): void; + error(fields: unknown, message?: string): void; +} + +export function createLogger(level: LogLevel = "info"): Logger { + return { + debug: (fields, message) => write("debug", level, fields, message), + info: (fields, message) => write("info", level, fields, message), + warn: (fields, message) => write("warn", level, fields, message), + error: (fields, message) => write("error", level, fields, message), + }; +} + +function write(level: LogLevel, threshold: LogLevel, fields: unknown, message?: string): void { + if (ORDER[level] < ORDER[threshold]) return; + const record: Record = { + ts: new Date().toISOString(), + level, + }; + if (message) record.msg = message; + if (fields instanceof Error) { + record.error = { name: fields.name, message: fields.message, stack: fields.stack }; + } else if (fields && typeof fields === "object") { + Object.assign(record, fields); + } else if (fields !== undefined) { + record.detail = fields; + } + const line = JSON.stringify(record); + if (level === "error") console.error(line); + else if (level === "warn") console.warn(line); + else console.log(line); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..51ef6b8 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,393 @@ +import { sanitizeTitle } from "./utils/text.js"; +import { MAX_IMAGES_PER_REQUEST } from "./skills/params.js"; +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; +import cors from "@fastify/cors"; +import { z } from "zod"; +import { AgentError, type AgentService, type AgentStreamEvent } from "./agent/service.js"; +import { AuthError, resolveIdentity } from "./auth/jwt.js"; +import { PricingError, quoteSkill } from "./billing/pricing.js"; +import type { UsageLedger } from "./billing/ledger.js"; +import type { AppConfig } from "./config.js"; +import { GatewayError } from "./gateway/client.js"; +import type { Logger } from "./logger.js"; +import type { SessionStoreLike } from "./sessions/store.js"; +import type { SkillCatalog } from "./skills/catalog.js"; + +export class HttpError extends Error { + constructor( + readonly statusCode: number, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + +export interface ServerDeps { + config: AppConfig; + logger: Logger; + catalog: SkillCatalog; + ledger: UsageLedger; + store: SessionStoreLike; + service: AgentService; +} + +const createSessionSchema = z.object({ + title: z.string().max(200).optional(), + type: z.number().int().min(0).max(99).optional(), + team_id: z.string().optional(), + uid: z.string().optional(), +}); + +const messageSchema = z.object({ + message: z.string().min(1).max(20_000), + direct: z.boolean().optional(), + reference_images: z.array(z.url()).max(6).optional(), + skill_id: z.string().min(1).optional(), + industry: z.string().max(50).optional(), + stream: z.boolean().optional(), + team_id: z.string().optional(), + uid: z.string().optional(), +}); + +const runSchema = messageSchema.extend({ + session_id: z.string().optional(), + title: z.string().max(200).optional(), +}); + +const quoteSchema = z.object({ + skill_id: z.string().min(1), + size: z.string().optional(), + n: z.number().int().min(1).max(MAX_IMAGES_PER_REQUEST).optional(), +}); + +const confirmationSchema = z.object({ + approved: z.boolean(), + content: z.string().max(20_000).optional(), + reason: z.string().max(2000).optional(), + team_id: z.string().optional(), + uid: z.string().optional(), +}); + +const listQuerySchema = z.object({ + team_id: z.string().optional(), + uid: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +const usageQuerySchema = listQuerySchema.extend({ + skill_id: z.string().optional(), + status: z.enum(["pending", "succeeded", "failed"]).optional(), +}); + +export async function buildServer(deps: ServerDeps): Promise { + const { config, logger, catalog, ledger, store, service } = deps; + const app = Fastify({ logger: false, bodyLimit: 2 * 1024 * 1024 }); + + await app.register(cors, { + origin: config.corsOrigin === "*" ? true : config.corsOrigin.split(",").map((item) => item.trim()), + credentials: true, + }); + + app.setErrorHandler((error, _request, reply) => { + const status = error instanceof HttpError ? error.statusCode : mapErrorStatus(error); + const message = error instanceof Error ? error.message : String(error); + if (status >= 500) logger.error({ error }, "request failed"); + else logger.warn({ error: message }, "request rejected"); + void reply.status(status).send({ detail: message || "internal error" }); + }); + + app.get("/health", async () => ({ + ok: true, + service: "laimiao-skill-agent", + version: "0.1.0", + node: process.version, + llm_configured: service.isLlmConfigured(), + billing_mode: config.billing.mode, + currency: config.billing.currency, + gateway_mock: config.gateway.mock, + image_models: [...new Set(catalog.list().map((skill) => skill.manifest.model))], + skills: catalog.list().length, + skill_source: "mysql", + skill_diagnostics: catalog.diagnostics(), + })); + + app.get("/skills", async () => { + await catalog.refresh(); + return { + skills: catalog.publicSkills(), + diagnostics: catalog.diagnostics(), + }; + }); + + app.get("/skills/:key", async (request) => { + const key = (request.params as { key: string }).key; + await catalog.refresh(); + const skill = catalog.get(key); + if (!skill) throw new HttpError(404, `skill 不存在:${key}`); + return { ...catalog.toPublic(skill), content: skill.content }; + }); + + app.post("/images/quote", async (request) => { + const body = parse(quoteSchema, request.body ?? {}); + await catalog.refresh(); + const skill = catalog.get(body.skill_id); + if (!skill) throw new HttpError(404, `skill 不存在:${body.skill_id}`); + const defaults = catalog.defaultsFor(skill); + return quoteSkill( + skill, + { size: defaults.size, quantity: defaults.n }, + { size: body.size, quantity: body.n }, + config.billing.mode, + ); + }); + + app.post("/sessions", async (request) => { + const body = parse(createSessionSchema, request.body ?? {}); + const identity = resolveIdentity(config.auth, { + headers: request.headers, + teamId: body.team_id, + uid: body.uid, + }); + return await store.create( + { ...identity, team_id: identity.team_id || identity.uid }, + { title: sanitizeTitle(body.title), type: body.type }, + ); + }); + + app.get("/sessions", async (request) => { + const query = parse(listQuerySchema, request.query); + const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid }); + return await store.list({ + uid: identity.uid, + team_id: identity.team_id || query.team_id, + limit: query.limit, + offset: query.offset, + }); + }); + + app.get("/sessions/:id", async (request) => { + const sessionId = (request.params as { id: string }).id; + const identity = resolveIdentity(config.auth, { headers: request.headers }); + const session = await store.get(sessionId); + if (!session) throw new HttpError(404, "会话不存在"); + if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话"); + return { session, messages: await store.messages(sessionId) }; + }); + + app.get("/sessions/:id/messages", async (request) => { + const sessionId = (request.params as { id: string }).id; + const identity = resolveIdentity(config.auth, { headers: request.headers }); + const session = await store.get(sessionId); + if (!session) throw new HttpError(404, "会话不存在"); + if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话"); + return { messages: await store.messages(sessionId) }; + }); + + const runInSession = async ( + request: FastifyRequest, + sessionId: string, + body: z.infer, + ) => { + const identity = resolveIdentity(config.auth, { + headers: request.headers, + teamId: body.team_id, + uid: body.uid, + }); + const session = await store.get(sessionId); + if (!session) throw new HttpError(404, "会话不存在"); + if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话"); + return { identity: { ...identity, team_id: identity.team_id || session.team_id }, session }; + }; + + const handleRun = async ( + request: FastifyRequest, + reply: FastifyReply, + sessionId: string, + body: z.infer, + ) => { + const { identity } = await runInSession(request, sessionId, body); + + if (!body.stream) { + return service.run({ + identity, + sessionId, + message: body.message, + referenceImages: body.reference_images, + skillKey: body.skill_id, + industry: body.industry, + directPrompt: body.direct === true, + }); + } + + reply.hijack(); + const raw = reply.raw; + // hijack 后不再经过 @fastify/cors 的 onSend 钩子,跨域调用必须手动补 CORS 头 + const origin = request.headers.origin; + const allowOrigin = + config.corsOrigin === "*" + ? origin || "*" + : config.corsOrigin.split(",").map((item) => item.trim()).includes(origin || "") + ? origin + : ""; + const corsHeaders: Record = allowOrigin + ? { "access-control-allow-origin": allowOrigin, "access-control-allow-credentials": "true", vary: "Origin" } + : {}; + raw.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + ...corsHeaders, + }); + raw.write(": connected\n\n"); + + let clientClosed = false; + const heartbeat = setInterval(() => { + if (!clientClosed && !raw.destroyed && !raw.writableEnded) raw.write(": ping\n\n"); + }, 15_000); + raw.on("close", () => { + clientClosed = true; + clearInterval(heartbeat); + }); + let emittedError = false; + + const writeSse = (event: AgentStreamEvent): void => { + if (event.type === "error") emittedError = true; + if (clientClosed || raw.destroyed || raw.writableEnded) return; + raw.write(`data: ${JSON.stringify(event)}\n\n`); + }; + writeSse({ type: "session", session_id: sessionId }); + + try { + await service.run({ + identity, + sessionId, + message: body.message, + referenceImages: body.reference_images, + skillKey: body.skill_id, + industry: body.industry, + directPrompt: body.direct === true, + onEvent: writeSse, + }); + } catch (error) { + if (!emittedError) { + writeSse({ type: "error", message: error instanceof Error ? error.message : String(error) }); + } + } finally { + clearInterval(heartbeat); + if (!raw.destroyed && !raw.writableEnded) raw.end(); + } + }; + + app.post("/sessions/:id/messages", async (request, reply) => { + const sessionId = (request.params as { id: string }).id; + const body = parse(messageSchema, request.body ?? {}); + return handleRun(request, reply, sessionId, body); + }); + + app.post("/agent/run", async (request, reply) => { + const body = parse(runSchema, request.body ?? {}); + if (body.session_id) { + return handleRun(request, reply, body.session_id, body); + } + + const identity = resolveIdentity(config.auth, { + headers: request.headers, + teamId: body.team_id, + uid: body.uid, + }); + const session = await store.create({ ...identity, team_id: identity.team_id || identity.uid }, { title: sanitizeTitle(body.title) }); + + if (!body.stream) { + const result = await service.run({ + identity, + sessionId: session.session_id, + message: body.message, + referenceImages: body.reference_images, + skillKey: body.skill_id, + industry: body.industry, + }); + return { ...result, session: (await store.get(session.session_id)) ?? session }; + } + return handleRun(request, reply, session.session_id, body); + }); + + app.post("/sessions/:id/tool-confirmations/:toolCallId", async (request) => { + const sessionId = (request.params as { id: string }).id; + const toolCallId = (request.params as { toolCallId: string }).toolCallId; + const body = parse(confirmationSchema, request.body ?? {}); + const identity = resolveIdentity(config.auth, { + headers: request.headers, + teamId: body.team_id, + uid: body.uid, + }); + return service.confirmTool({ + identity, + sessionId, + toolCallId, + approved: body.approved, + content: body.content, + reason: body.reason, + }); + }); + + app.get("/usage", async (request) => { + const query = parse(usageQuerySchema, request.query); + const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid }); + const records = ledger.list({ + uid: identity.uid, + team_id: identity.team_id || query.team_id, + skill_id: query.skill_id, + status: query.status, + }); + const limit = query.limit ?? 50; + return { records: records.slice(0, limit), total: records.length }; + }); + + app.get("/usage/summary", async (request) => { + const query = parse(usageQuerySchema, request.query); + const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid }); + const summary = ledger.summary({ + uid: identity.uid, + team_id: identity.team_id || query.team_id, + skill_id: query.skill_id, + status: query.status, + }); + return { ...summary, billing_mode: config.billing.mode, currency: config.billing.currency }; + }); + + app.get("/billing", async () => ({ + mode: config.billing.mode, + currency: config.billing.currency, + default_skill_price: config.billing.defaultSkillPrice, + deduction_enabled: false, + note: config.billing.mode === "dry_run" ? "试点期按 skill 单价记账,不实际扣减团队积分" : "计费已关闭:不扣减费用,用量仅用于估算与审计", + ledger_file: config.ledgerFile, + })); + + return app; +} + +function parse(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) { + const issue = result.error.issues[0]; + throw new HttpError(400, `${issue?.path.join(".") || "body"}: ${issue?.message ?? "参数不合法"}`); + } + return result.data; +} + +function mapErrorStatus(error: unknown): number { + if (error instanceof HttpError) return error.statusCode; + if (error instanceof AgentError) return error.statusCode; + if (error instanceof AuthError) return 401; + if (error instanceof PricingError) return 400; + if (error instanceof GatewayError) return 502; + if (error && typeof error === "object" && "statusCode" in error) { + const statusCode = (error as { statusCode?: unknown }).statusCode; + if (typeof statusCode === "number" && statusCode >= 400 && statusCode < 600) return statusCode; + } + return 500; +} diff --git a/src/sessions/context_store.ts b/src/sessions/context_store.ts new file mode 100644 index 0000000..bc90f65 --- /dev/null +++ b/src/sessions/context_store.ts @@ -0,0 +1,103 @@ +import { gunzipSync, gzipSync } from "node:zlib"; +import { createPool, type Pool, type RowDataPacket } from "mysql2/promise"; + +import type { MysqlConfig } from "../config.js"; +import type { Logger } from "../logger.js"; + +export interface SessionContextStore { + load(sessionId: string): Promise; + save(sessionId: string, content: Buffer): Promise; + remove(sessionId: string): Promise; +} + +const CONTEXT_DDL = ` +CREATE TABLE IF NOT EXISTS agent_session_context ( + session_id VARCHAR(64) NOT NULL, + content LONGBLOB NOT NULL, + raw_size INT NOT NULL DEFAULT 0, + update_time INT NOT NULL DEFAULT 0, + PRIMARY KEY (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +`; + +interface ContextRow extends RowDataPacket { + content: Buffer; +} + +export class MySqlSessionContextStore implements SessionContextStore { + private readonly pool: Pool; + + constructor( + config: MysqlConfig, + private readonly logger: Logger, + ) { + this.pool = createPool({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.database, + connectionLimit: config.connectionLimit, + connectTimeout: config.connectTimeoutMs, + charset: "utf8mb4", + supportBigNumbers: true, + bigNumberStrings: true, + }); + } + + async init(): Promise { + await this.pool.query(CONTEXT_DDL); + this.logger.info("session context table ready"); + } + + async close(): Promise { + await this.pool.end(); + } + + async load(sessionId: string): Promise { + const [rows] = await this.pool.execute( + "SELECT content FROM agent_session_context WHERE session_id = ? LIMIT 1", + [sessionId], + ); + const row = rows[0]; + if (!row || !row.content) return null; + try { + return gunzipSync(row.content); + } catch (error) { + this.logger.warn( + { sessionId, error: error instanceof Error ? error.message : String(error) }, + "failed to decompress session context", + ); + return null; + } + } + + async save(sessionId: string, content: Buffer): Promise { + const compressed = gzipSync(content); + await this.pool.execute( + `INSERT INTO agent_session_context (session_id, content, raw_size, update_time) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE content = VALUES(content), raw_size = VALUES(raw_size), update_time = VALUES(update_time)`, + [sessionId, compressed, content.length, Math.floor(Date.now() / 1000)], + ); + } + + async remove(sessionId: string): Promise { + await this.pool.execute("DELETE FROM agent_session_context WHERE session_id = ?", [sessionId]); + } +} + +/** 本地文件模式:pi 自己持久化,这里不需要额外读写 */ +export class NoopSessionContextStore implements SessionContextStore { + async load(): Promise { + return null; + } + + async save(): Promise { + return; + } + + async remove(): Promise { + return; + } +} diff --git a/src/sessions/mysql_store.ts b/src/sessions/mysql_store.ts new file mode 100644 index 0000000..f7fcb8f --- /dev/null +++ b/src/sessions/mysql_store.ts @@ -0,0 +1,363 @@ +import { randomUUID } from "node:crypto"; +import { createPool, type Pool, type RowDataPacket } from "mysql2/promise"; + +import type { MysqlConfig } from "../config.js"; +import type { Logger } from "../logger.js"; +import type { Identity } from "../types.js"; +import type { SessionMessage, SessionMeta, SessionStoreLike } from "./store.js"; + +const SESSION_DDL = ` +CREATE TABLE IF NOT EXISTS agent_session ( + session_id VARCHAR(64) NOT NULL, + uid VARCHAR(64) NOT NULL, + team_id VARCHAR(64) NOT NULL DEFAULT '', + title VARCHAR(200) NOT NULL DEFAULT '', + type INT NOT NULL DEFAULT 0, + last_mode VARCHAR(16) NOT NULL DEFAULT 'agent', + message_count INT NOT NULL DEFAULT 0, + add_time INT NOT NULL DEFAULT 0, + update_time INT NOT NULL DEFAULT 0, + PRIMARY KEY (session_id), + KEY idx_uid_time (uid, update_time), + KEY idx_team_time (team_id, update_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +`; + +const MESSAGE_DDL = ` +CREATE TABLE IF NOT EXISTS agent_session_message ( + id VARCHAR(64) NOT NULL, + session_id VARCHAR(64) NOT NULL, + role VARCHAR(16) NOT NULL, + mode VARCHAR(16) NOT NULL DEFAULT 'agent', + content MEDIUMTEXT, + thinking MEDIUMTEXT, + media MEDIUMTEXT, + status VARCHAR(16) NOT NULL DEFAULT 'ok', + error VARCHAR(2000) NOT NULL DEFAULT '', + add_time INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_session_time (session_id, add_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +`; + +interface SessionRow extends RowDataPacket { + session_id: string; + uid: string; + team_id: string; + title: string; + type: number; + last_mode: string; + message_count: number; + add_time: number; + update_time: number; +} + +interface MessageRow extends RowDataPacket { + id: string; + session_id: string; + role: "user" | "assistant"; + mode: "agent" | "image" | "video"; + content: string | null; + thinking: string | null; + media: string | null; + status: "ok" | "error" | "loading"; + error: string | null; + add_time: number; +} + +function toMeta(row: SessionRow): SessionMeta { + return { + session_id: row.session_id, + uid: row.uid, + team_id: row.team_id, + title: row.title, + type: row.type, + last_mode: row.last_mode, + message_count: row.message_count, + add_time: row.add_time, + update_time: row.update_time, + }; +} + +function toMessage(row: MessageRow): SessionMessage { + let media: SessionMessage["media"] = []; + if (row.media) { + try { + const parsed = JSON.parse(row.media); + if (Array.isArray(parsed)) media = parsed; + } catch { + media = []; + } + } + const message: SessionMessage = { + id: row.id, + session_id: row.session_id, + role: row.role, + mode: row.mode, + content: row.content ?? "", + media, + status: row.status, + add_time: row.add_time, + }; + if (row.thinking) message.thinking = row.thinking; + if (row.error) message.error = row.error; + return message; +} + +export class MySqlSessionStore implements SessionStoreLike { + private readonly pool: Pool; + + constructor( + config: MysqlConfig, + private readonly logger: Logger, + ) { + this.pool = createPool({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.database, + connectionLimit: config.connectionLimit, + connectTimeout: config.connectTimeoutMs, + charset: "utf8mb4", + supportBigNumbers: true, + bigNumberStrings: true, + }); + } + + async init(): Promise { + await this.pool.query(SESSION_DDL); + await this.pool.query(MESSAGE_DDL); + this.logger.info("session tables ready"); + } + + async close(): Promise { + await this.pool.end(); + } + + async create(identity: Identity, input: { title?: string; type?: number } = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const meta: SessionMeta = { + session_id: randomUUID(), + uid: identity.uid, + team_id: identity.team_id || "", + title: input.title?.trim() ?? "", + type: input.type ?? 0, + last_mode: "agent", + message_count: 0, + add_time: now, + update_time: now, + }; + await this.pool.execute( + `INSERT INTO agent_session + (session_id, uid, team_id, title, type, last_mode, message_count, add_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + meta.session_id, + meta.uid, + meta.team_id, + meta.title, + meta.type, + meta.last_mode, + meta.message_count, + meta.add_time, + meta.update_time, + ], + ); + return meta; + } + + async get(sessionId: string): Promise { + const [rows] = await this.pool.execute( + "SELECT * FROM agent_session WHERE session_id = ? LIMIT 1", + [sessionId], + ); + return rows[0] ? toMeta(rows[0]) : undefined; + } + + async getOwned(identity: Identity, sessionId: string): Promise { + const session = await this.get(sessionId); + return session && session.uid === identity.uid ? session : undefined; + } + + async list( + filter: { uid?: string; team_id?: string; limit?: number; offset?: number } = {}, + ): Promise<{ sessions: SessionMeta[]; total: number }> { + const where: string[] = []; + const params: string[] = []; + if (filter.uid) { + where.push("uid = ?"); + params.push(filter.uid); + } + if (filter.team_id) { + where.push("team_id = ?"); + params.push(filter.team_id); + } + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + const limit = Math.max(1, Math.min(filter.limit ?? 50, 200)); + const offset = Math.max(0, filter.offset ?? 0); + + const [countRows] = await this.pool.execute<(RowDataPacket & { total: number })[]>( + `SELECT COUNT(*) AS total FROM agent_session ${whereSql}`, + params, + ); + const [rows] = await this.pool.execute( + `SELECT * FROM agent_session ${whereSql} + ORDER BY update_time DESC, session_id DESC + LIMIT ${limit} OFFSET ${offset}`, + params, + ); + return { sessions: rows.map(toMeta), total: Number(countRows[0]?.total ?? 0) }; + } + + async append( + sessionId: string, + message: Omit, + addTime?: number, + ): Promise { + const full: SessionMessage = { + ...message, + id: randomUUID(), + session_id: sessionId, + add_time: addTime ?? Math.floor(Date.now() / 1000), + }; + + const connection = await this.pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.execute( + `INSERT INTO agent_session_message + (id, session_id, role, mode, content, thinking, media, status, error, add_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + full.id, + sessionId, + full.role, + full.mode, + full.content ?? "", + full.thinking ?? null, + JSON.stringify(full.media ?? []), + full.status, + full.error ?? "", + full.add_time, + ], + ); + await connection.execute( + `UPDATE agent_session + SET message_count = message_count + 1, + update_time = ?, + last_mode = ?, + title = CASE WHEN title = '' AND ? = 'user' THEN LEFT(?, 30) ELSE title END + WHERE session_id = ?`, + [full.add_time, full.mode, full.role, full.content ?? "", sessionId], + ); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + return full; + } + + async update( + sessionId: string, + messageId: string, + patch: Partial>, + ): Promise { + const connection = await this.pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.execute( + `UPDATE agent_session_message + SET mode = COALESCE(?, mode), + content = COALESCE(?, content), + thinking = COALESCE(?, thinking), + media = COALESCE(?, media), + status = COALESCE(?, status), + error = COALESCE(?, error) + WHERE id = ? AND session_id = ?`, + [ + patch.mode ?? null, + patch.content ?? null, + patch.thinking ?? null, + patch.media ? JSON.stringify(patch.media) : null, + patch.status ?? null, + patch.error ?? null, + messageId, + sessionId, + ], + ); + if (patch.mode || patch.status) { + await connection.execute( + `UPDATE agent_session + SET update_time = ?, last_mode = COALESCE(?, last_mode) + WHERE session_id = ?`, + [Math.floor(Date.now() / 1000), patch.mode ?? null, sessionId], + ); + } + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + + async upsertSession(meta: SessionMeta): Promise { + await this.pool.execute( + `INSERT INTO agent_session + (session_id, uid, team_id, title, type, last_mode, message_count, add_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + uid = VALUES(uid), team_id = VALUES(team_id), title = VALUES(title), type = VALUES(type), + last_mode = VALUES(last_mode), message_count = VALUES(message_count), + add_time = VALUES(add_time), update_time = VALUES(update_time)`, + [ + meta.session_id, + meta.uid, + meta.team_id || "", + meta.title, + meta.type, + meta.last_mode, + meta.message_count, + meta.add_time, + meta.update_time, + ], + ); + } + + async insertMessage(message: SessionMessage): Promise { + await this.pool.execute( + `INSERT IGNORE INTO agent_session_message + (id, session_id, role, mode, content, thinking, media, status, error, add_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + message.id, + message.session_id, + message.role, + message.mode, + message.content ?? "", + message.thinking ?? null, + JSON.stringify(message.media ?? []), + message.status, + message.error ?? "", + message.add_time, + ], + ); + } + + async messages(sessionId: string, limit = 200): Promise { + const size = Math.max(1, Math.min(limit, 1000)); + const [rows] = await this.pool.execute( + `SELECT * FROM agent_session_message + WHERE session_id = ? + ORDER BY add_time DESC, id DESC + LIMIT ${size}`, + [sessionId], + ); + return rows.reverse().map(toMessage); + } +} diff --git a/src/sessions/store.ts b/src/sessions/store.ts new file mode 100644 index 0000000..6f96b81 --- /dev/null +++ b/src/sessions/store.ts @@ -0,0 +1,198 @@ +import { randomUUID } from "node:crypto"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Logger } from "../logger.js"; +import type { Identity } from "../types.js"; + +export interface SessionMeta { + session_id: string; + uid: string; + team_id: string; + title: string; + type: number; + last_mode: string; + message_count: number; + add_time: number; + update_time: number; +} + +export interface SessionMessage { + id: string; + session_id: string; + role: "user" | "assistant"; + mode: "agent" | "image" | "video"; + content: string; + thinking?: string; + media: Array<{ type: "image" | "video"; url: string }>; + status: "ok" | "error" | "loading"; + error?: string; + add_time: number; +} + +interface SessionIndex { + sessions: Record; +} + +export interface SessionStoreLike { + create(identity: Identity, input?: { title?: string; type?: number }): Promise; + get(sessionId: string): Promise; + getOwned(identity: Identity, sessionId: string): Promise; + list(filter?: { uid?: string; team_id?: string; limit?: number; offset?: number }): Promise<{ + sessions: SessionMeta[]; + total: number; + }>; + append(sessionId: string, message: Omit, addTime?: number): Promise; + update( + sessionId: string, + messageId: string, + patch: Partial>, + ): Promise; + messages(sessionId: string, limit?: number): Promise; +} + +export class SessionStore implements SessionStoreLike { + constructor( + private readonly indexFile: string, + private readonly messagesDir: string, + private readonly logger: Logger, + ) { + mkdirSync(messagesDir, { recursive: true }); + } + + async create(identity: Identity, input: { title?: string; type?: number } = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const session: SessionMeta = { + session_id: randomUUID(), + uid: identity.uid, + team_id: identity.team_id, + title: input.title?.trim() ?? "", + type: input.type ?? 0, + last_mode: "agent", + message_count: 0, + add_time: now, + update_time: now, + }; + const index = this.readIndex(); + index.sessions[session.session_id] = session; + this.writeIndex(index); + return session; + } + + async get(sessionId: string): Promise { + return this.readIndex().sessions[sessionId]; + } + + async getOwned(identity: Identity, sessionId: string): Promise { + const session = await this.get(sessionId); + return session && session.uid === identity.uid ? session : undefined; + } + + async list(filter: { uid?: string; team_id?: string; limit?: number; offset?: number } = {}): Promise<{ + sessions: SessionMeta[]; + total: number; + }> { + const all = Object.values(this.readIndex().sessions) + .filter((session) => { + if (filter.uid && session.uid !== filter.uid) return false; + if (filter.team_id && session.team_id !== filter.team_id) return false; + return true; + }) + .sort((a, b) => b.update_time - a.update_time || b.session_id.localeCompare(a.session_id)); + + const offset = Math.max(0, filter.offset ?? 0); + const limit = Math.max(1, Math.min(filter.limit ?? 50, 200)); + return { sessions: all.slice(offset, offset + limit), total: all.length }; + } + + async append(sessionId: string, message: Omit, addTime?: number): Promise { + const full: SessionMessage = { + ...message, + id: randomUUID(), + session_id: sessionId, + add_time: addTime ?? Math.floor(Date.now() / 1000), + }; + appendFileSync(this.messagesFile(sessionId), `${JSON.stringify(full)}\n`, "utf8"); + + const index = this.readIndex(); + const meta = index.sessions[sessionId]; + if (meta) { + meta.message_count += 1; + meta.update_time = full.add_time; + meta.last_mode = full.mode; + if (!meta.title && full.role === "user") { + meta.title = full.content.trim().slice(0, 30); + } + this.writeIndex(index); + } + return full; + } + + async update( + sessionId: string, + messageId: string, + patch: Partial>, + ): Promise { + const file = this.messagesFile(sessionId); + if (!existsSync(file)) return; + const lines = readFileSync(file, "utf8").split("\n"); + let found = false; + const next = lines.map((line) => { + const trimmed = line.trim(); + if (!trimmed) return line; + try { + const message = JSON.parse(trimmed) as SessionMessage; + if (message.id !== messageId) return line; + found = true; + return JSON.stringify({ ...message, ...patch }); + } catch { + return line; + } + }); + if (!found) return; + writeFileSync(file, next.join("\n"), "utf8"); + const index = this.readIndex(); + const meta = index.sessions[sessionId]; + if (meta) { + meta.update_time = Math.floor(Date.now() / 1000); + if (patch.mode) meta.last_mode = patch.mode; + this.writeIndex(index); + } + } + + async messages(sessionId: string, limit = 200): Promise { + const file = this.messagesFile(sessionId); + if (!existsSync(file)) return []; + const result: SessionMessage[] = []; + for (const line of readFileSync(file, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + result.push(JSON.parse(trimmed) as SessionMessage); + } catch { + this.logger.warn({ sessionId, line: trimmed.slice(0, 120) }, "skipping malformed message line"); + } + } + return result.slice(-Math.max(1, limit)); + } + + private messagesFile(sessionId: string): string { + return join(this.messagesDir, `${sessionId}.jsonl`); + } + + private readIndex(): SessionIndex { + if (!existsSync(this.indexFile)) return { sessions: {} }; + try { + const parsed = JSON.parse(readFileSync(this.indexFile, "utf8")) as SessionIndex; + return { sessions: parsed.sessions ?? {} }; + } catch { + this.logger.warn({ file: this.indexFile }, "session index is corrupt, starting empty"); + return { sessions: {} }; + } + } + + private writeIndex(index: SessionIndex): void { + const tmp = `${this.indexFile}.tmp`; + writeFileSync(tmp, JSON.stringify(index, null, 2), "utf8"); + renameSync(tmp, this.indexFile); + } +} diff --git a/src/skills/catalog.ts b/src/skills/catalog.ts new file mode 100644 index 0000000..14474ae --- /dev/null +++ b/src/skills/catalog.ts @@ -0,0 +1,242 @@ +import { z } from "zod"; +import type { Logger } from "../logger.js"; +import type { SkillDefinition, SkillManifest } from "../types.js"; +import type { SkillRepository, StoredSkill, StoredSkillConfig } from "./repository.js"; +import { parseSkillParams, MAX_IMAGES_PER_REQUEST } from "./params.js"; + +const KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const storedConfigSchema = z.object({ + pricePerImage: z.number().int().min(0), + enabled: z.boolean(), +}); + +export interface SkillCatalogOptions { + repository: SkillRepository; + defaultPrice: number; + defaultModel: string; + defaultSize: string; + currency: string; + logger: Logger; +} + +export interface PublicSkill { + key: string; + name: string; + display_name: string; + description: string; + industry: string; + model: string; + defaults: { size: string; n: number; watermark: boolean }; + price: { currency: string; per_image: number; size_multipliers: Record }; + capabilities: string[]; + tags: string[]; + max_images: number; + uses_defaults: boolean; + trigger_keywords: string; +} + +export class SkillCatalog { + private skills = new Map(); + private diagnosticMessages: string[] = []; + + constructor(private readonly options: SkillCatalogOptions) {} + + async load(): Promise { + await this.options.repository.ensureSchema(); + await this.refresh(); + return this; + } + + async refresh(): Promise { + const stored = await this.options.repository.listSkills(); + const next = new Map(); + const diagnostics: string[] = []; + + for (const row of stored) { + const skill = this.toSkill(row, diagnostics); + if (!skill) continue; + if (next.has(skill.key)) { + diagnostics.push(`skill ${skill.key} skipped: duplicate skill_key in database`); + continue; + } + next.set(skill.key, skill); + } + + this.skills = next; + this.diagnosticMessages = diagnostics; + this.options.logger.debug( + { skills: [...next.keys()], diagnostics: diagnostics.length }, + "skill catalog loaded from database", + ); + return this; + } + + list(): SkillDefinition[] { + return [...this.skills.values()].filter((skill) => skill.manifest.enabled); + } + + get(key: string): SkillDefinition | undefined { + const skill = this.skills.get(key); + return skill?.manifest.enabled ? skill : undefined; + } + + diagnostics(): readonly string[] { + return this.diagnosticMessages; + } + + requireReady(): void { + if (this.list().length === 0) { + throw new Error("no enabled skills found in the skill database"); + } + } + + publicSkills(): PublicSkill[] { + return this.list().map((skill) => this.toPublic(skill)); + } + + toPublic(skill: SkillDefinition): PublicSkill { + const defaults = this.defaultsFor(skill); + return { + key: skill.key, + name: skill.name, + display_name: skill.displayName, + description: skill.description, + industry: skill.manifest.industry, + model: skill.manifest.model, + defaults, + price: { + currency: skill.manifest.price.currency, + per_image: skill.manifest.price.per_image, + size_multipliers: skill.manifest.price.size_multipliers, + }, + capabilities: skill.manifest.capabilities, + tags: skill.manifest.tags, + max_images: skill.manifest.max_images, + uses_defaults: skill.usesDefaults, + trigger_keywords: skill.triggerKeywords, + }; + } + + promptCatalog(skills?: SkillDefinition[]): string { + return (skills ?? this.list()) + .map((skill) => { + const defaults = this.defaultsFor(skill); + const multipliers = Object.entries(skill.manifest.price.size_multipliers) + .map(([size, value]) => `${size} ×${value}`) + .join(","); + return [ + `- skill_id: ${skill.key}`, + ` 名称: ${skill.displayName}`, + ` 用途: ${skill.description}`, + skill.triggerKeywords ? ` 触发词: ${skill.triggerKeywords}` : "", + ` 默认模型: ${skill.manifest.model}`, + ` 默认尺寸: ${defaults.size}`, + ` 单价: ${skill.manifest.price.per_image} ${skill.manifest.price.currency}/张`, + multipliers ? ` 尺寸加价: ${multipliers}` : "", + ` 单次最多: ${skill.manifest.max_images} 张`, + ] + .filter(Boolean) + .join("\n"); + }) + .join("\n"); + } + + defaultsFor(skill: SkillDefinition): { size: string; n: number; watermark: boolean } { + return { + size: skill.manifest.defaults.size ?? this.options.defaultSize, + n: skill.manifest.defaults.n ?? 1, + watermark: skill.manifest.defaults.watermark ?? false, + }; + } + + private toSkill(row: StoredSkill, diagnostics: string[]): SkillDefinition | undefined { + if (!row.libraryEnabled) return undefined; + if (!row.key) { + diagnostics.push(`skill id=${row.key || "unknown"} skipped: skill_key is empty`); + return undefined; + } + if (!KEY.test(row.key)) { + diagnostics.push(`skill ${row.key} skipped: skill_key must be lowercase kebab-case`); + return undefined; + } + if (!row.name.trim()) { + diagnostics.push(`skill ${row.key} skipped: name is required`); + return undefined; + } + const description = row.description.trim() || row.name.trim(); + if (!row.description.trim()) { + diagnostics.push(`skill ${row.key} description is empty, using name as description`); + } + if (!row.content.trim()) { + diagnostics.push(`skill ${row.key} skipped: content is empty`); + return undefined; + } + + let usesDefaults = false; + let config: StoredSkillConfig; + if (row.config) { + const parsed = storedConfigSchema.safeParse(row.config); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + diagnostics.push( + `skill ${row.key} skipped: invalid agent_skill_config (${issue?.path.join(".") ?? "?"}: ${issue?.message ?? "unknown"})`, + ); + return undefined; + } + config = parsed.data; + if (!config.enabled) return undefined; + } else { + usesDefaults = true; + config = { + pricePerImage: this.options.defaultPrice, + enabled: true, + }; + diagnostics.push( + `skill ${row.key} has no agent_skill_config, using defaults (${this.options.defaultModel}, ${this.options.defaultPrice} ${this.options.currency})`, + ); + } + + const bodyParams = parseSkillParams(row.content); + const manifest: SkillManifest = { + key: row.key, + display_name: row.name, + version: "1.0.0", + industry: row.industry, + model: this.options.defaultModel, + defaults: { + size: bodyParams.size ?? this.options.defaultSize, + n: bodyParams.n ?? 1, + watermark: bodyParams.watermark ?? false, + }, + price: { + currency: this.options.currency, + per_image: config.pricePerImage, + size_multipliers: {}, + }, + capabilities: [], + tags: bodyParams.tags, + max_images: bodyParams.maxImages ?? MAX_IMAGES_PER_REQUEST, + enabled: true, + }; + + return { + key: row.key, + name: row.name, + displayName: row.name, + description, + content: stripFrontmatter(row.content), + triggerKeywords: row.triggerKeywords, + manifest, + usesDefaults, + }; + } +} + +function stripFrontmatter(raw: string): string { + const text = raw.trim(); + if (!text.startsWith("---")) return text; + const end = text.indexOf("\n---", 3); + if (end === -1) return text; + return text.slice(end + 4).trim(); +} diff --git a/src/skills/direct.ts b/src/skills/direct.ts new file mode 100644 index 0000000..f435d39 --- /dev/null +++ b/src/skills/direct.ts @@ -0,0 +1,43 @@ +import { MAX_IMAGES_PER_REQUEST } from "./params.js"; +import type { AppConfig } from "../config.js"; +import type { SkillDefinition } from "../types.js"; + +export const DIRECT_SKILL_KEY = "__direct__"; + +/** + * Pseudo-skill used when the caller did not select a skill. + * It has no SKILL.md instructions; it only carries the platform defaults needed + * for pricing and gateway calls. + */ +export function directSkill(config: AppConfig): SkillDefinition { + return { + key: DIRECT_SKILL_KEY, + name: "直接生成", + displayName: "直接生成", + description: "未选择技能时的直接生图模式,不加载任何技能规范。", + content: "", + triggerKeywords: "", + usesDefaults: true, + manifest: { + key: DIRECT_SKILL_KEY, + display_name: "直接生成", + version: "1.0.0", + industry: "general", + model: config.skillDefaults.model, + defaults: { + size: config.skillDefaults.size, + n: 1, + watermark: false, + }, + price: { + currency: config.billing.currency, + per_image: config.billing.defaultSkillPrice, + size_multipliers: {}, + }, + capabilities: ["text2img"], + tags: ["direct"], + max_images: MAX_IMAGES_PER_REQUEST, + enabled: true, + }, + }; +} diff --git a/src/skills/params.ts b/src/skills/params.ts new file mode 100644 index 0000000..465ebbb --- /dev/null +++ b/src/skills/params.ts @@ -0,0 +1,91 @@ +export interface SkillBodyParams { + size?: string; + n?: number; + watermark?: boolean; + maxImages?: number; + tags: string[]; +} + +const SIZE_TOKEN = /(\d{3,5})\s*[x×]\s*(\d{3,5})/; +export const MAX_IMAGES_PER_REQUEST = 10; +const DEFAULT_MAX_IMAGES = MAX_IMAGES_PER_REQUEST; + +function toBool(value: string): boolean | undefined { + const text = value.trim().toLowerCase(); + if (["是", "true", "1", "yes", "on", "开启", "需要", "加水印"].includes(text)) return true; + if (["否", "false", "0", "no", "off", "关闭", "不需要", "不加"].includes(text)) return false; + return undefined; +} + +function toTags(value: string): string[] { + return value + .replace(/[[\]"]/g, "") + .split(/[,,、;;\s]+/) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 12); +} + +function readFrontMatter(content: string): Record { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const body = match?.[1]; + if (!body) return {}; + const result: Record = {}; + for (const line of body.split(/\r?\n/)) { + const pair = line.match(/^\s*([A-Za-z0-9_]+)\s*:\s*(.*)$/); + if (!pair || !pair[1]) continue; + result[pair[1].toLowerCase()] = (pair[2] ?? "").trim(); + } + return result; +} + +function readInline(content: string, keys: string[]): string | undefined { + for (const key of keys) { + // 仅匹配行首的「参数:值」,避免正文里随口提到的“标签/尺寸”等词被误当成参数 + const pattern = new RegExp(`^[ \\t]*${key}\\s*[::]\\s*([^\\n]+)$`, "m"); + const match = content.match(pattern); + const value = match?.[1]; + if (value) return value.trim(); + } + return undefined; +} + +export function parseSkillParams(content: string): SkillBodyParams { + const text = String(content || ""); + const front = readFrontMatter(text); + const params: SkillBodyParams = { tags: [] }; + + const sizeRaw = + front["default_size"] || + front["defaultsize"] || + front["size"] || + readInline(text, ["默认尺寸", "默认图片尺寸", "图片尺寸", "生成尺寸"]); + if (sizeRaw) { + const sizeMatch = sizeRaw.match(SIZE_TOKEN); + if (sizeMatch && sizeMatch[1] && sizeMatch[2]) params.size = `${sizeMatch[1]}x${sizeMatch[2]}`; + } + + const nRaw = front["default_n"] || front["n"] || front["quantity"] || readInline(text, ["默认生成数量", "生成数量", "默认数量", "默认张数", "张数"]); + if (nRaw) { + const n = Number.parseInt(nRaw, 10); + if (Number.isFinite(n) && n >= 1 && n <= MAX_IMAGES_PER_REQUEST) params.n = n; + } + + const watermarkRaw = front["watermark"] || readInline(text, ["默认水印", "水印"]); + if (watermarkRaw) { + const watermark = toBool(watermarkRaw); + if (watermark !== undefined) params.watermark = watermark; + } + + const maxRaw = front["max_images"] || front["maximages"] || readInline(text, ["最大生成张数", "最大张数", "最大数量"]); + if (maxRaw) { + const max = Number.parseInt(maxRaw, 10); + if (Number.isFinite(max) && max >= 1 && max <= MAX_IMAGES_PER_REQUEST) params.maxImages = max; + } + + const tagsRaw = front["tags"] || readInline(text, ["技能标签", "标签"]); + if (tagsRaw) params.tags = toTags(tagsRaw); + + if (!params.maxImages) params.maxImages = DEFAULT_MAX_IMAGES; + return params; +} diff --git a/src/skills/repository.ts b/src/skills/repository.ts new file mode 100644 index 0000000..66a4e87 --- /dev/null +++ b/src/skills/repository.ts @@ -0,0 +1,168 @@ +import { createPool, type Pool, type RowDataPacket } from "mysql2/promise"; +import type { MysqlConfig } from "../config.js"; +import type { Logger } from "../logger.js"; + +const SKILL_LIBRARY_DDL = ` +CREATE TABLE IF NOT EXISTS skill_library ( + id BIGINT NOT NULL AUTO_INCREMENT, + skill_key VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + industry VARCHAR(64) NOT NULL DEFAULT '', + description VARCHAR(512) NOT NULL DEFAULT '', + trigger_keywords VARCHAR(255) NOT NULL DEFAULT '', + default_input VARCHAR(512) NOT NULL DEFAULT '', + content MEDIUMTEXT, + enabled TINYINT NOT NULL DEFAULT 1, + add_time INT(10) NOT NULL, + update_time BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_skill_key (skill_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +`; + +const SKILL_CONFIG_DDL = ` +CREATE TABLE IF NOT EXISTS agent_skill_config ( + skill_key VARCHAR(64) NOT NULL, + price_per_image INT UNSIGNED NOT NULL DEFAULT 10, + enabled TINYINT UNSIGNED NOT NULL DEFAULT 1, + add_time INT(10) NOT NULL, + update_time BIGINT NOT NULL, + PRIMARY KEY (skill_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +`; + +export interface StoredSkillConfig { + pricePerImage: number; + enabled: boolean; +} + +export interface StoredSkill { + key: string; + name: string; + industry: string; + description: string; + triggerKeywords: string; + content: string; + libraryEnabled: boolean; + config?: StoredSkillConfig; +} + +export interface SkillRepository { + ensureSchema(): Promise; + listSkills(): Promise; + close(): Promise; +} + +export interface MySqlSkillDefaults { + size: string; + price: number; + currency: string; +} + +export class MySqlSkillRepository implements SkillRepository { + private readonly pool: Pool; + + constructor( + config: MysqlConfig, + private readonly defaults: MySqlSkillDefaults, + private readonly logger: Logger, + ) { + this.pool = createPool({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.database, + connectionLimit: config.connectionLimit, + charset: "utf8mb4", + connectTimeout: config.connectTimeoutMs, + decimalNumbers: true, + }); + } + + async ensureSchema(): Promise { + await this.pool.query(SKILL_LIBRARY_DDL); + await this.pool.query(SKILL_CONFIG_DDL); + + // 旧版本的尺寸/数量/水印等参数列已改为从 skill 正文解析,这里清理残留列 + const legacyColumns = [ + "model", + "default_size", + "default_n", + "watermark", + "size_multipliers", + "max_images", + "currency", + "version", + "capabilities", + "tags", + ]; + const [existing] = await this.pool.query( + `SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agent_skill_config'`, + ); + const existingNames = new Set(existing.map((row) => String(row.COLUMN_NAME))); + for (const column of legacyColumns) { + if (!existingNames.has(column)) continue; + await this.pool.query(`ALTER TABLE agent_skill_config DROP COLUMN ${column}`); + this.logger.warn({ column }, "dropped legacy agent_skill_config column"); + } + + const nowSeconds = Math.floor(Date.now() / 1000); + await this.pool.execute( + `INSERT IGNORE INTO agent_skill_config + (skill_key, price_per_image, enabled, add_time, update_time) + SELECT skill_key, ?, 1, ?, ? + FROM skill_library`, + [this.defaults.price, nowSeconds, Date.now()], + ); + this.logger.info({ database: "skill_library" }, "skill database schema ready"); + } + + async listSkills(): Promise { + const [rows] = await this.pool.query( + `SELECT + s.skill_key, s.name, s.industry, s.description, s.trigger_keywords, s.content, + s.enabled AS library_enabled, + c.price_per_image, c.enabled AS config_enabled + FROM skill_library s + LEFT JOIN agent_skill_config c ON c.skill_key = s.skill_key + ORDER BY s.id ASC`, + ); + + return rows.map((row) => { + const hasConfig = row.price_per_image !== null && row.price_per_image !== undefined; + return { + key: stringValue(row.skill_key), + name: stringValue(row.name), + industry: stringValue(row.industry), + description: stringValue(row.description), + triggerKeywords: stringValue(row.trigger_keywords), + content: stringValue(row.content), + libraryEnabled: Number(row.library_enabled) === 1, + config: hasConfig + ? { + pricePerImage: numberValue(row.price_per_image, this.defaults.price), + enabled: row.config_enabled === null || row.config_enabled === undefined + ? true + : Number(row.config_enabled) === 1, + } + : undefined, + }; + }); + } + + async close(): Promise { + await this.pool.end(); + } +} + +function stringValue(value: unknown): string { + return value === null || value === undefined ? "" : String(value); +} + +function numberValue(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a196ab8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,84 @@ +import type { BillingMode } from "./config.js"; + +export interface Identity { + uid: string; + team_id: string; + nickname?: string; + avatar?: string; + mobile?: string; +} + +export interface SkillDefaults { + size?: string; + n?: number; + watermark?: boolean; +} + +export interface SkillPrice { + currency: string; + per_image: number; + size_multipliers: Record; +} + +export interface SkillManifest { + key: string; + display_name?: string; + version: string; + industry: string; + model: string; + defaults: SkillDefaults; + price: SkillPrice; + capabilities: string[]; + tags: string[]; + max_images: number; + enabled: boolean; +} + +export interface SkillDefinition { + key: string; + name: string; + displayName: string; + description: string; + content: string; + triggerKeywords: string; + manifest: SkillManifest; + usesDefaults: boolean; +} + +export interface Quote { + skill_id: string; + skill_name: string; + model: string; + size: string; + quantity: number; + unit_credits: number; + size_multiplier: number; + total_credits: number; + currency: string; + billing_mode: BillingMode; +} + +export type LedgerStatus = "pending" | "succeeded" | "failed"; + +export interface UsageRecord { + id: string; + ts: number; + uid: string; + team_id: string; + session_id: string; + tool_call_id: string; + skill_id: string; + skill_name: string; + model: string; + gateway_task_id: string; + size: string; + quantity: number; + unit_credits: number; + size_multiplier: number; + total_credits: number; + currency: string; + billing_mode: BillingMode; + status: LedgerStatus; + image_urls: string[]; + error?: string; +} diff --git a/src/utils/net.ts b/src/utils/net.ts new file mode 100644 index 0000000..47f9d70 --- /dev/null +++ b/src/utils/net.ts @@ -0,0 +1,300 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * 出站请求防护(SSRF)。 + * + * 本服务会把「用户/模型可控的 URL」交给服务端 fetch(文档下载、参考图下载), + * 因此必须在每一跳校验主机:协议、凭证、域名白名单、以及解析后的真实 IP 是否落在 + * 内网 / 回环 / 链路本地(含云元数据 169.254.169.254)/ 保留网段。 + */ + +export class UnsafeUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsafeUrlError"; + } +} + +export interface UrlPolicy { + /** 允许的主机名;空数组表示只限制「必须是公网」,不限制具体域名。 */ + allowedHosts?: string[]; + maxRedirects?: number; +} + +const DEFAULT_MAX_REDIRECTS = 5; + +/** `a.com,b.com,*.cdn.com` -> ["a.com","b.com","*.cdn.com"] */ +export function parseHostAllowlist(value: string | undefined | null): string[] { + return (value ?? "") + .split(",") + .map((item) => item.trim().toLowerCase().replace(/\.$/, "")) + .filter(Boolean); +} + +/** 支持精确匹配与 `*.example.com`(同时匹配 example.com 本身与任意子域)。 */ +export function hostMatches(hostname: string, patterns: readonly string[]): boolean { + if (patterns.length === 0) return true; + const host = hostname.toLowerCase().replace(/\.$/, ""); + return patterns.some((pattern) => { + if (pattern.startsWith("*.")) { + const base = pattern.slice(2); + return host === base || host.endsWith(`.${base}`); + } + return host === pattern; + }); +} + +const BLOCKED_IPV4_RANGES: ReadonlyArray = [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], // CGNAT + ["127.0.0.0", 8], + ["169.254.0.0", 16], // 链路本地,含 169.254.169.254 云元数据 + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], // benchmark + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], // 组播 + ["240.0.0.0", 4], // 保留 + 广播 +]; + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet < 0 || octet > 255) return null; + value = value * 256 + octet; + } + return value >>> 0; +} + +function inIpv4Range(ip: number, base: number, prefix: number): boolean { + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return (ip & mask) === (base & mask); +} + +function isBlockedIpv4(ip: string): boolean { + const value = ipv4ToInt(ip); + if (value === null) return true; + return BLOCKED_IPV4_RANGES.some(([base, prefix]) => { + const baseValue = ipv4ToInt(base); + return baseValue !== null && inIpv4Range(value, baseValue, prefix); + }); +} + +/** 解析 IPv6 为 8 个 16 位分组;支持 `::` 缩写与内嵌 IPv4。 */ +function parseIpv6(raw: string): number[] | null { + let value = raw.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); + const zoneIndex = value.indexOf("%"); + if (zoneIndex >= 0) value = value.slice(0, zoneIndex); + if (!value.includes(":")) return null; + + const lastColon = value.lastIndexOf(":"); + const lastPart = value.slice(lastColon + 1); + if (lastPart.includes(".")) { + const octets = lastPart.split(".").map(Number); + if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; + const high = ((octets[0]! << 8) | octets[1]!) >>> 0; + const low = ((octets[2]! << 8) | octets[3]!) >>> 0; + value = `${value.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`; + } + + const halves = value.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + if (halves.length === 1 && head.length !== 8) return null; + + const missing = 8 - head.length - tail.length; + if (halves.length === 2 && missing < 1) return null; + if (halves.length === 1 && missing !== 0) return null; + + const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...tail]; + if (groups.length !== 8) return null; + + const parsed: number[] = []; + for (const group of groups) { + if (!/^[0-9a-f]{1,4}$/.test(group)) return null; + parsed.push(Number.parseInt(group, 16)); + } + return parsed; +} + +function isBlockedIpv6(groups: number[]): boolean { + const [g0, g1, g2, g3, g4, g5, g6, g7] = groups as [ + number, + number, + number, + number, + number, + number, + number, + number, + ]; + + // :: + if (groups.every((group) => group === 0)) return true; + // ::1 + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) { + return true; + } + // fc00::/7 唯一本地地址 + if ((g0 & 0xfe00) === 0xfc00) return true; + // fe80::/10 链路本地 + if ((g0 & 0xffc0) === 0xfe80) return true; + // ff00::/8 组播 + if ((g0 & 0xff00) === 0xff00) return true; + // 2001:db8::/32 文档地址 + if (g0 === 0x2001 && g1 === 0x0db8) return true; + // 100::/64 discard-only + if (g0 === 0x0100 && g1 === 0 && g2 === 0 && g3 === 0) return true; + + // IPv4-mapped ::ffff:a.b.c.d、IPv4-compatible ::a.b.c.d、NAT64 64:ff9b::/96 的内嵌地址在末尾两个分组 + if ( + (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && (g5 === 0xffff || g5 === 0)) || + (g0 === 0x0064 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) + ) { + return isBlockedIpv4(`${g6 >> 8}.${g6 & 0xff}.${g7 >> 8}.${g7 & 0xff}`); + } + // 6to4 2002:AABB:CCDD::/48 的内嵌地址在第 2、3 个分组 + if (g0 === 0x2002) { + return isBlockedIpv4(`${g1 >> 8}.${g1 & 0xff}.${g2 >> 8}.${g2 & 0xff}`); + } + + return false; +} + +export function isBlockedAddress(address: string): boolean { + const value = address.trim().replace(/^\[/, "").replace(/\]$/, ""); + const family = isIP(value); + if (family === 4) return isBlockedIpv4(value); + if (family === 6) { + const groups = parseIpv6(value); + return groups === null ? true : isBlockedIpv6(groups); + } + return true; +} + +const BLOCKED_HOST_SUFFIXES = [ + ".localhost", + ".local", + ".internal", + ".home.arpa", + ".lan", + ".intranet", + ".corp", + ".svc", + ".cluster.local", +]; + +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "metadata", + "metadata.google.internal", + "instance-data", + "kubernetes.default", +]); + +/** 主机名层面的粗筛:字面量 IP、单标签主机名(走 search domain)、内部域名。 */ +export function isBlockedHostname(hostname: string): boolean { + const host = hostname.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, ""); + if (!host) return true; + if (isIP(host) !== 0) return isBlockedAddress(host); + if (BLOCKED_HOSTNAMES.has(host)) return true; + if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true; + // 单标签主机名会命中搜索域 / 内网 DNS,一律拒绝 + if (!host.includes(".")) return true; + return false; +} + +/** + * 校验一个用户可控 URL:协议、凭证、主机名、域名白名单,以及 DNS 解析后的真实 IP。 + * 注意:DNS 校验与真正发起请求之间仍存在理论上的 rebinding 窗口, + * 因此生产环境还应配合容器级出网策略做兜底。 + */ +export async function assertSafeUrl(rawUrl: string, policy: UrlPolicy = {}): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new UnsafeUrlError("URL 格式不合法"); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new UnsafeUrlError("仅支持 http/https 地址"); + } + if (url.username || url.password) { + throw new UnsafeUrlError("URL 不允许包含账号或密码"); + } + + const hostname = url.hostname.replace(/^\[/, "").replace(/\]$/, ""); + if (isBlockedHostname(hostname)) { + throw new UnsafeUrlError(`禁止访问该主机:${hostname}`); + } + if (!hostMatches(hostname, policy.allowedHosts ?? [])) { + throw new UnsafeUrlError(`主机不在白名单内:${hostname}`); + } + + if (isIP(hostname) !== 0) return url; + + let records: Array<{ address: string }>; + try { + records = await lookup(hostname, { all: true, verbatim: true }); + } catch { + throw new UnsafeUrlError(`域名解析失败:${hostname}`); + } + if (records.length === 0) { + throw new UnsafeUrlError(`域名解析为空:${hostname}`); + } + for (const record of records) { + if (isBlockedAddress(record.address)) { + throw new UnsafeUrlError(`域名解析到内网地址:${hostname} -> ${record.address}`); + } + } + return url; +} + +function isRedirect(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; +} + +/** + * 逐跳校验重定向的安全 fetch:每一跳都重新做 assertSafeUrl。 + * 重定向一律手动处理,避免被 30x 带到内网。 + */ +export async function safeFetch( + rawUrl: string, + init: RequestInit = {}, + policy: UrlPolicy = {}, +): Promise { + const maxRedirects = policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + let current = rawUrl; + + for (let hop = 0; hop <= maxRedirects; hop += 1) { + const url = await assertSafeUrl(current, policy); + const response = await fetch(url, { ...init, redirect: "manual" }); + + if (!isRedirect(response.status)) return response; + + const location = response.headers.get("location"); + if (!location) return response; + if (hop === maxRedirects) { + throw new UnsafeUrlError("重定向次数过多"); + } + try { + await response.body?.cancel(); + } catch { + // 忽略取消 body 的异常 + } + current = new URL(location, url).toString(); + } + + throw new UnsafeUrlError("重定向次数过多"); +} diff --git a/src/utils/text.ts b/src/utils/text.ts new file mode 100644 index 0000000..c5015f8 --- /dev/null +++ b/src/utils/text.ts @@ -0,0 +1,27 @@ +/** 前端为 Agent 追加的附件指令块,例如: + * 【用户上传了以下文档(请先用 read_document_text …):\n文件名:url】 + * 【用户上传了以下行程文档(请先用 read_document …):\nurl】 + * 入库/展示时应去掉,只保留用户真正输入的内容。 + */ +const UPLOADED_BLOCK = /【用户上传了[\s\S]*?】\s*/g; +const UPLOADED_MARK = "【用户上传了"; + +export function stripUploadedDocuments(message: string): string { + const text = String(message ?? ""); + let cleaned = text.replace(UPLOADED_BLOCK, ""); + // 标题被截断时可能只有起始标记、没有收尾的 】,从标记处直接截断 + const mark = cleaned.indexOf(UPLOADED_MARK); + if (mark !== -1) cleaned = cleaned.slice(0, mark); + cleaned = cleaned.trim(); + return cleaned || text.trim(); +} + +export function sanitizeTitle(title: string | undefined, fallback = "新对话", maxLength = 40): string { + const text = String(title ?? ""); + const cleaned = stripUploadedDocuments(text); + // 只剩脏标题本身时视为空 + const usable = cleaned === text.trim() && text.includes(UPLOADED_MARK) ? "" : cleaned; + const normalized = usable.replace(/\s+/g, " ").trim(); + if (!normalized) return fallback; + return normalized.slice(0, maxLength); +} diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..65c3e89 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,44 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { AuthError, resolveIdentity, verifyJwt } from "../src/auth/jwt.js"; + +function sign(payload: Record, secret = "secret", alg = "HS256"): string { + const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signature = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url"); + return `${header}.${body}.${signature}`; +} + +describe("auth", () => { + it("verifies a valid HS256 token and resolves identity", () => { + const token = sign({ uid: 42, nickname: "小明", exp: Math.floor(Date.now() / 1000) + 60 }); + const identity = resolveIdentity( + { jwtKey: "secret", jwtAlgorithm: "HS256" }, + { headers: { authorization: `Bearer ${token}`, "x-team-id": "9" } }, + ); + expect(identity).toMatchObject({ uid: "42", nickname: "小明", team_id: "9" }); + }); + + it("rejects missing, expired, tampered, and wrong-algorithm tokens", () => { + const auth = { jwtKey: "secret", jwtAlgorithm: "HS256" }; + expect(() => resolveIdentity(auth, { headers: {} })).toThrow(AuthError); + + const expired = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) - 1 }); + expect(() => verifyJwt(expired, "secret", "HS256")).toThrow(/过期/); + + const valid = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) + 60 }); + const tampered = `${valid.split(".").slice(0, 2).join(".")}.bad`; + expect(() => verifyJwt(tampered, "secret", "HS256")).toThrow(/签名/); + + const wrongAlg = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) + 60 }, "secret", "HS512"); + expect(() => verifyJwt(wrongAlg, "secret", "HS256")).toThrow(/算法/); + }); + + it("uses x-uid headers in development mode", () => { + const identity = resolveIdentity( + { jwtAlgorithm: "HS256" }, + { headers: { "x-uid": "7", "x-team-id": "8", "x-nickname": "dev" } }, + ); + expect(identity).toEqual({ uid: "7", team_id: "8", nickname: "dev" }); + }); +}); diff --git a/tests/batch.test.ts b/tests/batch.test.ts new file mode 100644 index 0000000..8683d68 --- /dev/null +++ b/tests/batch.test.ts @@ -0,0 +1,203 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createAgentTools } from "../src/agent/tools.js"; +import { UsageLedger } from "../src/billing/ledger.js"; +import { GatewayClient } from "../src/gateway/client.js"; +import type { Quote } from "../src/types.js"; +import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js"; + +interface BatchDetails { + ok: boolean; + quote?: Quote; + images?: string[]; + error?: string; + idempotent?: boolean; +} + +interface TestTool { + name: string; + execute: ( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ) => Promise<{ content: Array<{ type: string; text: string }>; details: BatchDetails }>; +} + +async function setup(options: { content?: string } = {}) { + const root = tempRoot(); + const config = testConfig(root); + const catalog = await makeCatalog([ + skillFixture({ key: "suite", config: { pricePerImage: 20 }, content: options.content }), + ]); + const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger); + const gateway = new GatewayClient(config.gateway, silentLogger); + const captured: string[] = []; + const tools = createAgentTools({ + config, + catalog, + ledger, + gateway, + logger: silentLogger, + identity: { uid: "1", team_id: "1" }, + sessionId: "session-1", + allowedSkillKeys: ["suite"], + onImages: (urls) => { captured.push(...urls); }, + }); + const find = (name: string): TestTool => { + const tool = tools.find((item) => item.name === name); + if (!tool) throw new Error(`missing tool ${name}`); + return tool as unknown as TestTool; + }; + return { find, ledger, gateway, captured }; +} + +const style = "统一风格:轻奢商业摄影,暖白背景,柔和侧光,低饱和暖棕色调,衬线标题字,大量留白"; +const images = [ + { content: "主图,产品正面全身展示" }, + { content: "主图,佩戴场景特写" }, + { content: "详情图,材质细节" }, + { content: "详情图,规格参数" }, +]; + +describe("generate_images_batch", () => { + it("submits every image as its own request but all concurrently", async () => { + const { find, gateway, ledger, captured } = await setup(); + const prompts: string[] = []; + let call = 0; + const spy = vi.spyOn(gateway, "generateImage").mockImplementation(async (input) => { + const index = call++; + prompts.push(input.prompt); + expect(input.n).toBe(1); + await new Promise((resolve) => setTimeout(resolve, 120)); + return { + taskId: `task-${index}`, + urls: [`https://img/${index}.png`], + status: "success", + raw: {}, + elapsedMs: 120, + }; + }); + + const t0 = Date.now(); + const result = await find("generate_images_batch").execute("batch-1", { + skill_id: "suite", + style, + images, + }); + const elapsed = Date.now() - t0; + + expect(spy).toHaveBeenCalledTimes(4); + expect(prompts.every((prompt) => prompt.startsWith(style))).toBe(true); + expect(prompts[0]).toContain("图1:主图,产品正面全身展示"); + expect(prompts[3]).toContain("图4:详情图,规格参数"); + expect(result.details.ok).toBe(true); + expect(result.details.images).toHaveLength(4); + expect(captured).toHaveLength(4); + expect(result.details.quote?.total_credits).toBe(80); + expect(ledger.list()).toHaveLength(4); + expect(ledger.list().every((record) => record.status === "succeeded")).toBe(true); + // 4 张并发:总耗时应接近单张耗时,而不是 4 倍 + expect(elapsed).toBeLessThan(320); + }); + + it("supports a different size per image", async () => { + const { find, gateway } = await setup(); + const sizes: string[] = []; + vi.spyOn(gateway, "generateImage").mockImplementation(async (input) => { + sizes.push(input.size); + return { + taskId: `task-${sizes.length}`, + urls: [`https://img/${sizes.length}.png`], + status: "success", + raw: {}, + elapsedMs: 5, + }; + }); + + const result = await find("generate_images_batch").execute("batch-size", { + skill_id: "suite", + style, + images: [ + { content: "主图01", size: "800x800" }, + { content: "详情图01", size: "790x1200" }, + ], + }); + expect(result.details.ok).toBe(true); + expect(sizes).toEqual(["800x800", "790x1200"]); + }); + + it("keeps successful images when one item fails", async () => { + const { find, gateway, ledger } = await setup(); + let call = 0; + vi.spyOn(gateway, "generateImage").mockImplementation(async () => { + const index = call++; + if (index === 1) throw new Error("gateway timeout"); + await new Promise((resolve) => setTimeout(resolve, 20)); + return { + taskId: `task-${index}`, + urls: [`https://img/${index}.png`], + status: "success", + raw: {}, + elapsedMs: 20, + }; + }); + + const result = await find("generate_images_batch").execute("batch-2", { + skill_id: "suite", + style, + images, + }); + expect(result.details.ok).toBe(true); + expect(result.details.images).toHaveLength(3); + expect(result.content[0]?.text).toContain("失败 1 张"); + expect(ledger.list().filter((record) => record.status === "failed")).toHaveLength(1); + expect(ledger.list().filter((record) => record.status === "succeeded")).toHaveLength(3); + }); + + it("replays a batch without re-generating or re-billing", async () => { + const { find, gateway } = await setup(); + let call = 0; + const spy = vi.spyOn(gateway, "generateImage").mockImplementation(async () => { + const index = call++; + return { + taskId: `task-${index}`, + urls: [`https://img/${index}.png`], + status: "success", + raw: {}, + elapsedMs: 5, + }; + }); + + await find("generate_images_batch").execute("batch-3", { skill_id: "suite", style, images }); + expect(spy).toHaveBeenCalledTimes(4); + + const replay = await find("generate_images_batch").execute("batch-3", { skill_id: "suite", style, images }); + expect(spy).toHaveBeenCalledTimes(4); + expect(replay.details.images).toHaveLength(4); + }); + + it("requires one overall style for a multi-image set", async () => { + const { find, gateway } = await setup(); + const spy = vi.spyOn(gateway, "generateImage"); + const result = await find("generate_images_batch").execute("batch-no-style", { + skill_id: "suite", + images, + }); + expect(result.details.ok).toBe(false); + expect(result.content[0]?.text).toContain("总体风格"); + expect(spy).not.toHaveBeenCalled(); + }); + + it("rejects batches larger than the skill max_images", async () => { + const { find, gateway } = await setup({ content: "---\nmax_images: 2\n---\n# suite\n" }); + const spy = vi.spyOn(gateway, "generateImage"); + const result = await find("generate_images_batch").execute("batch-4", { + skill_id: "suite", + style, + images, + }); + expect(result.details.ok).toBe(false); + expect(result.content[0]?.text).toContain("超过技能"); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts new file mode 100644 index 0000000..f7fe36a --- /dev/null +++ b/tests/catalog.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { SkillCatalog } from "../src/skills/catalog.js"; +import { FakeSkillRepository, makeCatalog, silentLogger, skillFixture } from "./helpers.js"; + +describe("SkillCatalog (database)", () => { + it("loads valid skills and exposes pricing metadata", async () => { + const catalog = await makeCatalog([ + skillFixture({ + key: "poster", + config: { pricePerImage: 30 }, + content: "---\ndefault_size: 1024x1536\ndefault_n: 2\nwatermark: false\n---\n# Skill", + }), + skillFixture({ key: "space", config: { pricePerImage: 50 } }), + ]); + + expect(catalog.diagnostics()).toEqual([]); + expect(catalog.list().map((skill) => skill.key)).toEqual(["poster", "space"]); + expect(catalog.get("poster")?.displayName).toBe("Test poster"); + expect(catalog.toPublic(catalog.get("poster")!)).toMatchObject({ + key: "poster", + display_name: "Test poster", + price: { per_image: 30 }, + defaults: { size: "1024x1536", n: 2, watermark: false }, + }); + expect(catalog.promptCatalog()).toContain("skill_id: poster"); + expect(catalog.promptCatalog()).toContain("单价: 30 credits/张"); + }); + + it("parses size, quantity, watermark and tags from skill body", async () => { + const catalog = await makeCatalog([ + skillFixture({ + key: "body-params", + content: "---\ndefault_size: 1536x864\ndefault_n: 1\nmax_images: 3\ntags: [旅游, 海报]\n---\n# Body", + }), + skillFixture({ + key: "inline-params", + content: "# 技能\n默认尺寸:1024x1820\n生成数量:2\n水印:否\n标签:电商、海报", + }), + ]); + + const body = catalog.get("body-params"); + expect(body?.manifest.defaults).toMatchObject({ size: "1536x864", n: 1 }); + expect(body?.manifest.max_images).toBe(3); + expect(body?.manifest.tags).toEqual(["旅游", "海报"]); + + const inline = catalog.get("inline-params"); + expect(inline?.manifest.defaults).toMatchObject({ size: "1024x1820", n: 2, watermark: false }); + expect(inline?.manifest.tags).toEqual(["电商", "海报"]); + }); + + it("falls back to defaults when agent_skill_config is absent", async () => { + const catalog = await makeCatalog([skillFixture({ key: "no-config", config: null })], { defaultPrice: 17 }); + const skill = catalog.get("no-config"); + expect(skill?.usesDefaults).toBe(true); + expect(skill?.manifest.price.per_image).toBe(17); + expect(skill?.manifest.model).toBe("gpt-image-2"); + expect(catalog.diagnostics().join("\n")).toContain("no agent_skill_config"); + }); + + it("rejects disabled, missing content, and invalid keys", async () => { + const catalog = await makeCatalog([ + skillFixture({ key: "good" }), + skillFixture({ key: "no-description", description: "" }), + skillFixture({ key: "disabled", config: { enabled: false } }), + skillFixture({ key: "library-disabled", libraryEnabled: false }), + skillFixture({ key: "empty", content: "" }), + skillFixture({ key: "bad_name" }), + ]); + + expect(catalog.list().map((skill) => skill.key)).toEqual(["good", "no-description"]); + expect(catalog.get("disabled")).toBeUndefined(); + expect(catalog.get("library-disabled")).toBeUndefined(); + const diagnostics = catalog.diagnostics().join("\n"); + expect(diagnostics).toContain("content is empty"); + expect(diagnostics).toContain("description is empty"); + expect(diagnostics).toContain("lowercase kebab-case"); + }); + + it("rejects invalid database config without crashing", async () => { + const catalog = await makeCatalog([ + skillFixture({ key: "bad-price", config: { pricePerImage: -1 } }), + ]); + + expect(catalog.list()).toHaveLength(0); + expect(catalog.diagnostics().join("\n")).toContain("bad-price"); + }); + + it("refreshes the in-memory catalog from the repository", async () => { + const repository = new FakeSkillRepository([skillFixture({ key: "first" })]); + const catalog = new SkillCatalog({ + repository, + defaultPrice: 10, + defaultModel: "seedream@5.0", + defaultSize: "1024x1024", + currency: "credits", + logger: silentLogger, + }); + await catalog.load(); + expect(catalog.list().map((skill) => skill.key)).toEqual(["first"]); + + repository.setSkills([skillFixture({ key: "second", config: { pricePerImage: 20 } })]); + await catalog.refresh(); + expect(catalog.list().map((skill) => skill.key)).toEqual(["second"]); + expect(catalog.get("first")).toBeUndefined(); + }); + + it("requires at least one enabled skill", async () => { + const catalog = await makeCatalog([]); + expect(() => catalog.requireReady()).toThrow(/no enabled skills/); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..e44b7a3 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { loadConfig } from "../src/config.js"; +import { tempRoot } from "./helpers.js"; + +describe("loadConfig", () => { + it("applies safe defaults", () => { + const root = tempRoot(); + const config = loadConfig({ APP_ROOT: root }); + expect(config.port).toBe(8100); + expect(config.billing.mode).toBe("live"); + expect(config.billing.currency).toBe("credits"); + expect(config.gateway.mock).toBe(false); + expect(config.gateway.pollTimeoutMs).toBe(900_000); + expect(config.llm.modelId).toBe("deepseek-chat"); + expect(config.dataDir).toBe(join(root, "data")); + expect(config.mysql.host).toBe("127.0.0.1"); + expect(config.mysql.database).toBe("laimiaodb"); + expect(config.skillDefaults.model).toBe("gpt-image-2"); + expect(config.toolConfirmation.enabled).toBe(true); + expect(config.toolConfirmation.tools).toEqual(["generate_image"]); + expect(config.toolConfirmation.timeoutMs).toBe(300_000); + expect(config.modelsFile).toBe(join(root, "data", "pi", "models.json")); + }); + + it("parses booleans and integers from env strings", () => { + const config = loadConfig({ + APP_ROOT: tempRoot(), + PORT: "9001", + GATEWAY_MOCK: "TRUE", + BILLING_MODE: "off", + LAIMIAO_LLM_REASONING: "1", + GATEWAY_POLL_INTERVAL_MS: "250", + TOOL_CONFIRMATION_TOOLS: "generate_image, quote_image", + }); + expect(config.port).toBe(9001); + expect(config.gateway.mock).toBe(true); + expect(config.billing.mode).toBe("off"); + expect(config.llm.reasoning).toBe(true); + expect(config.gateway.pollIntervalMs).toBe(250); + expect(config.toolConfirmation.tools).toEqual(["generate_image", "quote_image"]); + }); + + it("treats an empty integer as the default", () => { + const config = loadConfig({ APP_ROOT: tempRoot(), PORT: "" }); + expect(config.port).toBe(8100); + }); + + it("rejects invalid port and billing mode", () => { + expect(() => loadConfig({ APP_ROOT: tempRoot(), PORT: "0" })).toThrow(/PORT/); + expect(() => loadConfig({ APP_ROOT: tempRoot(), BILLING_MODE: "always" })).toThrow(/BILLING_MODE/); + }); + + it("rejects malformed gateway and LLM URLs", () => { + expect(() => loadConfig({ APP_ROOT: tempRoot(), GATEWAY_CN_URL: "not-a-url" })).toThrow(/GATEWAY_CN_URL/); + expect(() => loadConfig({ APP_ROOT: tempRoot(), LAIMIAO_LLM_BASE_URL: "ftp://x" })).toThrow( + /LAIMIAO_LLM_BASE_URL/, + ); + }); + + it("enforces production security requirements", () => { + const base = { + APP_ROOT: tempRoot(), + NODE_ENV: "production", + JWT_KEY: "secret", + CORS_ORIGIN: "https://app.example.com", + FILE_FETCH_ALLOWED_HOSTS: "cdn.example.com,*.oss.example.com", + }; + + expect(() => loadConfig({ ...base, JWT_KEY: "" })).toThrow(/JWT_KEY/); + expect(() => loadConfig({ ...base, CORS_ORIGIN: "*" })).toThrow(/CORS_ORIGIN/); + expect(() => loadConfig({ ...base, CORS_ORIGIN: "https://app.example.com/path" })).toThrow(/CORS_ORIGIN/); + expect(() => loadConfig({ ...base, FILE_FETCH_ALLOWED_HOSTS: "" })).toThrow(/FILE_FETCH_ALLOWED_HOSTS/); + + const config = loadConfig(base); + expect(config.isProduction).toBe(true); + expect(config.fileFetchAllowedHosts).toEqual(["cdn.example.com", "*.oss.example.com"]); + }); + + it("keeps permissive defaults outside production", () => { + const config = loadConfig({ APP_ROOT: tempRoot() }); + expect(config.nodeEnv).toBe("development"); + expect(config.isProduction).toBe(false); + expect(config.fileFetchAllowedHosts).toEqual([]); + }); + + it("resolves relative paths against APP_ROOT", () => { + const root = tempRoot(); + const config = loadConfig({ APP_ROOT: root, DATA_DIR: "var/state" }); + expect(config.dataDir).toBe(join(root, "var/state")); + expect(config.ledgerFile).toBe(join(root, "var/state/usage-ledger.jsonl")); + }); +}); diff --git a/tests/confirm_tools.test.ts b/tests/confirm_tools.test.ts new file mode 100644 index 0000000..d86e1fe --- /dev/null +++ b/tests/confirm_tools.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { createConfirmTools } from "../src/agent/confirm_tools.js"; +import { ToolConfirmationManager } from "../src/agent/confirmations.js"; +import type { AgentStreamEvent } from "../src/agent/service.js"; +import { makeCatalog, silentLogger } from "./helpers.js"; + +interface TestTool { + name: string; + execute: ( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ) => Promise<{ + content: Array<{ type: string; text: string }>; + details: Record; + isError?: boolean; + }>; +} + +async function setup() { + const catalog = await makeCatalog([]); + const manager = new ToolConfirmationManager(1_000); + const events: AgentStreamEvent[] = []; + const tools = createConfirmTools({ + catalog, + confirmations: manager, + sessionId: "s1", + logger: silentLogger, + emit: (event) => events.push(event), + hasStream: true, + }) as unknown as TestTool[]; + return { manager, events, tools }; +} + +function resultText(result: { content: Array<{ type: string; text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? "{}"); +} + +describe("confirm tools", () => { + it("select mode emits options and returns the selected id", async () => { + const { manager, events, tools } = await setup(); + const tool = tools.find((item) => item.name === "confirm_user"); + expect(tool).toBeDefined(); + const promise = tool!.execute("call-1", { + message: "请选择风格", + mode: "select", + options: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ], + }); + expect(events[0]).toMatchObject({ + type: "content_confirmation_required", + confirm_type: "user_confirm", + payload: { message: "请选择风格", mode: "select" }, + }); + expect(manager.resolve("s1", "call-1", { approved: true, content: "b" })).toBe(true); + expect(resultText(await promise)).toEqual({ approved: true, content: "b" }); + expect(events[1]).toMatchObject({ + type: "content_confirmation_resolved", + confirm_type: "user_confirm", + approved: true, + content: "b", + }); + }); + + it("treats provided options as select mode", async () => { + const { manager, events, tools } = await setup(); + const tool = tools.find((item) => item.name === "confirm_user")!; + const promise = tool.execute("call-options", { + message: "请选择风格", + mode: "confirm", + options: ["a", "b"], + }); + expect(events[0]).toMatchObject({ + type: "content_confirmation_required", + confirm_type: "user_confirm", + payload: { mode: "select" }, + }); + manager.resolve("s1", "call-options", { approved: true, content: "a" }); + expect(resultText(await promise)).toEqual({ approved: true, content: "a" }); + }); + + it("edit mode returns edited content", async () => { + const { manager, tools } = await setup(); + const tool = tools.find((item) => item.name === "confirm_user")!; + const promise = tool.execute("call-edit", { + message: "请补充信息", + mode: "edit", + default_value: "默认内容", + }); + manager.resolve("s1", "call-edit", { approved: true, content: "用户修改后的内容" }); + expect(resultText(await promise)).toEqual({ approved: true, content: "用户修改后的内容" }); + }); + + it("confirm mode clears content", async () => { + const { manager, tools } = await setup(); + const tool = tools.find((item) => item.name === "confirm_user")!; + const promise = tool.execute("call-confirm", { message: "确认继续?", mode: "confirm" }); + manager.resolve("s1", "call-confirm", { approved: true, content: "忽略内容" }); + expect(resultText(await promise)).toEqual({ approved: true, content: "" }); + }); + + it("rejects an option that was not offered", async () => { + const { manager, tools } = await setup(); + const tool = tools.find((item) => item.name === "confirm_user")!; + const promise = tool.execute("call-invalid", { + message: "请选择", + mode: "select", + options: ["a", "b"], + }); + manager.resolve("s1", "call-invalid", { approved: true, content: "c" }); + expect(resultText(await promise)).toEqual({ approved: false, content: "" }); + }); +}); diff --git a/tests/confirmations.test.ts b/tests/confirmations.test.ts new file mode 100644 index 0000000..280ab74 --- /dev/null +++ b/tests/confirmations.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { ToolConfirmationManager, type ToolConfirmationRequest } from "../src/agent/confirmations.js"; + +function request(manager: ToolConfirmationManager, toolCallId = "call-1", signal?: AbortSignal) { + let emitted: ToolConfirmationRequest | undefined; + const promise = manager.request( + { + sessionId: "s1", + toolCallId, + toolName: "generate_image", + args: { prompt: "poster" }, + }, + (value) => { + emitted = value; + }, + signal, + ); + return { promise, get emitted() { return emitted; } }; +} + +describe("ToolConfirmationManager", () => { + it("waits for approval and resolves the tool call", async () => { + const manager = new ToolConfirmationManager(1_000); + const pending = request(manager); + expect(pending.emitted?.toolCallId).toBe("call-1"); + expect(pending.emitted?.expiresAt).toBeGreaterThan(Date.now()); + + expect(manager.resolve("s1", "call-1", true)).toBe(true); + await expect(pending.promise).resolves.toEqual({ approved: true, reason: undefined }); + expect(manager.count()).toBe(0); + }); + + it("carries confirm type/payload and resolves edited content", async () => { + const manager = new ToolConfirmationManager(1_000); + let emitted: ToolConfirmationRequest | undefined; + const promise = manager.request( + { + sessionId: "s1", + toolCallId: "call-content", + toolName: "confirm_travel_content", + confirmType: "travel_content_confirm", + payload: { summary: "线路:测试" }, + args: {}, + }, + (value) => { + emitted = value; + }, + ); + expect(emitted?.confirmType).toBe("travel_content_confirm"); + expect(emitted?.payload).toEqual({ summary: "线路:测试" }); + expect(manager.resolve("s1", "call-content", { approved: true, content: "用户修改后的内容" })).toBe(true); + await expect(promise).resolves.toEqual({ approved: true, content: "用户修改后的内容" }); + }); + + it("returns a rejection reason", async () => { + const manager = new ToolConfirmationManager(1_000); + const pending = request(manager); + manager.resolve("s1", "call-1", false, "不要生成"); + await expect(pending.promise).resolves.toEqual({ approved: false, reason: "不要生成" }); + }); + + it("times out and does not leave stale entries", async () => { + const manager = new ToolConfirmationManager(10); + const pending = request(manager); + await expect(pending.promise).resolves.toMatchObject({ approved: false, reason: "等待用户确认超时" }); + expect(manager.count()).toBe(0); + }); + + it("cancels when the run is aborted", async () => { + const manager = new ToolConfirmationManager(1_000); + const controller = new AbortController(); + const pending = request(manager, "call-abort", controller.signal); + controller.abort(); + await expect(pending.promise).resolves.toEqual({ approved: false, reason: "请求已中止" }); + }); + + it("clears all pending confirmations for a session", async () => { + const manager = new ToolConfirmationManager(1_000); + const first = request(manager, "call-1"); + const second = request(manager, "call-2"); + manager.clearSession("s1"); + await expect(first.promise).resolves.toMatchObject({ approved: false, reason: "会话已结束" }); + await expect(second.promise).resolves.toMatchObject({ approved: false, reason: "会话已结束" }); + }); + + it("rejects duplicate pending confirmations for the same tool call", async () => { + const manager = new ToolConfirmationManager(1_000); + const first = request(manager); + const duplicate = request(manager); + await expect(duplicate.promise).resolves.toMatchObject({ approved: false }); + manager.resolve("s1", "call-1", true); + await expect(first.promise).resolves.toEqual({ approved: true, reason: undefined }); + }); +}); diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts new file mode 100644 index 0000000..1fc6c5f --- /dev/null +++ b/tests/gateway.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { GatewayClient, GatewayError, extractImageUrls, normalizeSize } from "../src/gateway/client.js"; +import { silentLogger, tempRoot, testConfig } from "./helpers.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function client(overrides: Record = {}) { + const config = testConfig(tempRoot(), overrides); + return new GatewayClient(config.gateway, silentLogger); +} + +describe("GatewayClient", () => { + it("routes known CN model families to CN and unknown models to global", () => { + const gateway = client({ GATEWAY_MOCK: "false", GATEWAY_CN_URL: "https://cn.test/", GATEWAY_GLOBAL_URL: "https://global.test/" }); + expect(gateway.baseUrlFor("seedream@5.0")).toBe("https://cn.test"); + expect(gateway.baseUrlFor("kling-v2")).toBe("https://cn.test"); + expect(gateway.baseUrlFor("gpt-image-2")).toBe("https://global.test"); + expect(gateway.baseUrlFor("unknown-model")).toBe("https://global.test"); + }); + + it("honours GATEWAY_FORCE_URL", () => { + const gateway = client({ GATEWAY_FORCE_URL: "https://forced.test/", GATEWAY_MOCK: "false" }); + expect(gateway.baseUrlFor("seedream@5.0")).toBe("https://forced.test"); + expect(gateway.baseUrlFor("gpt-image-2")).toBe("https://forced.test"); + }); + + it("normalizes gpt-image sizes to multiples of 16", () => { + expect(normalizeSize("gpt-image-2", "1024x1536")).toBe("1024x1536"); + expect(normalizeSize("gpt-image-2", "1000x1501")).toBe("1008x1504"); + expect(normalizeSize("seedream@5.0", "1000x1501")).toBe("1000x1501"); + }); + + it("extracts URLs from common gateway response shapes", () => { + expect( + extractImageUrls({ + data: [{ url: "https://a/1.png" }, { image_url: "https://a/2.png" }], + output: { images: ["https://a/3.png"] }, + drawing: "https://a/4.png", + media_list: [{ oss_url: "https://a/5.png" }], + }), + ).toEqual(["https://a/1.png", "https://a/2.png", "https://a/3.png", "https://a/4.png", "https://a/5.png"]); + }); + + it("generates mock URLs for each requested image", async () => { + const gateway = client(); + const result = await gateway.generateImage({ + uid: "1", + team_id: "1", + model: "seedream@5.0", + prompt: "a cat", + size: "1024x1024", + n: 2, + }); + expect(result.taskId).toMatch(/^mock-/); + expect(result.urls).toHaveLength(2); + expect(result.urls[0]).toContain("picsum.photos"); + }); + + it("submits a task and polls until images are available", async () => { + const gateway = client({ + GATEWAY_MOCK: "false", + GATEWAY_CN_URL: "https://cn.test", + GATEWAY_POLL_INTERVAL_MS: "10", + }); + let call = 0; + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit): Promise => { + call += 1; + if (call === 1) { + return new Response(JSON.stringify({ ret: 0, task_id: "task-1" }), { status: 200 }); + } + return new Response( + JSON.stringify({ status: "running", data: call === 2 ? [] : [{ url: "https://img/1.png" }] }), + { status: 200 }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await gateway.generateImage({ + uid: "1", + team_id: "2", + model: "seedream@5.0", + prompt: "a cat", + size: "1024x1536", + n: 1, + watermark: true, + }); + + expect(result.taskId).toBe("task-1"); + expect(result.urls).toEqual(["https://img/1.png"]); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://cn.test/generate/v1/images"); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record; + expect(body).toMatchObject({ uid: 1, team_id: 2, model: "seedream@5.0", n: 1, watermark: true }); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe("https://cn.test/generate/v1/images/task-1"); + }); + + it("retries transient poll network failures", async () => { + const gateway = client({ + GATEWAY_MOCK: "false", + GATEWAY_CN_URL: "https://cn.test", + GATEWAY_POLL_INTERVAL_MS: "10", + }); + let call = 0; + const fetchMock = vi.fn(async (): Promise => { + call += 1; + if (call === 1) { + return new Response(JSON.stringify({ ret: 0, task_id: "task-retry" }), { status: 200 }); + } + if (call === 2) { + throw new TypeError("fetch failed"); + } + return new Response(JSON.stringify({ status: "success", data: [{ url: "https://img/retry.png" }] }), { + status: 200, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await gateway.generateImage({ + uid: "1", + team_id: "1", + model: "seedream@5.0", + prompt: "a cat", + size: "1024x1024", + n: 1, + }); + expect(result.urls).toEqual(["https://img/retry.png"]); + expect(call).toBe(3); + }); + + it("waits briefly when the gateway reports success before urls are attached", async () => { + const gateway = client({ + GATEWAY_MOCK: "false", + GATEWAY_CN_URL: "https://cn.test", + GATEWAY_POLL_INTERVAL_MS: "10", + }); + let call = 0; + const fetchMock = vi.fn(async (): Promise => { + call += 1; + if (call === 1) { + return new Response(JSON.stringify({ ret: 0, task_id: "task-finalize" }), { status: 200 }); + } + if (call === 2) { + return new Response(JSON.stringify({ status: "success", data: [] }), { status: 200 }); + } + return new Response(JSON.stringify({ status: "success", data: [{ url: "https://img/final.png" }] }), { + status: 200, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await gateway.generateImage({ + uid: "1", + team_id: "1", + model: "seedream@5.0", + prompt: "a cat", + size: "1024x1024", + n: 1, + }); + expect(result.urls).toEqual(["https://img/final.png"]); + expect(call).toBe(3); + }); + + it("returns inline urls without polling", async () => { + const gateway = client({ GATEWAY_MOCK: "false" }); + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit): Promise => + new Response(JSON.stringify({ ret: 0, task_id: "t", images: ["https://img/1.png"] }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + const result = await gateway.generateImage({ + uid: "1", + team_id: "1", + model: "gpt-image-2", + prompt: "a cat", + size: "1000x1501", + n: 2, + }); + expect(result.urls).toEqual(["https://img/1.png"]); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record; + expect(body.size).toBe("1008x1504"); + }); + + it("fails fast on a non-zero gateway ret", async () => { + const gateway = client({ GATEWAY_MOCK: "false" }); + vi.stubGlobal( + "fetch", + vi.fn(async (): Promise => new Response(JSON.stringify({ ret: 1, msg: "no quota" }), { status: 200 })), + ); + await expect( + gateway.generateImage({ + uid: "1", + team_id: "1", + model: "seedream@5.0", + prompt: "a cat", + size: "1024x1024", + n: 1, + }), + ).rejects.toThrow(/no quota/); + }); + + it("maps HTTP failures to GatewayError", async () => { + const gateway = client({ GATEWAY_MOCK: "false" }); + vi.stubGlobal("fetch", vi.fn(async (): Promise => new Response("down", { status: 503 }))); + await expect( + gateway.generateImage({ + uid: "1", + team_id: "1", + model: "seedream@5.0", + prompt: "a cat", + size: "1024x1024", + n: 1, + }), + ).rejects.toBeInstanceOf(GatewayError); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..395916b --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,92 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, type AppConfig } from "../src/config.js"; +import type { Logger } from "../src/logger.js"; +import { SkillCatalog } from "../src/skills/catalog.js"; +import type { SkillRepository, StoredSkill, StoredSkillConfig } from "../src/skills/repository.js"; + +export const silentLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +export function tempRoot(prefix = "laimiao-v2-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export class FakeSkillRepository implements SkillRepository { + constructor(private skills: StoredSkill[]) {} + + async ensureSchema(): Promise {} + + async listSkills(): Promise { + return this.skills; + } + + setSkills(skills: StoredSkill[]): void { + this.skills = skills; + } + + async close(): Promise {} +} + +export interface SkillFixture { + key: string; + name?: string; + description?: string; + content?: string; + triggerKeywords?: string; + libraryEnabled?: boolean; + config?: Partial | null; +} + +export function skillFixture(fixture: SkillFixture): StoredSkill { + const config: StoredSkillConfig | undefined = + fixture.config === null + ? undefined + : { + pricePerImage: 10, + enabled: true, + ...fixture.config, + }; + + return { + key: fixture.key, + name: fixture.name ?? `Test ${fixture.key}`, + industry: "test", + description: fixture.description ?? `Test skill ${fixture.key}`, + triggerKeywords: fixture.triggerKeywords ?? "", + content: fixture.content ?? `# ${fixture.key}\n\nUse this skill for tests.\n`, + libraryEnabled: fixture.libraryEnabled ?? true, + config, + }; +} + +export async function makeCatalog( + skills: StoredSkill[], + options: { defaultPrice?: number; defaultModel?: string; defaultSize?: string } = {}, +): Promise { + return new SkillCatalog({ + repository: new FakeSkillRepository(skills), + defaultPrice: options.defaultPrice ?? 10, + defaultModel: options.defaultModel ?? "gpt-image-2", + defaultSize: options.defaultSize ?? "1024x1024", + currency: "credits", + logger: silentLogger, + }).load(); +} + +export function testConfig(root: string, overrides: Record = {}): AppConfig { + return loadConfig({ + APP_ROOT: root, + DATA_DIR: "data", + LOG_LEVEL: "error", + GATEWAY_MOCK: "true", + GATEWAY_POLL_INTERVAL_MS: "10", + BILLING_MODE: "dry_run", + ...overrides, + }); +} diff --git a/tests/ledger.test.ts b/tests/ledger.test.ts new file mode 100644 index 0000000..ebc6923 --- /dev/null +++ b/tests/ledger.test.ts @@ -0,0 +1,89 @@ +import { appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { UsageLedger, type BeginUsage } from "../src/billing/ledger.js"; +import { silentLogger, tempRoot } from "./helpers.js"; + +function usage(overrides: Partial = {}): BeginUsage { + return { + uid: "1", + team_id: "1", + session_id: "s1", + tool_call_id: "call-1", + skill_id: "demo", + skill_name: "Demo", + model: "seedream@5.0", + size: "1024x1536", + quantity: 2, + unit_credits: 30, + size_multiplier: 1, + total_credits: 60, + currency: "credits", + billing_mode: "dry_run", + ...overrides, + }; +} + +describe("UsageLedger", () => { + it("records pending and succeeded usage and finds it by tool call", () => { + const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger); + const record = ledger.begin(usage()); + expect(record.status).toBe("pending"); + + ledger.settle(record.id, "succeeded", { + gateway_task_id: "task-1", + image_urls: ["https://img/1.png", "https://img/2.png"], + }); + + const found = ledger.findSucceededToolCall("1", "s1", "call-1"); + expect(found?.id).toBe(record.id); + expect(found?.status).toBe("succeeded"); + expect(found?.image_urls).toHaveLength(2); + expect(ledger.findSucceededToolCall("2", "s1", "call-1")).toBeUndefined(); + }); + + it("persists records and rebuilds the idempotency index", () => { + const file = join(tempRoot(), "ledger.jsonl"); + const ledger = new UsageLedger(file, silentLogger); + const record = ledger.begin(usage()); + ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] }); + + const reloaded = new UsageLedger(file, silentLogger); + expect(reloaded.list()).toHaveLength(1); + expect(reloaded.findSucceededToolCall("1", "s1", "call-1")?.id).toBe(record.id); + }); + + it("does not index failed calls and summarises by status and skill", () => { + const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger); + const failed = ledger.begin(usage({ tool_call_id: "call-failed" })); + ledger.settle(failed.id, "failed", { error: "nope" }); + const ok = ledger.begin(usage({ tool_call_id: "call-ok", skill_id: "poster", total_credits: 30, quantity: 1 })); + ledger.settle(ok.id, "succeeded", { image_urls: ["https://img/p.png"] }); + const pending = ledger.begin(usage({ tool_call_id: "call-pending", skill_id: "poster", total_credits: 30 })); + + expect(ledger.findSucceededToolCall("1", "s1", "call-failed")).toBeUndefined(); + const summary = ledger.summary({ uid: "1" }); + expect(summary.totals).toEqual({ + records: 3, + images: 1, + credits_succeeded: 30, + credits_pending: 30, + credits_failed: 60, + }); + expect(summary.by_skill).toEqual([ + { skill_id: "poster", skill_name: "Demo", records: 2, images: 1, credits: 30 }, + { skill_id: "demo", skill_name: "Demo", records: 1, images: 0, credits: 0 }, + ]); + expect(ledger.list({ status: "pending" }).map((item) => item.id)).toEqual([pending.id]); + expect(ledger.list({ limit: 1 })).toHaveLength(1); + }); + + it("skips malformed lines instead of losing the ledger", () => { + const file = join(tempRoot(), "ledger.jsonl"); + const ledger = new UsageLedger(file, silentLogger); + ledger.begin(usage()); + appendFileSync(file, "{bad json\n", "utf8"); + const reloaded = new UsageLedger(file, silentLogger); + expect(reloaded.list()).toHaveLength(1); + }); +}); diff --git a/tests/net.test.ts b/tests/net.test.ts new file mode 100644 index 0000000..d671c84 --- /dev/null +++ b/tests/net.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + assertSafeUrl, + hostMatches, + isBlockedAddress, + isBlockedHostname, + parseHostAllowlist, + safeFetch, +} from "../src/utils/net.js"; + +describe("parseHostAllowlist", () => { + it("normalizes comma separated hosts", () => { + expect(parseHostAllowlist(" CDN.Example.com , *.OSS.example.com.,, ")).toEqual([ + "cdn.example.com", + "*.oss.example.com", + ]); + }); + + it("returns an empty list for empty input", () => { + expect(parseHostAllowlist("")).toEqual([]); + expect(parseHostAllowlist(undefined)).toEqual([]); + }); +}); + +describe("hostMatches", () => { + it("allows every host when the allowlist is empty", () => { + expect(hostMatches("anything.example.com", [])).toBe(true); + }); + + it("matches exact hosts case-insensitively", () => { + expect(hostMatches("CDN.example.com", ["cdn.example.com"])).toBe(true); + expect(hostMatches("other.example.com", ["cdn.example.com"])).toBe(false); + }); + + it("matches wildcard subdomains and the apex", () => { + expect(hostMatches("a.oss.example.com", ["*.oss.example.com"])).toBe(true); + expect(hostMatches("oss.example.com", ["*.oss.example.com"])).toBe(true); + expect(hostMatches("evil-oss.example.com", ["*.oss.example.com"])).toBe(false); + }); +}); + +describe("isBlockedAddress", () => { + it.each([ + "0.0.0.0", + "10.1.2.3", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + ])("blocks private or reserved IPv4 %s", (address) => { + expect(isBlockedAddress(address)).toBe(true); + }); + + it.each(["8.8.8.8", "1.1.1.1", "172.32.0.1", "93.184.216.34"])("allows public IPv4 %s", (address) => { + expect(isBlockedAddress(address)).toBe(false); + }); + + it.each(["::", "::1", "fe80::1", "fd00::1", "fc00::1", "ff02::1", "2001:db8::1"])( + "blocks private or reserved IPv6 %s", + (address) => { + expect(isBlockedAddress(address)).toBe(true); + }, + ); + + it("blocks IPv4 mapped into IPv6", () => { + expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true); + expect(isBlockedAddress("::ffff:169.254.169.254")).toBe(true); + expect(isBlockedAddress("::ffff:8.8.8.8")).toBe(false); + }); + + it("blocks 6to4 addresses that embed a private IPv4", () => { + expect(isBlockedAddress("2002:7f00:1::")).toBe(true); + expect(isBlockedAddress("2002:0808:0808::")).toBe(false); + }); +}); + +describe("isBlockedHostname", () => { + it.each([ + "localhost", + "api.localhost", + "metadata.google.internal", + "db.internal", + "redis", + "127.0.0.1", + "169.254.169.254", + "[::1]", + ])("blocks %s", (host) => { + expect(isBlockedHostname(host)).toBe(true); + }); + + it.each(["example.com", "cdn.example.com", "8.8.8.8"])("allows %s", (host) => { + expect(isBlockedHostname(host)).toBe(false); + }); +}); + +describe("assertSafeUrl", () => { + it("rejects non-http protocols", async () => { + await expect(assertSafeUrl("ftp://example.com/a")).rejects.toThrow(/http/); + await expect(assertSafeUrl("file:///etc/passwd")).rejects.toThrow(/http/); + }); + + it("rejects URLs with credentials", async () => { + await expect(assertSafeUrl("https://user:pass@example.com/a")).rejects.toThrow(/账号|密码/); + }); + + it("rejects loopback and cloud metadata endpoints", async () => { + await expect(assertSafeUrl("http://127.0.0.1:3306")).rejects.toThrow(/禁止访问/); + await expect(assertSafeUrl("http://169.254.169.254/latest/meta-data")).rejects.toThrow(/禁止访问/); + await expect(assertSafeUrl("http://[::1]/")).rejects.toThrow(/禁止访问/); + }); + + it("rejects hosts outside the allowlist", async () => { + await expect(assertSafeUrl("https://evil.example.com/a", { allowedHosts: ["cdn.example.com"] })).rejects.toThrow( + /白名单/, + ); + await expect( + assertSafeUrl("https://93.184.216.34/a", { allowedHosts: ["93.184.216.34"] }), + ).resolves.toBeInstanceOf(URL); + }); + + it("accepts public IP literals", async () => { + await expect(assertSafeUrl("https://93.184.216.34/a")).resolves.toBeInstanceOf(URL); + }); +}); + +describe("safeFetch", () => { + it("refuses to connect to a blocked host before any request is made", async () => { + await expect(safeFetch("http://127.0.0.1:9/")).rejects.toThrow(/禁止访问/); + }); +}); diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts new file mode 100644 index 0000000..e8320d9 --- /dev/null +++ b/tests/pricing.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { PricingError, quoteSkill, repriceForQuantity } from "../src/billing/pricing.js"; +import type { SkillDefinition } from "../src/types.js"; + +const skill: SkillDefinition = { + key: "demo", + name: "demo", + displayName: "Demo Skill", + description: "demo", + content: "# demo", + triggerKeywords: "demo", + usesDefaults: false, + manifest: { + key: "demo", + version: "1.0.0", + industry: "test", + model: "seedream@5.0", + defaults: { size: "1024x1536", n: 1 }, + price: { currency: "credits", per_image: 30, size_multipliers: { "2048x3072": 2 } }, + capabilities: [], + tags: [], + max_images: 4, + enabled: true, + }, +}; + +describe("pricing", () => { + it("quotes one image using defaults", () => { + expect(quoteSkill(skill, { size: "1024x1536", quantity: 1 }, {}, "dry_run")).toEqual({ + skill_id: "demo", + skill_name: "Demo Skill", + model: "seedream@5.0", + size: "1024x1536", + quantity: 1, + unit_credits: 30, + size_multiplier: 1, + total_credits: 30, + currency: "credits", + billing_mode: "dry_run", + }); + }); + + it("applies size multipliers and quantity", () => { + const quote = quoteSkill( + skill, + { size: "1024x1536", quantity: 1 }, + { size: "2048x3072", quantity: 3 }, + "off", + ); + expect(quote.size_multiplier).toBe(2); + expect(quote.total_credits).toBe(180); + expect(quote.billing_mode).toBe("off"); + }); + + it("rejects over-limit, non-integer, and malformed sizes", () => { + expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 5 }, "dry_run")).toThrow( + PricingError, + ); + expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 1.5 }, "dry_run")).toThrow( + /integer/, + ); + expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { size: "large" }, "dry_run")).toThrow( + /size/, + ); + }); + + it("reprices to the actually returned image count", () => { + const quote = quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 3 }, "dry_run"); + const repriced = repriceForQuantity(quote, 1); + expect(repriced.quantity).toBe(1); + expect(repriced.total_credits).toBe(30); + expect(() => repriceForQuantity(quote, -1)).toThrow(PricingError); + }); +}); diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts new file mode 100644 index 0000000..980bc12 --- /dev/null +++ b/tests/prompt.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { buildSystemPrompts } from "../src/agent/prompt.js"; +import { makeCatalog, skillFixture, tempRoot, testConfig } from "./helpers.js"; + +describe("system prompts", () => { + it("uses direct mode when no skill is selected", async () => { + const catalog = await makeCatalog([ + skillFixture({ key: "poster", content: "# Poster\n\nUse poster rules." }), + ]); + const prompts = buildSystemPrompts(catalog, testConfig(tempRoot())); + const text = [prompts.systemPrompt, ...prompts.appendSystemPrompt].join("\n"); + expect(text).toContain("直接生成模式"); + expect(text).toContain("普通直接生成不要读取技能规范"); + expect(text).toContain("省略 skill_id"); + expect(text).toContain("confirm_travel_style"); + expect(text).not.toContain("Use poster rules"); + expect(text).not.toContain("skill_id: poster"); + }); + + it("injects only the selected skill spec", async () => { + const catalog = await makeCatalog([ + skillFixture({ key: "poster", content: "# Poster\n\nUse poster rules." }), + skillFixture({ key: "other", content: "# Other\n\nUse other rules." }), + ]); + const skill = catalog.get("poster"); + if (!skill) throw new Error("missing fixture"); + + const prompts = buildSystemPrompts(catalog, testConfig(tempRoot()), skill); + const text = [prompts.systemPrompt, ...prompts.appendSystemPrompt].join("\n"); + expect(text).toContain("skill_id: poster"); + expect(text).toContain("Test skill poster"); + expect(text).toContain("Use poster rules"); + expect(text).not.toContain("Use other rules"); + expect(text).not.toContain("直接生成模式"); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..1c136dd --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,216 @@ +import { join } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentService, ConfirmToolInput, RunInput, RunResult } from "../src/agent/service.js"; +import { UsageLedger } from "../src/billing/ledger.js"; +import { buildServer } from "../src/server.js"; +import { SessionStore } from "../src/sessions/store.js"; +import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js"; + +const apps: FastifyInstance[] = []; + +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); +}); + +async function makeApp(overrides: Record = {}) { + const root = tempRoot(); + const config = testConfig(root, overrides); + const catalog = await makeCatalog([skillFixture({ key: "demo", config: { pricePerImage: 10 } })]); + const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger); + const store = new SessionStore(join(root, "sessions.json"), join(root, "messages"), silentLogger); + const run = vi.fn(async (_input: RunInput): Promise => { + throw new Error("not used"); + }); + const confirmTool = vi.fn(async (_input: ConfirmToolInput) => ({ + ok: true as const, + tool_call_id: "call-1", + approved: true, + })); + const service = { + isLlmConfigured: () => true, + run, + confirmTool, + } as unknown as AgentService; + const app = await buildServer({ config, logger: silentLogger, catalog, ledger, store, service }); + apps.push(app); + return { app, ledger, store, root, run, confirmTool }; +} + +describe("HTTP API", () => { + it("exposes health, skills, and skill detail", async () => { + const { app } = await makeApp(); + const health = await app.inject({ method: "GET", url: "/health" }); + expect(health.statusCode).toBe(200); + expect(health.json()).toMatchObject({ + ok: true, + billing_mode: "dry_run", + skills: 1, + gateway_mock: true, + image_models: ["gpt-image-2"], + }); + + const skills = await app.inject({ method: "GET", url: "/skills" }); + expect(skills.json()).toMatchObject({ skills: [{ key: "demo", display_name: "Test demo" }] }); + + const detail = await app.inject({ method: "GET", url: "/skills/demo" }); + expect(detail.statusCode).toBe(200); + expect(detail.json()).toMatchObject({ key: "demo", content: expect.stringContaining("Use this skill") }); + + const missing = await app.inject({ method: "GET", url: "/skills/nope" }); + expect(missing.statusCode).toBe(404); + }); + + it("quotes images and validates input", async () => { + const { app } = await makeApp(); + const quote = await app.inject({ + method: "POST", + url: "/images/quote", + payload: { skill_id: "demo", n: 2 }, + }); + expect(quote.statusCode).toBe(200); + expect(quote.json()).toMatchObject({ total_credits: 20, quantity: 2, billing_mode: "dry_run" }); + + const invalid = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "demo", n: 0 } }); + expect(invalid.statusCode).toBe(400); + const missing = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "nope" } }); + expect(missing.statusCode).toBe(404); + }); + + it("passes an explicitly selected skill_id to the agent", async () => { + const { app, store, run } = await makeApp(); + const session = await store.create({ uid: "1", team_id: "t1" }); + run.mockResolvedValueOnce({ + sessionId: session.session_id, + messageId: "msg-1", + answer: "ok", + media: [], + usage: { input: 0, output: 0, total: 0, cost: 0 }, + skill_key: "demo", + }); + + const response = await app.inject({ + method: "POST", + url: "/agent/run", + headers: { "x-uid": "1", "x-team-id": "t1" }, + payload: { session_id: session.session_id, message: "做一张海报", skill_id: "demo" }, + }); + expect(response.statusCode).toBe(200); + expect(run.mock.calls[0]?.[0]).toMatchObject({ skillKey: "demo", message: "做一张海报" }); + + run.mockResolvedValueOnce({ + sessionId: session.session_id, + messageId: "msg-2", + answer: "ok", + media: [], + usage: { input: 0, output: 0, total: 0, cost: 0 }, + }); + await app.inject({ + method: "POST", + url: "/agent/run", + headers: { "x-uid": "1", "x-team-id": "t1" }, + payload: { session_id: session.session_id, message: "直接画" }, + }); + expect(run.mock.calls[1]?.[0]).toMatchObject({ skillKey: undefined, message: "直接画" }); + }); + + it("resolves a pending tool confirmation over HTTP", async () => { + const { app, store, confirmTool } = await makeApp(); + const session = await store.create({ uid: "1", team_id: "t1" }); + + const response = await app.inject({ + method: "POST", + url: `/sessions/${session.session_id}/tool-confirmations/call-1`, + headers: { "x-uid": "1", "x-team-id": "t1" }, + payload: { approved: true }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ ok: true, approved: true, tool_call_id: "call-1" }); + expect(confirmTool.mock.calls[0]?.[0]).toMatchObject({ + sessionId: session.session_id, + toolCallId: "call-1", + approved: true, + }); + }); + + it("creates and lists sessions scoped to the caller", async () => { + const { app } = await makeApp(); + const created = await app.inject({ + method: "POST", + url: "/sessions", + headers: { "x-uid": "1", "x-team-id": "t1" }, + payload: { title: "first" }, + }); + expect(created.statusCode).toBe(200); + const session = created.json() as { session_id: string; uid: string }; + expect(session.uid).toBe("1"); + + const list = await app.inject({ method: "GET", url: "/sessions", headers: { "x-uid": "1", "x-team-id": "t1" } }); + expect(list.json()).toMatchObject({ total: 1, sessions: [{ session_id: session.session_id }] }); + + const detail = await app.inject({ + method: "GET", + url: `/sessions/${session.session_id}`, + headers: { "x-uid": "1" }, + }); + expect(detail.statusCode).toBe(200); + + const forbidden = await app.inject({ + method: "GET", + url: `/sessions/${session.session_id}`, + headers: { "x-uid": "2" }, + }); + expect(forbidden.statusCode).toBe(403); + }); + + it("returns 400 for malformed JSON bodies", async () => { + const { app } = await makeApp(); + const response = await app.inject({ + method: "POST", + url: "/sessions", + headers: { "content-type": "application/json", "x-uid": "1" }, + payload: "{bad json", + }); + expect(response.statusCode).toBe(400); + }); + + it("requires a JWT when JWT_KEY is configured", async () => { + const { app } = await makeApp({ JWT_KEY: "secret" }); + const response = await app.inject({ method: "POST", url: "/sessions", payload: {} }); + expect(response.statusCode).toBe(401); + }); + + it("summarises usage for the caller", async () => { + const { app, ledger } = await makeApp(); + const record = ledger.begin({ + uid: "1", + team_id: "t1", + session_id: "s1", + tool_call_id: "call-1", + skill_id: "demo", + skill_name: "Demo", + model: "seedream@5.0", + size: "1024x1536", + quantity: 1, + unit_credits: 10, + size_multiplier: 1, + total_credits: 10, + currency: "credits", + billing_mode: "dry_run", + }); + ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] }); + + const summary = await app.inject({ method: "GET", url: "/usage/summary", headers: { "x-uid": "1" } }); + expect(summary.statusCode).toBe(200); + expect(summary.json()).toMatchObject({ + billing_mode: "dry_run", + totals: { records: 1, images: 1, credits_succeeded: 10, credits_pending: 0, credits_failed: 0 }, + }); + + const usage = await app.inject({ method: "GET", url: "/usage", headers: { "x-uid": "1" } }); + expect(usage.json()).toMatchObject({ total: 1, records: [{ skill_id: "demo", status: "succeeded" }] }); + + const billing = await app.inject({ method: "GET", url: "/billing" }); + expect(billing.json()).toMatchObject({ mode: "dry_run", deduction_enabled: false }); + }); +}); diff --git a/tests/sessions.test.ts b/tests/sessions.test.ts new file mode 100644 index 0000000..05115e4 --- /dev/null +++ b/tests/sessions.test.ts @@ -0,0 +1,61 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { SessionStore } from "../src/sessions/store.js"; +import { silentLogger, tempRoot } from "./helpers.js"; + +function makeStore(root: string) { + return new SessionStore(join(root, "sessions.json"), join(root, "messages"), silentLogger); +} + +describe("SessionStore", () => { + it("creates, lists, and owns sessions", async () => { + const store = makeStore(tempRoot()); + const session = await store.create({ uid: "1", team_id: "t1" }, { title: "hello" }); + expect((await store.get(session.session_id))?.title).toBe("hello"); + expect((await store.getOwned({ uid: "1", team_id: "t1" }, session.session_id))?.session_id).toBe( + session.session_id, + ); + expect(await store.getOwned({ uid: "2", team_id: "t1" }, session.session_id)).toBeUndefined(); + expect((await store.list({ uid: "1" })).total).toBe(1); + expect((await store.list({ uid: "2" })).total).toBe(0); + }); + + it("appends messages, updates metadata, and auto-titles from the first user message", async () => { + const store = makeStore(tempRoot()); + const session = await store.create({ uid: "1", team_id: "t1" }); + await store.append(session.session_id, { + role: "user", + mode: "agent", + content: "帮我做一张云南海报", + media: [], + status: "ok", + }); + await store.append(session.session_id, { + role: "assistant", + mode: "image", + content: "已完成", + media: [{ type: "image", url: "https://img/1.png" }], + status: "ok", + }); + + const meta = await store.get(session.session_id); + expect(meta?.message_count).toBe(2); + expect(meta?.last_mode).toBe("image"); + expect(meta?.title).toBe("帮我做一张云南海报"); + const messages = await store.messages(session.session_id); + expect(messages).toHaveLength(2); + expect(messages[1]?.media[0]?.url).toBe("https://img/1.png"); + }); + + it("persists the index and paginates", async () => { + const root = tempRoot(); + const store = makeStore(root); + for (let index = 0; index < 3; index += 1) { + await store.create({ uid: "1", team_id: "t1" }, { title: `s${index}` }); + } + const reloaded = makeStore(root); + expect((await reloaded.list({ uid: "1" })).total).toBe(3); + expect((await reloaded.list({ uid: "1", limit: 2 })).sessions).toHaveLength(2); + expect((await reloaded.list({ uid: "1", limit: 2, offset: 2 })).sessions).toHaveLength(1); + }); +}); diff --git a/tests/text.test.ts b/tests/text.test.ts new file mode 100644 index 0000000..0b64985 --- /dev/null +++ b/tests/text.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeTitle, stripUploadedDocuments } from "../src/utils/text.js"; + +describe("stripUploadedDocuments", () => { + it("removes the document instruction block appended by the frontend", () => { + const message = "根据上传文档生成旅游详情长图\n\n【用户上传了以下文档(请先用 read_document_text 逐个读取原文;如果是旅游行程文档,可再用 read_document 提取线路摘要):\n行程.pdf:https://oss/x.pdf】"; + expect(stripUploadedDocuments(message)).toBe("根据上传文档生成旅游详情长图"); + }); + + it("removes older block variants and the text after them", () => { + const message = "【用户上传了以下行程文档(请先用 read_document 逐个读取,再依据文档内容处理):\nhttps://oss/x.docx】\n请阅读文档并整理线路摘要。"; + expect(stripUploadedDocuments(message)).toBe("请阅读文档并整理线路摘要。"); + }); + + it("keeps plain messages untouched", () => { + expect(stripUploadedDocuments("生成三张产品图")).toBe("生成三张产品图"); + }); +}); + +describe("sanitizeTitle", () => { + it("cleans and truncates dirty titles", () => { + expect(sanitizeTitle("生成一张旅游海报\n\n【用户上传了以下文档(请先用 read_document_text 逐个读取原文")).toBe("生成一张旅游海报"); + expect(sanitizeTitle("【用户上传了以下文档(请先用 read_document_text 逐个")).toBe("新对话"); + expect(sanitizeTitle(undefined)).toBe("新对话"); + expect(sanitizeTitle("内容", "新对话", 2)).toBe("内容"); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts new file mode 100644 index 0000000..64c4e96 --- /dev/null +++ b/tests/tools.test.ts @@ -0,0 +1,200 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createAgentTools } from "../src/agent/tools.js"; +import { UsageLedger } from "../src/billing/ledger.js"; +import { GatewayClient } from "../src/gateway/client.js"; +import { DIRECT_SKILL_KEY, directSkill } from "../src/skills/direct.js"; +import type { Quote } from "../src/types.js"; +import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js"; + +interface GenerateDetails { + ok: boolean; + quote?: Quote; + images?: string[]; + task_id?: string; + idempotent?: boolean; + error?: string; +} + +interface TestTool { + name: string; + execute: ( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ) => Promise<{ content: Array<{ type: string; text: string }>; details: GenerateDetails }>; +} + +async function setup(perImage = 20, allowedSkillKeys?: string[], direct = false) { + const root = tempRoot(); + const config = testConfig(root); + const catalog = await makeCatalog([ + skillFixture({ key: "poster", config: { pricePerImage: perImage } }), + skillFixture({ key: "other", config: { pricePerImage: perImage } }), + ]); + const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger); + const gateway = new GatewayClient(config.gateway, silentLogger); + const directModeSkill = direct ? directSkill(config) : undefined; + const tools = createAgentTools({ + config, + catalog, + ledger, + gateway, + logger: silentLogger, + identity: { uid: "1", team_id: "1" }, + sessionId: "session-1", + allowedSkillKeys: directModeSkill ? [DIRECT_SKILL_KEY] : allowedSkillKeys, + directSkill: directModeSkill, + onImages: () => {}, + }); + const find = (name: string): TestTool => { + const tool = tools.find((item) => item.name === name); + if (!tool) throw new Error(`missing tool ${name}`); + return tool as unknown as TestTool; + }; + return { root, config, catalog, ledger, gateway, tools, find }; +} + +describe("agent tools", () => { + it("lists and reads skills", async () => { + const { find } = await setup(); + const list = await find("list_skills").execute("call-list", {}); + expect(list.content[0]?.text).toContain("poster"); + + const read = await find("read_skill").execute("call-read", { skill_id: "poster" }); + expect(read.content[0]?.text).toContain("Use this skill for tests"); + expect(read.details.ok).toBe(true); + }); + + it("no longer exposes the quote_image tool", async () => { + const { tools } = await setup(); + expect(tools.some((tool) => tool.name === "quote_image")).toBe(false); + }); + + it("generates images and records the actual returned count", async () => { + const { find, ledger, gateway } = await setup(); + const spy = vi + .spyOn(gateway, "generateImage") + .mockResolvedValueOnce({ + taskId: "task-1", + urls: ["https://img/1.png", "https://img/2.png"], + status: "success", + raw: {}, + elapsedMs: 12, + }) + .mockResolvedValueOnce({ + taskId: "task-2", + urls: ["https://img/3.png"], + status: "success", + raw: {}, + elapsedMs: 8, + }); + + const first = await find("generate_image").execute("call-1", { + skill_id: "poster", + prompt: "a travel poster", + n: 2, + }); + expect(first.details.ok).toBe(true); + expect(first.details.images).toHaveLength(2); + expect(first.details.quote?.total_credits).toBe(40); + expect(first.details.quote?.billing_mode).toBe("dry_run"); + expect(ledger.list()).toHaveLength(1); + expect(ledger.list()[0]?.status).toBe("succeeded"); + expect(ledger.list()[0]?.tool_call_id).toBe("call-1"); + + const replay = await find("generate_image").execute("call-1", { + skill_id: "poster", + prompt: "a travel poster", + n: 2, + }); + expect(replay.details.idempotent).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + expect(ledger.list()).toHaveLength(1); + + const fewer = await find("generate_image").execute("call-2", { + skill_id: "poster", + prompt: "a travel poster", + n: 2, + }); + expect(fewer.details.quote?.quantity).toBe(1); + expect(fewer.details.quote?.total_credits).toBe(20); + expect(ledger.list()).toHaveLength(2); + }); + + it("records a failed generation without charging it", async () => { + const { find, ledger, gateway } = await setup(); + vi.spyOn(gateway, "generateImage").mockRejectedValue(new Error("gateway exploded")); + + const result = await find("generate_image").execute("call-fail", { + skill_id: "poster", + prompt: "a travel poster", + n: 1, + }); + expect(result.details.ok).toBe(false); + expect(result.content[0]?.text).toContain("gateway exploded"); + expect(ledger.list()).toHaveLength(1); + expect(ledger.list()[0]?.status).toBe("failed"); + }); + + it("limits tools to the routed skill when allowedSkillKeys is set", async () => { + const { find } = await setup(20, ["poster"]); + const list = await find("list_skills").execute("call-list", {}); + expect(list.content[0]?.text).toContain("poster"); + expect(list.content[0]?.text).not.toContain("other"); + + const blocked = await find("generate_image").execute("call-blocked", { + skill_id: "other", + prompt: "a travel poster", + }); + expect(blocked.details.ok).toBe(false); + expect(blocked.details.error).toBe("unknown_skill"); + }); + + it("generates directly without a skill_id in direct mode", async () => { + const { find, ledger, gateway } = await setup(20, undefined, true); + const spy = vi.spyOn(gateway, "generateImage").mockResolvedValue({ + taskId: "task-direct", + urls: ["https://img/direct.png"], + status: "success", + raw: {}, + elapsedMs: 3, + }); + + const result = await find("generate_image").execute("call-direct", { prompt: "a clean product photo" }); + expect(result.details.ok).toBe(true); + expect(result.details.images).toEqual(["https://img/direct.png"]); + expect(spy).toHaveBeenCalledOnce(); + expect(ledger.list()[0]?.skill_id).toBe(DIRECT_SKILL_KEY); + // 直接生成按默认单价(10)的 1.3 倍计费:ceil(10 * 1.3) = 13 + expect(ledger.list()[0]?.total_credits).toBe(13); + + const blocked = await find("generate_image").execute("call-direct-blocked", { + skill_id: "poster", + prompt: "a poster", + }); + expect(blocked.details.ok).toBe(false); + expect(blocked.details.error).toBe("unknown_skill"); + }); + + it("rejects unknown skills and invalid quantities before calling the gateway", async () => { + const { find, ledger, gateway } = await setup(); + const spy = vi.spyOn(gateway, "generateImage"); + + const unknown = await find("generate_image").execute("call-unknown", { + skill_id: "nope", + prompt: "a travel poster", + }); + expect(unknown.details.ok).toBe(false); + expect(unknown.details.error).toBe("unknown_skill"); + + const invalid = await find("generate_image").execute("call-invalid", { + skill_id: "poster", + prompt: "a travel poster", + n: 99, + }); + expect(invalid.details.ok).toBe(false); + expect(spy).not.toHaveBeenCalled(); + expect(ledger.list()).toHaveLength(0); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3c9e64f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..46dfe0e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a738707 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + testTimeout: 20_000, + hookTimeout: 20_000, + }, +});