feat: migrate runtime backend to node server
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user