1
This commit is contained in:
@@ -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