feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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
```

View 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 后端。

View File

@@ -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 方案。

View File

@@ -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
View 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
View 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
View 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',
});

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

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
View 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
View 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
View 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;
}

View 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,
};
}

View 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,
});
}

View 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
View 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,
),
},
};
}

View 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
View 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
View 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
View 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;
}

View 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,
);
}

View 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);
}
};
}

View 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,
},
});
};

View 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();
};

View 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);
}
}

View 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);
}
}

View 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;
}

View 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
View 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();
}

View 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),
});

View 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>;
}

View 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);
}
}

View 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);
}
}

View 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,
);
}
}

View 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]!),
);
}

View 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,
};
}

View 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
View 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
View 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"
]
}

View File

@@ -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
View File

@@ -0,0 +1,10 @@
import App from './App';
import { AuthGate } from './components/auth/AuthGate';
export default function AuthenticatedApp() {
return (
<AuthGate>
<App />
</AuthGate>
);
}

View File

@@ -19,7 +19,7 @@ import {
import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/ai';
} from '../services/aiService';
import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,

View File

@@ -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 {

View 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>
);
}

View 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>
);
}

View File

@@ -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>
)}

View File

@@ -4,7 +4,7 @@ import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
} from '../../services/ai';
} from '../../services/aiService';
import type {StoryGenerationContext} from '../../services/aiTypes';
import type {
Character,

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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));

View File

@@ -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,

View File

@@ -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');

View File

@@ -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
View 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
View 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);
}

View 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
View 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();
}
}

View File

@@ -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({

View File

@@ -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: {
);
}
}

View File

@@ -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),

View 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
View 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
}
}

View File

@@ -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/**'],
},