This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -1,51 +1,497 @@
import { Router } from 'express';
import { type Request, Router } from 'express';
import { z } from 'zod';
import { entryWithPassword, logoutUser } from '../auth/authService.js';
import type {
AuthEntryRequest,
AuthPhoneChangeRequest,
AuthPhoneLoginRequest,
AuthPhoneSendCodeRequest,
AuthWechatBindPhoneRequest,
} from '../../../packages/shared/src/contracts/auth.js';
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
import {
bindWechatPhone,
buildAuthMeResponse,
changeUserPhone,
createRefreshSession,
entryWithPassword,
entryWithPhoneCode,
liftRiskBlock,
listActiveRiskBlocks,
listAuthAuditLogs,
listUserSessions,
logoutAllUserSessions,
logoutUser,
refreshAuthSession,
resolveWechatCallback,
revokeRefreshSession,
revokeUserSession,
sendPhoneLoginCode,
startWechatLogin,
} from '../auth/authService.js';
import {
clearRefreshSessionCookie,
readRefreshSessionToken,
setRefreshSessionCookie,
} from '../auth/refreshSessionCookie.js';
import type { AppContext } from '../context.js';
import { asyncHandler } from '../http.js';
import { asyncHandler, sendApiResponse } from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
const authEntrySchema = z.object({
username: z.string(),
password: z.string(),
});
const authPhoneSendCodeSchema = z.object({
phone: z.string(),
scene: z.enum(['login', 'bind_phone', 'change_phone']).optional(),
captchaChallengeId: z.string().optional(),
captchaAnswer: z.string().optional(),
});
const authPhoneLoginSchema = z.object({
phone: z.string(),
code: z.string(),
});
const authPhoneChangeSchema = z.object({
phone: z.string(),
code: z.string(),
});
const authWechatBindPhoneSchema = z.object({
phone: z.string(),
code: z.string(),
});
function resolveRequestOrigin(request: Request) {
const forwardedProto = request.header('x-forwarded-proto')?.split(',')[0]?.trim();
const forwardedHost = request.header('x-forwarded-host')?.split(',')[0]?.trim();
const protocol = forwardedProto || request.protocol || 'http';
const host = forwardedHost || request.header('host') || '127.0.0.1:8081';
return `${protocol}://${host}`;
}
function normalizeRedirectPath(rawValue: unknown, fallback: string) {
if (typeof rawValue !== 'string' || !rawValue.trim()) {
return fallback;
}
const value = rawValue.trim();
if (value.startsWith('/')) {
return value;
}
try {
const url = new URL(value);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return fallback;
}
}
function buildAuthResultRedirectUrl(
redirectPath: string,
params: Record<string, string>,
) {
const hash = new URLSearchParams(params).toString();
const [pathWithoutHash] = redirectPath.split('#');
return `${pathWithoutHash || '/'}#${hash}`;
}
function buildRefreshCookieLifetimeSeconds(
context: AppContext,
expiresAt: string,
) {
return Math.max(
0,
Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000),
);
}
export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.post(
'/entry',
routeMeta({ operation: 'auth.entry' }),
asyncHandler(async (request, response) => {
const payload = authEntrySchema.parse(request.body);
response.json(
await entryWithPassword(context, payload.username, payload.password),
const payload = authEntrySchema.parse(request.body) as AuthEntryRequest;
const requestContext = buildAuthRequestContext(request);
const result = await entryWithPassword(
context,
payload.username,
payload.password,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after password entry');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.post(
'/phone/send-code',
routeMeta({ operation: 'auth.phone.send_code' }),
asyncHandler(async (request, response) => {
const payload = authPhoneSendCodeSchema.parse(
request.body,
) as AuthPhoneSendCodeRequest;
sendApiResponse(
response,
await sendPhoneLoginCode(
context,
payload.phone,
payload.scene,
buildAuthRequestContext(request),
{
captchaChallengeId: payload.captchaChallengeId,
captchaAnswer: payload.captchaAnswer,
},
),
);
}),
);
router.post(
'/phone/change',
routeMeta({ operation: 'auth.phone.change' }),
requireAuth,
asyncHandler(async (request, response) => {
const payload = authPhoneChangeSchema.parse(
request.body,
) as AuthPhoneChangeRequest;
const requestContext = buildAuthRequestContext(request);
sendApiResponse(
response,
await changeUserPhone(
context,
request.userId!,
payload.phone,
payload.code,
requestContext,
),
);
}),
);
router.post(
'/phone/login',
routeMeta({ operation: 'auth.phone.login' }),
asyncHandler(async (request, response) => {
const payload = authPhoneLoginSchema.parse(
request.body,
) as AuthPhoneLoginRequest;
const requestContext = buildAuthRequestContext(request);
const result = await entryWithPhoneCode(
context,
payload.phone,
payload.code,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after phone entry');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.get(
'/wechat/start',
routeMeta({ operation: 'auth.wechat.start' }),
asyncHandler(async (request, response) => {
const redirectPath = normalizeRedirectPath(
request.query.redirectPath,
context.config.wechatAuth.defaultRedirectPath,
);
const callbackUrl = new URL(
context.config.wechatAuth.callbackPath,
resolveRequestOrigin(request),
).toString();
sendApiResponse(
response,
await startWechatLogin(context, callbackUrl, redirectPath),
);
}),
);
router.get(
'/wechat/callback',
routeMeta({ operation: 'auth.wechat.callback' }),
asyncHandler(async (request, response) => {
const state =
typeof request.query.state === 'string' ? request.query.state.trim() : '';
const stateRecord = context.wechatAuthStates.consume(state);
const redirectPath =
stateRecord?.redirectPath ?? context.config.wechatAuth.defaultRedirectPath;
if (!stateRecord) {
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_error: '微信登录状态已失效,请重新发起登录。',
}),
);
return;
}
try {
const requestContext = buildAuthRequestContext(request);
const result = await resolveWechatCallback(context, {
code: typeof request.query.code === 'string' ? request.query.code : null,
mockCode:
typeof request.query.mock_code === 'string'
? request.query.mock_code
: null,
}, requestContext);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after wechat callback');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_token: result.token,
auth_binding_status: result.user.bindingStatus,
}),
);
} catch (error) {
const message =
error instanceof Error ? error.message : '微信登录失败,请稍后再试。';
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_error: message,
}),
);
}
}),
);
router.post(
'/wechat/bind-phone',
routeMeta({ operation: 'auth.wechat.bind_phone' }),
requireAuth,
asyncHandler(async (request, response) => {
const payload = authWechatBindPhoneSchema.parse(
request.body,
) as AuthWechatBindPhoneRequest;
const requestContext = buildAuthRequestContext(request);
const result = await bindWechatPhone(
context,
request.userId!,
payload.phone,
payload.code,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after wechat bind');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.post(
'/refresh',
routeMeta({ operation: 'auth.refresh' }),
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
try {
const result = await refreshAuthSession(context, refreshToken);
setRefreshSessionCookie(
response,
context.config,
result.refreshToken,
buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt),
);
sendApiResponse(response, {
token: result.token,
});
} catch (error) {
clearRefreshSessionCookie(response, context.config);
throw error;
}
}),
);
router.get(
'/risk-blocks',
routeMeta({ operation: 'auth.risk_blocks' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(
response,
await listActiveRiskBlocks(
context,
user!,
buildAuthRequestContext(request),
),
);
}),
);
router.post(
'/risk-blocks/:scopeType/lift',
routeMeta({ operation: 'auth.risk_blocks.lift' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(
response,
await liftRiskBlock(
context,
user!,
buildAuthRequestContext(request),
request.params.scopeType === 'phone' ? 'phone' : 'ip',
),
);
}),
);
router.get(
'/sessions',
routeMeta({ operation: 'auth.sessions' }),
requireAuth,
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
sendApiResponse(
response,
await listUserSessions(context, request.userId!, refreshToken),
);
}),
);
router.post(
'/sessions/:sessionId/revoke',
routeMeta({ operation: 'auth.sessions.revoke' }),
requireAuth,
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
sendApiResponse(
response,
await revokeUserSession(
context,
request.userId!,
request.params.sessionId,
refreshToken,
buildAuthRequestContext(request),
),
);
}),
);
router.get(
'/audit-logs',
routeMeta({ operation: 'auth.audit_logs' }),
requireAuth,
asyncHandler(async (request, response) => {
sendApiResponse(
response,
await listAuthAuditLogs(context, request.userId!),
);
}),
);
router.get(
'/me',
routeMeta({ operation: 'auth.me' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = context.userRepository.findById(request.userId!);
response.json({
user: user
? {
id: user.id,
username: user.username,
}
: null,
});
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(response, await buildAuthMeResponse(context, user));
}),
);
router.post(
'/logout-all',
routeMeta({ operation: 'auth.logout_all' }),
requireAuth,
asyncHandler(async (request, response) => {
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
await logoutAllUserSessions(
context,
request.userId!,
buildAuthRequestContext(request),
),
);
}),
);
router.post(
'/logout',
routeMeta({ operation: 'auth.logout' }),
requireAuth,
asyncHandler(async (request, response) => {
response.json(await logoutUser(context, request.userId!));
const refreshToken = readRefreshSessionToken(request, context.config);
await revokeRefreshSession(context, refreshToken);
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
await logoutUser(
context,
request.userId!,
buildAuthRequestContext(request),
),
);
}),
);

View File

@@ -1,27 +1,67 @@
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';
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
RuntimeSettings,
SavedGameSnapshotInput,
} from '../../../packages/shared/src/contracts/runtime.js';
import { CUSTOM_WORLD_GENERATION_MODES } from '../../../packages/shared/src/contracts/runtime.js';
import type {
QuestGenerationRequest,
RuntimeItemIntentRequest,
} from '../../../packages/shared/src/contracts/story.js';
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js';
import { asyncHandler, jsonClone } from '../http.js';
import {
asyncHandler,
jsonClone,
prepareEventStreamResponse,
sendApiResponse,
} from '../http.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
generateCharacterChatSummaryFromOrchestrator,
streamCharacterChatReplyFromOrchestrator,
streamNpcChatDialogueFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { plainTextRequestSchema } from '../services/chatService.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
} from '../modules/runtime/runtimeSnapshotHydration.js';
import {
characterChatReplyRequestSchema,
characterChatSuggestionsRequestSchema,
characterChatSummaryRequestSchema,
npcChatDialogueRequestSchema,
npcRecruitDialogueRequestSchema,
} 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 {
generateSceneImage,
sceneImageSchema,
} from '../services/sceneImageService.js';
import {
generateHighQualityInitialStory,
generateHighQualityNextStory,
parseStoryRequest,
} from '../services/storyService.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
const saveSnapshotSchema = z.object({
gameState: z.unknown(),
bottomTab: z.string().trim().min(1),
@@ -34,13 +74,13 @@ const settingsSchema = z.object({
});
const customWorldProfileSchema = z.object({
profile: z.record(z.string(), z.unknown()),
profile: jsonObjectSchema,
});
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'),
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'),
});
const customWorldAnswerSchema = z.object({
@@ -49,16 +89,16 @@ const customWorldAnswerSchema = z.object({
});
const runtimeItemIntentSchema = z.object({
context: z.custom<RuntimeItemGenerationContext>(),
plans: z.array(z.custom<RuntimeItemPlan>()),
context: jsonObjectSchema,
plans: z.array(jsonObjectSchema),
});
const questGenerationSchema = z.object({
state: z.custom<GameState>(),
encounter: z.custom<Encounter>(),
state: jsonObjectSchema,
encounter: jsonObjectSchema,
});
const llmProxySchema = z.record(z.string(), z.unknown());
const llmProxySchema = jsonObjectSchema;
function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
@@ -72,84 +112,115 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/llm/chat/completions',
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
asyncHandler(async (request, response) => {
const body = llmProxySchema.parse(request.body);
await context.llmClient.forwardCompletion(body, response);
await context.llmClient.forwardCompletion(request, body, response);
}),
);
router.post(
'/custom-world/scene-image',
routeMeta({ operation: 'runtime.customWorld.sceneImage' }),
asyncHandler(async (request, response) => {
const payload = sceneImageSchema.parse(request.body);
response.json(await generateSceneImage(context, payload));
sendApiResponse(response, await generateSceneImage(context, payload));
}),
);
router.get(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.get' }),
asyncHandler(async (request, response) => {
response.json(context.runtimeRepository.getSnapshot(request.userId!) ?? null);
sendApiResponse(
response,
hydrateSavedSnapshot(
await context.runtimeRepository.getSnapshot(request.userId!),
),
);
}),
);
router.put(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.put' }),
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,
}),
const payload = saveSnapshotSchema.parse(
request.body,
) as SavedGameSnapshotInput;
const normalizedSnapshot = normalizeSavedSnapshotPayload({
savedAt: payload.savedAt || new Date().toISOString(),
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory ?? null,
});
sendApiResponse(
response,
hydrateSavedSnapshot(
await context.runtimeRepository.putSnapshot(
request.userId!,
normalizedSnapshot,
),
),
);
}),
);
router.delete(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.delete' }),
asyncHandler(async (request, response) => {
context.runtimeRepository.deleteSnapshot(request.userId!);
response.json({ ok: true });
await context.runtimeRepository.deleteSnapshot(request.userId!);
sendApiResponse(response, { ok: true });
}),
);
router.get(
'/runtime/settings',
routeMeta({ operation: 'runtime.settings.get' }),
asyncHandler(async (request, response) => {
response.json(context.runtimeRepository.getSettings(request.userId!));
sendApiResponse(
response,
await context.runtimeRepository.getSettings(request.userId!),
);
}),
);
router.put(
'/runtime/settings',
routeMeta({ operation: 'runtime.settings.put' }),
asyncHandler(async (request, response) => {
const payload = settingsSchema.parse(request.body);
response.json(context.runtimeRepository.putSettings(request.userId!, payload));
const payload = settingsSchema.parse(request.body) as RuntimeSettings;
sendApiResponse(
response,
await context.runtimeRepository.putSettings(request.userId!, payload),
);
}),
);
router.get(
'/runtime/custom-world-library',
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
asyncHandler(async (request, response) => {
response.json({
profiles: context.runtimeRepository.listCustomWorldProfiles(request.userId!),
sendApiResponse(response, {
profiles: await context.runtimeRepository.listCustomWorldProfiles(
request.userId!,
),
});
}),
);
router.put(
'/runtime/custom-world-library/:profileId',
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),
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(
sendApiResponse(response, {
profiles: await context.runtimeRepository.upsertCustomWorldProfile(
request.userId!,
profileId,
jsonClone(payload.profile),
@@ -160,13 +231,14 @@ export function createRuntimeRoutes(context: AppContext) {
router.delete(
'/runtime/custom-world-library/:profileId',
routeMeta({ operation: 'runtime.customWorldLibrary.delete' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
response.json({
profiles: context.runtimeRepository.deleteCustomWorldProfile(
sendApiResponse(response, {
profiles: await context.runtimeRepository.deleteCustomWorldProfile(
request.userId!,
profileId,
),
@@ -176,78 +248,114 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/runtime/story/initial',
routeMeta({ operation: 'runtime.story.initial' }),
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
response.json(await generateHighQualityInitialStory(payload));
sendApiResponse(
response,
await generateHighQualityInitialStory(context.llmClient, payload),
);
}),
);
router.post(
'/runtime/story/continue',
routeMeta({ operation: 'runtime.story.continue' }),
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
response.json(await generateHighQualityNextStory(payload));
sendApiResponse(
response,
await generateHighQualityNextStory(context.llmClient, payload),
);
}),
);
router.post(
'/runtime/chat/character/suggestions',
routeMeta({ operation: 'runtime.chat.character.suggestions' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
response.json({
text: await context.llmClient.requestMessageContent(payload),
const payload = characterChatSuggestionsRequestSchema.parse(
request.body,
) as CharacterChatSuggestionsRequest;
sendApiResponse(response, {
text: await generateCharacterChatSuggestionsFromOrchestrator(
context.llmClient,
payload,
),
});
}),
);
router.post(
'/runtime/chat/character/summary',
routeMeta({ operation: 'runtime.chat.character.summary' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
response.json({
text: await context.llmClient.requestMessageContent(payload),
const payload = characterChatSummaryRequestSchema.parse(
request.body,
) as CharacterChatSummaryRequest;
sendApiResponse(response, {
text: await generateCharacterChatSummaryFromOrchestrator(
context.llmClient,
payload,
),
});
}),
);
router.post(
'/runtime/chat/character/reply/stream',
routeMeta({ operation: 'runtime.chat.character.replyStream' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
const payload = characterChatReplyRequestSchema.parse(
request.body,
) as CharacterChatReplyRequest;
await streamCharacterChatReplyFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/dialogue/stream',
routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
const payload = npcChatDialogueRequestSchema.parse(
request.body,
) as NpcChatDialogueRequest;
await streamNpcChatDialogueFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/recruit/stream',
routeMeta({ operation: 'runtime.chat.npc.recruitStream' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
const payload = npcRecruitDialogueRequestSchema.parse(
request.body,
) as NpcRecruitDialogueRequest;
await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/custom-world/sessions',
routeMeta({ operation: 'runtime.customWorldSession.create' }),
asyncHandler(async (request, response) => {
const payload = customWorldSessionSchema.parse(request.body);
response.json(
const payload = customWorldSessionSchema.parse(
request.body,
) as CreateCustomWorldSessionRequest;
sendApiResponse(
response,
context.customWorldSessions.create(
request.userId!,
payload.settingText,
@@ -260,6 +368,7 @@ export function createRuntimeRoutes(context: AppContext) {
router.get(
'/runtime/custom-world/sessions/:sessionId',
routeMeta({ operation: 'runtime.customWorldSession.get' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
request.userId!,
@@ -268,14 +377,17 @@ export function createRuntimeRoutes(context: AppContext) {
if (!session) {
throw notFound('custom world session not found');
}
response.json(session);
sendApiResponse(response, session);
}),
);
router.post(
'/runtime/custom-world/sessions/:sessionId/answers',
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
asyncHandler(async (request, response) => {
const payload = customWorldAnswerSchema.parse(request.body);
const payload = customWorldAnswerSchema.parse(
request.body,
) as AnswerCustomWorldSessionQuestionRequest;
const session = context.customWorldSessions.answer(
request.userId!,
readParam(request.params.sessionId),
@@ -285,12 +397,13 @@ export function createRuntimeRoutes(context: AppContext) {
if (!session) {
throw notFound('custom world session not found');
}
response.json(session);
sendApiResponse(response, session);
}),
);
router.get(
'/runtime/custom-world/sessions/:sessionId/generate/stream',
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
request.userId!,
@@ -300,11 +413,7 @@ export function createRuntimeRoutes(context: AppContext) {
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');
prepareEventStreamResponse(request, response);
const controller = new AbortController();
request.on('close', () => {
@@ -328,7 +437,10 @@ export function createRuntimeRoutes(context: AppContext) {
const profile = await generateCustomWorldProfile(context, session, {
signal: controller.signal,
onProgress: (progress) => {
writeEvent('progress', progress as unknown as Record<string, unknown>);
writeEvent(
'progress',
progress as unknown as Record<string, unknown>,
);
},
});
context.customWorldSessions.setResult(
@@ -341,7 +453,9 @@ export function createRuntimeRoutes(context: AppContext) {
writeEvent('done', { ok: true });
} catch (error) {
const message =
error instanceof Error ? error.message : 'custom world generation failed';
error instanceof Error
? error.message
: 'custom world generation failed';
context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
@@ -357,9 +471,12 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/runtime/items/runtime-intent',
routeMeta({ operation: 'runtime.items.intent' }),
asyncHandler(async (request, response) => {
const payload = runtimeItemIntentSchema.parse(request.body);
response.json({
const payload = runtimeItemIntentSchema.parse(
request.body,
) as RuntimeItemIntentRequest;
sendApiResponse(response, {
intents: await generateRuntimeItemIntents(context.llmClient, payload),
});
}),
@@ -367,20 +484,28 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/runtime/quests/generate',
routeMeta({ operation: 'runtime.quests.generate' }),
asyncHandler(async (request, response) => {
const payload = questGenerationSchema.parse(request.body);
response.json(
const payload = questGenerationSchema.parse(
request.body,
) as QuestGenerationRequest;
sendApiResponse(
response,
await generateQuestForNpcEncounter(context.llmClient, payload),
);
}),
);
router.get('/ws/health', (_request, response) => {
response.json({
ok: true,
message: 'websocket routes reserved for future real-time support',
});
});
router.get(
'/ws/health',
routeMeta({ operation: 'runtime.ws.health' }),
(_request, response) => {
sendApiResponse(response, {
ok: true,
message: 'websocket routes reserved for future real-time support',
});
},
);
return router;
}