1
This commit is contained in:
@@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user