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