feat: migrate runtime backend to node server
This commit is contained in:
13
server-node/build.mjs
Normal file
13
server-node/build.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import esbuild from 'esbuild';
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/server.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
outfile: 'dist/server.js',
|
||||
sourcemap: true,
|
||||
packages: 'external',
|
||||
tsconfig: 'tsconfig.json',
|
||||
});
|
||||
1
server-node/data/.gitkeep
Normal file
1
server-node/data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
server-node/logs/.gitkeep
Normal file
1
server-node/logs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2957
server-node/package-lock.json
generated
Normal file
2957
server-node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
server-node/package.json
Normal file
32
server-node/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "genarrative-server-node",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "node build.mjs",
|
||||
"start": "node dist/server.js",
|
||||
"test": "node --test --import tsx src/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jose": "^6.1.0",
|
||||
"pino": "^9.9.5",
|
||||
"pino-http": "^10.5.0",
|
||||
"pino-roll": "^3.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.6.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
264
server-node/src/app.test.ts
Normal file
264
server-node/src/app.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createApp } from './app.js';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { createAppContext } from './server.js';
|
||||
|
||||
function createTestConfig(testName: string): AppConfig {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot: tempRoot,
|
||||
publicDir: path.join(tempRoot, 'public'),
|
||||
logsDir: path.join(tempRoot, 'logs'),
|
||||
dataDir: path.join(tempRoot, 'data'),
|
||||
sqlitePath: path.join(tempRoot, 'data', 'test.sqlite'),
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
jwtSecret: 'test-secret',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'genarrative-server-node-test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withTestServer<T>(
|
||||
testName: string,
|
||||
run: (options: { baseUrl: string }) => Promise<T>,
|
||||
) {
|
||||
const context = createAppContext(createTestConfig(testName));
|
||||
const app = createApp(context);
|
||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address() as AddressInfo;
|
||||
return await run({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
context.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function authEntry(baseUrl: string, username: string, password: string) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/entry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json() as {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(payload.token);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function withBearer(token: string, init: RequestInit = {}) {
|
||||
return {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} satisfies RequestInit;
|
||||
}
|
||||
|
||||
test('auth entry auto-registers, me works, logout invalidates old token', async () => {
|
||||
await withTestServer('auth', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'hero_test', 'secret123');
|
||||
|
||||
const meResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
const mePayload = await meResponse.json() as {
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(meResponse.status, 200);
|
||||
assert.equal(mePayload.user.username, 'hero_test');
|
||||
|
||||
const logoutResponse = await fetch(
|
||||
`${baseUrl}/api/auth/logout`,
|
||||
withBearer(entry.token, { method: 'POST' }),
|
||||
);
|
||||
assert.equal(logoutResponse.status, 200);
|
||||
|
||||
const expiredResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
assert.equal(expiredResponse.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test('issued jwt remains valid without exp until logout invalidates token version', async () => {
|
||||
await withTestServer('permanent-jwt', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'hero_eternal', 'secret123');
|
||||
const tokenParts = entry.token.split('.');
|
||||
assert.equal(tokenParts.length, 3);
|
||||
|
||||
const payloadJson = JSON.parse(
|
||||
Buffer.from(tokenParts[1] || '', 'base64url').toString('utf8'),
|
||||
) as {
|
||||
exp?: number;
|
||||
sub?: string;
|
||||
ver?: number;
|
||||
};
|
||||
|
||||
assert.equal(typeof payloadJson.sub, 'string');
|
||||
assert.equal(typeof payloadJson.ver, 'number');
|
||||
assert.equal('exp' in payloadJson, false);
|
||||
|
||||
const meResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
assert.equal(meResponse.status, 200);
|
||||
|
||||
const logoutResponse = await fetch(
|
||||
`${baseUrl}/api/auth/logout`,
|
||||
withBearer(entry.token, { method: 'POST' }),
|
||||
);
|
||||
assert.equal(logoutResponse.status, 200);
|
||||
|
||||
const invalidatedResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
});
|
||||
assert.equal(invalidatedResponse.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime persistence is isolated by user', async () => {
|
||||
await withTestServer('persistence', async ({ baseUrl }) => {
|
||||
const userA = await authEntry(baseUrl, 'player_one', 'secret123');
|
||||
const userB = await authEntry(baseUrl, 'player_two', 'secret123');
|
||||
|
||||
const saveResponse = await fetch(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(userA.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
gameState: { worldType: 'WUXIA', value: 1 },
|
||||
bottomTab: 'adventure',
|
||||
currentStory: { text: 'story A' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(saveResponse.status, 200);
|
||||
|
||||
const settingsResponse = await fetch(
|
||||
`${baseUrl}/api/runtime/settings`,
|
||||
withBearer(userA.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
musicVolume: 0.25,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(settingsResponse.status, 200);
|
||||
|
||||
const libraryResponse = await fetch(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-a`,
|
||||
withBearer(userA.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
id: 'world-a',
|
||||
name: '世界 A',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(libraryResponse.status, 200);
|
||||
|
||||
const userASave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${userA.token}`,
|
||||
},
|
||||
});
|
||||
const userASavePayload = await userASave.json() as {
|
||||
gameState: {
|
||||
value: number;
|
||||
};
|
||||
};
|
||||
assert.equal(userASavePayload.gameState.value, 1);
|
||||
|
||||
const userBSave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${userB.token}`,
|
||||
},
|
||||
});
|
||||
const userBSavePayload = await userBSave.json();
|
||||
assert.equal(userBSavePayload, null);
|
||||
|
||||
const userBSettings = await fetch(`${baseUrl}/api/runtime/settings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${userB.token}`,
|
||||
},
|
||||
});
|
||||
const userBSettingsPayload = await userBSettings.json() as {
|
||||
musicVolume: number;
|
||||
};
|
||||
assert.equal(userBSettingsPayload.musicVolume, 0.42);
|
||||
|
||||
const userBLibrary = await fetch(
|
||||
`${baseUrl}/api/runtime/custom-world-library`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${userB.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const userBLibraryPayload = await userBLibrary.json() as {
|
||||
profiles: unknown[];
|
||||
};
|
||||
assert.deepEqual(userBLibraryPayload.profiles, []);
|
||||
});
|
||||
});
|
||||
69
server-node/src/app.ts
Normal file
69
server-node/src/app.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import express from 'express';
|
||||
import pinoHttp from 'pino-http';
|
||||
|
||||
import type { AppContext } from './context.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { requestIdMiddleware } from './middleware/requestId.js';
|
||||
import { createAuthRoutes } from './routes/authRoutes.js';
|
||||
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
|
||||
|
||||
export function createApp(context: AppContext) {
|
||||
const app = express();
|
||||
const createHttpLogger = pinoHttp as unknown as (options: Record<string, unknown>) => express.RequestHandler;
|
||||
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(requestIdMiddleware);
|
||||
app.use(
|
||||
createHttpLogger({
|
||||
logger: context.logger,
|
||||
genReqId: (request) => request.requestId,
|
||||
customProps: (request: express.Request) => ({
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
}),
|
||||
customSuccessObject: (
|
||||
request: express.Request,
|
||||
response: express.Response,
|
||||
baseObject: Record<string, unknown> & { responseTime?: number },
|
||||
) => ({
|
||||
...baseObject,
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
status: response.statusCode,
|
||||
latency_ms: baseObject.responseTime,
|
||||
}),
|
||||
customErrorObject: (
|
||||
request: express.Request,
|
||||
response: express.Response,
|
||||
error: unknown,
|
||||
baseObject: Record<string, unknown> & { responseTime?: number },
|
||||
) => ({
|
||||
...baseObject,
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
status: response.statusCode,
|
||||
latency_ms: baseObject.responseTime,
|
||||
err: error,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.get('/healthz', (_request, response) => {
|
||||
response.json({
|
||||
ok: true,
|
||||
service: 'genarrative-node-server',
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/auth', createAuthRoutes(context));
|
||||
app.use('/api', createRuntimeRoutes(context));
|
||||
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
70
server-node/src/auth/authService.ts
Normal file
70
server-node/src/auth/authService.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest, unauthorized } from '../errors.js';
|
||||
import { hashPassword, verifyPassword } from './password.js';
|
||||
import { signAccessToken } from './token.js';
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u;
|
||||
|
||||
function normalizeUsername(username: string) {
|
||||
return username.trim();
|
||||
}
|
||||
|
||||
function validateCredentials(username: string, password: string) {
|
||||
if (!USERNAME_PATTERN.test(username)) {
|
||||
throw badRequest('用户名只允许 3 到 24 位字母、数字、下划线');
|
||||
}
|
||||
if (password.length < 6 || password.length > 128) {
|
||||
throw badRequest('密码长度需要在 6 到 128 位之间');
|
||||
}
|
||||
}
|
||||
|
||||
export async function entryWithPassword(
|
||||
context: AppContext,
|
||||
usernameInput: string,
|
||||
password: string,
|
||||
) {
|
||||
const username = normalizeUsername(usernameInput);
|
||||
validateCredentials(username, password);
|
||||
|
||||
let user = context.userRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
user = context.userRepository.create(username, passwordHash);
|
||||
} else {
|
||||
const isValid = await verifyPassword(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
throw unauthorized('用户名或密码错误');
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error('failed to resolve user after auth entry');
|
||||
}
|
||||
|
||||
const token = await signAccessToken(
|
||||
{
|
||||
userId: user.id,
|
||||
tokenVersion: user.tokenVersion,
|
||||
},
|
||||
context.config,
|
||||
);
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function logoutUser(context: AppContext, userId: string) {
|
||||
const user = context.userRepository.incrementTokenVersion(userId);
|
||||
if (!user) {
|
||||
throw unauthorized('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
};
|
||||
}
|
||||
16
server-node/src/auth/password.ts
Normal file
16
server-node/src/auth/password.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Algorithm, hash, verify } from '@node-rs/argon2';
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
return hash(password, {
|
||||
algorithm: Algorithm.Argon2id,
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(passwordHash: string, password: string) {
|
||||
return verify(passwordHash, password, {
|
||||
algorithm: Algorithm.Argon2id,
|
||||
});
|
||||
}
|
||||
46
server-node/src/auth/token.ts
Normal file
46
server-node/src/auth/token.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { jwtVerify, SignJWT } from 'jose';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { unauthorized } from '../errors.js';
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
userId: string;
|
||||
tokenVersion: number;
|
||||
};
|
||||
|
||||
function getSecret(config: AppConfig) {
|
||||
return new TextEncoder().encode(config.jwtSecret);
|
||||
}
|
||||
|
||||
export async function signAccessToken(
|
||||
claims: AccessTokenClaims,
|
||||
config: AppConfig,
|
||||
) {
|
||||
return new SignJWT({ ver: claims.tokenVersion })
|
||||
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||
.setSubject(claims.userId)
|
||||
.setIssuer(config.jwtIssuer)
|
||||
.setIssuedAt()
|
||||
.sign(getSecret(config));
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string, config: AppConfig) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret(config), {
|
||||
issuer: config.jwtIssuer,
|
||||
});
|
||||
const userId = typeof payload.sub === 'string' ? payload.sub : '';
|
||||
const tokenVersion = typeof payload.ver === 'number' ? payload.ver : NaN;
|
||||
|
||||
if (!userId || !Number.isFinite(tokenVersion)) {
|
||||
throw unauthorized('JWT 内容无效');
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
tokenVersion,
|
||||
} satisfies AccessTokenClaims;
|
||||
} catch (error) {
|
||||
throw unauthorized('JWT 校验失败');
|
||||
}
|
||||
}
|
||||
162
server-node/src/config.ts
Normal file
162
server-node/src/config.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type AppConfig = {
|
||||
nodeEnv: string;
|
||||
projectRoot: string;
|
||||
publicDir: string;
|
||||
logsDir: string;
|
||||
dataDir: string;
|
||||
sqlitePath: string;
|
||||
serverAddr: string;
|
||||
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
|
||||
jwtSecret: string;
|
||||
jwtExpiresIn: string;
|
||||
jwtIssuer: string;
|
||||
llm: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
dashScope: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
imageModel: string;
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
type LoadConfigOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
projectRoot?: string;
|
||||
};
|
||||
|
||||
function parseEnvContents(contents: string) {
|
||||
return contents
|
||||
.split(/\r?\n/u)
|
||||
.reduce<Record<string, string>>((envMap, rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex < 0) {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envMap[key] = value;
|
||||
return envMap;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readEnvFile(filePath: string) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseEnvContents(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function resolveDefaultProjectRoot() {
|
||||
const cwd = process.cwd();
|
||||
return path.basename(cwd) === 'server-node'
|
||||
? path.resolve(cwd, '..')
|
||||
: cwd;
|
||||
}
|
||||
|
||||
function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) {
|
||||
return {
|
||||
...readEnvFile(path.join(projectRoot, '.env.example')),
|
||||
...readEnvFile(path.join(projectRoot, '.env.local')),
|
||||
...processEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function readString(
|
||||
env: Record<string, string | undefined>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
) {
|
||||
const value = env[key]?.trim();
|
||||
return value ? value : fallback;
|
||||
}
|
||||
|
||||
function readPositiveInt(
|
||||
env: Record<string, string | undefined>,
|
||||
key: string,
|
||||
fallback: number,
|
||||
) {
|
||||
const parsed = Number(env[key]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
||||
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
|
||||
const env = readMergedEnv(projectRoot, options.env ?? process.env);
|
||||
const logsDir = path.join(projectRoot, 'server-node', 'logs');
|
||||
const dataDir = path.join(projectRoot, 'server-node', 'data');
|
||||
|
||||
return {
|
||||
nodeEnv: readString(env, 'NODE_ENV', 'development'),
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir,
|
||||
dataDir,
|
||||
sqlitePath: readString(
|
||||
env,
|
||||
'SQLITE_PATH',
|
||||
path.join(dataDir, 'genarrative.sqlite'),
|
||||
),
|
||||
serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'),
|
||||
logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'],
|
||||
jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'),
|
||||
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '7d'),
|
||||
jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'),
|
||||
llm: {
|
||||
baseUrl: readString(
|
||||
env,
|
||||
'LLM_BASE_URL',
|
||||
'https://ark.cn-beijing.volces.com/api/v3',
|
||||
),
|
||||
apiKey:
|
||||
env.LLM_API_KEY?.trim() ||
|
||||
env.ARK_API_KEY?.trim() ||
|
||||
env.VITE_LLM_API_KEY?.trim() ||
|
||||
'',
|
||||
model: readString(
|
||||
env,
|
||||
'LLM_MODEL',
|
||||
readString(
|
||||
env,
|
||||
'VITE_LLM_MODEL',
|
||||
'doubao-1-5-pro-32k-character-250715',
|
||||
),
|
||||
),
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: readString(
|
||||
env,
|
||||
'DASHSCOPE_BASE_URL',
|
||||
'https://dashscope.aliyuncs.com/api/v1',
|
||||
),
|
||||
apiKey: env.DASHSCOPE_API_KEY?.trim() || '',
|
||||
imageModel: readString(env, 'DASHSCOPE_IMAGE_MODEL', 'wan2.2-t2i-flash'),
|
||||
requestTimeoutMs: readPositiveInt(
|
||||
env,
|
||||
'DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS',
|
||||
150000,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
18
server-node/src/context.ts
Normal file
18
server-node/src/context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from './config.js';
|
||||
import type { AppDatabase } from './db.js';
|
||||
import { RuntimeRepository } from './repositories/runtimeRepository.js';
|
||||
import { UserRepository } from './repositories/userRepository.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
|
||||
export type AppContext = {
|
||||
config: AppConfig;
|
||||
logger: Logger;
|
||||
db: AppDatabase;
|
||||
userRepository: UserRepository;
|
||||
runtimeRepository: RuntimeRepository;
|
||||
llmClient: UpstreamLlmClient;
|
||||
customWorldSessions: CustomWorldSessionStore;
|
||||
};
|
||||
57
server-node/src/db.ts
Normal file
57
server-node/src/db.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import type { AppConfig } from './config.js';
|
||||
|
||||
const schemaSql = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
token_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS save_snapshots (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
bottom_tab TEXT NOT NULL,
|
||||
game_state_json TEXT NOT NULL,
|
||||
current_story_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runtime_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
music_volume REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_world_profiles (
|
||||
user_id TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export type AppDatabase = Database.Database;
|
||||
|
||||
export function createDatabase(config: AppConfig) {
|
||||
const sqliteDir = path.dirname(config.sqlitePath);
|
||||
fs.mkdirSync(sqliteDir, { recursive: true });
|
||||
|
||||
const db = new Database(config.sqlitePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(schemaSql);
|
||||
return db;
|
||||
}
|
||||
35
server-node/src/errors.ts
Normal file
35
server-node/src/errors.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
expose: boolean;
|
||||
|
||||
constructor(statusCode: number, message: string, expose = true) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
this.statusCode = statusCode;
|
||||
this.expose = expose;
|
||||
}
|
||||
}
|
||||
|
||||
export function badRequest(message: string) {
|
||||
return new HttpError(400, message);
|
||||
}
|
||||
|
||||
export function unauthorized(message = '未授权访问') {
|
||||
return new HttpError(401, message);
|
||||
}
|
||||
|
||||
export function forbidden(message = '禁止访问') {
|
||||
return new HttpError(403, message);
|
||||
}
|
||||
|
||||
export function notFound(message = '资源不存在') {
|
||||
return new HttpError(404, message);
|
||||
}
|
||||
|
||||
export function conflict(message: string) {
|
||||
return new HttpError(409, message);
|
||||
}
|
||||
|
||||
export function upstreamError(message: string) {
|
||||
return new HttpError(502, message);
|
||||
}
|
||||
48
server-node/src/http.ts
Normal file
48
server-node/src/http.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
export function asyncHandler(
|
||||
handler: (
|
||||
request: Request,
|
||||
response: Response,
|
||||
next: NextFunction,
|
||||
) => Promise<unknown> | unknown,
|
||||
): RequestHandler {
|
||||
return (request, response, next) => {
|
||||
Promise.resolve(handler(request, response, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export function extractApiErrorMessage(
|
||||
rawText: string,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
if (!rawText.trim()) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as {
|
||||
error?: { message?: string };
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
if (typeof parsed.error?.message === 'string' && parsed.error.message.trim()) {
|
||||
return parsed.error.message.trim();
|
||||
}
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||||
return parsed.message.trim();
|
||||
}
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return `${fallbackMessage}(${parsed.code.trim()})`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed json responses.
|
||||
}
|
||||
|
||||
return rawText.trim() || fallbackMessage;
|
||||
}
|
||||
|
||||
export function jsonClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
65
server-node/src/logging.ts
Normal file
65
server-node/src/logging.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import pino, { type Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from './config.js';
|
||||
|
||||
const LOG_RETENTION_DAYS = 7;
|
||||
|
||||
function cleanupExpiredLogs(logsDir: string) {
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expiryTime = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const entry of fs.readdirSync(logsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.startsWith('server.log')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(logsDir, entry.name);
|
||||
const stats = fs.statSync(fullPath);
|
||||
if (stats.mtimeMs < expiryTime) {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(config: AppConfig): Logger {
|
||||
fs.mkdirSync(config.logsDir, { recursive: true });
|
||||
cleanupExpiredLogs(config.logsDir);
|
||||
|
||||
const transport = pino.transport({
|
||||
targets: [
|
||||
{
|
||||
target: 'pino-roll',
|
||||
level: config.logLevel,
|
||||
options: {
|
||||
file: path.join(config.logsDir, 'server.log'),
|
||||
mkdir: true,
|
||||
size: '10m',
|
||||
frequency: 'daily',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
},
|
||||
},
|
||||
{
|
||||
target: 'pino/file',
|
||||
level: config.logLevel,
|
||||
options: {
|
||||
destination: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return pino(
|
||||
{
|
||||
level: config.logLevel,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
base: undefined,
|
||||
},
|
||||
transport,
|
||||
);
|
||||
}
|
||||
40
server-node/src/middleware/auth.ts
Normal file
40
server-node/src/middleware/auth.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { verifyAccessToken } from '../auth/token.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { unauthorized } from '../errors.js';
|
||||
import { type UserRepository } from '../repositories/userRepository.js';
|
||||
|
||||
function readBearerToken(request: Request) {
|
||||
const authorization = request.header('authorization')?.trim() || '';
|
||||
if (!authorization.startsWith('Bearer ')) {
|
||||
return '';
|
||||
}
|
||||
return authorization.slice('Bearer '.length).trim();
|
||||
}
|
||||
|
||||
export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) {
|
||||
return async (request: Request, _response: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = readBearerToken(request);
|
||||
if (!token) {
|
||||
throw unauthorized('缺少 Authorization Bearer Token');
|
||||
}
|
||||
|
||||
const claims = await verifyAccessToken(token, config);
|
||||
const user = userRepository.findById(claims.userId);
|
||||
if (!user) {
|
||||
throw unauthorized('用户不存在');
|
||||
}
|
||||
if (user.tokenVersion !== claims.tokenVersion) {
|
||||
throw unauthorized('登录状态已失效,请重新登录');
|
||||
}
|
||||
|
||||
request.auth = claims;
|
||||
request.userId = claims.userId;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
27
server-node/src/middleware/errorHandler.ts
Normal file
27
server-node/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ErrorRequestHandler } from 'express';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (error, request, response, _next) => {
|
||||
const statusCode =
|
||||
error instanceof HttpError ? error.statusCode : 500;
|
||||
const message =
|
||||
error instanceof HttpError
|
||||
? error.message
|
||||
: '服务器内部错误';
|
||||
|
||||
request.log?.error(
|
||||
{
|
||||
err: error,
|
||||
request_id: request.requestId,
|
||||
user_id: request.userId ?? null,
|
||||
},
|
||||
'request failed',
|
||||
);
|
||||
|
||||
response.status(statusCode).json({
|
||||
error: {
|
||||
message,
|
||||
},
|
||||
});
|
||||
};
|
||||
8
server-node/src/middleware/requestId.ts
Normal file
8
server-node/src/middleware/requestId.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export const requestIdMiddleware: RequestHandler = (request, _response, next) => {
|
||||
request.requestId = request.header('x-request-id')?.trim() || crypto.randomUUID();
|
||||
next();
|
||||
};
|
||||
182
server-node/src/repositories/runtimeRepository.ts
Normal file
182
server-node/src/repositories/runtimeRepository.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
const SAVE_SNAPSHOT_VERSION = 2;
|
||||
const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
const MAX_CUSTOM_WORLD_PROFILES = 12;
|
||||
|
||||
export type SavedSnapshot = {
|
||||
version: number;
|
||||
savedAt: string;
|
||||
gameState: unknown;
|
||||
bottomTab: string;
|
||||
currentStory: unknown;
|
||||
};
|
||||
|
||||
export type RuntimeSettings = {
|
||||
musicVolume: number;
|
||||
};
|
||||
|
||||
function parseJson<T>(value: string): T {
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
function toJson(value: unknown) {
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
export class RuntimeRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
getSnapshot(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
|
||||
FROM save_snapshots
|
||||
WHERE user_id = ?`,
|
||||
)
|
||||
.get(userId) as
|
||||
| {
|
||||
version: number;
|
||||
saved_at: string;
|
||||
game_state_json: string;
|
||||
bottom_tab: string;
|
||||
current_story_json: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: row.version,
|
||||
savedAt: row.saved_at,
|
||||
gameState: parseJson(row.game_state_json),
|
||||
bottomTab: row.bottom_tab,
|
||||
currentStory: parseJson(row.current_story_json),
|
||||
} satisfies SavedSnapshot;
|
||||
}
|
||||
|
||||
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
|
||||
const snapshot = {
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
savedAt: payload.savedAt,
|
||||
gameState: payload.gameState,
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO save_snapshots (
|
||||
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
saved_at = excluded.saved_at,
|
||||
bottom_tab = excluded.bottom_tab,
|
||||
game_state_json = excluded.game_state_json,
|
||||
current_story_json = excluded.current_story_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
snapshot.version,
|
||||
snapshot.savedAt,
|
||||
snapshot.bottomTab,
|
||||
toJson(snapshot.gameState),
|
||||
toJson(snapshot.currentStory),
|
||||
now,
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
deleteSnapshot(userId: string) {
|
||||
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
|
||||
}
|
||||
|
||||
getSettings(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT music_volume
|
||||
FROM runtime_settings
|
||||
WHERE user_id = ?`,
|
||||
)
|
||||
.get(userId) as { music_volume: number } | undefined;
|
||||
|
||||
return {
|
||||
musicVolume:
|
||||
typeof row?.music_volume === 'number'
|
||||
? row.music_volume
|
||||
: DEFAULT_MUSIC_VOLUME,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
putSettings(userId: string, settings: RuntimeSettings) {
|
||||
const nextSettings = {
|
||||
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
|
||||
} satisfies RuntimeSettings;
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
music_volume = excluded.music_volume,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(userId, nextSettings.musicVolume, new Date().toISOString());
|
||||
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
listCustomWorldProfiles(userId: string) {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT payload_json
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
|
||||
|
||||
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
|
||||
}
|
||||
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
) {
|
||||
const payload = {
|
||||
...profile,
|
||||
id: profileId,
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, profile_id) DO UPDATE SET
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(userId, profileId, JSON.stringify(payload), new Date().toISOString());
|
||||
|
||||
return this.listCustomWorldProfiles(userId);
|
||||
}
|
||||
|
||||
deleteCustomWorldProfile(userId: string, profileId: string) {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM custom_world_profiles
|
||||
WHERE user_id = ? AND profile_id = ?`,
|
||||
)
|
||||
.run(userId, profileId);
|
||||
|
||||
return this.listCustomWorldProfiles(userId);
|
||||
}
|
||||
}
|
||||
88
server-node/src/repositories/userRepository.ts
Normal file
88
server-node/src/repositories/userRepository.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { AppDatabase } from '../db.js';
|
||||
|
||||
export type UserRecord = {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
tokenVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
token_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function toUserRecord(row: UserRow | undefined): UserRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
tokenVersion: row.token_version,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
findByUsername(username: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, username, password_hash, token_version, created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = ?`,
|
||||
)
|
||||
.get(username) as UserRow | undefined;
|
||||
return toUserRecord(row);
|
||||
}
|
||||
|
||||
findById(userId: string) {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, username, password_hash, token_version, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.get(userId) as UserRow | undefined;
|
||||
return toUserRecord(row);
|
||||
}
|
||||
|
||||
create(username: string, passwordHash: string) {
|
||||
const now = new Date().toISOString();
|
||||
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.run(id, username, passwordHash, now, now);
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
incrementTokenVersion(userId: string) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE users
|
||||
SET token_version = token_version + 1, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(new Date().toISOString(), userId);
|
||||
|
||||
return this.findById(userId);
|
||||
}
|
||||
}
|
||||
53
server-node/src/routes/authRoutes.ts
Normal file
53
server-node/src/routes/authRoutes.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { entryWithPassword, logoutUser } from '../auth/authService.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { asyncHandler } from '../http.js';
|
||||
import { requireJwtAuth } from '../middleware/auth.js';
|
||||
|
||||
const authEntrySchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export function createAuthRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.post(
|
||||
'/entry',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = authEntrySchema.parse(request.body);
|
||||
response.json(
|
||||
await entryWithPassword(context, payload.username, payload.password),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/me',
|
||||
requireAuth,
|
||||
asyncHandler(async (request, response) => {
|
||||
const user = context.userRepository.findById(request.userId!);
|
||||
response.json({
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/logout',
|
||||
requireAuth,
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json(await logoutUser(context, request.userId!));
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
386
server-node/src/routes/runtimeRoutes.ts
Normal file
386
server-node/src/routes/runtimeRoutes.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameState } from '../../../src/types/game.js';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../../../src/types/runtimeItem.js';
|
||||
import type { Encounter } from '../../../src/types/scene.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest, notFound } from '../errors.js';
|
||||
import { asyncHandler, jsonClone } from '../http.js';
|
||||
import { requireJwtAuth } from '../middleware/auth.js';
|
||||
import { plainTextRequestSchema } from '../services/chatService.js';
|
||||
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
|
||||
import { generateQuestForNpcEncounter } from '../services/questService.js';
|
||||
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
|
||||
import { generateSceneImage, sceneImageSchema } from '../services/sceneImageService.js';
|
||||
import {
|
||||
generateHighQualityInitialStory,
|
||||
generateHighQualityNextStory,
|
||||
parseStoryRequest,
|
||||
} from '../services/storyService.js';
|
||||
|
||||
const saveSnapshotSchema = z.object({
|
||||
gameState: z.unknown(),
|
||||
bottomTab: z.string().trim().min(1),
|
||||
currentStory: z.unknown().nullable().optional().default(null),
|
||||
savedAt: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const settingsSchema = z.object({
|
||||
musicVolume: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
const customWorldProfileSchema = z.object({
|
||||
profile: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
const customWorldSessionSchema = z.object({
|
||||
settingText: z.string().trim().min(1),
|
||||
creatorIntent: z.record(z.string(), z.unknown()).nullable().optional().default(null),
|
||||
generationMode: z.enum(['fast', 'full']).default('fast'),
|
||||
});
|
||||
|
||||
const customWorldAnswerSchema = z.object({
|
||||
questionId: z.string().trim().min(1),
|
||||
answer: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const runtimeItemIntentSchema = z.object({
|
||||
context: z.custom<RuntimeItemGenerationContext>(),
|
||||
plans: z.array(z.custom<RuntimeItemPlan>()),
|
||||
});
|
||||
|
||||
const questGenerationSchema = z.object({
|
||||
state: z.custom<GameState>(),
|
||||
encounter: z.custom<Encounter>(),
|
||||
});
|
||||
|
||||
const llmProxySchema = z.record(z.string(), z.unknown());
|
||||
|
||||
function readParam(param: string | string[] | undefined) {
|
||||
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
|
||||
}
|
||||
|
||||
export function createRuntimeRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.post(
|
||||
'/llm/chat/completions',
|
||||
asyncHandler(async (request, response) => {
|
||||
const body = llmProxySchema.parse(request.body);
|
||||
await context.llmClient.forwardCompletion(body, response);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/scene-image',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = sceneImageSchema.parse(request.body);
|
||||
response.json(await generateSceneImage(context, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/save/snapshot',
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json(context.runtimeRepository.getSnapshot(request.userId!) ?? null);
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/save/snapshot',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = saveSnapshotSchema.parse(request.body);
|
||||
response.json(
|
||||
context.runtimeRepository.putSnapshot(request.userId!, {
|
||||
savedAt: payload.savedAt || new Date().toISOString(),
|
||||
gameState: payload.gameState,
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory ?? null,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/runtime/save/snapshot',
|
||||
asyncHandler(async (request, response) => {
|
||||
context.runtimeRepository.deleteSnapshot(request.userId!);
|
||||
response.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/settings',
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json(context.runtimeRepository.getSettings(request.userId!));
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/settings',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = settingsSchema.parse(request.body);
|
||||
response.json(context.runtimeRepository.putSettings(request.userId!, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-library',
|
||||
asyncHandler(async (request, response) => {
|
||||
response.json({
|
||||
profiles: context.runtimeRepository.listCustomWorldProfiles(request.userId!),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/custom-world-library/:profileId',
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
const payload = customWorldProfileSchema.parse(request.body);
|
||||
response.json({
|
||||
profiles: context.runtimeRepository.upsertCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
jsonClone(payload.profile),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/runtime/custom-world-library/:profileId',
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
response.json({
|
||||
profiles: context.runtimeRepository.deleteCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/story/initial',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = parseStoryRequest(request.body);
|
||||
response.json(await generateHighQualityInitialStory(payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/story/continue',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = parseStoryRequest(request.body);
|
||||
response.json(await generateHighQualityNextStory(payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/suggestions',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
response.json({
|
||||
text: await context.llmClient.requestMessageContent(payload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/summary',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
response.json({
|
||||
text: await context.llmClient.requestMessageContent(payload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/reply/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
await context.llmClient.forwardSseText({
|
||||
...payload,
|
||||
response,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/npc/dialogue/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
await context.llmClient.forwardSseText({
|
||||
...payload,
|
||||
response,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/npc/recruit/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = plainTextRequestSchema.parse(request.body);
|
||||
await context.llmClient.forwardSseText({
|
||||
...payload,
|
||||
response,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldSessionSchema.parse(request.body);
|
||||
response.json(
|
||||
context.customWorldSessions.create(
|
||||
request.userId!,
|
||||
payload.settingText,
|
||||
payload.creatorIntent,
|
||||
payload.generationMode,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId',
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
response.json(session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions/:sessionId/answers',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldAnswerSchema.parse(request.body);
|
||||
const session = context.customWorldSessions.answer(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
payload.questionId,
|
||||
payload.answer,
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
response.json(session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
|
||||
response.status(200);
|
||||
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
response.setHeader('Cache-Control', 'no-cache');
|
||||
response.setHeader('Connection', 'keep-alive');
|
||||
response.setHeader('X-Accel-Buffering', 'no');
|
||||
const controller = new AbortController();
|
||||
|
||||
request.on('close', () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const writeEvent = (event: string, payload: Record<string, unknown>) => {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
||||
context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generating',
|
||||
);
|
||||
writeEvent('progress', { phase: 'requesting_llm', progress: 45 });
|
||||
|
||||
try {
|
||||
const profile = await generateCustomWorldProfile(context, session, {
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => {
|
||||
writeEvent('progress', progress as unknown as Record<string, unknown>);
|
||||
},
|
||||
});
|
||||
context.customWorldSessions.setResult(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
profile,
|
||||
);
|
||||
writeEvent('progress', { phase: 'completed', progress: 100 });
|
||||
writeEvent('result', { profile });
|
||||
writeEvent('done', { ok: true });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'custom world generation failed';
|
||||
context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generation_error',
|
||||
message,
|
||||
);
|
||||
writeEvent('error', { message });
|
||||
} finally {
|
||||
response.end();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/items/runtime-intent',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = runtimeItemIntentSchema.parse(request.body);
|
||||
response.json({
|
||||
intents: await generateRuntimeItemIntents(context.llmClient, payload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/quests/generate',
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = questGenerationSchema.parse(request.body);
|
||||
response.json(
|
||||
await generateQuestForNpcEncounter(context.llmClient, payload),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get('/ws/health', (_request, response) => {
|
||||
response.json({
|
||||
ok: true,
|
||||
message: 'websocket routes reserved for future real-time support',
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
93
server-node/src/server.ts
Normal file
93
server-node/src/server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { createApp } from './app.js';
|
||||
import { type AppConfig,loadConfig } from './config.js';
|
||||
import type { AppContext } from './context.js';
|
||||
import { createDatabase } from './db.js';
|
||||
import { createLogger } from './logging.js';
|
||||
import { RuntimeRepository } from './repositories/runtimeRepository.js';
|
||||
import { UserRepository } from './repositories/userRepository.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
|
||||
function resolveListenTarget(serverAddr: string) {
|
||||
const trimmed = serverAddr.trim();
|
||||
if (!trimmed) {
|
||||
return { host: '0.0.0.0', port: 8081 };
|
||||
}
|
||||
if (trimmed.startsWith(':')) {
|
||||
return {
|
||||
host: '0.0.0.0',
|
||||
port: Number(trimmed.slice(1)),
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
const url = new URL(trimmed);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 80),
|
||||
};
|
||||
}
|
||||
if (trimmed.includes(':')) {
|
||||
const [host, portText] = trimmed.split(':');
|
||||
return {
|
||||
host: host || '0.0.0.0',
|
||||
port: Number(portText),
|
||||
};
|
||||
}
|
||||
return {
|
||||
host: '0.0.0.0',
|
||||
port: Number(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAppContext(config: AppConfig = loadConfig()) {
|
||||
const logger = createLogger(config);
|
||||
const db = createDatabase(config);
|
||||
const context: AppContext = {
|
||||
config,
|
||||
logger,
|
||||
db,
|
||||
userRepository: new UserRepository(db),
|
||||
runtimeRepository: new RuntimeRepository(db),
|
||||
llmClient: new UpstreamLlmClient(config, logger),
|
||||
customWorldSessions: new CustomWorldSessionStore(),
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const context = createAppContext();
|
||||
const app = createApp(context);
|
||||
const { host, port } = resolveListenTarget(context.config.serverAddr);
|
||||
const server = app.listen(port, host, () => {
|
||||
context.logger.info(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
sqlite_path: context.config.sqlitePath,
|
||||
},
|
||||
'server-node started',
|
||||
);
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
context.logger.info('server-node shutting down');
|
||||
server.close(() => {
|
||||
context.db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
const isEntryPoint =
|
||||
typeof process.argv[1] === 'string' &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (isEntryPoint) {
|
||||
void main();
|
||||
}
|
||||
6
server-node/src/services/chatService.ts
Normal file
6
server-node/src/services/chatService.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const plainTextRequestSchema = z.object({
|
||||
systemPrompt: z.string().trim().min(1),
|
||||
userPrompt: z.string().trim().min(1),
|
||||
});
|
||||
29
server-node/src/services/customWorldGenerationService.ts
Normal file
29
server-node/src/services/customWorldGenerationService.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile as generateCustomWorldProfileFromAi,
|
||||
type GenerateCustomWorldProfileInput,
|
||||
} from '../../../src/services/ai.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import type { CustomWorldSession } from './customWorldSessionStore.js';
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
_context: AppContext,
|
||||
session: CustomWorldSession,
|
||||
options: {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
const input = {
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
} satisfies GenerateCustomWorldProfileInput;
|
||||
|
||||
const profile = await generateCustomWorldProfileFromAi(input, {
|
||||
onProgress: options.onProgress,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
|
||||
}
|
||||
174
server-node/src/services/customWorldSessionStore.ts
Normal file
174
server-node/src/services/customWorldSessionStore.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export type CustomWorldSessionStatus =
|
||||
| 'clarifying'
|
||||
| 'ready_to_generate'
|
||||
| 'generating'
|
||||
| 'completed'
|
||||
| 'generation_error';
|
||||
|
||||
export type CustomWorldQuestion = {
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
};
|
||||
|
||||
export type CustomWorldSession = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
status: CustomWorldSessionStatus;
|
||||
settingText: string;
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
questions: CustomWorldQuestion[];
|
||||
result?: Record<string, unknown>;
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function cloneSession(session: CustomWorldSession) {
|
||||
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
|
||||
}
|
||||
|
||||
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
|
||||
return questions.some((question) => !question.answer?.trim());
|
||||
}
|
||||
|
||||
function buildClarificationQuestions(
|
||||
settingText: string,
|
||||
creatorIntent: Record<string, unknown> | null,
|
||||
) {
|
||||
const questions: CustomWorldQuestion[] = [];
|
||||
const worldHook =
|
||||
typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : '';
|
||||
const playerPremise =
|
||||
typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : '';
|
||||
const openingSituation =
|
||||
typeof creatorIntent?.openingSituation === 'string'
|
||||
? creatorIntent.openingSituation.trim()
|
||||
: '';
|
||||
const coreConflicts = Array.isArray(creatorIntent?.coreConflicts)
|
||||
? creatorIntent.coreConflicts
|
||||
: [];
|
||||
|
||||
if (!worldHook && settingText.trim().length < 24) {
|
||||
questions.push({
|
||||
id: 'world_hook',
|
||||
label: '世界核心',
|
||||
question: '请用一句话补充这个世界最核心的命题或独特卖点。',
|
||||
});
|
||||
}
|
||||
if (!playerPremise) {
|
||||
questions.push({
|
||||
id: 'player_premise',
|
||||
label: '玩家身份',
|
||||
question: '玩家在这个世界里是什么身份、立场或来历?',
|
||||
});
|
||||
}
|
||||
if (!openingSituation) {
|
||||
questions.push({
|
||||
id: 'opening_situation',
|
||||
label: '开局处境',
|
||||
question: '故事开局时,玩家正处于什么局面?',
|
||||
});
|
||||
}
|
||||
if (coreConflicts.length === 0) {
|
||||
questions.push({
|
||||
id: 'core_conflict',
|
||||
label: '核心冲突',
|
||||
question: '这个世界当前最核心的冲突、危机或悬念是什么?',
|
||||
});
|
||||
}
|
||||
|
||||
return questions;
|
||||
}
|
||||
|
||||
export class CustomWorldSessionStore {
|
||||
private readonly sessions = new Map<string, Map<string, CustomWorldSession>>();
|
||||
|
||||
create(
|
||||
userId: string,
|
||||
settingText: string,
|
||||
creatorIntent: Record<string, unknown> | null,
|
||||
generationMode: 'fast' | 'full',
|
||||
) {
|
||||
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const session: CustomWorldSession = {
|
||||
sessionId,
|
||||
userId,
|
||||
status: 'ready_to_generate',
|
||||
settingText,
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
questions: buildClarificationQuestions(settingText, creatorIntent),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (hasPendingQuestion(session.questions)) {
|
||||
session.status = 'clarifying';
|
||||
}
|
||||
|
||||
const userSessions = this.sessions.get(userId) ?? new Map<string, CustomWorldSession>();
|
||||
userSessions.set(sessionId, session);
|
||||
this.sessions.set(userId, userSessions);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
get(userId: string, sessionId: string) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
return session ? cloneSession(session) : null;
|
||||
}
|
||||
|
||||
answer(userId: string, sessionId: string, questionId: string, answer: string) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = session.questions.find((item) => item.id === questionId);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
question.answer = answer;
|
||||
session.status = hasPendingQuestion(session.questions)
|
||||
? 'clarifying'
|
||||
: 'ready_to_generate';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
updateStatus(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
status: CustomWorldSessionStatus,
|
||||
lastError = '',
|
||||
) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = status;
|
||||
session.lastError = lastError || undefined;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
setResult(userId: string, sessionId: string, result: Record<string, unknown>) {
|
||||
const session = this.sessions.get(userId)?.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = 'completed';
|
||||
session.lastError = undefined;
|
||||
session.result = JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
return cloneSession(session);
|
||||
}
|
||||
}
|
||||
169
server-node/src/services/llmClient.ts
Normal file
169
server-node/src/services/llmClient.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import type { Response as ExpressResponse } from 'express';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { upstreamError } from '../errors.js';
|
||||
import { extractApiErrorMessage } from '../http.js';
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
type CompletionRequest = {
|
||||
model?: string;
|
||||
stream?: boolean;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string) {
|
||||
return baseUrl.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function buildCompletionUrl(baseUrl: string) {
|
||||
return `${normalizeBaseUrl(baseUrl)}/chat/completions`;
|
||||
}
|
||||
|
||||
export class UpstreamLlmClient {
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
private resolveModel(model?: string) {
|
||||
return model?.trim() || this.config.llm.model;
|
||||
}
|
||||
|
||||
private buildHeaders() {
|
||||
if (!this.config.llm.apiKey) {
|
||||
throw upstreamError('服务端缺少 LLM_API_KEY');
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${this.config.llm.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async requestCompletion(body: CompletionRequest, signal?: AbortSignal) {
|
||||
const response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model: this.resolveModel(body.model),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawText = await response.text();
|
||||
throw upstreamError(
|
||||
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async requestMessageContent(params: {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const response = await this.requestCompletion(
|
||||
{
|
||||
model: params.model,
|
||||
messages: [
|
||||
{ role: 'system', content: params.systemPrompt },
|
||||
{ role: 'user', content: params.userPrompt },
|
||||
],
|
||||
},
|
||||
params.signal,
|
||||
);
|
||||
const rawText = await response.text();
|
||||
const parsed = JSON.parse(rawText) as {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const content = parsed.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!content) {
|
||||
throw upstreamError('LLM 返回内容为空');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
async forwardCompletion(body: Record<string, unknown>, response: ExpressResponse) {
|
||||
const upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model:
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model
|
||||
: this.config.llm.model,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
const rawText = await upstreamResponse.text();
|
||||
throw upstreamError(
|
||||
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
|
||||
);
|
||||
}
|
||||
|
||||
response.status(upstreamResponse.status);
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8',
|
||||
);
|
||||
|
||||
if (!upstreamResponse.body) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
|
||||
}
|
||||
|
||||
async forwardSseText(params: {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
response: ExpressResponse;
|
||||
model?: string;
|
||||
}) {
|
||||
const upstreamResponse = await this.requestCompletion({
|
||||
model: params.model,
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'system', content: params.systemPrompt },
|
||||
{ role: 'user', content: params.userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
params.response.status(upstreamResponse.status);
|
||||
params.response.setHeader(
|
||||
'Content-Type',
|
||||
upstreamResponse.headers.get('content-type') || 'text/event-stream; charset=utf-8',
|
||||
);
|
||||
params.response.setHeader('Cache-Control', 'no-cache');
|
||||
params.response.setHeader('Connection', 'keep-alive');
|
||||
params.response.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
if (!upstreamResponse.body) {
|
||||
params.response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response);
|
||||
}
|
||||
}
|
||||
140
server-node/src/services/questService.ts
Normal file
140
server-node/src/services/questService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
buildFallbackQuestIntent,
|
||||
compileQuestIntentToQuest,
|
||||
evaluateQuestOpportunity,
|
||||
} from '../../../src/data/questFlow.js';
|
||||
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
|
||||
import { buildQuestGenerationContextFromState } from '../../../src/services/questDirector.js';
|
||||
import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT } from '../../../src/services/questPrompt.js';
|
||||
import type { QuestIntent, QuestPreviewRequest } from '../../../src/services/questTypes.js';
|
||||
import type { GameState } from '../../../src/types/game.js';
|
||||
import type { Encounter } from '../../../src/types/scene.js';
|
||||
import type { QuestLogEntry } from '../../../src/types/story.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const items = value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
return items.length > 0 ? items : fallback;
|
||||
}
|
||||
|
||||
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
title: coerceString(intent.title, fallback.title),
|
||||
description: coerceString(intent.description, fallback.description),
|
||||
summary: coerceString(intent.summary, fallback.summary),
|
||||
narrativeType:
|
||||
typeof intent.narrativeType === 'string' &&
|
||||
['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
|
||||
? (intent.narrativeType as QuestIntent['narrativeType'])
|
||||
: fallback.narrativeType,
|
||||
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
|
||||
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
|
||||
playerHook: coerceString(intent.playerHook, fallback.playerHook),
|
||||
worldReason: coerceString(intent.worldReason, fallback.worldReason),
|
||||
recommendedObjectiveKinds: coerceStringArray(
|
||||
intent.recommendedObjectiveKinds,
|
||||
fallback.recommendedObjectiveKinds,
|
||||
).filter((kind) =>
|
||||
[
|
||||
'defeat_hostile_npc',
|
||||
'inspect_treasure',
|
||||
'spar_with_npc',
|
||||
'talk_to_npc',
|
||||
'reach_scene',
|
||||
'deliver_item',
|
||||
].includes(kind),
|
||||
) as QuestIntent['recommendedObjectiveKinds'],
|
||||
urgency:
|
||||
typeof intent.urgency === 'string' &&
|
||||
['low', 'medium', 'high'].includes(intent.urgency)
|
||||
? (intent.urgency as QuestIntent['urgency'])
|
||||
: fallback.urgency,
|
||||
intimacy:
|
||||
typeof intent.intimacy === 'string' &&
|
||||
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
|
||||
? (intent.intimacy as QuestIntent['intimacy'])
|
||||
: fallback.intimacy,
|
||||
rewardTheme:
|
||||
typeof intent.rewardTheme === 'string' &&
|
||||
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
|
||||
? (intent.rewardTheme as QuestIntent['rewardTheme'])
|
||||
: fallback.rewardTheme,
|
||||
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuestForNpcEncounter(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
},
|
||||
): Promise<QuestLogEntry | null> {
|
||||
const { state, encounter } = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const request: QuestPreviewRequest = {
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: state.quests.map((quest: QuestLogEntry) => ({
|
||||
id: quest.id,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
status: quest.status,
|
||||
})),
|
||||
context: buildQuestGenerationContextFromState({ state, encounter }),
|
||||
origin: 'ai_compiled',
|
||||
};
|
||||
const opportunity = evaluateQuestOpportunity(request);
|
||||
if (!opportunity.shouldOffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackIntent = buildFallbackQuestIntent(request);
|
||||
|
||||
try {
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt: QUEST_INTENT_SYSTEM_PROMPT,
|
||||
userPrompt: buildQuestIntentPrompt({
|
||||
context: request.context!,
|
||||
scene: request.scene,
|
||||
opportunity,
|
||||
}),
|
||||
});
|
||||
const parsed = parseJsonResponseText(content) as { intent?: unknown };
|
||||
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'ai_compiled',
|
||||
},
|
||||
intent,
|
||||
);
|
||||
} catch {
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
fallbackIntent,
|
||||
);
|
||||
}
|
||||
}
|
||||
104
server-node/src/services/runtimeItemService.ts
Normal file
104
server-node/src/services/runtimeItemService.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { buildRuntimeItemAiIntent } from '../../../src/data/runtimeItemNarrative.js';
|
||||
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
|
||||
import {
|
||||
buildRuntimeItemIntentPrompt,
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
} from '../../../src/services/runtimeItemAiPrompt.js';
|
||||
import type {
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../../../src/types/runtimeItem.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function sanitizeRuntimeItemAiIntent(
|
||||
rawIntent: unknown,
|
||||
fallback: RuntimeItemAiIntent,
|
||||
): RuntimeItemAiIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
const desiredFunctionalBias = coerceStringArray(
|
||||
intent.desiredFunctionalBias,
|
||||
fallback.desiredFunctionalBias,
|
||||
2,
|
||||
).filter(
|
||||
(
|
||||
item,
|
||||
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
|
||||
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
|
||||
);
|
||||
const tone = coerceString(intent.tone, fallback.tone);
|
||||
|
||||
return {
|
||||
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
|
||||
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
|
||||
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
|
||||
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
|
||||
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
|
||||
desiredFunctionalBias:
|
||||
desiredFunctionalBias.length > 0
|
||||
? desiredFunctionalBias
|
||||
: fallback.desiredFunctionalBias,
|
||||
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
|
||||
? (tone as RuntimeItemAiIntent['tone'])
|
||||
: fallback.tone,
|
||||
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
|
||||
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
|
||||
unfinishedBusiness: coerceString(
|
||||
intent.unfinishedBusiness,
|
||||
fallback.unfinishedBusiness ?? '',
|
||||
),
|
||||
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
|
||||
reactionHooks: coerceStringArray(
|
||||
intent.reactionHooks,
|
||||
fallback.reactionHooks ?? [],
|
||||
4,
|
||||
),
|
||||
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateRuntimeItemIntents(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
},
|
||||
) {
|
||||
const fallbackIntents = params.plans.map((plan) =>
|
||||
buildRuntimeItemAiIntent(params.context, plan),
|
||||
);
|
||||
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt: RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
userPrompt: buildRuntimeItemIntentPrompt(params),
|
||||
});
|
||||
const parsed = parseJsonResponseText(content) as {
|
||||
intents?: unknown[];
|
||||
};
|
||||
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
|
||||
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
}
|
||||
193
server-node/src/services/sceneImageService.ts
Normal file
193
server-node/src/services/sceneImageService.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import { extractApiErrorMessage } from '../http.js';
|
||||
|
||||
export const sceneImageSchema = z.object({
|
||||
prompt: z.string().trim().min(1),
|
||||
negativePrompt: z.string().trim().optional().default(''),
|
||||
size: z.string().trim().optional().default('1280*720'),
|
||||
model: z.string().trim().optional().default(''),
|
||||
worldName: z.string().trim().optional().default(''),
|
||||
profileId: z.string().trim().optional().default(''),
|
||||
landmarkName: z.string().trim().optional().default(''),
|
||||
landmarkId: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
model: payload.model || defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const createResponse = await fetch(
|
||||
`${baseUrl}/services/aigc/text2image/image-synthesis`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
input: {
|
||||
prompt: payload.prompt,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: payload.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const createText = await createResponse.text();
|
||||
if (!createResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(createText, '创建场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const createPayload = JSON.parse(createText) as {
|
||||
output?: {
|
||||
task_id?: string;
|
||||
};
|
||||
};
|
||||
const taskId = createPayload.output?.task_id?.trim();
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as {
|
||||
output?: {
|
||||
task_status?: string;
|
||||
results?: Array<{
|
||||
url?: string;
|
||||
actual_prompt?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const status = pollPayload.output?.task_status?.trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl =
|
||||
pollPayload.output?.results?.find((item) => item.url?.trim())?.url?.trim() || '';
|
||||
actualPrompt =
|
||||
pollPayload.output?.results?.find((item) => item.url?.trim())?.actual_prompt?.trim() || '';
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw badRequest('场景图片生成超时或未返回图片地址');
|
||||
}
|
||||
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw badRequest('下载生成图片失败');
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const contentType = imageResponse.headers.get('content-type') || '';
|
||||
const extension = contentType.includes('png')
|
||||
? 'png'
|
||||
: contentType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const assetId = `custom-scene-${Date.now()}`;
|
||||
const worldSegment = (payload.profileId || payload.worldName || 'world')
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.slice(0, 48);
|
||||
const landmarkSegment = (payload.landmarkId || payload.landmarkName || 'landmark')
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.slice(0, 48);
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-scenes',
|
||||
worldSegment || 'world',
|
||||
landmarkSegment || 'landmark',
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(context.config.publicDir, relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `scene.${extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), imageBuffer);
|
||||
|
||||
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
taskId,
|
||||
model: payload.model,
|
||||
size: payload.size,
|
||||
prompt: payload.prompt,
|
||||
negativePrompt: payload.negativePrompt,
|
||||
actualPrompt,
|
||||
imageSrc,
|
||||
worldName: payload.worldName,
|
||||
landmarkName: payload.landmarkName,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
imageSrc,
|
||||
assetId,
|
||||
taskId,
|
||||
model: payload.model,
|
||||
size: payload.size,
|
||||
prompt: payload.prompt,
|
||||
actualPrompt,
|
||||
};
|
||||
}
|
||||
74
server-node/src/services/storyService.ts
Normal file
74
server-node/src/services/storyService.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
generateInitialStoryStrict as generateInitialStoryFromAi,
|
||||
generateNextStepStrict as generateNextStepFromAi,
|
||||
type StoryGenerationContext,
|
||||
type StoryRequestOptions,
|
||||
} from '../../../src/services/ai.js';
|
||||
import type { Character } from '../../../src/types/characters.js';
|
||||
import type { WorldType } from '../../../src/types/core.js';
|
||||
import type { SceneHostileNpc } from '../../../src/types/scene.js';
|
||||
import type { StoryMoment } from '../../../src/types/story.js';
|
||||
|
||||
const storyRequestSchema = z.object({
|
||||
worldType: z.string().trim().min(1),
|
||||
character: z.record(z.string(), z.unknown()),
|
||||
monsters: z.array(z.record(z.string(), z.unknown())).default([]),
|
||||
history: z.array(z.record(z.string(), z.unknown())).default([]),
|
||||
choice: z.string().optional().default(''),
|
||||
context: z.record(z.string(), z.unknown()),
|
||||
requestOptions: z.object({
|
||||
availableOptions: z.array(z.record(z.string(), z.unknown())).optional().default([]),
|
||||
optionCatalog: z.array(z.record(z.string(), z.unknown())).optional().default([]),
|
||||
}).optional().default({
|
||||
availableOptions: [],
|
||||
optionCatalog: [],
|
||||
}),
|
||||
});
|
||||
|
||||
export function parseStoryRequest(body: unknown) {
|
||||
return storyRequestSchema.parse(body);
|
||||
}
|
||||
|
||||
function toTypedStoryParams(
|
||||
request: ReturnType<typeof parseStoryRequest>,
|
||||
) {
|
||||
return {
|
||||
worldType: request.worldType as WorldType,
|
||||
character: request.character as unknown as Character,
|
||||
monsters: request.monsters as unknown as SceneHostileNpc[],
|
||||
history: request.history as unknown as StoryMoment[],
|
||||
choice: request.choice.trim(),
|
||||
context: request.context as unknown as StoryGenerationContext,
|
||||
requestOptions: request.requestOptions as unknown as StoryRequestOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateHighQualityInitialStory(
|
||||
request: ReturnType<typeof parseStoryRequest>,
|
||||
) {
|
||||
const params = toTypedStoryParams(request);
|
||||
return generateInitialStoryFromAi(
|
||||
params.worldType,
|
||||
params.character,
|
||||
params.monsters,
|
||||
params.context,
|
||||
params.requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateHighQualityNextStory(
|
||||
request: ReturnType<typeof parseStoryRequest>,
|
||||
) {
|
||||
const params = toTypedStoryParams(request);
|
||||
return generateNextStepFromAi(
|
||||
params.worldType,
|
||||
params.character,
|
||||
params.monsters,
|
||||
params.history,
|
||||
params.choice,
|
||||
params.context,
|
||||
params.requestOptions,
|
||||
);
|
||||
}
|
||||
14
server-node/src/types/express.d.ts
vendored
Normal file
14
server-node/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
requestId: string;
|
||||
userId?: string;
|
||||
auth?: {
|
||||
userId: string;
|
||||
tokenVersion: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
23
server-node/tsconfig.json
Normal file
23
server-node/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user