feat: migrate runtime backend to node server
This commit is contained in:
15
.env.example
15
.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"
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,3 +5,8 @@ coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
/public/generated-custom-world-scenes
|
||||
/server-node/dist/
|
||||
/server-node/logs/*
|
||||
!/server-node/logs/.gitkeep
|
||||
/server-node/data/*
|
||||
!/server-node/data/.gitkeep
|
||||
|
||||
79
AGENTS.md
79
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文件。
|
||||
- 不要在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
|
||||
```
|
||||
|
||||
194
docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md
Normal file
194
docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md
Normal file
@@ -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 <token>`
|
||||
- 后端 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 后端。
|
||||
@@ -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 生成角色形象与角色动画的技术路线。
|
||||
- [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md):PixelMotion 产品形态与能力拆解。
|
||||
- [SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md](./SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md):服务端部署、代理层与 CORS 方案。
|
||||
|
||||
@@ -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",
|
||||
|
||||
198
scripts/dev-node.mjs
Normal file
198
scripts/dev-node.mjs
Normal file
@@ -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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
149
scripts/run-caddy-dev.mjs
Normal file
149
scripts/run-caddy-dev.mjs
Normal file
@@ -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);
|
||||
});
|
||||
13
server-node/build.mjs
Normal file
13
server-node/build.mjs
Normal file
@@ -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',
|
||||
});
|
||||
1
server-node/data/.gitkeep
Normal file
1
server-node/data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
server-node/logs/.gitkeep
Normal file
1
server-node/logs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2957
server-node/package-lock.json
generated
Normal file
2957
server-node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
server-node/package.json
Normal file
32
server-node/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
264
server-node/src/app.test.ts
Normal file
264
server-node/src/app.test.ts
Normal file
@@ -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<T>(
|
||||
testName: string,
|
||||
run: (options: { baseUrl: string }) => Promise<T>,
|
||||
) {
|
||||
const context = createAppContext(createTestConfig(testName));
|
||||
const app = createApp(context);
|
||||
const server = await new Promise<import('node:http').Server>((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<void>((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, []);
|
||||
});
|
||||
});
|
||||
69
server-node/src/app.ts
Normal file
69
server-node/src/app.ts
Normal file
@@ -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<string, unknown>) => 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<string, unknown> & { 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<string, unknown> & { 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;
|
||||
}
|
||||
70
server-node/src/auth/authService.ts
Normal file
70
server-node/src/auth/authService.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
16
server-node/src/auth/password.ts
Normal file
16
server-node/src/auth/password.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
46
server-node/src/auth/token.ts
Normal file
46
server-node/src/auth/token.ts
Normal file
@@ -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 校验失败');
|
||||
}
|
||||
}
|
||||
162
server-node/src/config.ts
Normal file
162
server-node/src/config.ts
Normal file
@@ -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<Record<string, string>>((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<string, string | undefined>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
) {
|
||||
const value = env[key]?.trim();
|
||||
return value ? value : fallback;
|
||||
}
|
||||
|
||||
function readPositiveInt(
|
||||
env: Record<string, string | undefined>,
|
||||
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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
18
server-node/src/context.ts
Normal file
18
server-node/src/context.ts
Normal file
@@ -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;
|
||||
};
|
||||
57
server-node/src/db.ts
Normal file
57
server-node/src/db.ts
Normal file
@@ -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;
|
||||
}
|
||||
35
server-node/src/errors.ts
Normal file
35
server-node/src/errors.ts
Normal file
@@ -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);
|
||||
}
|
||||
48
server-node/src/http.ts
Normal file
48
server-node/src/http.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
export function asyncHandler(
|
||||
handler: (
|
||||
request: Request,
|
||||
response: Response,
|
||||
next: NextFunction,
|
||||
) => Promise<unknown> | 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<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
65
server-node/src/logging.ts
Normal file
65
server-node/src/logging.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
40
server-node/src/middleware/auth.ts
Normal file
40
server-node/src/middleware/auth.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
27
server-node/src/middleware/errorHandler.ts
Normal file
27
server-node/src/middleware/errorHandler.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
8
server-node/src/middleware/requestId.ts
Normal file
8
server-node/src/middleware/requestId.ts
Normal file
@@ -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();
|
||||
};
|
||||
182
server-node/src/repositories/runtimeRepository.ts
Normal file
182
server-node/src/repositories/runtimeRepository.ts
Normal file
@@ -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<T>(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<SavedSnapshot, 'version'>) {
|
||||
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<Record<string, unknown>>(row.payload_json));
|
||||
}
|
||||
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
88
server-node/src/repositories/userRepository.ts
Normal file
88
server-node/src/repositories/userRepository.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
53
server-node/src/routes/authRoutes.ts
Normal file
53
server-node/src/routes/authRoutes.ts
Normal file
@@ -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;
|
||||
}
|
||||
386
server-node/src/routes/runtimeRoutes.ts
Normal file
386
server-node/src/routes/runtimeRoutes.ts
Normal file
@@ -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<RuntimeItemGenerationContext>(),
|
||||
plans: z.array(z.custom<RuntimeItemPlan>()),
|
||||
});
|
||||
|
||||
const questGenerationSchema = z.object({
|
||||
state: z.custom<GameState>(),
|
||||
encounter: z.custom<Encounter>(),
|
||||
});
|
||||
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>);
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
93
server-node/src/server.ts
Normal file
93
server-node/src/server.ts
Normal file
@@ -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();
|
||||
}
|
||||
6
server-node/src/services/chatService.ts
Normal file
6
server-node/src/services/chatService.ts
Normal file
@@ -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),
|
||||
});
|
||||
29
server-node/src/services/customWorldGenerationService.ts
Normal file
29
server-node/src/services/customWorldGenerationService.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
174
server-node/src/services/customWorldSessionStore.ts
Normal file
174
server-node/src/services/customWorldSessionStore.ts
Normal file
@@ -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<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
questions: CustomWorldQuestion[];
|
||||
result?: Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, Map<string, CustomWorldSession>>();
|
||||
|
||||
create(
|
||||
userId: string,
|
||||
settingText: string,
|
||||
creatorIntent: Record<string, unknown> | 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<string, CustomWorldSession>();
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
}
|
||||
169
server-node/src/services/llmClient.ts
Normal file
169
server-node/src/services/llmClient.ts
Normal file
@@ -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<string, unknown>, 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);
|
||||
}
|
||||
}
|
||||
140
server-node/src/services/questService.ts
Normal file
140
server-node/src/services/questService.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<QuestLogEntry | null> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
104
server-node/src/services/runtimeItemService.ts
Normal file
104
server-node/src/services/runtimeItemService.ts
Normal file
@@ -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<string, unknown>;
|
||||
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]!),
|
||||
);
|
||||
}
|
||||
193
server-node/src/services/sceneImageService.ts
Normal file
193
server-node/src/services/sceneImageService.ts
Normal file
@@ -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<typeof sceneImageSchema>,
|
||||
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<typeof sceneImageSchema>,
|
||||
) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
74
server-node/src/services/storyService.ts
Normal file
74
server-node/src/services/storyService.ts
Normal file
@@ -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<typeof parseStoryRequest>,
|
||||
) {
|
||||
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<typeof parseStoryRequest>,
|
||||
) {
|
||||
const params = toTypedStoryParams(request);
|
||||
return generateInitialStoryFromAi(
|
||||
params.worldType,
|
||||
params.character,
|
||||
params.monsters,
|
||||
params.context,
|
||||
params.requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateHighQualityNextStory(
|
||||
request: ReturnType<typeof parseStoryRequest>,
|
||||
) {
|
||||
const params = toTypedStoryParams(request);
|
||||
return generateNextStepFromAi(
|
||||
params.worldType,
|
||||
params.character,
|
||||
params.monsters,
|
||||
params.history,
|
||||
params.choice,
|
||||
params.context,
|
||||
params.requestOptions,
|
||||
);
|
||||
}
|
||||
14
server-node/src/types/express.d.ts
vendored
Normal file
14
server-node/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
requestId: string;
|
||||
userId?: string;
|
||||
auth?: {
|
||||
userId: string;
|
||||
tokenVersion: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
23
server-node/tsconfig.json
Normal file
23
server-node/tsconfig.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -94,18 +94,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,
|
||||
|
||||
10
src/AuthenticatedApp.tsx
Normal file
10
src/AuthenticatedApp.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import App from './App';
|
||||
import { AuthGate } from './components/auth/AuthGate';
|
||||
|
||||
export default function AuthenticatedApp() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<App />
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import {
|
||||
type CustomWorldSceneImageResult,
|
||||
generateCustomWorldSceneImage,
|
||||
} from '../services/ai';
|
||||
} from '../services/aiService';
|
||||
import {
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
160
src/components/auth/AuthGate.tsx
Normal file
160
src/components/auth/AuthGate.tsx
Normal file
@@ -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<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
正在校验登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'recovering') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
正在自动创建或恢复账号...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
|
||||
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="text-base font-medium text-zinc-50">自动登录失败</div>
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 rounded-full border border-amber-300/30 px-4 py-2 text-sm text-amber-100 transition hover:border-amber-300/60 hover:bg-amber-300/10"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
重新尝试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||
<span>{user.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
||||
onClick={() => {
|
||||
void logoutAuthUser();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/auth/LoginScreen.tsx
Normal file
89
src/components/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type LoginScreenProps = {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
onSubmit: (username: string, password: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function LoginScreen({
|
||||
loading,
|
||||
error,
|
||||
onSubmit,
|
||||
}: LoginScreenProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-8 text-zinc-100">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-5xl items-center justify-center">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-amber-200/70">
|
||||
Genarrative
|
||||
</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
登录后进入冒险
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
当前版本已切到后端账号模式。输入用户名和密码即可直接进入,用户名不存在时会自动创建账号。
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
用户名:3 到 24 位字母、数字、下划线
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
第一次提交会自动注册,后续同名即登录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(username, password);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>用户名</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="hero_name"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? '正在进入...' : '进入游戏'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,20 +4,20 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
readSavedCustomWorldProfiles,
|
||||
upsertSavedCustomWorldProfile,
|
||||
} from '../../data/customWorldLibrary';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile,
|
||||
} from '../../services/ai';
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
listCustomWorldLibrary,
|
||||
upsertCustomWorldProfile,
|
||||
} from '../../services/storageService';
|
||||
import {
|
||||
type CustomWorldCreatorIntent,
|
||||
type CustomWorldGenerationMode,
|
||||
@@ -172,7 +172,7 @@ export function PreGameSelectionFlow({
|
||||
useState<GameState['customWorldProfile']>(null);
|
||||
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
|
||||
CustomWorldProfile[]
|
||||
>(() => readSavedCustomWorldProfiles());
|
||||
>([]);
|
||||
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
|
||||
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
|
||||
() => generateWorldOnlineCounts(),
|
||||
@@ -280,6 +280,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);
|
||||
@@ -331,18 +350,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;
|
||||
}
|
||||
@@ -650,7 +669,7 @@ export function PreGameSelectionFlow({
|
||||
id: generatedCustomWorldProfile.id,
|
||||
}
|
||||
: profile;
|
||||
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
|
||||
const savedProfiles = await upsertCustomWorldProfile(persistedProfile);
|
||||
setSavedCustomWorldProfiles(savedProfiles);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
@@ -1034,7 +1053,9 @@ export function PreGameSelectionFlow({
|
||||
onRegenerateLandmarkNetwork={() => {
|
||||
void regenerateLandmarkNetwork();
|
||||
}}
|
||||
onSave={saveGeneratedCustomWorld}
|
||||
onSave={() => {
|
||||
void saveGeneratedCustomWorld();
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
} from '../../services/ai';
|
||||
} from '../../services/aiService';
|
||||
import type {StoryGenerationContext} from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -39,7 +39,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,7 +12,12 @@ 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,
|
||||
@@ -169,9 +174,24 @@ export function useGamePersistence({
|
||||
resetStoryState: () => void;
|
||||
}) {
|
||||
const [hasSavedGame, setHasSavedGame] = useState(false);
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasSavedGame(Boolean(readSavedSnapshot()));
|
||||
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(() => {
|
||||
@@ -180,21 +200,24 @@ export function useGamePersistence({
|
||||
if (!canPersist) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const didSave = writeSavedSnapshot({
|
||||
void putSaveSnapshot({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
});
|
||||
|
||||
if (didSave) {
|
||||
setHasSavedGame(true);
|
||||
}
|
||||
})
|
||||
.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;
|
||||
@@ -207,28 +230,40 @@ export function useGamePersistence({
|
||||
return false;
|
||||
}
|
||||
|
||||
const didSave = writeSavedSnapshot({
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
});
|
||||
|
||||
if (didSave) {
|
||||
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();
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -236,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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
sortStoryOptionsByPriority,
|
||||
} from '../data/stateFunctions';
|
||||
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
|
||||
import { generateInitialStory, generateNextStep } from '../services/ai';
|
||||
import { generateInitialStory, generateNextStep } from '../services/aiService';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
|
||||
@@ -25,7 +25,7 @@ export type ResolvedAppRoute = {
|
||||
componentProps?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const GameApp = lazy(() => import('../App')) as AppRouteComponent;
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const PresetEditorApp = lazy(async () => {
|
||||
const module = await import('../components/PresetEditor');
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
@@ -129,6 +130,8 @@ export type {
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
type RawOptionItem = {
|
||||
functionId: string;
|
||||
actionText?: string;
|
||||
@@ -139,7 +142,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 对象的文本。
|
||||
@@ -155,7 +158,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;
|
||||
})();
|
||||
|
||||
@@ -1776,6 +1779,60 @@ async function requestCompletion(
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateInitialStoryStrict(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
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<AIResponse> {
|
||||
return requestCompletion(
|
||||
buildUserPrompt(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
choice,
|
||||
requestOptions.availableOptions,
|
||||
requestOptions.optionCatalog,
|
||||
),
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage({
|
||||
profile,
|
||||
landmark,
|
||||
@@ -1794,7 +1851,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({
|
||||
|
||||
825
src/services/aiService.ts
Normal file
825
src/services/aiService.ts
Normal file
@@ -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<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestJson<T>(
|
||||
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<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson<AIResponse>(
|
||||
`${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<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson<AIResponse>(
|
||||
`${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<string, unknown> | null,
|
||||
generationMode: normalizedInput.generationMode,
|
||||
});
|
||||
|
||||
const fallbackAnswerMap: Record<string, string> = {
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
throw new Error(
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: '生成自定义世界失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestProfile) {
|
||||
throw new Error('自定义世界生成未返回结果');
|
||||
}
|
||||
|
||||
return latestProfile as unknown as Awaited<
|
||||
ReturnType<typeof aiClient.generateCustomWorldProfile>
|
||||
>;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
...args: Parameters<typeof aiClient.generateCustomWorldSceneImage>
|
||||
) {
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
}
|
||||
|
||||
export async function createCustomWorldSession(payload: {
|
||||
settingText: string;
|
||||
creatorIntent?: Record<string, unknown> | 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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
143
src/services/apiClient.ts
Normal file
143
src/services/apiClient.ts
Normal file
@@ -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<string, string> = {};
|
||||
|
||||
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<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
): Promise<T> {
|
||||
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);
|
||||
}
|
||||
113
src/services/authService.test.ts
Normal file
113
src/services/authService.test.ts
Normal file
@@ -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<string, string>();
|
||||
|
||||
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<typeof import('./apiClient')>('./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',
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
101
src/services/authService.ts
Normal file
101
src/services/authService.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,66 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
|
||||
const ENV: Partial<ImportMetaEnv> = 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<string, string | undefined>;
|
||||
};
|
||||
|
||||
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<string, string> = {};
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -57,6 +120,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;
|
||||
}
|
||||
@@ -99,7 +178,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),
|
||||
@@ -175,7 +254,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({
|
||||
|
||||
@@ -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';
|
||||
@@ -204,6 +205,22 @@ export async function generateQuestForNpcEncounter(params: {
|
||||
|
||||
const fallbackIntent = buildFallbackQuestIntent(request);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
return await requestJson<QuestLogEntry | null>(
|
||||
'/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,
|
||||
@@ -237,4 +254,3 @@ export async function generateQuestForNpcEncounter(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
97
src/services/storageService.ts
Normal file
97
src/services/storageService.ts
Normal file
@@ -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<SavedGameSnapshot | null>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(snapshot: SavedGameSnapshotInput) {
|
||||
return requestJson<SavedGameSnapshot>(
|
||||
`${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<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSettings(settings: SavedGameSettings) {
|
||||
return requestJson<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary() {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${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<CustomWorldLibraryResponse>(
|
||||
`${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<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
21
tools/Caddyfile.dev
Normal file
21
tools/Caddyfile.dev
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ 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 runtimeServerTarget =
|
||||
env.NODE_SERVER_TARGET ||
|
||||
'http://127.0.0.1:8081';
|
||||
|
||||
return {
|
||||
root: __dirname,
|
||||
@@ -15,7 +16,6 @@ export default defineConfig(({mode}) => {
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
...createLocalApiPlugins(__dirname, mode, env),
|
||||
],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
@@ -29,6 +29,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: ['**/dist/**', '**/dist_check/**', '**/dist_check_final/**', '**/dist_check_monster_position/**'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user