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

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