diff --git a/.env.example b/.env.example index 88aaf88c..cef5f848 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,21 @@ VITE_LLM_PROXY_BASE_URL="/api/llm" # Optional frontend override for the local custom-world scene image proxy path. VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image" +# Local Node backend address and target used by Vite's dev proxy for runtime API routes. +NODE_SERVER_ADDR=":8081" +NODE_SERVER_TARGET="http://127.0.0.1:8081" + +# Local Caddy upstream target used for dist-based testing. +CADDY_API_UPSTREAM="http://127.0.0.1:8081" + +# Node backend SQLite database path. +SQLITE_PATH="" + +# Node backend JWT settings. +JWT_SECRET="CHANGE_ME_FOR_PRODUCTION" +# 当前默认签发永久 JWT,此字段暂未使用,后续如果恢复有限期 token 再启用。 +JWT_EXPIRES_IN="7d" + # Model name for chat completions. VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" diff --git a/.gitignore b/.gitignore index be860fb2..2ba6b519 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ coverage/ *.log /public/generated-custom-world-scenes temp*build*/ +/server-node/dist/ +/server-node/logs/* +!/server-node/logs/.gitkeep +/server-node/data/* +!/server-node/data/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index b66f2beb..afcec428 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,6 @@ ## 项目约束 -- 积极学习 `docs/` 与根目录经验文档后再开始实现。 - 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。 - 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。 - 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。 @@ -10,4 +9,80 @@ - 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。 - UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。 - UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。 -- 不要在gitignore中添加.env.local文件。 \ No newline at end of file +- 不要在gitignore中添加.env.local文件。 +- 严格遵循简洁的代码风格 + +## 文档图谱 + +```text +docs/ +├─ README.md +├─ audits/ +│ ├─ README.md +│ ├─ FUNCTION_DESIGN_AUDIT_2026-04-03.md +│ ├─ ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md +│ ├─ engineering/ +│ │ ├─ README.md +│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md +│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md +│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md +│ │ └─ MONSTER_NPC_UNIFICATION_AUDIT_2026-04-06.md +│ └─ text/ +│ ├─ README.md +│ ├─ CHINESE_MOJIBAKE_INVENTORY.md +│ ├─ EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md +│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md +│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md +│ ├─ GAME_UI_PRESET_EDITOR_NPC_PROMPT_TEXT_AUDIT_2026-04-02_DEEP_SCAN.md +│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md +│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md +│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md +│ └─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md +├─ design/ +│ ├─ README.md +│ ├─ AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md +│ ├─ COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md +│ ├─ CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md +│ ├─ EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md +│ └─ npc-conversation-situation-draft.md +├─ experience/ +│ ├─ README.md +│ ├─ ADVENTURE_RUNTIME_DEV_EXPERIENCE.md +│ ├─ AGENT_UI_CHANGELOG.md +│ ├─ CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md +│ ├─ CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md +│ ├─ MOBILE_UI_DEV_EXPERIENCE.md +│ ├─ PROJECT_DEVELOPMENT_EXPERIENCE.md +│ └─ PROJECT_WORK_EXPERIENCE_PLAYBOOK.md +├─ planning/ +│ ├─ README.md +│ └─ CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md +├─ prd/ +│ ├─ AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md +│ ├─ AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md +│ ├─ AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md +│ ├─ AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md +│ ├─ AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md +│ ├─ AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md +│ ├─ AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md +│ ├─ AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md +│ ├─ AI_NATIVE_STORY_ENGINE_PHASE2_IMPLEMENTATION_PLAN_2026-04-06.md +│ ├─ AI_NATIVE_STORY_ENGINE_PHASE3_IMPLEMENTATION_PLAN_2026-04-06.md +│ ├─ AI_NATIVE_STORY_ENGINE_PHASE4_IMPLEMENTATION_PLAN_2026-04-06.md +│ ├─ AI_NATIVE_STORY_ENGINE_PHASE5_IMPLEMENTATION_PLAN_2026-04-06.md +│ ├─ AI_NATIVE_STORY_ENGINE_PHASE6_IMPLEMENTATION_PLAN_2026-04-06.md +│ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md +│ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md +│ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md +├─ reference/ +│ ├─ README.md +│ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md +└─ technical/ + ├─ README.md + ├─ AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md + ├─ GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md + ├─ GO_SERVER_TASKLIST_2026-04-08.md + ├─ NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md + ├─ PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md + └─ SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md +``` diff --git a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md new file mode 100644 index 00000000..bbaab796 --- /dev/null +++ b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md @@ -0,0 +1,194 @@ +# Node 后端知识图谱 + +日期:`2026-04-08` + +## 1. 当前定位 + +当前运行时后端以 `server-node/` 为唯一有效服务端实现。 + +当前职责: + +- 承接运行时鉴权 +- 承接运行时持久化 +- 承接运行时 AI 接口 +- 为 Vite 前端提供开发期代理目标 + +当前不再使用: + +- Vite 本地 API 插件 `scripts/dev-server/` + +## 2. 技术栈 + +- HTTP 框架:`Express` +- 语言与构建:`TypeScript` + `tsx` + `esbuild` +- 数据库:`better-sqlite3` +- JWT:`jose` +- 密码哈希:`@node-rs/argon2` +- 日志:`pino` + `pino-http` + `pino-roll` + +## 3. 运行入口 + +推荐命令: + +```bash +npm run dev:node +``` + +相关脚本: + +- 根目录联调:`npm run dev:node` +- 单独启动后端开发模式:`npm run server-node:dev` +- 构建后端:`npm run server-node:build` +- 运行后端测试:`npm run server-node:test` + +默认监听: + +- 前端:`3000` +- Node 后端:`8081` + +## 4. 目录与主入口 + +服务端主入口: + +- `server-node/src/server.ts` +- `server-node/src/app.ts` + +路由入口: + +- `server-node/src/routes/authRoutes.ts` +- `server-node/src/routes/runtimeRoutes.ts` + +基础设施: + +- `server-node/src/config.ts` +- `server-node/src/logging.ts` +- `server-node/src/db.ts` +- `server-node/src/context.ts` + +数据访问: + +- `server-node/src/repositories/userRepository.ts` +- `server-node/src/repositories/runtimeRepository.ts` + +鉴权相关: + +- `server-node/src/auth/authService.ts` +- `server-node/src/auth/token.ts` +- `server-node/src/auth/password.ts` +- `server-node/src/middleware/auth.ts` + +## 5. 鉴权模型 + +当前采用: + +- 前端本地保存 `JWT + 自动生成的用户名密码` +- 请求头使用 `Authorization: Bearer ` +- 后端 middleware 统一解析出 `UserID` +- handler 不直接解析 token + +当前账号策略: + +- 默认自动匿名账号启动 +- 本地无 JWT 时,前端会自动生成随机用户名密码并调用 `POST /api/auth/entry` +- 本地 JWT 失效但仍保留随机凭据时,前端自动重新调用 `auth/entry` 恢复同一账号 + +JWT 现状: + +- 当前为永久签发 +- claim 仍保留:`sub`、`iat`、`iss`、`ver` +- `logout` 通过递增 `token_version` 立即失效旧 token + +## 6. 数据存储 + +当前数据库: + +- 默认 SQLite 文件:`server-node/data/genarrative.sqlite` +- 可通过 `SQLITE_PATH` 覆盖 + +当前核心表: + +- `users` +- `save_snapshots` +- `runtime_settings` +- `custom_world_profiles` + +当前隔离原则: + +- 所有运行时数据按用户隔离 + +## 7. 已承接接口 + +鉴权: + +- `POST /api/auth/entry` +- `GET /api/auth/me` +- `POST /api/auth/logout` + +运行时持久化: + +- `GET /api/runtime/save/snapshot` +- `PUT /api/runtime/save/snapshot` +- `DELETE /api/runtime/save/snapshot` +- `GET /api/runtime/settings` +- `PUT /api/runtime/settings` +- `GET /api/runtime/custom-world-library` +- `PUT /api/runtime/custom-world-library/:profileId` +- `DELETE /api/runtime/custom-world-library/:profileId` + +运行时 AI: + +- `POST /api/llm/chat/completions` +- `POST /api/custom-world/scene-image` +- `POST /api/runtime/story/initial` +- `POST /api/runtime/story/continue` +- `POST /api/runtime/custom-world/sessions` +- `GET /api/runtime/custom-world/sessions/:sessionId` +- `POST /api/runtime/custom-world/sessions/:sessionId/answers` +- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` +- `POST /api/runtime/chat/character/suggestions` +- `POST /api/runtime/chat/character/summary` +- `POST /api/runtime/chat/character/reply/stream` +- `POST /api/runtime/chat/npc/dialogue/stream` +- `POST /api/runtime/chat/npc/recruit/stream` +- `POST /api/runtime/items/runtime-intent` +- `POST /api/runtime/quests/generate` + +## 8. Story 与 Custom World 现状 + +Story: + +- Node 后端直接复用前端成熟 prompt 与归一化逻辑 +- 服务端走 `src/services/ai.ts` 中的严格版 story 生成链 + +Custom World: + +- Node 后端直接复用前端现有多阶段生成编排 +- 当前保留 `session + answers + SSE progress/result/error` 协议 +- 前端已支持接收真实阶段进度对象 + +## 9. 前端接入点 + +鉴权与请求: + +- `src/services/apiClient.ts` +- `src/services/authService.ts` +- `src/components/auth/AuthGate.tsx` + +运行时服务层: + +- `src/services/storageService.ts` +- `src/services/aiService.ts` + +## 10. 当前 Vite 角色 + +Vite 当前只负责代理,不再提供本地 API 插件。 + +当前代理目标: + +- `/api/auth` +- `/api/runtime` +- `/api/llm` +- `/api/custom-world/scene-image` +- `/api/ws` + +全部转发到 Node 后端。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 4dc7f8af..e281bf04 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,9 @@ ## 文档列表 +- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 +- [GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md](./GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md):Go 服务端接入、运行时持久化迁移与当前进展记录。 +- [GO_SERVER_TASKLIST_2026-04-08.md](./GO_SERVER_TASKLIST_2026-04-08.md):Go 服务端已完成与未完成事项的执行清单。 - [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md):AI 生成角色形象与角色动画的技术路线。 - [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。 - [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md):PixelMotion 产品形态与能力拆解。 diff --git a/package.json b/package.json index 70b477aa..39222333 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,11 @@ "type": "module", "scripts": { "dev": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0", + "dev:node": "node scripts/dev-node.mjs", + "serve:caddy": "node scripts/run-caddy-dev.mjs", + "server-node:dev": "npm --prefix server-node run dev", + "server-node:build": "npm --prefix server-node run build", + "server-node:test": "npm --prefix server-node run test", "build": "node scripts/build-gate.mjs", "build:raw": "node scripts/vite-cli.mjs build", "preview": "node scripts/vite-cli.mjs preview", diff --git a/scripts/dev-node.mjs b/scripts/dev-node.mjs new file mode 100644 index 00000000..16c42143 --- /dev/null +++ b/scripts/dev-node.mjs @@ -0,0 +1,198 @@ +import {spawn} from 'node:child_process'; +import {existsSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const repoRoot = fileURLToPath(new URL('../', import.meta.url)); +const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url)); +const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url)); +const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url)); +const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url)); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +function parseEnvContents(contents) { + return contents + .split(/\r?\n/u) + .reduce((envMap, rawLine) => { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + return envMap; + } + + const separatorIndex = line.indexOf('='); + if (separatorIndex < 0) { + return envMap; + } + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + envMap[key] = value; + return envMap; + }, {}); +} + +function readEnvFile(filePath) { + if (!existsSync(filePath)) { + return {}; + } + + return parseEnvContents(readFileSync(filePath, 'utf8')); +} + +function resolveServerTarget(serverAddr) { + const trimmed = serverAddr.trim(); + + if (!trimmed) { + return 'http://127.0.0.1:8081'; + } + + if (/^https?:\/\//u.test(trimmed)) { + try { + const url = new URL(trimmed); + if (url.hostname === '0.0.0.0') { + url.hostname = '127.0.0.1'; + } + return url.toString().replace(/\/$/u, ''); + } catch { + return trimmed.replace(/\/$/u, ''); + } + } + + if (trimmed.startsWith(':')) { + return `http://127.0.0.1${trimmed}`; + } + + if (trimmed.startsWith('0.0.0.0:')) { + return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`; + } + + return `http://${trimmed}`; +} + +const mergedEnv = { + ...readEnvFile(envExamplePath), + ...readEnvFile(envLocalPath), + ...process.env, +}; + +mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot; +mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081'; +mergedEnv.NODE_SERVER_TARGET = + mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR); +mergedEnv.SQLITE_PATH = + mergedEnv.SQLITE_PATH || path.join(repoRoot, 'server-node', 'data', 'genarrative.sqlite'); + +console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`); +console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`); +console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`); +console.log(`[dev:node] SQLITE_PATH=${mergedEnv.SQLITE_PATH}`); + +const children = new Set(); +let shuttingDown = false; +let pendingExitCode = 0; + +function stopChild(child) { + if (!child || child.exitCode !== null) { + return; + } + + child.kill('SIGTERM'); + + setTimeout(() => { + if (child.exitCode === null) { + child.kill('SIGKILL'); + } + }, 2000).unref(); +} + +function stopAllChildren() { + for (const child of children) { + stopChild(child); + } +} + +function finalizeExit(code = 0) { + pendingExitCode = code; + if (children.size === 0) { + process.exit(pendingExitCode); + } +} + +function requestShutdown(code = 0) { + if (!shuttingDown) { + shuttingDown = true; + pendingExitCode = code; + stopAllChildren(); + } + + finalizeExit(pendingExitCode); +} + +function registerChild(name, child, siblingProvider) { + children.add(child); + + child.on('error', (error) => { + console.error(`[dev:node] ${name} failed to start`, error); + requestShutdown(1); + }); + + child.on('exit', (code, signal) => { + children.delete(child); + + if (!shuttingDown) { + const resolvedExitCode = code ?? 1; + const signalSuffix = signal ? ` (${signal})` : ''; + console.error( + `[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`, + ); + + const sibling = siblingProvider(); + if (sibling) { + stopChild(sibling); + } + + requestShutdown(resolvedExitCode); + return; + } + + finalizeExit(pendingExitCode); + }); +} + +const serverProcess = spawn(npmCommand, ['run', 'dev'], { + cwd: serverRoot, + env: mergedEnv, + shell: process.platform === 'win32', + stdio: 'inherit', +}); + +const viteProcess = spawn( + process.execPath, + [viteCliPath, '--port=3000', '--host=0.0.0.0'], + { + cwd: repoRoot, + env: mergedEnv, + stdio: 'inherit', + }, +); + +registerChild('node server', serverProcess, () => viteProcess); +registerChild('vite dev server', viteProcess, () => serverProcess); + +process.on('SIGINT', () => { + console.log('[dev:node] received SIGINT, shutting down...'); + requestShutdown(0); +}); + +process.on('SIGTERM', () => { + console.log('[dev:node] received SIGTERM, shutting down...'); + requestShutdown(0); +}); diff --git a/scripts/run-caddy-dev.mjs b/scripts/run-caddy-dev.mjs new file mode 100644 index 00000000..41ac4f5c --- /dev/null +++ b/scripts/run-caddy-dev.mjs @@ -0,0 +1,149 @@ +import {spawn} from 'node:child_process'; +import {existsSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const repoRoot = fileURLToPath(new URL('../', import.meta.url)); +const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url)); +const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url)); +const caddyConfigPath = fileURLToPath(new URL('../tools/Caddyfile.dev', import.meta.url)); +const distRoot = fileURLToPath(new URL('../dist/', import.meta.url)); +const bundledCaddyExe = fileURLToPath(new URL('../tools/caddy.exe', import.meta.url)); + +function parseEnvContents(contents) { + return contents + .split(/\r?\n/u) + .reduce((envMap, rawLine) => { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + return envMap; + } + + const separatorIndex = line.indexOf('='); + if (separatorIndex < 0) { + return envMap; + } + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + envMap[key] = value; + return envMap; + }, {}); +} + +function readEnvFile(filePath) { + if (!existsSync(filePath)) { + return {}; + } + + return parseEnvContents(readFileSync(filePath, 'utf8')); +} + +function normalizePathForCaddy(filePath) { + return path.resolve(filePath).replace(/\\/gu, '/'); +} + +function resolveApiUpstream(env) { + return ( + env.CADDY_API_UPSTREAM + || env.NODE_SERVER_TARGET + || 'http://127.0.0.1:8081' + ); +} + +function resolveCaddyBinary() { + if (process.platform === 'win32' && existsSync(bundledCaddyExe)) { + return bundledCaddyExe; + } + + return process.platform === 'win32' ? 'caddy.exe' : 'caddy'; +} + +const mergedEnv = { + ...readEnvFile(envExamplePath), + ...readEnvFile(envLocalPath), + ...process.env, +}; + +if (!existsSync(path.join(distRoot, 'index.html'))) { + console.error('[serve:caddy] dist/index.html 不存在,请先运行 npm run build:raw'); + process.exit(1); +} + +mergedEnv.CADDY_SITE_ROOT = mergedEnv.CADDY_SITE_ROOT || normalizePathForCaddy(distRoot); +mergedEnv.CADDY_API_UPSTREAM = resolveApiUpstream(mergedEnv); + +const caddyBinary = resolveCaddyBinary(); + +console.log('[serve:caddy] listen=:8080'); +console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`); +console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`); +console.log(`[serve:caddy] config=${caddyConfigPath}`); + +const caddyProcess = spawn( + caddyBinary, + ['run', '--config', caddyConfigPath, '--adapter', 'caddyfile'], + { + cwd: repoRoot, + env: mergedEnv, + stdio: 'inherit', + shell: process.platform === 'win32' && !existsSync(bundledCaddyExe), + }, +); + +let shuttingDown = false; + +function requestShutdown(code = 0) { + if (shuttingDown) { + return; + } + + shuttingDown = true; + + if (caddyProcess.exitCode === null) { + caddyProcess.kill('SIGTERM'); + setTimeout(() => { + if (caddyProcess.exitCode === null) { + caddyProcess.kill('SIGKILL'); + } + }, 2000).unref(); + } + + if (caddyProcess.exitCode !== null) { + process.exit(code); + } +} + +caddyProcess.on('error', (error) => { + console.error('[serve:caddy] 启动 Caddy 失败', error); + process.exit(1); +}); + +caddyProcess.on('exit', (code, signal) => { + if (!shuttingDown) { + const resolvedExitCode = code ?? 1; + const signalSuffix = signal ? ` (${signal})` : ''; + console.error( + `[serve:caddy] Caddy exited with code ${resolvedExitCode}${signalSuffix}`, + ); + process.exit(resolvedExitCode); + } +}); + +process.on('SIGINT', () => { + console.log('[serve:caddy] received SIGINT, shutting down...'); + requestShutdown(0); +}); + +process.on('SIGTERM', () => { + console.log('[serve:caddy] received SIGTERM, shutting down...'); + requestShutdown(0); +}); diff --git a/server-node/build.mjs b/server-node/build.mjs new file mode 100644 index 00000000..f34a2f84 --- /dev/null +++ b/server-node/build.mjs @@ -0,0 +1,13 @@ +import esbuild from 'esbuild'; + +await esbuild.build({ + entryPoints: ['src/server.ts'], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + outfile: 'dist/server.js', + sourcemap: true, + packages: 'external', + tsconfig: 'tsconfig.json', +}); diff --git a/server-node/data/.gitkeep b/server-node/data/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/server-node/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/server-node/logs/.gitkeep b/server-node/logs/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/server-node/logs/.gitkeep @@ -0,0 +1 @@ + diff --git a/server-node/package-lock.json b/server-node/package-lock.json new file mode 100644 index 00000000..f2db6739 --- /dev/null +++ b/server-node/package-lock.json @@ -0,0 +1,2957 @@ +{ + "name": "genarrative-server-node", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "genarrative-server-node", + "version": "0.1.0", + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "better-sqlite3": "^12.4.1", + "cors": "^2.8.5", + "express": "^4.21.2", + "jose": "^6.1.0", + "pino": "^9.9.5", + "pino-http": "^10.5.0", + "pino-roll": "^3.1.0", + "zod": "^4.1.8" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/cors": "^2.8.18", + "@types/express": "^5.0.3", + "@types/node": "^24.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@node-rs/argon2": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", + "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.0.2", + "@node-rs/argon2-android-arm64": "2.0.2", + "@node-rs/argon2-darwin-arm64": "2.0.2", + "@node-rs/argon2-darwin-x64": "2.0.2", + "@node-rs/argon2-freebsd-x64": "2.0.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", + "@node-rs/argon2-linux-arm64-gnu": "2.0.2", + "@node-rs/argon2-linux-arm64-musl": "2.0.2", + "@node-rs/argon2-linux-x64-gnu": "2.0.2", + "@node-rs/argon2-linux-x64-musl": "2.0.2", + "@node-rs/argon2-wasm32-wasi": "2.0.2", + "@node-rs/argon2-win32-arm64-msvc": "2.0.2", + "@node-rs/argon2-win32-ia32-msvc": "2.0.2", + "@node-rs/argon2-win32-x64-msvc": "2.0.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", + "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", + "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", + "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", + "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", + "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", + "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", + "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", + "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", + "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", + "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-wasm32-wasi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", + "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", + "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", + "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", + "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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/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/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-roll": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pino-roll/-/pino-roll-3.1.0.tgz", + "integrity": "sha512-UimuzDe5FJSqzHZjBOQIgXArc6GE8rcJ7XsmhMkTI37msWqeI8yOqNKdPH3qucrvSxdL+y+GksqPTgQlSFWFEQ==", + "license": "MIT", + "dependencies": { + "date-fns": "^4.1.0", + "sonic-boom": "^4.0.1" + } + }, + "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/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "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-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/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "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/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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server-node/package.json b/server-node/package.json new file mode 100644 index 00000000..5f524348 --- /dev/null +++ b/server-node/package.json @@ -0,0 +1,32 @@ +{ + "name": "genarrative-server-node", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "node build.mjs", + "start": "node dist/server.js", + "test": "node --test --import tsx src/**/*.test.ts" + }, + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "better-sqlite3": "^12.4.1", + "cors": "^2.8.5", + "express": "^4.21.2", + "jose": "^6.1.0", + "pino": "^9.9.5", + "pino-http": "^10.5.0", + "pino-roll": "^3.1.0", + "zod": "^4.1.8" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/cors": "^2.8.18", + "@types/express": "^5.0.3", + "@types/node": "^24.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts new file mode 100644 index 00000000..a3e8e5d3 --- /dev/null +++ b/server-node/src/app.test.ts @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createApp } from './app.js'; +import type { AppConfig } from './config.js'; +import { createAppContext } from './server.js'; + +function createTestConfig(testName: string): AppConfig { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-server-node-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot: tempRoot, + publicDir: path.join(tempRoot, 'public'), + logsDir: path.join(tempRoot, 'logs'), + dataDir: path.join(tempRoot, 'data'), + sqlitePath: path.join(tempRoot, 'data', 'test.sqlite'), + serverAddr: ':0', + logLevel: 'silent', + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-server-node-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + }; +} + +async function withTestServer( + testName: string, + run: (options: { baseUrl: string }) => Promise, +) { + const context = createAppContext(createTestConfig(testName)); + const app = createApp(context); + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address() as AddressInfo; + return await run({ + baseUrl: `http://127.0.0.1:${address.port}`, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + context.db.close(); + } +} + +async function authEntry(baseUrl: string, username: string, password: string) { + const response = await fetch(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + }), + }); + const payload = await response.json() as { + token: string; + user: { + id: string; + username: string; + }; + }; + + assert.equal(response.status, 200); + assert.ok(payload.token); + return payload; +} + +function withBearer(token: string, init: RequestInit = {}) { + return { + ...init, + headers: { + ...(init.headers ?? {}), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } satisfies RequestInit; +} + +test('auth entry auto-registers, me works, logout invalidates old token', async () => { + await withTestServer('auth', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'hero_test', 'secret123'); + + const meResponse = await fetch(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const mePayload = await meResponse.json() as { + user: { + username: string; + }; + }; + + assert.equal(meResponse.status, 200); + assert.equal(mePayload.user.username, 'hero_test'); + + const logoutResponse = await fetch( + `${baseUrl}/api/auth/logout`, + withBearer(entry.token, { method: 'POST' }), + ); + assert.equal(logoutResponse.status, 200); + + const expiredResponse = await fetch(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + assert.equal(expiredResponse.status, 401); + }); +}); + +test('issued jwt remains valid without exp until logout invalidates token version', async () => { + await withTestServer('permanent-jwt', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'hero_eternal', 'secret123'); + const tokenParts = entry.token.split('.'); + assert.equal(tokenParts.length, 3); + + const payloadJson = JSON.parse( + Buffer.from(tokenParts[1] || '', 'base64url').toString('utf8'), + ) as { + exp?: number; + sub?: string; + ver?: number; + }; + + assert.equal(typeof payloadJson.sub, 'string'); + assert.equal(typeof payloadJson.ver, 'number'); + assert.equal('exp' in payloadJson, false); + + const meResponse = await fetch(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + assert.equal(meResponse.status, 200); + + const logoutResponse = await fetch( + `${baseUrl}/api/auth/logout`, + withBearer(entry.token, { method: 'POST' }), + ); + assert.equal(logoutResponse.status, 200); + + const invalidatedResponse = await fetch(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + assert.equal(invalidatedResponse.status, 401); + }); +}); + +test('runtime persistence is isolated by user', async () => { + await withTestServer('persistence', async ({ baseUrl }) => { + const userA = await authEntry(baseUrl, 'player_one', 'secret123'); + const userB = await authEntry(baseUrl, 'player_two', 'secret123'); + + const saveResponse = await fetch( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(userA.token, { + method: 'PUT', + body: JSON.stringify({ + gameState: { worldType: 'WUXIA', value: 1 }, + bottomTab: 'adventure', + currentStory: { text: 'story A' }, + }), + }), + ); + assert.equal(saveResponse.status, 200); + + const settingsResponse = await fetch( + `${baseUrl}/api/runtime/settings`, + withBearer(userA.token, { + method: 'PUT', + body: JSON.stringify({ + musicVolume: 0.25, + }), + }), + ); + assert.equal(settingsResponse.status, 200); + + const libraryResponse = await fetch( + `${baseUrl}/api/runtime/custom-world-library/world-a`, + withBearer(userA.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + id: 'world-a', + name: '世界 A', + }, + }), + }), + ); + assert.equal(libraryResponse.status, 200); + + const userASave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, { + headers: { + Authorization: `Bearer ${userA.token}`, + }, + }); + const userASavePayload = await userASave.json() as { + gameState: { + value: number; + }; + }; + assert.equal(userASavePayload.gameState.value, 1); + + const userBSave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, { + headers: { + Authorization: `Bearer ${userB.token}`, + }, + }); + const userBSavePayload = await userBSave.json(); + assert.equal(userBSavePayload, null); + + const userBSettings = await fetch(`${baseUrl}/api/runtime/settings`, { + headers: { + Authorization: `Bearer ${userB.token}`, + }, + }); + const userBSettingsPayload = await userBSettings.json() as { + musicVolume: number; + }; + assert.equal(userBSettingsPayload.musicVolume, 0.42); + + const userBLibrary = await fetch( + `${baseUrl}/api/runtime/custom-world-library`, + { + headers: { + Authorization: `Bearer ${userB.token}`, + }, + }, + ); + const userBLibraryPayload = await userBLibrary.json() as { + profiles: unknown[]; + }; + assert.deepEqual(userBLibraryPayload.profiles, []); + }); +}); diff --git a/server-node/src/app.ts b/server-node/src/app.ts new file mode 100644 index 00000000..d8d79a05 --- /dev/null +++ b/server-node/src/app.ts @@ -0,0 +1,69 @@ +import express from 'express'; +import pinoHttp from 'pino-http'; + +import type { AppContext } from './context.js'; +import { errorHandler } from './middleware/errorHandler.js'; +import { requestIdMiddleware } from './middleware/requestId.js'; +import { createAuthRoutes } from './routes/authRoutes.js'; +import { createRuntimeRoutes } from './routes/runtimeRoutes.js'; + +export function createApp(context: AppContext) { + const app = express(); + const createHttpLogger = pinoHttp as unknown as (options: Record) => express.RequestHandler; + + app.disable('x-powered-by'); + + app.use(requestIdMiddleware); + app.use( + createHttpLogger({ + logger: context.logger, + genReqId: (request) => request.requestId, + customProps: (request: express.Request) => ({ + request_id: request.requestId, + user_id: request.userId ?? null, + }), + customSuccessObject: ( + request: express.Request, + response: express.Response, + baseObject: Record & { responseTime?: number }, + ) => ({ + ...baseObject, + request_id: request.requestId, + user_id: request.userId ?? null, + method: request.method, + path: request.url, + status: response.statusCode, + latency_ms: baseObject.responseTime, + }), + customErrorObject: ( + request: express.Request, + response: express.Response, + error: unknown, + baseObject: Record & { responseTime?: number }, + ) => ({ + ...baseObject, + request_id: request.requestId, + user_id: request.userId ?? null, + method: request.method, + path: request.url, + status: response.statusCode, + latency_ms: baseObject.responseTime, + err: error, + }), + }), + ); + app.use(express.json({ limit: '10mb' })); + + app.get('/healthz', (_request, response) => { + response.json({ + ok: true, + service: 'genarrative-node-server', + }); + }); + + app.use('/api/auth', createAuthRoutes(context)); + app.use('/api', createRuntimeRoutes(context)); + + app.use(errorHandler); + return app; +} diff --git a/server-node/src/auth/authService.ts b/server-node/src/auth/authService.ts new file mode 100644 index 00000000..aecfeeaa --- /dev/null +++ b/server-node/src/auth/authService.ts @@ -0,0 +1,70 @@ +import type { AppContext } from '../context.js'; +import { badRequest, unauthorized } from '../errors.js'; +import { hashPassword, verifyPassword } from './password.js'; +import { signAccessToken } from './token.js'; + +const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u; + +function normalizeUsername(username: string) { + return username.trim(); +} + +function validateCredentials(username: string, password: string) { + if (!USERNAME_PATTERN.test(username)) { + throw badRequest('用户名只允许 3 到 24 位字母、数字、下划线'); + } + if (password.length < 6 || password.length > 128) { + throw badRequest('密码长度需要在 6 到 128 位之间'); + } +} + +export async function entryWithPassword( + context: AppContext, + usernameInput: string, + password: string, +) { + const username = normalizeUsername(usernameInput); + validateCredentials(username, password); + + let user = context.userRepository.findByUsername(username); + if (!user) { + const passwordHash = await hashPassword(password); + user = context.userRepository.create(username, passwordHash); + } else { + const isValid = await verifyPassword(user.passwordHash, password); + if (!isValid) { + throw unauthorized('用户名或密码错误'); + } + } + + if (!user) { + throw new Error('failed to resolve user after auth entry'); + } + + const token = await signAccessToken( + { + userId: user.id, + tokenVersion: user.tokenVersion, + }, + context.config, + ); + + return { + token, + user: { + id: user.id, + username: user.username, + }, + }; +} + +export async function logoutUser(context: AppContext, userId: string) { + const user = context.userRepository.incrementTokenVersion(userId); + if (!user) { + throw unauthorized('用户不存在'); + } + + return { + ok: true as const, + }; +} diff --git a/server-node/src/auth/password.ts b/server-node/src/auth/password.ts new file mode 100644 index 00000000..e91ad827 --- /dev/null +++ b/server-node/src/auth/password.ts @@ -0,0 +1,16 @@ +import { Algorithm, hash, verify } from '@node-rs/argon2'; + +export async function hashPassword(password: string) { + return hash(password, { + algorithm: Algorithm.Argon2id, + memoryCost: 19456, + timeCost: 2, + parallelism: 1, + }); +} + +export async function verifyPassword(passwordHash: string, password: string) { + return verify(passwordHash, password, { + algorithm: Algorithm.Argon2id, + }); +} diff --git a/server-node/src/auth/token.ts b/server-node/src/auth/token.ts new file mode 100644 index 00000000..98d5e15f --- /dev/null +++ b/server-node/src/auth/token.ts @@ -0,0 +1,46 @@ +import { jwtVerify, SignJWT } from 'jose'; + +import type { AppConfig } from '../config.js'; +import { unauthorized } from '../errors.js'; + +export type AccessTokenClaims = { + userId: string; + tokenVersion: number; +}; + +function getSecret(config: AppConfig) { + return new TextEncoder().encode(config.jwtSecret); +} + +export async function signAccessToken( + claims: AccessTokenClaims, + config: AppConfig, +) { + return new SignJWT({ ver: claims.tokenVersion }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setSubject(claims.userId) + .setIssuer(config.jwtIssuer) + .setIssuedAt() + .sign(getSecret(config)); +} + +export async function verifyAccessToken(token: string, config: AppConfig) { + try { + const { payload } = await jwtVerify(token, getSecret(config), { + issuer: config.jwtIssuer, + }); + const userId = typeof payload.sub === 'string' ? payload.sub : ''; + const tokenVersion = typeof payload.ver === 'number' ? payload.ver : NaN; + + if (!userId || !Number.isFinite(tokenVersion)) { + throw unauthorized('JWT 内容无效'); + } + + return { + userId, + tokenVersion, + } satisfies AccessTokenClaims; + } catch (error) { + throw unauthorized('JWT 校验失败'); + } +} diff --git a/server-node/src/config.ts b/server-node/src/config.ts new file mode 100644 index 00000000..15bbc14b --- /dev/null +++ b/server-node/src/config.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type AppConfig = { + nodeEnv: string; + projectRoot: string; + publicDir: string; + logsDir: string; + dataDir: string; + sqlitePath: string; + serverAddr: string; + logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; + jwtSecret: string; + jwtExpiresIn: string; + jwtIssuer: string; + llm: { + baseUrl: string; + apiKey: string; + model: string; + }; + dashScope: { + baseUrl: string; + apiKey: string; + imageModel: string; + requestTimeoutMs: number; + }; +}; + +type LoadConfigOptions = { + env?: NodeJS.ProcessEnv; + projectRoot?: string; +}; + +function parseEnvContents(contents: string) { + return contents + .split(/\r?\n/u) + .reduce>((envMap, rawLine) => { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + return envMap; + } + + const separatorIndex = line.indexOf('='); + if (separatorIndex < 0) { + return envMap; + } + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + envMap[key] = value; + return envMap; + }, {}); +} + +function readEnvFile(filePath: string) { + if (!fs.existsSync(filePath)) { + return {}; + } + + return parseEnvContents(fs.readFileSync(filePath, 'utf8')); +} + +function resolveDefaultProjectRoot() { + const cwd = process.cwd(); + return path.basename(cwd) === 'server-node' + ? path.resolve(cwd, '..') + : cwd; +} + +function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) { + return { + ...readEnvFile(path.join(projectRoot, '.env.example')), + ...readEnvFile(path.join(projectRoot, '.env.local')), + ...processEnv, + }; +} + +function readString( + env: Record, + key: string, + fallback: string, +) { + const value = env[key]?.trim(); + return value ? value : fallback; +} + +function readPositiveInt( + env: Record, + key: string, + fallback: number, +) { + const parsed = Number(env[key]); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; +} + +export function loadConfig(options: LoadConfigOptions = {}): AppConfig { + const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot(); + const env = readMergedEnv(projectRoot, options.env ?? process.env); + const logsDir = path.join(projectRoot, 'server-node', 'logs'); + const dataDir = path.join(projectRoot, 'server-node', 'data'); + + return { + nodeEnv: readString(env, 'NODE_ENV', 'development'), + projectRoot, + publicDir: path.join(projectRoot, 'public'), + logsDir, + dataDir, + sqlitePath: readString( + env, + 'SQLITE_PATH', + path.join(dataDir, 'genarrative.sqlite'), + ), + serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'), + logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'], + jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'), + jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '7d'), + jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'), + llm: { + baseUrl: readString( + env, + 'LLM_BASE_URL', + 'https://ark.cn-beijing.volces.com/api/v3', + ), + apiKey: + env.LLM_API_KEY?.trim() || + env.ARK_API_KEY?.trim() || + env.VITE_LLM_API_KEY?.trim() || + '', + model: readString( + env, + 'LLM_MODEL', + readString( + env, + 'VITE_LLM_MODEL', + 'doubao-1-5-pro-32k-character-250715', + ), + ), + }, + dashScope: { + baseUrl: readString( + env, + 'DASHSCOPE_BASE_URL', + 'https://dashscope.aliyuncs.com/api/v1', + ), + apiKey: env.DASHSCOPE_API_KEY?.trim() || '', + imageModel: readString(env, 'DASHSCOPE_IMAGE_MODEL', 'wan2.2-t2i-flash'), + requestTimeoutMs: readPositiveInt( + env, + 'DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS', + 150000, + ), + }, + }; +} diff --git a/server-node/src/context.ts b/server-node/src/context.ts new file mode 100644 index 00000000..9d64b375 --- /dev/null +++ b/server-node/src/context.ts @@ -0,0 +1,18 @@ +import type { Logger } from 'pino'; + +import type { AppConfig } from './config.js'; +import type { AppDatabase } from './db.js'; +import { RuntimeRepository } from './repositories/runtimeRepository.js'; +import { UserRepository } from './repositories/userRepository.js'; +import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; +import { UpstreamLlmClient } from './services/llmClient.js'; + +export type AppContext = { + config: AppConfig; + logger: Logger; + db: AppDatabase; + userRepository: UserRepository; + runtimeRepository: RuntimeRepository; + llmClient: UpstreamLlmClient; + customWorldSessions: CustomWorldSessionStore; +}; diff --git a/server-node/src/db.ts b/server-node/src/db.ts new file mode 100644 index 00000000..5422f3c8 --- /dev/null +++ b/server-node/src/db.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; + +import type { AppConfig } from './config.js'; + +const schemaSql = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + token_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS save_snapshots ( + user_id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + saved_at TEXT NOT NULL, + bottom_tab TEXT NOT NULL, + game_state_json TEXT NOT NULL, + current_story_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS runtime_settings ( + user_id TEXT PRIMARY KEY, + music_volume REAL NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS custom_world_profiles ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, profile_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +`; + +export type AppDatabase = Database.Database; + +export function createDatabase(config: AppConfig) { + const sqliteDir = path.dirname(config.sqlitePath); + fs.mkdirSync(sqliteDir, { recursive: true }); + + const db = new Database(config.sqlitePath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(schemaSql); + return db; +} diff --git a/server-node/src/errors.ts b/server-node/src/errors.ts new file mode 100644 index 00000000..7beca192 --- /dev/null +++ b/server-node/src/errors.ts @@ -0,0 +1,35 @@ +export class HttpError extends Error { + statusCode: number; + expose: boolean; + + constructor(statusCode: number, message: string, expose = true) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + this.expose = expose; + } +} + +export function badRequest(message: string) { + return new HttpError(400, message); +} + +export function unauthorized(message = '未授权访问') { + return new HttpError(401, message); +} + +export function forbidden(message = '禁止访问') { + return new HttpError(403, message); +} + +export function notFound(message = '资源不存在') { + return new HttpError(404, message); +} + +export function conflict(message: string) { + return new HttpError(409, message); +} + +export function upstreamError(message: string) { + return new HttpError(502, message); +} diff --git a/server-node/src/http.ts b/server-node/src/http.ts new file mode 100644 index 00000000..e3a59d1c --- /dev/null +++ b/server-node/src/http.ts @@ -0,0 +1,48 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +export function asyncHandler( + handler: ( + request: Request, + response: Response, + next: NextFunction, + ) => Promise | unknown, +): RequestHandler { + return (request, response, next) => { + Promise.resolve(handler(request, response, next)).catch(next); + }; +} + +export function extractApiErrorMessage( + rawText: string, + fallbackMessage: string, +) { + if (!rawText.trim()) { + return fallbackMessage; + } + + try { + const parsed = JSON.parse(rawText) as { + error?: { message?: string }; + message?: string; + code?: string; + }; + + if (typeof parsed.error?.message === 'string' && parsed.error.message.trim()) { + return parsed.error.message.trim(); + } + if (typeof parsed.message === 'string' && parsed.message.trim()) { + return parsed.message.trim(); + } + if (typeof parsed.code === 'string' && parsed.code.trim()) { + return `${fallbackMessage}(${parsed.code.trim()})`; + } + } catch { + // Ignore malformed json responses. + } + + return rawText.trim() || fallbackMessage; +} + +export function jsonClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/server-node/src/logging.ts b/server-node/src/logging.ts new file mode 100644 index 00000000..e20c5e29 --- /dev/null +++ b/server-node/src/logging.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import pino, { type Logger } from 'pino'; + +import type { AppConfig } from './config.js'; + +const LOG_RETENTION_DAYS = 7; + +function cleanupExpiredLogs(logsDir: string) { + if (!fs.existsSync(logsDir)) { + return; + } + + const expiryTime = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + + for (const entry of fs.readdirSync(logsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.startsWith('server.log')) { + continue; + } + + const fullPath = path.join(logsDir, entry.name); + const stats = fs.statSync(fullPath); + if (stats.mtimeMs < expiryTime) { + fs.rmSync(fullPath, { force: true }); + } + } +} + +export function createLogger(config: AppConfig): Logger { + fs.mkdirSync(config.logsDir, { recursive: true }); + cleanupExpiredLogs(config.logsDir); + + const transport = pino.transport({ + targets: [ + { + target: 'pino-roll', + level: config.logLevel, + options: { + file: path.join(config.logsDir, 'server.log'), + mkdir: true, + size: '10m', + frequency: 'daily', + dateFormat: 'yyyy-MM-dd', + }, + }, + { + target: 'pino/file', + level: config.logLevel, + options: { + destination: 1, + }, + }, + ], + }); + + return pino( + { + level: config.logLevel, + timestamp: pino.stdTimeFunctions.isoTime, + base: undefined, + }, + transport, + ); +} diff --git a/server-node/src/middleware/auth.ts b/server-node/src/middleware/auth.ts new file mode 100644 index 00000000..88df1a29 --- /dev/null +++ b/server-node/src/middleware/auth.ts @@ -0,0 +1,40 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { verifyAccessToken } from '../auth/token.js'; +import type { AppConfig } from '../config.js'; +import { unauthorized } from '../errors.js'; +import { type UserRepository } from '../repositories/userRepository.js'; + +function readBearerToken(request: Request) { + const authorization = request.header('authorization')?.trim() || ''; + if (!authorization.startsWith('Bearer ')) { + return ''; + } + return authorization.slice('Bearer '.length).trim(); +} + +export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) { + return async (request: Request, _response: Response, next: NextFunction) => { + try { + const token = readBearerToken(request); + if (!token) { + throw unauthorized('缺少 Authorization Bearer Token'); + } + + const claims = await verifyAccessToken(token, config); + const user = userRepository.findById(claims.userId); + if (!user) { + throw unauthorized('用户不存在'); + } + if (user.tokenVersion !== claims.tokenVersion) { + throw unauthorized('登录状态已失效,请重新登录'); + } + + request.auth = claims; + request.userId = claims.userId; + next(); + } catch (error) { + next(error); + } + }; +} diff --git a/server-node/src/middleware/errorHandler.ts b/server-node/src/middleware/errorHandler.ts new file mode 100644 index 00000000..b7473a44 --- /dev/null +++ b/server-node/src/middleware/errorHandler.ts @@ -0,0 +1,27 @@ +import type { ErrorRequestHandler } from 'express'; + +import { HttpError } from '../errors.js'; + +export const errorHandler: ErrorRequestHandler = (error, request, response, _next) => { + const statusCode = + error instanceof HttpError ? error.statusCode : 500; + const message = + error instanceof HttpError + ? error.message + : '服务器内部错误'; + + request.log?.error( + { + err: error, + request_id: request.requestId, + user_id: request.userId ?? null, + }, + 'request failed', + ); + + response.status(statusCode).json({ + error: { + message, + }, + }); +}; diff --git a/server-node/src/middleware/requestId.ts b/server-node/src/middleware/requestId.ts new file mode 100644 index 00000000..1bfebcd0 --- /dev/null +++ b/server-node/src/middleware/requestId.ts @@ -0,0 +1,8 @@ +import crypto from 'node:crypto'; + +import type { RequestHandler } from 'express'; + +export const requestIdMiddleware: RequestHandler = (request, _response, next) => { + request.requestId = request.header('x-request-id')?.trim() || crypto.randomUUID(); + next(); +}; diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts new file mode 100644 index 00000000..70a32d82 --- /dev/null +++ b/server-node/src/repositories/runtimeRepository.ts @@ -0,0 +1,182 @@ +import type { AppDatabase } from '../db.js'; + +const SAVE_SNAPSHOT_VERSION = 2; +const DEFAULT_MUSIC_VOLUME = 0.42; +const MAX_CUSTOM_WORLD_PROFILES = 12; + +export type SavedSnapshot = { + version: number; + savedAt: string; + gameState: unknown; + bottomTab: string; + currentStory: unknown; +}; + +export type RuntimeSettings = { + musicVolume: number; +}; + +function parseJson(value: string): T { + return JSON.parse(value) as T; +} + +function toJson(value: unknown) { + return JSON.stringify(value ?? null); +} + +export class RuntimeRepository { + constructor(private readonly db: AppDatabase) {} + + getSnapshot(userId: string) { + const row = this.db + .prepare( + `SELECT version, saved_at, game_state_json, bottom_tab, current_story_json + FROM save_snapshots + WHERE user_id = ?`, + ) + .get(userId) as + | { + version: number; + saved_at: string; + game_state_json: string; + bottom_tab: string; + current_story_json: string; + } + | undefined; + + if (!row) { + return null; + } + + return { + version: row.version, + savedAt: row.saved_at, + gameState: parseJson(row.game_state_json), + bottomTab: row.bottom_tab, + currentStory: parseJson(row.current_story_json), + } satisfies SavedSnapshot; + } + + putSnapshot(userId: string, payload: Omit) { + const snapshot = { + version: SAVE_SNAPSHOT_VERSION, + savedAt: payload.savedAt, + gameState: payload.gameState, + bottomTab: payload.bottomTab, + currentStory: payload.currentStory, + } satisfies SavedSnapshot; + const now = new Date().toISOString(); + + this.db + .prepare( + `INSERT INTO save_snapshots ( + user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + version = excluded.version, + saved_at = excluded.saved_at, + bottom_tab = excluded.bottom_tab, + game_state_json = excluded.game_state_json, + current_story_json = excluded.current_story_json, + updated_at = excluded.updated_at`, + ) + .run( + userId, + snapshot.version, + snapshot.savedAt, + snapshot.bottomTab, + toJson(snapshot.gameState), + toJson(snapshot.currentStory), + now, + ); + + return snapshot; + } + + deleteSnapshot(userId: string) { + this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId); + } + + getSettings(userId: string) { + const row = this.db + .prepare( + `SELECT music_volume + FROM runtime_settings + WHERE user_id = ?`, + ) + .get(userId) as { music_volume: number } | undefined; + + return { + musicVolume: + typeof row?.music_volume === 'number' + ? row.music_volume + : DEFAULT_MUSIC_VOLUME, + } satisfies RuntimeSettings; + } + + putSettings(userId: string, settings: RuntimeSettings) { + const nextSettings = { + musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), + } satisfies RuntimeSettings; + + this.db + .prepare( + `INSERT INTO runtime_settings (user_id, music_volume, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + music_volume = excluded.music_volume, + updated_at = excluded.updated_at`, + ) + .run(userId, nextSettings.musicVolume, new Date().toISOString()); + + return nextSettings; + } + + listCustomWorldProfiles(userId: string) { + const rows = this.db + .prepare( + `SELECT payload_json + FROM custom_world_profiles + WHERE user_id = ? + ORDER BY updated_at DESC + LIMIT ?`, + ) + .all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>; + + return rows.map((row) => parseJson>(row.payload_json)); + } + + upsertCustomWorldProfile( + userId: string, + profileId: string, + profile: Record, + ) { + const payload = { + ...profile, + id: profileId, + }; + + this.db + .prepare( + `INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, profile_id) DO UPDATE SET + payload_json = excluded.payload_json, + updated_at = excluded.updated_at`, + ) + .run(userId, profileId, JSON.stringify(payload), new Date().toISOString()); + + return this.listCustomWorldProfiles(userId); + } + + deleteCustomWorldProfile(userId: string, profileId: string) { + this.db + .prepare( + `DELETE FROM custom_world_profiles + WHERE user_id = ? AND profile_id = ?`, + ) + .run(userId, profileId); + + return this.listCustomWorldProfiles(userId); + } +} diff --git a/server-node/src/repositories/userRepository.ts b/server-node/src/repositories/userRepository.ts new file mode 100644 index 00000000..95da9992 --- /dev/null +++ b/server-node/src/repositories/userRepository.ts @@ -0,0 +1,88 @@ +import crypto from 'node:crypto'; + +import type { AppDatabase } from '../db.js'; + +export type UserRecord = { + id: string; + username: string; + passwordHash: string; + tokenVersion: number; + createdAt: string; + updatedAt: string; +}; + +type UserRow = { + id: string; + username: string; + password_hash: string; + token_version: number; + created_at: string; + updated_at: string; +}; + +function toUserRecord(row: UserRow | undefined): UserRecord | null { + if (!row) { + return null; + } + + return { + id: row.id, + username: row.username, + passwordHash: row.password_hash, + tokenVersion: row.token_version, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class UserRepository { + constructor(private readonly db: AppDatabase) {} + + findByUsername(username: string) { + const row = this.db + .prepare( + `SELECT id, username, password_hash, token_version, created_at, updated_at + FROM users + WHERE username = ?`, + ) + .get(username) as UserRow | undefined; + return toUserRecord(row); + } + + findById(userId: string) { + const row = this.db + .prepare( + `SELECT id, username, password_hash, token_version, created_at, updated_at + FROM users + WHERE id = ?`, + ) + .get(userId) as UserRow | undefined; + return toUserRecord(row); + } + + create(username: string, passwordHash: string) { + const now = new Date().toISOString(); + const id = `user_${crypto.randomBytes(16).toString('hex')}`; + + this.db + .prepare( + `INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?)`, + ) + .run(id, username, passwordHash, now, now); + + return this.findById(id); + } + + incrementTokenVersion(userId: string) { + this.db + .prepare( + `UPDATE users + SET token_version = token_version + 1, updated_at = ? + WHERE id = ?`, + ) + .run(new Date().toISOString(), userId); + + return this.findById(userId); + } +} diff --git a/server-node/src/routes/authRoutes.ts b/server-node/src/routes/authRoutes.ts new file mode 100644 index 00000000..55c06ab2 --- /dev/null +++ b/server-node/src/routes/authRoutes.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import { entryWithPassword, logoutUser } from '../auth/authService.js'; +import type { AppContext } from '../context.js'; +import { asyncHandler } from '../http.js'; +import { requireJwtAuth } from '../middleware/auth.js'; + +const authEntrySchema = z.object({ + username: z.string(), + password: z.string(), +}); + +export function createAuthRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + router.post( + '/entry', + asyncHandler(async (request, response) => { + const payload = authEntrySchema.parse(request.body); + response.json( + await entryWithPassword(context, payload.username, payload.password), + ); + }), + ); + + router.get( + '/me', + requireAuth, + asyncHandler(async (request, response) => { + const user = context.userRepository.findById(request.userId!); + response.json({ + user: user + ? { + id: user.id, + username: user.username, + } + : null, + }); + }), + ); + + router.post( + '/logout', + requireAuth, + asyncHandler(async (request, response) => { + response.json(await logoutUser(context, request.userId!)); + }), + ); + + return router; +} diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts new file mode 100644 index 00000000..c5f21437 --- /dev/null +++ b/server-node/src/routes/runtimeRoutes.ts @@ -0,0 +1,386 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { GameState } from '../../../src/types/game.js'; +import type { + RuntimeItemGenerationContext, + RuntimeItemPlan, +} from '../../../src/types/runtimeItem.js'; +import type { Encounter } from '../../../src/types/scene.js'; +import type { AppContext } from '../context.js'; +import { badRequest, notFound } from '../errors.js'; +import { asyncHandler, jsonClone } from '../http.js'; +import { requireJwtAuth } from '../middleware/auth.js'; +import { plainTextRequestSchema } from '../services/chatService.js'; +import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js'; +import { generateQuestForNpcEncounter } from '../services/questService.js'; +import { generateRuntimeItemIntents } from '../services/runtimeItemService.js'; +import { generateSceneImage, sceneImageSchema } from '../services/sceneImageService.js'; +import { + generateHighQualityInitialStory, + generateHighQualityNextStory, + parseStoryRequest, +} from '../services/storyService.js'; + +const saveSnapshotSchema = z.object({ + gameState: z.unknown(), + bottomTab: z.string().trim().min(1), + currentStory: z.unknown().nullable().optional().default(null), + savedAt: z.string().trim().optional().default(''), +}); + +const settingsSchema = z.object({ + musicVolume: z.number().min(0).max(1), +}); + +const customWorldProfileSchema = z.object({ + profile: z.record(z.string(), z.unknown()), +}); + +const customWorldSessionSchema = z.object({ + settingText: z.string().trim().min(1), + creatorIntent: z.record(z.string(), z.unknown()).nullable().optional().default(null), + generationMode: z.enum(['fast', 'full']).default('fast'), +}); + +const customWorldAnswerSchema = z.object({ + questionId: z.string().trim().min(1), + answer: z.string().trim().min(1), +}); + +const runtimeItemIntentSchema = z.object({ + context: z.custom(), + plans: z.array(z.custom()), +}); + +const questGenerationSchema = z.object({ + state: z.custom(), + encounter: z.custom(), +}); + +const llmProxySchema = z.record(z.string(), z.unknown()); + +function readParam(param: string | string[] | undefined) { + return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; +} + +export function createRuntimeRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + router.use(requireAuth); + + router.post( + '/llm/chat/completions', + asyncHandler(async (request, response) => { + const body = llmProxySchema.parse(request.body); + await context.llmClient.forwardCompletion(body, response); + }), + ); + + router.post( + '/custom-world/scene-image', + asyncHandler(async (request, response) => { + const payload = sceneImageSchema.parse(request.body); + response.json(await generateSceneImage(context, payload)); + }), + ); + + router.get( + '/runtime/save/snapshot', + asyncHandler(async (request, response) => { + response.json(context.runtimeRepository.getSnapshot(request.userId!) ?? null); + }), + ); + + router.put( + '/runtime/save/snapshot', + asyncHandler(async (request, response) => { + const payload = saveSnapshotSchema.parse(request.body); + response.json( + context.runtimeRepository.putSnapshot(request.userId!, { + savedAt: payload.savedAt || new Date().toISOString(), + gameState: payload.gameState, + bottomTab: payload.bottomTab, + currentStory: payload.currentStory ?? null, + }), + ); + }), + ); + + router.delete( + '/runtime/save/snapshot', + asyncHandler(async (request, response) => { + context.runtimeRepository.deleteSnapshot(request.userId!); + response.json({ ok: true }); + }), + ); + + router.get( + '/runtime/settings', + asyncHandler(async (request, response) => { + response.json(context.runtimeRepository.getSettings(request.userId!)); + }), + ); + + router.put( + '/runtime/settings', + asyncHandler(async (request, response) => { + const payload = settingsSchema.parse(request.body); + response.json(context.runtimeRepository.putSettings(request.userId!, payload)); + }), + ); + + router.get( + '/runtime/custom-world-library', + asyncHandler(async (request, response) => { + response.json({ + profiles: context.runtimeRepository.listCustomWorldProfiles(request.userId!), + }); + }), + ); + + router.put( + '/runtime/custom-world-library/:profileId', + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + const payload = customWorldProfileSchema.parse(request.body); + response.json({ + profiles: context.runtimeRepository.upsertCustomWorldProfile( + request.userId!, + profileId, + jsonClone(payload.profile), + ), + }); + }), + ); + + router.delete( + '/runtime/custom-world-library/:profileId', + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + response.json({ + profiles: context.runtimeRepository.deleteCustomWorldProfile( + request.userId!, + profileId, + ), + }); + }), + ); + + router.post( + '/runtime/story/initial', + asyncHandler(async (request, response) => { + const payload = parseStoryRequest(request.body); + response.json(await generateHighQualityInitialStory(payload)); + }), + ); + + router.post( + '/runtime/story/continue', + asyncHandler(async (request, response) => { + const payload = parseStoryRequest(request.body); + response.json(await generateHighQualityNextStory(payload)); + }), + ); + + router.post( + '/runtime/chat/character/suggestions', + asyncHandler(async (request, response) => { + const payload = plainTextRequestSchema.parse(request.body); + response.json({ + text: await context.llmClient.requestMessageContent(payload), + }); + }), + ); + + router.post( + '/runtime/chat/character/summary', + asyncHandler(async (request, response) => { + const payload = plainTextRequestSchema.parse(request.body); + response.json({ + text: await context.llmClient.requestMessageContent(payload), + }); + }), + ); + + router.post( + '/runtime/chat/character/reply/stream', + asyncHandler(async (request, response) => { + const payload = plainTextRequestSchema.parse(request.body); + await context.llmClient.forwardSseText({ + ...payload, + response, + }); + }), + ); + + router.post( + '/runtime/chat/npc/dialogue/stream', + asyncHandler(async (request, response) => { + const payload = plainTextRequestSchema.parse(request.body); + await context.llmClient.forwardSseText({ + ...payload, + response, + }); + }), + ); + + router.post( + '/runtime/chat/npc/recruit/stream', + asyncHandler(async (request, response) => { + const payload = plainTextRequestSchema.parse(request.body); + await context.llmClient.forwardSseText({ + ...payload, + response, + }); + }), + ); + + router.post( + '/runtime/custom-world/sessions', + asyncHandler(async (request, response) => { + const payload = customWorldSessionSchema.parse(request.body); + response.json( + context.customWorldSessions.create( + request.userId!, + payload.settingText, + payload.creatorIntent, + payload.generationMode, + ), + ); + }), + ); + + router.get( + '/runtime/custom-world/sessions/:sessionId', + asyncHandler(async (request, response) => { + const session = context.customWorldSessions.get( + request.userId!, + readParam(request.params.sessionId), + ); + if (!session) { + throw notFound('custom world session not found'); + } + response.json(session); + }), + ); + + router.post( + '/runtime/custom-world/sessions/:sessionId/answers', + asyncHandler(async (request, response) => { + const payload = customWorldAnswerSchema.parse(request.body); + const session = context.customWorldSessions.answer( + request.userId!, + readParam(request.params.sessionId), + payload.questionId, + payload.answer, + ); + if (!session) { + throw notFound('custom world session not found'); + } + response.json(session); + }), + ); + + router.get( + '/runtime/custom-world/sessions/:sessionId/generate/stream', + asyncHandler(async (request, response) => { + const session = context.customWorldSessions.get( + request.userId!, + readParam(request.params.sessionId), + ); + if (!session) { + throw notFound('custom world session not found'); + } + + response.status(200); + response.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + response.setHeader('Cache-Control', 'no-cache'); + response.setHeader('Connection', 'keep-alive'); + response.setHeader('X-Accel-Buffering', 'no'); + const controller = new AbortController(); + + request.on('close', () => { + controller.abort(); + }); + + const writeEvent = (event: string, payload: Record) => { + response.write(`event: ${event}\n`); + response.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + writeEvent('progress', { phase: 'preparing', progress: 10 }); + context.customWorldSessions.updateStatus( + request.userId!, + readParam(request.params.sessionId), + 'generating', + ); + writeEvent('progress', { phase: 'requesting_llm', progress: 45 }); + + try { + const profile = await generateCustomWorldProfile(context, session, { + signal: controller.signal, + onProgress: (progress) => { + writeEvent('progress', progress as unknown as Record); + }, + }); + context.customWorldSessions.setResult( + request.userId!, + readParam(request.params.sessionId), + profile, + ); + writeEvent('progress', { phase: 'completed', progress: 100 }); + writeEvent('result', { profile }); + writeEvent('done', { ok: true }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'custom world generation failed'; + context.customWorldSessions.updateStatus( + request.userId!, + readParam(request.params.sessionId), + 'generation_error', + message, + ); + writeEvent('error', { message }); + } finally { + response.end(); + } + }), + ); + + router.post( + '/runtime/items/runtime-intent', + asyncHandler(async (request, response) => { + const payload = runtimeItemIntentSchema.parse(request.body); + response.json({ + intents: await generateRuntimeItemIntents(context.llmClient, payload), + }); + }), + ); + + router.post( + '/runtime/quests/generate', + asyncHandler(async (request, response) => { + const payload = questGenerationSchema.parse(request.body); + response.json( + await generateQuestForNpcEncounter(context.llmClient, payload), + ); + }), + ); + + router.get('/ws/health', (_request, response) => { + response.json({ + ok: true, + message: 'websocket routes reserved for future real-time support', + }); + }); + + return router; +} diff --git a/server-node/src/server.ts b/server-node/src/server.ts new file mode 100644 index 00000000..6a2c14fd --- /dev/null +++ b/server-node/src/server.ts @@ -0,0 +1,93 @@ +import { pathToFileURL } from 'node:url'; + +import { createApp } from './app.js'; +import { type AppConfig,loadConfig } from './config.js'; +import type { AppContext } from './context.js'; +import { createDatabase } from './db.js'; +import { createLogger } from './logging.js'; +import { RuntimeRepository } from './repositories/runtimeRepository.js'; +import { UserRepository } from './repositories/userRepository.js'; +import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; +import { UpstreamLlmClient } from './services/llmClient.js'; + +function resolveListenTarget(serverAddr: string) { + const trimmed = serverAddr.trim(); + if (!trimmed) { + return { host: '0.0.0.0', port: 8081 }; + } + if (trimmed.startsWith(':')) { + return { + host: '0.0.0.0', + port: Number(trimmed.slice(1)), + }; + } + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + const url = new URL(trimmed); + return { + host: url.hostname, + port: Number(url.port || 80), + }; + } + if (trimmed.includes(':')) { + const [host, portText] = trimmed.split(':'); + return { + host: host || '0.0.0.0', + port: Number(portText), + }; + } + return { + host: '0.0.0.0', + port: Number(trimmed), + }; +} + +export function createAppContext(config: AppConfig = loadConfig()) { + const logger = createLogger(config); + const db = createDatabase(config); + const context: AppContext = { + config, + logger, + db, + userRepository: new UserRepository(db), + runtimeRepository: new RuntimeRepository(db), + llmClient: new UpstreamLlmClient(config, logger), + customWorldSessions: new CustomWorldSessionStore(), + }; + + return context; +} + +async function main() { + const context = createAppContext(); + const app = createApp(context); + const { host, port } = resolveListenTarget(context.config.serverAddr); + const server = app.listen(port, host, () => { + context.logger.info( + { + host, + port, + sqlite_path: context.config.sqlitePath, + }, + 'server-node started', + ); + }); + + const shutdown = () => { + context.logger.info('server-node shutting down'); + server.close(() => { + context.db.close(); + process.exit(0); + }); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +const isEntryPoint = + typeof process.argv[1] === 'string' && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isEntryPoint) { + void main(); +} diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts new file mode 100644 index 00000000..e7f4332a --- /dev/null +++ b/server-node/src/services/chatService.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const plainTextRequestSchema = z.object({ + systemPrompt: z.string().trim().min(1), + userPrompt: z.string().trim().min(1), +}); diff --git a/server-node/src/services/customWorldGenerationService.ts b/server-node/src/services/customWorldGenerationService.ts new file mode 100644 index 00000000..240c27cc --- /dev/null +++ b/server-node/src/services/customWorldGenerationService.ts @@ -0,0 +1,29 @@ +import { + type CustomWorldGenerationProgress, + generateCustomWorldProfile as generateCustomWorldProfileFromAi, + type GenerateCustomWorldProfileInput, +} from '../../../src/services/ai.js'; +import type { AppContext } from '../context.js'; +import type { CustomWorldSession } from './customWorldSessionStore.js'; + +export async function generateCustomWorldProfile( + _context: AppContext, + session: CustomWorldSession, + options: { + onProgress?: (progress: CustomWorldGenerationProgress) => void; + signal?: AbortSignal; + } = {}, +) { + const input = { + settingText: session.settingText, + creatorIntent: session.creatorIntent, + generationMode: session.generationMode, + } satisfies GenerateCustomWorldProfileInput; + + const profile = await generateCustomWorldProfileFromAi(input, { + onProgress: options.onProgress, + signal: options.signal, + }); + + return JSON.parse(JSON.stringify(profile)) as Record; +} diff --git a/server-node/src/services/customWorldSessionStore.ts b/server-node/src/services/customWorldSessionStore.ts new file mode 100644 index 00000000..7f356cc8 --- /dev/null +++ b/server-node/src/services/customWorldSessionStore.ts @@ -0,0 +1,174 @@ +import crypto from 'node:crypto'; + +export type CustomWorldSessionStatus = + | 'clarifying' + | 'ready_to_generate' + | 'generating' + | 'completed' + | 'generation_error'; + +export type CustomWorldQuestion = { + id: string; + label: string; + question: string; + answer?: string; +}; + +export type CustomWorldSession = { + sessionId: string; + userId: string; + status: CustomWorldSessionStatus; + settingText: string; + creatorIntent: Record | null; + generationMode: 'fast' | 'full'; + questions: CustomWorldQuestion[]; + result?: Record; + lastError?: string; + createdAt: string; + updatedAt: string; +}; + +function cloneSession(session: CustomWorldSession) { + return JSON.parse(JSON.stringify(session)) as CustomWorldSession; +} + +function hasPendingQuestion(questions: CustomWorldQuestion[]) { + return questions.some((question) => !question.answer?.trim()); +} + +function buildClarificationQuestions( + settingText: string, + creatorIntent: Record | null, +) { + const questions: CustomWorldQuestion[] = []; + const worldHook = + typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : ''; + const playerPremise = + typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : ''; + const openingSituation = + typeof creatorIntent?.openingSituation === 'string' + ? creatorIntent.openingSituation.trim() + : ''; + const coreConflicts = Array.isArray(creatorIntent?.coreConflicts) + ? creatorIntent.coreConflicts + : []; + + if (!worldHook && settingText.trim().length < 24) { + questions.push({ + id: 'world_hook', + label: '世界核心', + question: '请用一句话补充这个世界最核心的命题或独特卖点。', + }); + } + if (!playerPremise) { + questions.push({ + id: 'player_premise', + label: '玩家身份', + question: '玩家在这个世界里是什么身份、立场或来历?', + }); + } + if (!openingSituation) { + questions.push({ + id: 'opening_situation', + label: '开局处境', + question: '故事开局时,玩家正处于什么局面?', + }); + } + if (coreConflicts.length === 0) { + questions.push({ + id: 'core_conflict', + label: '核心冲突', + question: '这个世界当前最核心的冲突、危机或悬念是什么?', + }); + } + + return questions; +} + +export class CustomWorldSessionStore { + private readonly sessions = new Map>(); + + create( + userId: string, + settingText: string, + creatorIntent: Record | null, + generationMode: 'fast' | 'full', + ) { + const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`; + const now = new Date().toISOString(); + const session: CustomWorldSession = { + sessionId, + userId, + status: 'ready_to_generate', + settingText, + creatorIntent, + generationMode, + questions: buildClarificationQuestions(settingText, creatorIntent), + createdAt: now, + updatedAt: now, + }; + + if (hasPendingQuestion(session.questions)) { + session.status = 'clarifying'; + } + + const userSessions = this.sessions.get(userId) ?? new Map(); + userSessions.set(sessionId, session); + this.sessions.set(userId, userSessions); + return cloneSession(session); + } + + get(userId: string, sessionId: string) { + const session = this.sessions.get(userId)?.get(sessionId); + return session ? cloneSession(session) : null; + } + + answer(userId: string, sessionId: string, questionId: string, answer: string) { + const session = this.sessions.get(userId)?.get(sessionId); + if (!session) { + return null; + } + + const question = session.questions.find((item) => item.id === questionId); + if (!question) { + return null; + } + + question.answer = answer; + session.status = hasPendingQuestion(session.questions) + ? 'clarifying' + : 'ready_to_generate'; + session.updatedAt = new Date().toISOString(); + return cloneSession(session); + } + + updateStatus( + userId: string, + sessionId: string, + status: CustomWorldSessionStatus, + lastError = '', + ) { + const session = this.sessions.get(userId)?.get(sessionId); + if (!session) { + return null; + } + + session.status = status; + session.lastError = lastError || undefined; + session.updatedAt = new Date().toISOString(); + return cloneSession(session); + } + + setResult(userId: string, sessionId: string, result: Record) { + const session = this.sessions.get(userId)?.get(sessionId); + if (!session) { + return null; + } + + session.status = 'completed'; + session.lastError = undefined; + session.result = JSON.parse(JSON.stringify(result)) as Record; + session.updatedAt = new Date().toISOString(); + return cloneSession(session); + } +} diff --git a/server-node/src/services/llmClient.ts b/server-node/src/services/llmClient.ts new file mode 100644 index 00000000..d03162cc --- /dev/null +++ b/server-node/src/services/llmClient.ts @@ -0,0 +1,169 @@ +import { Readable } from 'node:stream'; + +import type { Response as ExpressResponse } from 'express'; +import type { Logger } from 'pino'; + +import type { AppConfig } from '../config.js'; +import { upstreamError } from '../errors.js'; +import { extractApiErrorMessage } from '../http.js'; + +export type ChatMessage = { + role: 'system' | 'user' | 'assistant'; + content: string; +}; + +type CompletionRequest = { + model?: string; + stream?: boolean; + messages: ChatMessage[]; +}; + +function normalizeBaseUrl(baseUrl: string) { + return baseUrl.replace(/\/+$/u, ''); +} + +function buildCompletionUrl(baseUrl: string) { + return `${normalizeBaseUrl(baseUrl)}/chat/completions`; +} + +export class UpstreamLlmClient { + constructor( + private readonly config: AppConfig, + private readonly logger: Logger, + ) {} + + private resolveModel(model?: string) { + return model?.trim() || this.config.llm.model; + } + + private buildHeaders() { + if (!this.config.llm.apiKey) { + throw upstreamError('服务端缺少 LLM_API_KEY'); + } + + return { + Authorization: `Bearer ${this.config.llm.apiKey}`, + 'Content-Type': 'application/json', + }; + } + + async requestCompletion(body: CompletionRequest, signal?: AbortSignal) { + const response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify({ + ...body, + model: this.resolveModel(body.model), + }), + signal, + }); + + if (!response.ok) { + const rawText = await response.text(); + throw upstreamError( + extractApiErrorMessage(rawText, 'LLM 上游请求失败'), + ); + } + + return response; + } + + async requestMessageContent(params: { + systemPrompt: string; + userPrompt: string; + model?: string; + signal?: AbortSignal; + }) { + const response = await this.requestCompletion( + { + model: params.model, + messages: [ + { role: 'system', content: params.systemPrompt }, + { role: 'user', content: params.userPrompt }, + ], + }, + params.signal, + ); + const rawText = await response.text(); + const parsed = JSON.parse(rawText) as { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + }; + const content = parsed.choices?.[0]?.message?.content?.trim(); + + if (!content) { + throw upstreamError('LLM 返回内容为空'); + } + + return content; + } + + async forwardCompletion(body: Record, response: ExpressResponse) { + const upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify({ + ...body, + model: + typeof body.model === 'string' && body.model.trim() + ? body.model + : this.config.llm.model, + }), + }); + + if (!upstreamResponse.ok) { + const rawText = await upstreamResponse.text(); + throw upstreamError( + extractApiErrorMessage(rawText, 'LLM 上游请求失败'), + ); + } + + response.status(upstreamResponse.status); + response.setHeader( + 'Content-Type', + upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8', + ); + + if (!upstreamResponse.body) { + response.end(); + return; + } + + await Readable.fromWeb(upstreamResponse.body as never).pipe(response); + } + + async forwardSseText(params: { + systemPrompt: string; + userPrompt: string; + response: ExpressResponse; + model?: string; + }) { + const upstreamResponse = await this.requestCompletion({ + model: params.model, + stream: true, + messages: [ + { role: 'system', content: params.systemPrompt }, + { role: 'user', content: params.userPrompt }, + ], + }); + + params.response.status(upstreamResponse.status); + params.response.setHeader( + 'Content-Type', + upstreamResponse.headers.get('content-type') || 'text/event-stream; charset=utf-8', + ); + params.response.setHeader('Cache-Control', 'no-cache'); + params.response.setHeader('Connection', 'keep-alive'); + params.response.setHeader('X-Accel-Buffering', 'no'); + + if (!upstreamResponse.body) { + params.response.end(); + return; + } + + await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response); + } +} diff --git a/server-node/src/services/questService.ts b/server-node/src/services/questService.ts new file mode 100644 index 00000000..587b8f4c --- /dev/null +++ b/server-node/src/services/questService.ts @@ -0,0 +1,140 @@ +import { + buildFallbackQuestIntent, + compileQuestIntentToQuest, + evaluateQuestOpportunity, +} from '../../../src/data/questFlow.js'; +import { parseJsonResponseText } from '../../../src/services/llmParsers.js'; +import { buildQuestGenerationContextFromState } from '../../../src/services/questDirector.js'; +import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT } from '../../../src/services/questPrompt.js'; +import type { QuestIntent, QuestPreviewRequest } from '../../../src/services/questTypes.js'; +import type { GameState } from '../../../src/types/game.js'; +import type { Encounter } from '../../../src/types/scene.js'; +import type { QuestLogEntry } from '../../../src/types/story.js'; +import type { UpstreamLlmClient } from './llmClient.js'; + +function coerceString(value: unknown, fallback: string) { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function coerceStringArray(value: unknown, fallback: string[]) { + if (!Array.isArray(value)) { + return fallback; + } + + const items = value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean); + + return items.length > 0 ? items : fallback; +} + +function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent { + if (!rawIntent || typeof rawIntent !== 'object') { + return fallback; + } + + const intent = rawIntent as Record; + + return { + title: coerceString(intent.title, fallback.title), + description: coerceString(intent.description, fallback.description), + summary: coerceString(intent.summary, fallback.summary), + narrativeType: + typeof intent.narrativeType === 'string' && + ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType) + ? (intent.narrativeType as QuestIntent['narrativeType']) + : fallback.narrativeType, + dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed), + issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal), + playerHook: coerceString(intent.playerHook, fallback.playerHook), + worldReason: coerceString(intent.worldReason, fallback.worldReason), + recommendedObjectiveKinds: coerceStringArray( + intent.recommendedObjectiveKinds, + fallback.recommendedObjectiveKinds, + ).filter((kind) => + [ + 'defeat_hostile_npc', + 'inspect_treasure', + 'spar_with_npc', + 'talk_to_npc', + 'reach_scene', + 'deliver_item', + ].includes(kind), + ) as QuestIntent['recommendedObjectiveKinds'], + urgency: + typeof intent.urgency === 'string' && + ['low', 'medium', 'high'].includes(intent.urgency) + ? (intent.urgency as QuestIntent['urgency']) + : fallback.urgency, + intimacy: + typeof intent.intimacy === 'string' && + ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy) + ? (intent.intimacy as QuestIntent['intimacy']) + : fallback.intimacy, + rewardTheme: + typeof intent.rewardTheme === 'string' && + ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme) + ? (intent.rewardTheme as QuestIntent['rewardTheme']) + : fallback.rewardTheme, + followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks), + }; +} + +export async function generateQuestForNpcEncounter( + llmClient: UpstreamLlmClient, + params: { + state: GameState; + encounter: Encounter; + }, +): Promise { + const { state, encounter } = params; + const issuerNpcId = encounter.id ?? encounter.npcName; + const request: QuestPreviewRequest = { + issuerNpcId, + issuerNpcName: encounter.npcName, + roleText: encounter.context, + scene: state.currentScenePreset, + worldType: state.worldType, + currentQuests: state.quests.map((quest: QuestLogEntry) => ({ + id: quest.id, + issuerNpcId: quest.issuerNpcId, + status: quest.status, + })), + context: buildQuestGenerationContextFromState({ state, encounter }), + origin: 'ai_compiled', + }; + const opportunity = evaluateQuestOpportunity(request); + if (!opportunity.shouldOffer) { + return null; + } + + const fallbackIntent = buildFallbackQuestIntent(request); + + try { + const content = await llmClient.requestMessageContent({ + systemPrompt: QUEST_INTENT_SYSTEM_PROMPT, + userPrompt: buildQuestIntentPrompt({ + context: request.context!, + scene: request.scene, + opportunity, + }), + }); + const parsed = parseJsonResponseText(content) as { intent?: unknown }; + const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent); + return compileQuestIntentToQuest( + { + ...request, + origin: 'ai_compiled', + }, + intent, + ); + } catch { + return compileQuestIntentToQuest( + { + ...request, + origin: 'fallback_builder', + }, + fallbackIntent, + ); + } +} diff --git a/server-node/src/services/runtimeItemService.ts b/server-node/src/services/runtimeItemService.ts new file mode 100644 index 00000000..3b97f7d9 --- /dev/null +++ b/server-node/src/services/runtimeItemService.ts @@ -0,0 +1,104 @@ +import { buildRuntimeItemAiIntent } from '../../../src/data/runtimeItemNarrative.js'; +import { parseJsonResponseText } from '../../../src/services/llmParsers.js'; +import { + buildRuntimeItemIntentPrompt, + RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, +} from '../../../src/services/runtimeItemAiPrompt.js'; +import type { + RuntimeItemAiIntent, + RuntimeItemGenerationContext, + RuntimeItemPlan, +} from '../../../src/types/runtimeItem.js'; +import type { UpstreamLlmClient } from './llmClient.js'; + +function coerceString(value: unknown, fallback: string) { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function coerceStringArray(value: unknown, fallback: string[], limit: number) { + if (!Array.isArray(value)) { + return fallback; + } + + const normalized = value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) + .slice(0, limit); + + return normalized.length > 0 ? normalized : fallback; +} + +function sanitizeRuntimeItemAiIntent( + rawIntent: unknown, + fallback: RuntimeItemAiIntent, +): RuntimeItemAiIntent { + if (!rawIntent || typeof rawIntent !== 'object') { + return fallback; + } + + const intent = rawIntent as Record; + const desiredFunctionalBias = coerceStringArray( + intent.desiredFunctionalBias, + fallback.desiredFunctionalBias, + 2, + ).filter( + ( + item, + ): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] => + ['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item), + ); + const tone = coerceString(intent.tone, fallback.tone); + + return { + shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed), + sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase), + reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear), + relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2), + desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3), + desiredFunctionalBias: + desiredFunctionalBias.length > 0 + ? desiredFunctionalBias + : fallback.desiredFunctionalBias, + tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone) + ? (tone as RuntimeItemAiIntent['tone']) + : fallback.tone, + visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''), + witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''), + unfinishedBusiness: coerceString( + intent.unfinishedBusiness, + fallback.unfinishedBusiness ?? '', + ), + hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''), + reactionHooks: coerceStringArray( + intent.reactionHooks, + fallback.reactionHooks ?? [], + 4, + ), + namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''), + }; +} + +export async function generateRuntimeItemIntents( + llmClient: UpstreamLlmClient, + params: { + context: RuntimeItemGenerationContext; + plans: RuntimeItemPlan[]; + }, +) { + const fallbackIntents = params.plans.map((plan) => + buildRuntimeItemAiIntent(params.context, plan), + ); + + const content = await llmClient.requestMessageContent({ + systemPrompt: RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, + userPrompt: buildRuntimeItemIntentPrompt(params), + }); + const parsed = parseJsonResponseText(content) as { + intents?: unknown[]; + }; + const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : []; + + return params.plans.map((_, index) => + sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!), + ); +} diff --git a/server-node/src/services/sceneImageService.ts b/server-node/src/services/sceneImageService.ts new file mode 100644 index 00000000..cd4917fa --- /dev/null +++ b/server-node/src/services/sceneImageService.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { z } from 'zod'; + +import type { AppContext } from '../context.js'; +import { badRequest } from '../errors.js'; +import { extractApiErrorMessage } from '../http.js'; + +export const sceneImageSchema = z.object({ + prompt: z.string().trim().min(1), + negativePrompt: z.string().trim().optional().default(''), + size: z.string().trim().optional().default('1280*720'), + model: z.string().trim().optional().default(''), + worldName: z.string().trim().optional().default(''), + profileId: z.string().trim().optional().default(''), + landmarkName: z.string().trim().optional().default(''), + landmarkId: z.string().trim().optional().default(''), +}); + +function ensurePayload( + payload: z.infer, + defaultModel: string, +) { + if (!payload.landmarkName && !payload.landmarkId) { + throw badRequest('landmarkName 或 landmarkId 至少要提供一个'); + } + + return { + ...payload, + model: payload.model || defaultModel, + }; +} + +export async function generateSceneImage( + context: AppContext, + input: z.infer, +) { + const payload = ensurePayload(input, context.config.dashScope.imageModel); + const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); + const createResponse = await fetch( + `${baseUrl}/services/aigc/text2image/image-synthesis`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${context.config.dashScope.apiKey}`, + 'Content-Type': 'application/json', + 'X-DashScope-Async': 'enable', + }, + body: JSON.stringify({ + model: payload.model, + input: { + prompt: payload.prompt, + ...(payload.negativePrompt + ? { negative_prompt: payload.negativePrompt } + : {}), + }, + parameters: { + n: 1, + size: payload.size, + prompt_extend: true, + watermark: false, + }, + }), + }, + ); + const createText = await createResponse.text(); + if (!createResponse.ok) { + throw badRequest( + extractApiErrorMessage(createText, '创建场景图片生成任务失败'), + ); + } + + const createPayload = JSON.parse(createText) as { + output?: { + task_id?: string; + }; + }; + const taskId = createPayload.output?.task_id?.trim(); + if (!taskId) { + throw badRequest('场景图片生成任务未返回 task_id'); + } + + const deadline = Date.now() + context.config.dashScope.requestTimeoutMs; + let imageUrl = ''; + let actualPrompt = ''; + + while (Date.now() < deadline) { + const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, { + headers: { + Authorization: `Bearer ${context.config.dashScope.apiKey}`, + }, + }); + const pollText = await pollResponse.text(); + if (!pollResponse.ok) { + throw badRequest( + extractApiErrorMessage(pollText, '查询场景图片任务失败'), + ); + } + + const pollPayload = JSON.parse(pollText) as { + output?: { + task_status?: string; + results?: Array<{ + url?: string; + actual_prompt?: string; + }>; + }; + }; + const status = pollPayload.output?.task_status?.trim(); + if (status === 'SUCCEEDED') { + imageUrl = + pollPayload.output?.results?.find((item) => item.url?.trim())?.url?.trim() || ''; + actualPrompt = + pollPayload.output?.results?.find((item) => item.url?.trim())?.actual_prompt?.trim() || ''; + break; + } + if (status === 'FAILED' || status === 'UNKNOWN') { + throw badRequest( + extractApiErrorMessage(pollText, '场景图片生成任务失败'), + ); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + if (!imageUrl) { + throw badRequest('场景图片生成超时或未返回图片地址'); + } + + const imageResponse = await fetch(imageUrl); + if (!imageResponse.ok) { + throw badRequest('下载生成图片失败'); + } + + const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); + const contentType = imageResponse.headers.get('content-type') || ''; + const extension = contentType.includes('png') + ? 'png' + : contentType.includes('webp') + ? 'webp' + : 'jpg'; + const assetId = `custom-scene-${Date.now()}`; + const worldSegment = (payload.profileId || payload.worldName || 'world') + .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') + .slice(0, 48); + const landmarkSegment = (payload.landmarkId || payload.landmarkName || 'landmark') + .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') + .slice(0, 48); + const relativeDir = path.join( + 'generated-custom-world-scenes', + worldSegment || 'world', + landmarkSegment || 'landmark', + assetId, + ); + const outputDir = path.join(context.config.publicDir, relativeDir); + fs.mkdirSync(outputDir, { recursive: true }); + const fileName = `scene.${extension}`; + fs.writeFileSync(path.join(outputDir, fileName), imageBuffer); + + const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; + fs.writeFileSync( + path.join(outputDir, 'manifest.json'), + `${JSON.stringify( + { + assetId, + taskId, + model: payload.model, + size: payload.size, + prompt: payload.prompt, + negativePrompt: payload.negativePrompt, + actualPrompt, + imageSrc, + worldName: payload.worldName, + landmarkName: payload.landmarkName, + createdAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + + return { + ok: true, + imageSrc, + assetId, + taskId, + model: payload.model, + size: payload.size, + prompt: payload.prompt, + actualPrompt, + }; +} diff --git a/server-node/src/services/storyService.ts b/server-node/src/services/storyService.ts new file mode 100644 index 00000000..81a5d63e --- /dev/null +++ b/server-node/src/services/storyService.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +import { + generateInitialStoryStrict as generateInitialStoryFromAi, + generateNextStepStrict as generateNextStepFromAi, + type StoryGenerationContext, + type StoryRequestOptions, +} from '../../../src/services/ai.js'; +import type { Character } from '../../../src/types/characters.js'; +import type { WorldType } from '../../../src/types/core.js'; +import type { SceneHostileNpc } from '../../../src/types/scene.js'; +import type { StoryMoment } from '../../../src/types/story.js'; + +const storyRequestSchema = z.object({ + worldType: z.string().trim().min(1), + character: z.record(z.string(), z.unknown()), + monsters: z.array(z.record(z.string(), z.unknown())).default([]), + history: z.array(z.record(z.string(), z.unknown())).default([]), + choice: z.string().optional().default(''), + context: z.record(z.string(), z.unknown()), + requestOptions: z.object({ + availableOptions: z.array(z.record(z.string(), z.unknown())).optional().default([]), + optionCatalog: z.array(z.record(z.string(), z.unknown())).optional().default([]), + }).optional().default({ + availableOptions: [], + optionCatalog: [], + }), +}); + +export function parseStoryRequest(body: unknown) { + return storyRequestSchema.parse(body); +} + +function toTypedStoryParams( + request: ReturnType, +) { + return { + worldType: request.worldType as WorldType, + character: request.character as unknown as Character, + monsters: request.monsters as unknown as SceneHostileNpc[], + history: request.history as unknown as StoryMoment[], + choice: request.choice.trim(), + context: request.context as unknown as StoryGenerationContext, + requestOptions: request.requestOptions as unknown as StoryRequestOptions, + }; +} + +export async function generateHighQualityInitialStory( + request: ReturnType, +) { + const params = toTypedStoryParams(request); + return generateInitialStoryFromAi( + params.worldType, + params.character, + params.monsters, + params.context, + params.requestOptions, + ); +} + +export async function generateHighQualityNextStory( + request: ReturnType, +) { + const params = toTypedStoryParams(request); + return generateNextStepFromAi( + params.worldType, + params.character, + params.monsters, + params.history, + params.choice, + params.context, + params.requestOptions, + ); +} diff --git a/server-node/src/types/express.d.ts b/server-node/src/types/express.d.ts new file mode 100644 index 00000000..67f10103 --- /dev/null +++ b/server-node/src/types/express.d.ts @@ -0,0 +1,14 @@ +declare global { + namespace Express { + interface Request { + requestId: string; + userId?: string; + auth?: { + userId: string; + tokenVersion: number; + }; + } + } +} + +export {}; diff --git a/server-node/tsconfig.json b/server-node/tsconfig.json new file mode 100644 index 00000000..2472ab58 --- /dev/null +++ b/server-node/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ] + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts" + ] +} diff --git a/src/App.tsx b/src/App.tsx index 46d25331..89a6d39d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -93,18 +93,18 @@ export default function App() { }; const handleContinueGame = () => { - persistence.continueSavedGame(); + void persistence.continueSavedGame(); }; const handleStartNewGame = () => { - persistence.clearSavedGame(); + void persistence.clearSavedGame(); storyFlow.resetStoryState(); resetGame(); }; const handleSaveAndExit = () => { const syncedGameState = syncGameStatePlayTime(gameState); - persistence.saveCurrentGame({ + void persistence.saveCurrentGame({ gameState: syncedGameState, bottomTab, currentStory: storyFlow.currentStory, diff --git a/src/AuthenticatedApp.tsx b/src/AuthenticatedApp.tsx new file mode 100644 index 00000000..2d6591c8 --- /dev/null +++ b/src/AuthenticatedApp.tsx @@ -0,0 +1,10 @@ +import App from './App'; +import { AuthGate } from './components/auth/AuthGate'; + +export default function AuthenticatedApp() { + return ( + + + + ); +} diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 0ccea4eb..ad85e4bf 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -22,6 +22,11 @@ import { generateCustomWorldSceneImage, } from '../services/ai'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; +} from '../services/aiService'; +import { + buildCustomWorldSceneImagePrompt, + DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, +} from '../services/customWorld'; import { AnimationState, CustomWorldLandmark, diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 79e1f073..2911405c 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -2,7 +2,7 @@ import { motion } from 'motion/react'; import type { CustomWorldGenerationProgress, -} from '../services/ai'; +} from '../services/aiService'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; interface CustomWorldGenerationViewProps { diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx new file mode 100644 index 00000000..36f18fa1 --- /dev/null +++ b/src/components/auth/AuthGate.tsx @@ -0,0 +1,160 @@ +import { type ReactNode, useEffect, useState } from 'react'; + +import { + AUTH_STATE_EVENT, + getStoredAccessToken, +} from '../../services/apiClient'; +import { + type AuthUser, + ensureAutoAuthUser, + getCurrentAuthUser, + logoutAuthUser, +} from '../../services/authService'; + +type AuthGateProps = { + children: ReactNode; +}; + +type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error'; + +export function AuthGate({ children }: AuthGateProps) { + const [status, setStatus] = useState('checking'); + const [user, setUser] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + let isActive = true; + + const ensureAutoUser = async () => { + if (!isActive) { + return; + } + + setStatus('recovering'); + + try { + const { user: nextUser } = await ensureAutoAuthUser(); + if (!isActive) { + return; + } + + setUser(nextUser); + setStatus('ready'); + setError(''); + } catch (autoAuthError) { + if (!isActive) { + return; + } + + setUser(null); + setStatus('error'); + setError( + autoAuthError instanceof Error + ? autoAuthError.message + : '自动登录失败,请稍后再试。', + ); + } + }; + + const hydrate = async () => { + const token = getStoredAccessToken(); + if (!token) { + await ensureAutoUser(); + return; + } + + try { + const nextUser = await getCurrentAuthUser(); + if (!isActive) { + return; + } + + if (nextUser) { + setUser(nextUser); + setStatus('ready'); + setError(''); + return; + } + + await ensureAutoUser(); + } catch { + if (!isActive) { + return; + } + await ensureAutoUser(); + } + }; + + void hydrate(); + + const handleAuthStateChange = () => { + setStatus('checking'); + void hydrate(); + }; + + window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange); + + return () => { + isActive = false; + window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); + }; + }, []); + + if (status === 'checking') { + return ( +
+ 正在校验登录状态... +
+ ); + } + + if (status === 'recovering') { + return ( +
+ 正在自动创建或恢复账号... +
+ ); + } + + if (status !== 'ready' || !user) { + return ( +
+
+
自动登录失败
+
+ {error || '账号恢复失败,请刷新页面后重试。'} +
+ +
+
+ ); + } + + return ( +
+
+
+ {user.username} + +
+
+ {children} +
+ ); +} diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx new file mode 100644 index 00000000..aee88e8a --- /dev/null +++ b/src/components/auth/LoginScreen.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; + +type LoginScreenProps = { + loading: boolean; + error: string; + onSubmit: (username: string, password: string) => Promise; +}; + +export function LoginScreen({ + loading, + error, + onSubmit, +}: LoginScreenProps) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + return ( +
+
+
+
+

+ Genarrative +

+

+ 登录后进入冒险 +

+

+ 当前版本已切到后端账号模式。输入用户名和密码即可直接进入,用户名不存在时会自动创建账号。 +

+
+
+ 用户名:3 到 24 位字母、数字、下划线 +
+
+ 第一次提交会自动注册,后续同名即登录 +
+
+
+ +
{ + event.preventDefault(); + void onSubmit(username, password); + }} + > + + + + + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+
+
+ ); +} diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index 4cbfd819..a5262174 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -9,16 +9,21 @@ import { upsertSavedCustomWorldProfile, } from '../../data/customWorldLibrary'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; +import { getScenePreset } from '../../data/scenePresets'; import { type CustomWorldGenerationProgress, generateCustomWorldProfile, -} from '../../services/ai'; +} from '../../services/aiService'; import { buildCustomWorldCreatorIntentDisplayText, buildCustomWorldCreatorIntentGenerationText, createEmptyCustomWorldCreatorIntent, } from '../../services/customWorldCreatorIntent'; import { detectCustomWorldThemeMode } from '../../services/customWorldTheme'; +import { + listCustomWorldLibrary, + upsertCustomWorldProfile, +} from '../../services/storageService'; import { type CustomWorldCreatorIntent, type CustomWorldGenerationMode, @@ -141,7 +146,7 @@ export function PreGameSelectionFlow({ useState(null); const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState< CustomWorldProfile[] - >(() => readSavedCustomWorldProfiles()); + >([]); const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false); const [showCustomWorldModal, setShowCustomWorldModal] = useState(false); const [customWorldCreatorIntent, setCustomWorldCreatorIntent] = @@ -217,6 +222,25 @@ export function PreGameSelectionFlow({ }, [], ); + useEffect(() => { + let isActive = true; + + void listCustomWorldLibrary() + .then((profiles) => { + if (!isActive) return; + setSavedCustomWorldProfiles(profiles); + }) + .catch((error) => { + console.warn( + '[PreGameSelectionFlow] failed to load custom world library', + error, + ); + }); + + return () => { + isActive = false; + }; + }, []); const leaveCustomWorldResult = () => { setGeneratedCustomWorldProfile(null); @@ -268,18 +292,18 @@ export function PreGameSelectionFlow({ setShowCustomWorldModal(true); }; - const saveGeneratedCustomWorld = () => { + const saveGeneratedCustomWorld = async () => { if (!generatedCustomWorldProfile) { return; } try { setSavedCustomWorldProfiles( - upsertSavedCustomWorldProfile(generatedCustomWorldProfile), + await upsertCustomWorldProfile(generatedCustomWorldProfile), ); } catch (error) { setCustomWorldError( - error instanceof Error ? error.message : '本地保存自定义世界失败。', + error instanceof Error ? error.message : '保存自定义世界失败。', ); return; } @@ -446,7 +470,7 @@ export function PreGameSelectionFlow({ id: generatedCustomWorldProfile.id, } : profile; - const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile); + const savedProfiles = await upsertCustomWorldProfile(persistedProfile); setSavedCustomWorldProfiles(savedProfiles); setGeneratedCustomWorldProfile(null); setCustomWorldError(null); @@ -760,7 +784,24 @@ export function PreGameSelectionFlow({ onContinueExpand={() => { void continueExpandCustomWorld(); }} - onSave={saveGeneratedCustomWorld} + onRegeneratePlayableNpc={(id) => { + void regeneratePlayableNpc(id); + }} + onRegenerateStoryNpc={(id) => { + void regenerateStoryNpc(id); + }} + onRegenerateLandmark={(id) => { + void regenerateLandmark(id); + }} + onRegenerateStoryExpansion={() => { + void regenerateStoryExpansion(); + }} + onRegenerateLandmarkNetwork={() => { + void regenerateLandmarkNetwork(); + }} + onSave={() => { + void saveGeneratedCustomWorld(); + }} /> )} diff --git a/src/hooks/story/characterChat.ts b/src/hooks/story/characterChat.ts index 1f59111c..facefaf7 100644 --- a/src/hooks/story/characterChat.ts +++ b/src/hooks/story/characterChat.ts @@ -4,7 +4,7 @@ import { generateCharacterPanelChatSuggestions, generateCharacterPanelChatSummary, streamCharacterPanelChatReply, -} from '../../services/ai'; +} from '../../services/aiService'; import type {StoryGenerationContext} from '../../services/aiTypes'; import type { Character, diff --git a/src/hooks/story/choiceActions.ts b/src/hooks/story/choiceActions.ts index 80c7b817..b4059ace 100644 --- a/src/hooks/story/choiceActions.ts +++ b/src/hooks/story/choiceActions.ts @@ -16,7 +16,7 @@ import { resolveSceneEncounterPreview, } from '../../data/sceneEncounterPreviews'; import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; -import { generateNextStep } from '../../services/ai'; +import { generateNextStep } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; import { createHistoryMoment } from '../../services/storyHistory'; diff --git a/src/hooks/story/npcEncounterActions.ts b/src/hooks/story/npcEncounterActions.ts index 4b3a1128..e5efe1fb 100644 --- a/src/hooks/story/npcEncounterActions.ts +++ b/src/hooks/story/npcEncounterActions.ts @@ -40,7 +40,7 @@ import { resolveSceneEncounterPreview, } from '../../data/sceneEncounterPreviews'; import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; -import { generateNextStep, streamNpcChatDialogue } from '../../services/ai'; +import { generateNextStep, streamNpcChatDialogue } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { generateQuestForNpcEncounter } from '../../services/questDirector'; import { diff --git a/src/hooks/story/npcInteraction.ts b/src/hooks/story/npcInteraction.ts index 8da8175b..f0b1d644 100644 --- a/src/hooks/story/npcInteraction.ts +++ b/src/hooks/story/npcInteraction.ts @@ -34,7 +34,7 @@ import { removeInventoryItem, syncNpcTradeInventory, } from '../../data/npcInteractions'; -import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai'; +import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { appendStoryEngineCarrierMemory, diff --git a/src/hooks/story/openingAdventure.ts b/src/hooks/story/openingAdventure.ts index f23a0ab8..e200da2f 100644 --- a/src/hooks/story/openingAdventure.ts +++ b/src/hooks/story/openingAdventure.ts @@ -11,7 +11,7 @@ import { } from '../../data/sceneEncounterPreviews'; import { getWorldCampScenePreset } from '../../data/scenePresets'; import { sortStoryOptionsByPriority } from '../../data/stateFunctions'; -import { generateNextStep } from '../../services/ai'; +import { generateNextStep } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { createHistoryMoment } from '../../services/storyHistory'; import type { diff --git a/src/hooks/useGamePersistence.ts b/src/hooks/useGamePersistence.ts index 24bc0728..2ddd3b0f 100644 --- a/src/hooks/useGamePersistence.ts +++ b/src/hooks/useGamePersistence.ts @@ -12,13 +12,18 @@ import { normalizeNpcPersistentState } from '../data/npcInteractions'; import { normalizeQuestLogEntries } from '../data/questFlow'; import { normalizeGameRuntimeStats } from '../data/runtimeStats'; import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews'; -import {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../persistence/gameSaveStorage'; +import type { SavedGameSnapshot } from '../persistence/gameSaveStorage'; +import { + deleteSaveSnapshot, + getSaveSnapshot, + putSaveSnapshot, +} from '../services/storageService'; import { applyStoryEngineMigration, buildSaveMigrationManifest, } from '../services/storyEngine/saveMigrationManifest'; import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine'; -import { GameState, StoryMoment, WorldType } from '../types'; +import { GameState, StoryMoment } from '../types'; import { BottomTab } from './useGameFlow'; const AUTO_SAVE_DELAY_MS = 400; @@ -31,14 +36,6 @@ function normalizeSavedStory(story: StoryMoment | null) { } satisfies StoryMoment; } -function isPlayableSavedGameState(gameState: GameState | null | undefined) { - return Boolean( - gameState - && gameState.worldType === WorldType.CUSTOM - && gameState.customWorldProfile, - ); -} - function normalizeCharacterChats(gameState: GameState) { const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [ characterId, @@ -108,10 +105,7 @@ function normalizeSavedGameState(gameState: GameState) { npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false, playerCurrency: typeof gameState.playerCurrency === 'number' ? gameState.playerCurrency - : getInitialPlayerCurrency( - gameState.worldType, - normalizedEncounterState.customWorldProfile, - ), + : getInitialPlayerCurrency(gameState.worldType), quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []), roster: normalizedRoster, npcStates: Object.fromEntries( @@ -180,10 +174,24 @@ export function useGamePersistence({ resetStoryState: () => void; }) { const [hasSavedGame, setHasSavedGame] = useState(false); + const [savedSnapshot, setSavedSnapshot] = useState(null); useEffect(() => { - const snapshot = readSavedSnapshot(); - setHasSavedGame(isPlayableSavedGameState(snapshot?.gameState ?? null)); + let isActive = true; + + void getSaveSnapshot() + .then((snapshot) => { + if (!isActive) return; + setSavedSnapshot(snapshot); + setHasSavedGame(Boolean(snapshot)); + }) + .catch((error) => { + console.warn('[useGamePersistence] failed to load remote snapshot', error); + }); + + return () => { + isActive = false; + }; }, []); useEffect(() => { @@ -192,21 +200,24 @@ export function useGamePersistence({ if (!canPersist) return; const timeoutId = window.setTimeout(() => { - const didSave = writeSavedSnapshot({ + void putSaveSnapshot({ gameState, bottomTab, currentStory, - }); - - if (didSave) { - setHasSavedGame(isPlayableSavedGameState(gameState)); - } + }) + .then((snapshot) => { + setSavedSnapshot(snapshot); + setHasSavedGame(true); + }) + .catch((error) => { + console.warn('[useGamePersistence] failed to autosave remote snapshot', error); + }); }, AUTO_SAVE_DELAY_MS); return () => window.clearTimeout(timeoutId); }, [bottomTab, currentStory, gameState, isLoading]); - const saveCurrentGame = useCallback((override?: { + const saveCurrentGame = useCallback(async (override?: { gameState?: GameState; bottomTab?: BottomTab; currentStory?: StoryMoment | null; @@ -219,33 +230,40 @@ export function useGamePersistence({ return false; } - const didSave = writeSavedSnapshot({ - gameState: nextGameState, - bottomTab: nextBottomTab, - currentStory: nextStory, - }); - - if (didSave) { - setHasSavedGame(isPlayableSavedGameState(nextGameState)); + try { + const snapshot = await putSaveSnapshot({ + gameState: nextGameState, + bottomTab: nextBottomTab, + currentStory: nextStory, + }); + setSavedSnapshot(snapshot); + setHasSavedGame(true); + return true; + } catch (error) { + console.warn('[useGamePersistence] failed to save remote snapshot', error); + return false; } - - return didSave; }, [bottomTab, currentStory, gameState]); - const clearSavedGame = useCallback(() => { - clearSavedSnapshot(); + const clearSavedGame = useCallback(async () => { + try { + await deleteSaveSnapshot(); + } catch (error) { + console.warn('[useGamePersistence] failed to delete remote snapshot', error); + } + + setSavedSnapshot(null); setHasSavedGame(false); }, []); - const continueSavedGame = useCallback(() => { - const snapshot = readSavedSnapshot(); + const continueSavedGame = useCallback(async () => { + const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => { + console.warn('[useGamePersistence] failed to refetch remote snapshot', error); + return null; + }); if (!snapshot) { - clearSavedGame(); - return false; - } - - if (!isPlayableSavedGameState(snapshot.gameState)) { - clearSavedGame(); + setSavedSnapshot(null); + setHasSavedGame(false); return false; } @@ -253,9 +271,10 @@ export function useGamePersistence({ setGameState(normalizeSavedGameState(snapshot.gameState)); setBottomTab(snapshot.bottomTab ?? 'adventure'); hydrateStoryState(normalizeSavedStory(snapshot.currentStory)); + setSavedSnapshot(snapshot); setHasSavedGame(true); return true; - }, [clearSavedGame, hydrateStoryState, resetStoryState, setBottomTab, setGameState]); + }, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]); return { hasSavedGame, diff --git a/src/hooks/useGameSettings.ts b/src/hooks/useGameSettings.ts index dcd1d441..43b0e1b4 100644 --- a/src/hooks/useGameSettings.ts +++ b/src/hooks/useGameSettings.ts @@ -1,13 +1,64 @@ -import {useCallback, useEffect, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; -import {clampVolume, readSavedSettings, writeSavedSettings} from '../persistence/gameSettingsStorage'; +import { + clampVolume, + DEFAULT_MUSIC_VOLUME, +} from '../persistence/gameSettingsStorage'; +import { getSettings, putSettings } from '../services/storageService'; export function useGameSettings() { - const [musicVolume, setMusicVolumeState] = useState(() => readSavedSettings().musicVolume); + const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME); + const [hasHydratedSettings, setHasHydratedSettings] = useState(false); + const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME); useEffect(() => { - writeSavedSettings({musicVolume}); - }, [musicVolume]); + let isActive = true; + + void getSettings() + .then((settings) => { + if (!isActive) return; + const nextVolume = clampVolume(settings.musicVolume); + lastSyncedVolumeRef.current = nextVolume; + setMusicVolumeState(nextVolume); + }) + .catch((error) => { + console.warn('[useGameSettings] failed to load remote settings', error); + }) + .finally(() => { + if (isActive) { + setHasHydratedSettings(true); + } + }); + + return () => { + isActive = false; + }; + }, []); + + useEffect(() => { + if (!hasHydratedSettings) { + return; + } + + if (lastSyncedVolumeRef.current === musicVolume) { + return; + } + + let isActive = true; + + void putSettings({musicVolume}) + .then((settings) => { + if (!isActive) return; + lastSyncedVolumeRef.current = clampVolume(settings.musicVolume); + }) + .catch((error) => { + console.warn('[useGameSettings] failed to persist remote settings', error); + }); + + return () => { + isActive = false; + }; + }, [hasHydratedSettings, musicVolume]); const setMusicVolume = useCallback((value: number) => { setMusicVolumeState(clampVolume(value)); diff --git a/src/hooks/useStoryGeneration.ts b/src/hooks/useStoryGeneration.ts index f6c23676..22675482 100644 --- a/src/hooks/useStoryGeneration.ts +++ b/src/hooks/useStoryGeneration.ts @@ -44,6 +44,7 @@ import { import { applyStoryReasoningRecovery } from '../data/storyRecovery'; import { generateInitialStory, generateNextStep } from '../services/ai'; import { hasMixedNarrativeLanguage } from '../services/narrativeLanguage'; +import { generateInitialStory, generateNextStep } from '../services/aiService'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index 0c410ce0..112e141f 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -28,7 +28,7 @@ export type ResolvedAppRoute = { componentProps?: Record; }; -const GameApp = lazy(() => import('../App')) as AppRouteComponent; +const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent; const PresetEditorApp = lazy(async () => { const module = await import('../components/PresetEditor'); diff --git a/src/services/ai.ts b/src/services/ai.ts index 0153c90a..d8996819 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -38,6 +38,7 @@ import type { StoryRequestOptions, TextStreamOptions, } from './aiTypes'; +import { fetchWithApiAuth } from './apiClient'; import { buildCharacterPanelChatPrompt, buildCharacterPanelChatSuggestionPrompt, @@ -130,6 +131,8 @@ export type { TextStreamOptions, } from './aiTypes'; +const ENV: Partial = import.meta.env ?? {}; + type RawOptionItem = { functionId: string; actionText?: string; @@ -140,7 +143,7 @@ type MergeableCustomWorldRoleEntry = { }; const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL = - import.meta.env.VITE_SCENE_IMAGE_PROXY_BASE_URL || + ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL || '/api/custom-world/scene-image'; const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。 你会收到一段本应为单个 JSON 对象的文本。 @@ -161,7 +164,7 @@ const CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE = 3; const CUSTOM_WORLD_PLAYABLE_BATCH_SIZE = 3; const CUSTOM_WORLD_STORY_BATCH_SIZE = 5; const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => { - const rawValue = Number(import.meta.env.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS); + const rawValue = Number(ENV.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS); return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000; })(); @@ -1933,6 +1936,60 @@ async function requestCompletion( ); } +export async function generateInitialStoryStrict( + world: WorldType, + character: Character, + monsters: SceneHostileNpc[], + context: StoryGenerationContext, + requestOptions: StoryRequestOptions = {}, +): Promise { + return requestCompletion( + buildUserPrompt( + world, + character, + monsters, + [], + context, + undefined, + requestOptions.availableOptions, + requestOptions.optionCatalog, + ), + world, + character, + monsters, + context, + requestOptions, + ); +} + +export async function generateNextStepStrict( + world: WorldType, + character: Character, + monsters: SceneHostileNpc[], + history: StoryMoment[], + choice: string, + context: StoryGenerationContext, + requestOptions: StoryRequestOptions = {}, +): Promise { + return requestCompletion( + buildUserPrompt( + world, + character, + monsters, + history, + context, + choice, + requestOptions.availableOptions, + requestOptions.optionCatalog, + ), + world, + character, + monsters, + context, + requestOptions, + ); +} + export async function generateCustomWorldSceneImage({ profile, landmark, @@ -1956,7 +2013,7 @@ export async function generateCustomWorldSceneImage({ ); try { - const response = await fetch(CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL, { + const response = await fetchWithApiAuth(CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/services/aiService.ts b/src/services/aiService.ts new file mode 100644 index 00000000..376f4597 --- /dev/null +++ b/src/services/aiService.ts @@ -0,0 +1,825 @@ +import { parseApiErrorMessage } from '../editor/shared/jsonClient'; +import type { + AIResponse, + Character, + CharacterChatTurn, + Encounter, + SceneHostileNpc, + StoryMoment, + WorldType, +} from '../types'; +import type { + CustomWorldGenerationProgress, + CustomWorldSceneImageResult, + GenerateCustomWorldProfileInput, + GenerateCustomWorldProfileOptions, + StoryGenerationContext, + StoryRequestOptions, + TextStreamOptions, +} from './ai'; +import * as aiClient from './ai'; +import { + buildOfflineCharacterPanelChatReply, + buildOfflineCharacterPanelChatSuggestions, + buildOfflineCharacterPanelChatSummary, + buildOfflineNpcChatDialogue, + buildOfflineNpcRecruitDialogue, +} from './aiFallbacks'; +import { fetchWithApiAuth, requestJson } from './apiClient'; +import { + buildCharacterPanelChatPrompt, + buildCharacterPanelChatSuggestionPrompt, + buildCharacterPanelChatSummaryPrompt, + CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, + CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, + CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, + type CharacterChatTargetStatus, +} from './characterChatPrompt'; +import { parseLineListContent } from './llmParsers'; +import { + buildNpcRecruitDialoguePrompt, + buildStrictNpcChatDialoguePrompt, + NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, + NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, +} from './prompt'; + +const RUNTIME_API_BASE = '/api/runtime'; + +async function requestPostJson( + url: string, + payload: unknown, + fallbackMessage: string, +) { + return requestJson( + url, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + fallbackMessage, + ); +} + +async function requestPlainText( + url: string, + payload: { systemPrompt: string; userPrompt: string }, + fallbackMessage: string, +) { + return requestPostJson<{ text: string }>(url, payload, fallbackMessage); +} + +async function requestPlainTextStream( + url: string, + payload: { systemPrompt: string; userPrompt: string }, + options: TextStreamOptions = {}, +) { + const response = await fetchWithApiAuth(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error(parseApiErrorMessage(responseText, '流式请求失败')); + } + + if (!response.body) { + throw new Error('streaming response body is unavailable'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let accumulatedText = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + while (buffer.includes('\n\n')) { + const boundary = buffer.indexOf('\n\n'); + const eventBlock = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + + for (const rawLine of eventBlock.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line.startsWith('data:')) { + continue; + } + + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') { + continue; + } + + try { + const parsed = JSON.parse(data); + const delta = parsed?.choices?.[0]?.delta?.content; + if (typeof delta === 'string' && delta.length > 0) { + accumulatedText += delta; + options.onUpdate?.(accumulatedText); + } + } catch { + // Ignore malformed SSE frames. + } + } + } + } + + return accumulatedText.trim(); +} + +function buildCharacterChatPromptContext(context: StoryGenerationContext) { + return { + playerHp: context.playerHp, + playerMaxHp: context.playerMaxHp, + playerMana: context.playerMana, + playerMaxMana: context.playerMaxMana, + inBattle: context.inBattle, + playerFacing: context.playerFacing, + playerAnimation: context.playerAnimation, + sceneName: context.sceneName ?? null, + sceneDescription: context.sceneDescription ?? null, + customWorldProfile: context.customWorldProfile ?? null, + }; +} + +export async function generateInitialStory( + world: WorldType, + character: Character, + monsters: SceneHostileNpc[], + context: StoryGenerationContext, + requestOptions: StoryRequestOptions = {}, +): Promise { + if (typeof window === 'undefined') { + return aiClient.generateInitialStory( + world, + character, + monsters, + context, + requestOptions, + ); + } + + try { + return await requestJson( + `${RUNTIME_API_BASE}/story/initial`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + worldType: world, + character, + monsters, + context, + requestOptions, + }), + }, + '剧情开局生成失败', + ); + } catch (error) { + console.warn('[aiService] story/initial fell back to frontend implementation', error); + return aiClient.generateInitialStory( + world, + character, + monsters, + context, + requestOptions, + ); + } +} + +export async function generateNextStep( + world: WorldType, + character: Character, + monsters: SceneHostileNpc[], + history: StoryMoment[], + choice: string, + context: StoryGenerationContext, + requestOptions: StoryRequestOptions = {}, +): Promise { + if (typeof window === 'undefined') { + return aiClient.generateNextStep( + world, + character, + monsters, + history, + choice, + context, + requestOptions, + ); + } + + try { + return await requestJson( + `${RUNTIME_API_BASE}/story/continue`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + worldType: world, + character, + monsters, + history, + choice, + context, + requestOptions, + }), + }, + '剧情续写失败', + ); + } catch (error) { + console.warn('[aiService] story/continue fell back to frontend implementation', error); + return aiClient.generateNextStep( + world, + character, + monsters, + history, + choice, + context, + requestOptions, + ); + } +} + +export async function generateCharacterPanelChatSuggestions( + world: WorldType, + playerCharacter: Character, + targetCharacter: Character, + storyHistory: StoryMoment[], + context: StoryGenerationContext, + conversationHistory: CharacterChatTurn[], + conversationSummary: string, + targetStatus: CharacterChatTargetStatus, +) { + const fallbackSuggestions = + buildOfflineCharacterPanelChatSuggestions(targetCharacter); + const userPrompt = buildCharacterPanelChatSuggestionPrompt({ + world, + playerCharacter, + targetCharacter, + storyHistory, + context: buildCharacterChatPromptContext(context), + conversationHistory, + conversationSummary, + targetStatus, + }); + + if (typeof window === 'undefined') { + return aiClient.generateCharacterPanelChatSuggestions( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + targetStatus, + ); + } + + try { + const { text } = await requestPlainText( + `${RUNTIME_API_BASE}/chat/character/suggestions`, + { + systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, + userPrompt, + }, + '角色聊天建议生成失败', + ); + const parsedSuggestions = parseLineListContent(text, 3); + return parsedSuggestions.length > 0 + ? [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3) + : fallbackSuggestions; + } catch (error) { + console.warn('[aiService] character suggestions fell back to frontend implementation', error); + return aiClient.generateCharacterPanelChatSuggestions( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + targetStatus, + ); + } +} + +export async function generateCharacterPanelChatSummary( + world: WorldType, + playerCharacter: Character, + targetCharacter: Character, + storyHistory: StoryMoment[], + context: StoryGenerationContext, + conversationHistory: CharacterChatTurn[], + previousSummary: string, + targetStatus: CharacterChatTargetStatus, +) { + const fallbackSummary = buildOfflineCharacterPanelChatSummary( + targetCharacter, + conversationHistory, + previousSummary, + ); + const userPrompt = buildCharacterPanelChatSummaryPrompt({ + world, + playerCharacter, + targetCharacter, + storyHistory, + context: buildCharacterChatPromptContext(context), + conversationHistory, + previousSummary, + targetStatus, + }); + + if (typeof window === 'undefined') { + return aiClient.generateCharacterPanelChatSummary( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + previousSummary, + targetStatus, + ); + } + + try { + const { text } = await requestPlainText( + `${RUNTIME_API_BASE}/chat/character/summary`, + { + systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, + userPrompt, + }, + '角色聊天摘要生成失败', + ); + return text.trim() || fallbackSummary; + } catch (error) { + console.warn('[aiService] character summary fell back to frontend implementation', error); + return aiClient.generateCharacterPanelChatSummary( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + previousSummary, + targetStatus, + ); + } +} + +export async function generateCustomWorldProfile( + input: GenerateCustomWorldProfileInput | string, + options: GenerateCustomWorldProfileOptions = {}, +) { + const normalizedInput = + typeof input === 'string' + ? { + settingText: input, + creatorIntent: null, + generationMode: 'full' as const, + } + : { + settingText: input.settingText, + creatorIntent: input.creatorIntent ?? null, + generationMode: input.generationMode === 'fast' ? 'fast' as const : 'full' as const, + }; + + const session = await createCustomWorldSession({ + settingText: normalizedInput.settingText, + creatorIntent: + normalizedInput.creatorIntent as Record | null, + generationMode: normalizedInput.generationMode, + }); + + const fallbackAnswerMap: Record = { + world_hook: + typeof normalizedInput.creatorIntent?.worldHook === 'string' && + normalizedInput.creatorIntent.worldHook.trim() + ? normalizedInput.creatorIntent.worldHook.trim() + : normalizedInput.settingText.trim().slice(0, 120) || '这是一个围绕失衡秩序展开的世界。', + player_premise: + typeof normalizedInput.creatorIntent?.playerPremise === 'string' && + normalizedInput.creatorIntent.playerPremise.trim() + ? normalizedInput.creatorIntent.playerPremise.trim() + : '玩家是一名被卷入局势中心的行动者。', + opening_situation: + typeof normalizedInput.creatorIntent?.openingSituation === 'string' && + normalizedInput.creatorIntent.openingSituation.trim() + ? normalizedInput.creatorIntent.openingSituation.trim() + : '故事开局时,玩家正身处风暴边缘,必须立刻判断立场与风险。', + core_conflict: + Array.isArray(normalizedInput.creatorIntent?.coreConflicts) && + normalizedInput.creatorIntent.coreConflicts.length > 0 + ? normalizedInput.creatorIntent.coreConflicts + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) + .join(';') + : '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。', + }; + + for (const question of session.questions ?? []) { + if (question.answer?.trim()) { + continue; + } + + const answer = fallbackAnswerMap[question.id] || normalizedInput.settingText.trim(); + await answerCustomWorldSessionQuestion(session.sessionId, { + questionId: question.id, + answer, + }); + } + + const response = await fetchWithApiAuth( + `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(session.sessionId)}/generate/stream`, + { + method: 'GET', + }, + ); + if (!response.ok) { + const responseText = await response.text(); + throw new Error(parseApiErrorMessage(responseText, '生成自定义世界失败')); + } + if (!response.body) { + throw new Error('自定义世界生成流不可用'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let latestProfile: Record | null = null; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + while (buffer.includes('\n\n')) { + const boundary = buffer.indexOf('\n\n'); + const eventBlock = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + + let eventName = ''; + for (const rawLine of eventBlock.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line) { + continue; + } + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + continue; + } + if (!line.startsWith('data:')) { + continue; + } + + const payloadText = line.slice(5).trim(); + if (!payloadText) { + continue; + } + + const payload = JSON.parse(payloadText) as Record; + if (eventName === 'progress') { + if ( + typeof payload.phaseId === 'string' + && typeof payload.phaseLabel === 'string' + && typeof payload.phaseDetail === 'string' + && typeof payload.overallProgress === 'number' + && Array.isArray(payload.steps) + ) { + options.onProgress?.(payload as unknown as CustomWorldGenerationProgress); + } else { + options.onProgress?.({ + phaseId: 'finalize', + phaseLabel: typeof payload.phase === 'string' ? payload.phase : 'generating', + phaseDetail: typeof payload.phase === 'string' ? payload.phase : 'generating', + overallProgress: + typeof payload.progress === 'number' ? payload.progress / 100 : 0, + completedWeight: + typeof payload.progress === 'number' ? payload.progress : 0, + totalWeight: 100, + elapsedMs: 0, + estimatedRemainingMs: null, + activeStepIndex: 0, + steps: [], + }); + } + } + if (eventName === 'result' && payload.profile && typeof payload.profile === 'object') { + latestProfile = payload.profile as Record; + } + if (eventName === 'error') { + throw new Error( + typeof payload.message === 'string' + ? payload.message + : '生成自定义世界失败', + ); + } + } + } + } + + if (!latestProfile) { + throw new Error('自定义世界生成未返回结果'); + } + + return latestProfile as unknown as Awaited< + ReturnType + >; +} + +export async function generateCustomWorldSceneImage( + ...args: Parameters +) { + return aiClient.generateCustomWorldSceneImage(...args); +} + +export async function createCustomWorldSession(payload: { + settingText: string; + creatorIntent?: Record | null; + generationMode: 'fast' | 'full'; +}) { + return requestJson<{ + sessionId: string; + status: string; + questions: Array<{ + id: string; + label: string; + question: string; + answer?: string; + }>; + }>( + `${RUNTIME_API_BASE}/custom-world/sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + '创建自定义世界会话失败', + ); +} + +export async function getCustomWorldSession(sessionId: string) { + return requestJson<{ + sessionId: string; + status: string; + settingText: string; + generationMode: string; + questions: Array<{ + id: string; + label: string; + question: string; + answer?: string; + }>; + result?: Record; + lastError?: string; + }>( + `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`, + { + method: 'GET', + }, + '读取自定义世界会话失败', + ); +} + +export async function answerCustomWorldSessionQuestion( + sessionId: string, + payload: { questionId: string; answer: string }, +) { + return requestJson<{ + sessionId: string; + status: string; + questions: Array<{ + id: string; + label: string; + question: string; + answer?: string; + }>; + }>( + `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + '提交自定义世界补充设定失败', + ); +} + +export async function streamCharacterPanelChatReply( + world: WorldType, + playerCharacter: Character, + targetCharacter: Character, + storyHistory: StoryMoment[], + context: StoryGenerationContext, + conversationHistory: CharacterChatTurn[], + conversationSummary: string, + playerMessage: string, + targetStatus: CharacterChatTargetStatus, + options: TextStreamOptions = {}, +) { + const userPrompt = buildCharacterPanelChatPrompt({ + world, + playerCharacter, + targetCharacter, + storyHistory, + context: buildCharacterChatPromptContext(context), + conversationHistory, + conversationSummary, + playerMessage, + targetStatus, + }); + + if (typeof window === 'undefined') { + return aiClient.streamCharacterPanelChatReply( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + playerMessage, + targetStatus, + options, + ); + } + + try { + const reply = await requestPlainTextStream( + `${RUNTIME_API_BASE}/chat/character/reply/stream`, + { + systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, + userPrompt, + }, + options, + ); + return ( + reply.trim() || + buildOfflineCharacterPanelChatReply( + targetCharacter, + playerMessage, + conversationSummary, + ) + ); + } catch (error) { + console.warn('[aiService] character reply stream fell back to frontend implementation', error); + return aiClient.streamCharacterPanelChatReply( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + playerMessage, + targetStatus, + options, + ); + } +} + +export async function streamNpcChatDialogue( + world: WorldType, + character: Character, + encounter: Encounter, + monsters: SceneHostileNpc[], + history: StoryMoment[], + context: StoryGenerationContext, + topic: string, + resultSummary: string, + options: TextStreamOptions = {}, +) { + const userPrompt = buildStrictNpcChatDialoguePrompt( + world, + character, + encounter, + monsters, + history, + context, + topic, + resultSummary, + ); + + if (typeof window === 'undefined') { + return aiClient.streamNpcChatDialogue( + world, + character, + encounter, + monsters, + history, + context, + topic, + resultSummary, + options, + ); + } + + try { + const dialogue = await requestPlainTextStream( + `${RUNTIME_API_BASE}/chat/npc/dialogue/stream`, + { + systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, + userPrompt, + }, + options, + ); + return dialogue.trim() || buildOfflineNpcChatDialogue(encounter, topic); + } catch (error) { + console.warn('[aiService] npc dialogue stream fell back to frontend implementation', error); + return aiClient.streamNpcChatDialogue( + world, + character, + encounter, + monsters, + history, + context, + topic, + resultSummary, + options, + ); + } +} + +export async function streamNpcRecruitDialogue( + world: WorldType, + character: Character, + encounter: Encounter, + monsters: SceneHostileNpc[], + history: StoryMoment[], + context: StoryGenerationContext, + invitationText: string, + recruitSummary: string, + options: TextStreamOptions = {}, +) { + const userPrompt = buildNpcRecruitDialoguePrompt( + world, + character, + encounter, + monsters, + history, + context, + invitationText, + recruitSummary, + ); + + if (typeof window === 'undefined') { + return aiClient.streamNpcRecruitDialogue( + world, + character, + encounter, + monsters, + history, + context, + invitationText, + recruitSummary, + options, + ); + } + + try { + const dialogue = await requestPlainTextStream( + `${RUNTIME_API_BASE}/chat/npc/recruit/stream`, + { + systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, + userPrompt, + }, + options, + ); + return dialogue.trim() || buildOfflineNpcRecruitDialogue(encounter); + } catch (error) { + console.warn('[aiService] npc recruit stream fell back to frontend implementation', error); + return aiClient.streamNpcRecruitDialogue( + world, + character, + encounter, + monsters, + history, + context, + invitationText, + recruitSummary, + options, + ); + } +} + +export type { + CustomWorldGenerationProgress, + CustomWorldSceneImageResult, + GenerateCustomWorldProfileInput, + GenerateCustomWorldProfileOptions, + StoryGenerationContext, + StoryRequestOptions, + TextStreamOptions, +}; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts new file mode 100644 index 00000000..bbdfa51e --- /dev/null +++ b/src/services/apiClient.ts @@ -0,0 +1,143 @@ +import { parseApiErrorMessage } from '../editor/shared/jsonClient'; + +const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; +const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1'; +const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1'; +export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed'; + +function canUseLocalStorage() { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function emitAuthStateChange() { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT)); +} + +export function getStoredAccessToken() { + if (!canUseLocalStorage()) { + return ''; + } + + return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; +} + +export function setStoredAccessToken(token: string) { + if (!canUseLocalStorage()) { + return; + } + + const nextToken = token.trim(); + if (nextToken) { + window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); + } else { + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + } + emitAuthStateChange(); +} + +export function clearStoredAccessToken() { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + emitAuthStateChange(); +} + +export function getStoredAutoAuthCredentials() { + if (!canUseLocalStorage()) { + return null; + } + + const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || ''; + const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || ''; + + if (!username || !password) { + return null; + } + + return { + username, + password, + }; +} + +export function setStoredAutoAuthCredentials(credentials: { + username: string; + password: string; +}) { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim()); + window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim()); +} + +export function clearStoredAutoAuthCredentials() { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY); + window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY); + emitAuthStateChange(); +} + +function withAuthorizationHeaders(headers?: HeadersInit) { + const nextHeaders: Record = {}; + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + nextHeaders[key] = value; + }); + } else if (Array.isArray(headers)) { + for (const [key, value] of headers) { + nextHeaders[key] = value; + } + } else if (headers) { + Object.assign(nextHeaders, headers); + } + + const token = getStoredAccessToken(); + if (token) { + nextHeaders.Authorization = `Bearer ${token}`; + } + return nextHeaders; +} + +export async function fetchWithApiAuth( + input: string, + init: RequestInit = {}, +) { + const response = await fetch(input, { + credentials: 'same-origin', + ...init, + headers: withAuthorizationHeaders(init.headers), + }); + + if (response.status === 401) { + clearStoredAccessToken(); + } + + return response; +} + +export async function requestJson( + url: string, + init: RequestInit, + fallbackMessage: string, +): Promise { + const response = await fetchWithApiAuth(url, init); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + } + + return responseText ? (JSON.parse(responseText) as T) : (null as T); +} diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts new file mode 100644 index 00000000..a34b55f9 --- /dev/null +++ b/src/services/authService.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { requestJsonMock } = vi.hoisted(() => ({ + requestJsonMock: vi.fn(), +})); + +import { + clearStoredAccessToken, + clearStoredAutoAuthCredentials, + getStoredAccessToken, + getStoredAutoAuthCredentials, +} from './apiClient'; +import { + authEntryWithStoredCredentials, + createAutoAuthCredentials, + ensureAutoAuthUser, +} from './authService'; + +function createMemoryStorage() { + const values = new Map(); + + return { + getItem(key: string) { + return values.has(key) ? values.get(key)! : null; + }, + setItem(key: string, value: string) { + values.set(key, value); + }, + removeItem(key: string) { + values.delete(key); + }, + clear() { + values.clear(); + }, + }; +} + +vi.mock('./apiClient', async () => { + const actual = await vi.importActual('./apiClient'); + return { + ...actual, + requestJson: requestJsonMock, + }; +}); + +describe('authService auto auth', () => { + beforeEach(() => { + vi.stubGlobal('window', { + localStorage: createMemoryStorage(), + dispatchEvent: vi.fn(), + }); + requestJsonMock.mockReset(); + clearStoredAccessToken(); + clearStoredAutoAuthCredentials(); + }); + + it('creates credentials that match current username/password constraints', () => { + const credentials = createAutoAuthCredentials(); + + expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u); + expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u); + expect(credentials.password.length).toBeGreaterThanOrEqual(6); + }); + + it('stores jwt and auto credentials after auth entry', async () => { + requestJsonMock.mockResolvedValue({ + token: 'jwt-token-value', + user: { + id: 'user_1', + username: 'guest_abc123abc123', + }, + }); + + const user = await authEntryWithStoredCredentials({ + username: 'guest_abc123abc123', + password: 'auto_secret_password', + }); + + expect(user.username).toBe('guest_abc123abc123'); + expect(getStoredAccessToken()).toBe('jwt-token-value'); + expect(getStoredAutoAuthCredentials()).toEqual({ + username: 'guest_abc123abc123', + password: 'auto_secret_password', + }); + }); + + it('reuses stored auto credentials before generating a new account', async () => { + window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01'); + window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password'); + requestJsonMock.mockResolvedValue({ + token: 'jwt-restored', + user: { + id: 'user_saved', + username: 'guest_saveduser01', + }, + }); + + const result = await ensureAutoAuthUser(); + + expect(result.user.username).toBe('guest_saveduser01'); + expect(result.credentials).toEqual({ + username: 'guest_saveduser01', + password: 'auto_saved_password', + }); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/entry', + expect.objectContaining({ + method: 'POST', + }), + '登录失败', + ); + }); +}); diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 00000000..f36c49b7 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,101 @@ +import { + clearStoredAccessToken, + clearStoredAutoAuthCredentials, + getStoredAutoAuthCredentials, + requestJson, + setStoredAccessToken, + setStoredAutoAuthCredentials, +} from './apiClient'; + +export type AuthUser = { + id: string; + username: string; +}; + +export type AutoAuthCredentials = { + username: string; + password: string; +}; + +function buildRandomSegment(length: number) { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const bytes = crypto.getRandomValues(new Uint8Array(length)); + + return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(''); +} + +export function createAutoAuthCredentials(): AutoAuthCredentials { + return { + username: `guest_${buildRandomSegment(12)}`, + password: `auto_${buildRandomSegment(24)}_${buildRandomSegment(8)}`, + }; +} + +export async function authEntry(username: string, password: string) { + const response = await requestJson<{ + token: string; + user: AuthUser; + }>( + '/api/auth/entry', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + }), + }, + '登录失败', + ); + + setStoredAccessToken(response.token); + return response.user; +} + +export async function authEntryWithStoredCredentials( + credentials: AutoAuthCredentials, +) { + const user = await authEntry(credentials.username, credentials.password); + setStoredAutoAuthCredentials(credentials); + return user; +} + +export async function ensureAutoAuthUser() { + const credentials = + getStoredAutoAuthCredentials() ?? createAutoAuthCredentials(); + const user = await authEntryWithStoredCredentials(credentials); + + return { + user, + credentials, + }; +} + +export async function getCurrentAuthUser() { + const response = await requestJson<{ + user: AuthUser | null; + }>( + '/api/auth/me', + { + method: 'GET', + }, + '读取当前用户失败', + ); + + return response.user; +} + +export async function logoutAuthUser() { + try { + await requestJson<{ ok: true }>( + '/api/auth/logout', + { + method: 'POST', + }, + '退出登录失败', + ); + } finally { + clearStoredAccessToken(); + clearStoredAutoAuthCredentials(); + } +} diff --git a/src/services/llmClient.ts b/src/services/llmClient.ts index 0b079744..686137a4 100644 --- a/src/services/llmClient.ts +++ b/src/services/llmClient.ts @@ -1,10 +1,66 @@ import type {TextStreamOptions} from './aiTypes'; +import { fetchWithApiAuth } from './apiClient'; const ENV: Partial = import.meta.env ?? {}; -const API_BASE_URL = ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm'; -const MODEL = ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715'; -const ENABLE_LLM_DEBUG_LOG = Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true'; +type NodeProcessLike = { + env?: Record; +}; + +function getNodeEnv() { + if (typeof window !== 'undefined') { + return {}; + } + + return ( + (globalThis as typeof globalThis & {process?: NodeProcessLike}).process?.env + ?? {} + ); +} + +function normalizeBaseUrl(value: string) { + return value.replace(/\/+$/u, ''); +} + +function coerceBoolean(value: string | undefined) { + return value?.trim().toLowerCase() === 'true'; +} + +function resolveHeaders(headers?: HeadersInit) { + const nextHeaders: Record = {}; + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + nextHeaders[key] = value; + }); + } else if (Array.isArray(headers)) { + for (const [key, value] of headers) { + nextHeaders[key] = value; + } + } else if (headers) { + Object.assign(nextHeaders, headers); + } + + return nextHeaders; +} + +const NODE_ENV = getNodeEnv(); +const IS_SERVER_RUNTIME = typeof window === 'undefined'; +const SERVER_API_KEY = + NODE_ENV.LLM_API_KEY || NODE_ENV.ARK_API_KEY || NODE_ENV.VITE_LLM_API_KEY || ''; +const API_BASE_URL = IS_SERVER_RUNTIME + ? normalizeBaseUrl( + NODE_ENV.LLM_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3', + ) + : (ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm'); +const MODEL = IS_SERVER_RUNTIME + ? (NODE_ENV.LLM_MODEL + || NODE_ENV.VITE_LLM_MODEL + || 'doubao-1-5-pro-32k-character-250715') + : (ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715'); +const ENABLE_LLM_DEBUG_LOG = IS_SERVER_RUNTIME + ? coerceBoolean(NODE_ENV.LLM_DEBUG_LOG) + : (Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true'); export interface PlainTextCompletionOptions { timeoutMs?: number; @@ -31,9 +87,16 @@ export function resolveTimeoutMs(rawValue: string | undefined, fallback: number) return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; } -export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000); +export const REQUEST_TIMEOUT_MS = resolveTimeoutMs( + IS_SERVER_RUNTIME + ? (NODE_ENV.LLM_REQUEST_TIMEOUT_MS || NODE_ENV.VITE_LLM_REQUEST_TIMEOUT_MS) + : ENV.VITE_LLM_REQUEST_TIMEOUT_MS, + 15000, +); export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs( - ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS, + IS_SERVER_RUNTIME + ? (NODE_ENV.LLM_CUSTOM_WORLD_TIMEOUT_MS || NODE_ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS) + : ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS, Math.max(REQUEST_TIMEOUT_MS, 120000), ); @@ -61,6 +124,22 @@ function normalizeLlmError(error: unknown): never { throw error; } +function requestLlmEndpoint(input: string, init: RequestInit = {}) { + const headers = resolveHeaders(init.headers); + if (IS_SERVER_RUNTIME && SERVER_API_KEY.trim()) { + headers.Authorization = `Bearer ${SERVER_API_KEY.trim()}`; + } + + const nextInit = { + ...init, + headers, + } satisfies RequestInit; + + return IS_SERVER_RUNTIME + ? fetch(input, nextInit) + : fetchWithApiAuth(input, nextInit); +} + export function isLlmConnectivityError(error: unknown): error is LlmConnectivityError { return error instanceof LlmConnectivityError; } @@ -103,7 +182,7 @@ async function requestMessageContent( try { logLlmDebug(`[LLM:${debugLabel}] prompt text`, rawPromptText); - const response = await fetch(`${API_BASE_URL}/chat/completions`, { + const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(requestBody), @@ -179,7 +258,7 @@ export async function streamPlainTextCompletion( const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { - const response = await fetch(`${API_BASE_URL}/chat/completions`, { + const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ diff --git a/src/services/questDirector.ts b/src/services/questDirector.ts index 96702538..39d0d832 100644 --- a/src/services/questDirector.ts +++ b/src/services/questDirector.ts @@ -10,6 +10,7 @@ import type { QuestLogEntry, } from '../types'; import type {QuestGenerationContext} from './aiTypes'; +import { requestJson } from './apiClient'; import {requestChatMessageContent} from './llmClient'; import {parseJsonResponseText} from './llmParsers'; import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt'; @@ -217,6 +218,22 @@ export async function generateQuestForNpcEncounter(params: { const fallbackIntent = buildFallbackQuestIntent(request); + if (typeof window !== 'undefined') { + try { + return await requestJson( + '/api/runtime/quests/generate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }, + '任务生成失败', + ); + } catch (error) { + console.warn('[QuestDirector] backend quest generation failed, falling back', error); + } + } + try { const content = await requestChatMessageContent( QUEST_INTENT_SYSTEM_PROMPT, diff --git a/src/services/runtimeItemAiDirector.ts b/src/services/runtimeItemAiDirector.ts index d0b8e5fd..6bc7d6c2 100644 --- a/src/services/runtimeItemAiDirector.ts +++ b/src/services/runtimeItemAiDirector.ts @@ -4,6 +4,7 @@ import type { RuntimeItemGenerationContext, RuntimeItemPlan, } from '../types'; +import { requestJson } from './apiClient'; import {requestChatMessageContent} from './llmClient'; import {parseJsonResponseText} from './llmParsers'; import { @@ -88,6 +89,28 @@ export async function generateRuntimeItemAiIntents(params: { buildRuntimeItemAiIntent(params.context, plan), ); + if (typeof window !== 'undefined') { + try { + const response = await requestJson<{ + intents?: unknown[]; + }>( + '/api/runtime/items/runtime-intent', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }, + '运行时物品意图生成失败', + ); + const rawIntents = Array.isArray(response.intents) ? response.intents : []; + return params.plans.map((_, index) => + sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!), + ); + } catch (error) { + console.warn('[runtimeItemAiDirector] backend intent generation failed, falling back', error); + } + } + const content = await requestChatMessageContent( RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, buildRuntimeItemIntentPrompt(params), diff --git a/src/services/storageService.ts b/src/services/storageService.ts new file mode 100644 index 00000000..3b8579fe --- /dev/null +++ b/src/services/storageService.ts @@ -0,0 +1,97 @@ +import type { + SavedGameSnapshot, + SavedGameSnapshotInput, +} from '../persistence/gameSaveStorage'; +import type { SavedGameSettings } from '../persistence/gameSettingsStorage'; +import type { CustomWorldProfile } from '../types'; +import { requestJson } from './apiClient'; + +const RUNTIME_API_BASE = '/api/runtime'; + +type CustomWorldLibraryResponse = { + profiles?: CustomWorldProfile[]; +}; + +export async function getSaveSnapshot() { + return requestJson( + `${RUNTIME_API_BASE}/save/snapshot`, + { method: 'GET' }, + '读取存档失败', + ); +} + +export async function putSaveSnapshot(snapshot: SavedGameSnapshotInput) { + return requestJson( + `${RUNTIME_API_BASE}/save/snapshot`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot), + }, + '保存存档失败', + ); +} + +export async function deleteSaveSnapshot() { + return requestJson<{ ok: true }>( + `${RUNTIME_API_BASE}/save/snapshot`, + { method: 'DELETE' }, + '删除存档失败', + ); +} + +export async function getSettings() { + return requestJson( + `${RUNTIME_API_BASE}/settings`, + { method: 'GET' }, + '读取设置失败', + ); +} + +export async function putSettings(settings: SavedGameSettings) { + return requestJson( + `${RUNTIME_API_BASE}/settings`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }, + '保存设置失败', + ); +} + +export async function listCustomWorldLibrary() { + const response = await requestJson( + `${RUNTIME_API_BASE}/custom-world-library`, + { method: 'GET' }, + '读取自定义世界库失败', + ); + + return Array.isArray(response?.profiles) ? response.profiles : []; +} + +export async function upsertCustomWorldProfile(profile: CustomWorldProfile) { + const response = await requestJson( + `${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profile.id)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profile, + }), + }, + '保存自定义世界失败', + ); + + return Array.isArray(response?.profiles) ? response.profiles : []; +} + +export async function deleteCustomWorldProfile(profileId: string) { + const response = await requestJson( + `${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profileId)}`, + { method: 'DELETE' }, + '删除自定义世界失败', + ); + + return Array.isArray(response?.profiles) ? response.profiles : []; +} diff --git a/tools/Caddyfile.dev b/tools/Caddyfile.dev new file mode 100644 index 00000000..17c05a5e --- /dev/null +++ b/tools/Caddyfile.dev @@ -0,0 +1,21 @@ +{ + auto_https off +} + +:8080 { + root * {$CADDY_SITE_ROOT} + + handle /api/* { + reverse_proxy {$CADDY_API_UPSTREAM} + } + + handle /healthz { + respond "ok" 200 + } + + handle { + encode gzip zstd + try_files {path} /index.html + file_server + } +} diff --git a/vite.config.ts b/vite.config.ts index ce38a2a7..9b73eb77 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,8 +4,6 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import {defineConfig, loadEnv} from 'vite'; -import {createLocalApiPlugins} from './scripts/dev-server/localApiPlugins'; - export default defineConfig(({mode}) => { const env = loadEnv(mode, __dirname, ''); const ignoredWatchGlobs = [ @@ -15,6 +13,9 @@ export default defineConfig(({mode}) => { '**/dist_check_monster_position/**', '**/temp*build*/**', ]; + const runtimeServerTarget = + env.NODE_SERVER_TARGET || + 'http://127.0.0.1:8081'; return { root: __dirname, @@ -22,7 +23,6 @@ export default defineConfig(({mode}) => { plugins: [ react(), tailwindcss(), - ...createLocalApiPlugins(__dirname, mode, env), ], define: { 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), @@ -44,6 +44,34 @@ export default defineConfig(({mode}) => { // HMR is disabled in AI Studio via DISABLE_HMR env var. // Do not modify; file watching is disabled to prevent flickering during agent edits. hmr: process.env.DISABLE_HMR !== 'true', + proxy: { + '/api/auth': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, + '/api/runtime': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, + '/api/custom-world/scene-image': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, + '/api/llm': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, + '/api/ws': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + ws: true, + }, + }, watch: { ignored: ignoredWatchGlobs, },