feat: migrate runtime backend to node server

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

View File

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