638 lines
18 KiB
TypeScript
638 lines
18 KiB
TypeScript
import { Router } from 'express';
|
|
import { z } from 'zod';
|
|
|
|
import type {
|
|
AnswerCustomWorldSessionQuestionRequest,
|
|
CreateCustomWorldSessionRequest,
|
|
CustomWorldGalleryDetailResponse,
|
|
CustomWorldGalleryResponse,
|
|
CustomWorldLibraryMutationResponse,
|
|
CustomWorldLibraryResponse,
|
|
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,
|
|
prepareEventStreamResponse,
|
|
sendApiResponse,
|
|
} from '../http.js';
|
|
import { requireJwtAuth } from '../middleware/auth.js';
|
|
import { routeMeta } from '../middleware/routeMeta.js';
|
|
import {
|
|
generateCharacterChatSuggestionsFromOrchestrator,
|
|
generateCharacterChatSummaryFromOrchestrator,
|
|
streamCharacterChatReplyFromOrchestrator,
|
|
streamNpcChatDialogueFromOrchestrator,
|
|
streamNpcRecruitDialogueFromOrchestrator,
|
|
} from '../modules/ai/chatOrchestrator.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 {
|
|
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),
|
|
currentStory: z.unknown().nullable().optional().default(null),
|
|
savedAt: z.string().trim().optional().default(''),
|
|
});
|
|
|
|
const settingsSchema = z.object({
|
|
musicVolume: z.number().min(0).max(1),
|
|
});
|
|
|
|
const customWorldProfileSchema = z.object({
|
|
profile: jsonObjectSchema,
|
|
});
|
|
|
|
const customWorldSessionSchema = z.object({
|
|
settingText: z.string().trim().min(1),
|
|
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
|
|
generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'),
|
|
});
|
|
|
|
const customWorldAnswerSchema = z.object({
|
|
questionId: z.string().trim().min(1),
|
|
answer: z.string().trim().min(1),
|
|
});
|
|
|
|
const runtimeItemIntentSchema = z.object({
|
|
context: jsonObjectSchema,
|
|
plans: z.array(jsonObjectSchema),
|
|
});
|
|
|
|
const questGenerationSchema = z.object({
|
|
state: jsonObjectSchema,
|
|
encounter: jsonObjectSchema,
|
|
});
|
|
|
|
const llmProxySchema = jsonObjectSchema;
|
|
|
|
function readParam(param: string | string[] | undefined) {
|
|
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
|
|
}
|
|
|
|
async function resolveAuthDisplayName(context: AppContext, userId: string) {
|
|
const user = await context.userRepository.findById(userId);
|
|
if (!user) {
|
|
throw notFound('user not found');
|
|
}
|
|
|
|
return user.displayName?.trim() || '玩家';
|
|
}
|
|
|
|
export function createRuntimeRoutes(context: AppContext) {
|
|
const router = Router();
|
|
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
|
|
|
router.use(requireAuth);
|
|
|
|
router.post(
|
|
'/llm/chat/completions',
|
|
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
|
|
asyncHandler(async (request, response) => {
|
|
const body = llmProxySchema.parse(request.body);
|
|
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);
|
|
sendApiResponse(response, await generateSceneImage(context, payload));
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/save/snapshot',
|
|
routeMeta({ operation: 'runtime.snapshot.get' }),
|
|
asyncHandler(async (request, response) => {
|
|
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,
|
|
) 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) => {
|
|
await context.runtimeRepository.deleteSnapshot(request.userId!);
|
|
sendApiResponse(response, { ok: true });
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/settings',
|
|
routeMeta({ operation: 'runtime.settings.get' }),
|
|
asyncHandler(async (request, response) => {
|
|
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) 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) => {
|
|
sendApiResponse(
|
|
response,
|
|
{
|
|
entries: await context.runtimeRepository.listCustomWorldProfiles(
|
|
request.userId!,
|
|
),
|
|
} satisfies CustomWorldLibraryResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/custom-world-gallery',
|
|
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
|
|
asyncHandler(async (_request, response) => {
|
|
sendApiResponse(
|
|
response,
|
|
{
|
|
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
|
|
} satisfies CustomWorldGalleryResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
|
|
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
|
|
asyncHandler(async (request, response) => {
|
|
const ownerUserId = readParam(request.params.ownerUserId);
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!ownerUserId || !profileId) {
|
|
throw badRequest('ownerUserId and profileId are required');
|
|
}
|
|
|
|
const entry =
|
|
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
|
|
ownerUserId,
|
|
profileId,
|
|
);
|
|
if (!entry) {
|
|
throw notFound('public custom world not found');
|
|
}
|
|
|
|
sendApiResponse(
|
|
response,
|
|
{
|
|
entry,
|
|
} satisfies CustomWorldGalleryDetailResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
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);
|
|
const authorDisplayName = await resolveAuthDisplayName(
|
|
context,
|
|
request.userId!,
|
|
);
|
|
sendApiResponse(
|
|
response,
|
|
await context.runtimeRepository.upsertCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
jsonClone(payload.profile),
|
|
authorDisplayName,
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
|
|
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');
|
|
}
|
|
sendApiResponse(
|
|
response,
|
|
{
|
|
entries: await context.runtimeRepository.deleteCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
),
|
|
} satisfies CustomWorldLibraryResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runtime/custom-world-library/:profileId/publish',
|
|
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
const authorDisplayName = await resolveAuthDisplayName(
|
|
context,
|
|
request.userId!,
|
|
);
|
|
const mutation =
|
|
await context.runtimeRepository.publishCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
authorDisplayName,
|
|
);
|
|
if (!mutation) {
|
|
throw notFound('custom world not found');
|
|
}
|
|
|
|
sendApiResponse(
|
|
response,
|
|
mutation satisfies CustomWorldLibraryMutationResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runtime/custom-world-library/:profileId/unpublish',
|
|
routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
const authorDisplayName = await resolveAuthDisplayName(
|
|
context,
|
|
request.userId!,
|
|
);
|
|
const mutation =
|
|
await context.runtimeRepository.unpublishCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
authorDisplayName,
|
|
);
|
|
if (!mutation) {
|
|
throw notFound('custom world not found');
|
|
}
|
|
|
|
sendApiResponse(
|
|
response,
|
|
mutation satisfies CustomWorldLibraryMutationResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runtime/story/initial',
|
|
routeMeta({ operation: 'runtime.story.initial' }),
|
|
asyncHandler(async (request, response) => {
|
|
const payload = parseStoryRequest(request.body);
|
|
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);
|
|
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 = 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 = 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 = 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 = 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 = 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,
|
|
) as CreateCustomWorldSessionRequest;
|
|
sendApiResponse(
|
|
response,
|
|
context.customWorldSessions.create(
|
|
request.userId!,
|
|
payload.settingText,
|
|
payload.creatorIntent,
|
|
payload.generationMode,
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/custom-world/sessions/:sessionId',
|
|
routeMeta({ operation: 'runtime.customWorldSession.get' }),
|
|
asyncHandler(async (request, response) => {
|
|
const session = context.customWorldSessions.get(
|
|
request.userId!,
|
|
readParam(request.params.sessionId),
|
|
);
|
|
if (!session) {
|
|
throw notFound('custom world session not found');
|
|
}
|
|
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,
|
|
) as AnswerCustomWorldSessionQuestionRequest;
|
|
const session = context.customWorldSessions.answer(
|
|
request.userId!,
|
|
readParam(request.params.sessionId),
|
|
payload.questionId,
|
|
payload.answer,
|
|
);
|
|
if (!session) {
|
|
throw notFound('custom world session not found');
|
|
}
|
|
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!,
|
|
readParam(request.params.sessionId),
|
|
);
|
|
if (!session) {
|
|
throw notFound('custom world session not found');
|
|
}
|
|
|
|
prepareEventStreamResponse(request, response);
|
|
const controller = new AbortController();
|
|
|
|
request.on('close', () => {
|
|
controller.abort();
|
|
});
|
|
|
|
const writeEvent = (event: string, payload: Record<string, unknown>) => {
|
|
response.write(`event: ${event}\n`);
|
|
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
};
|
|
|
|
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
|
context.customWorldSessions.updateStatus(
|
|
request.userId!,
|
|
readParam(request.params.sessionId),
|
|
'generating',
|
|
);
|
|
writeEvent('progress', { phase: 'requesting_llm', progress: 45 });
|
|
|
|
try {
|
|
const profile = await generateCustomWorldProfile(context, session, {
|
|
signal: controller.signal,
|
|
onProgress: (progress) => {
|
|
writeEvent(
|
|
'progress',
|
|
progress as unknown as Record<string, unknown>,
|
|
);
|
|
},
|
|
});
|
|
context.customWorldSessions.setResult(
|
|
request.userId!,
|
|
readParam(request.params.sessionId),
|
|
profile,
|
|
);
|
|
writeEvent('progress', { phase: 'completed', progress: 100 });
|
|
writeEvent('result', { profile });
|
|
writeEvent('done', { ok: true });
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: 'custom world generation failed';
|
|
context.customWorldSessions.updateStatus(
|
|
request.userId!,
|
|
readParam(request.params.sessionId),
|
|
'generation_error',
|
|
message,
|
|
);
|
|
writeEvent('error', { message });
|
|
} finally {
|
|
response.end();
|
|
}
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runtime/items/runtime-intent',
|
|
routeMeta({ operation: 'runtime.items.intent' }),
|
|
asyncHandler(async (request, response) => {
|
|
const payload = runtimeItemIntentSchema.parse(
|
|
request.body,
|
|
) as RuntimeItemIntentRequest;
|
|
sendApiResponse(response, {
|
|
intents: await generateRuntimeItemIntents(context.llmClient, payload),
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runtime/quests/generate',
|
|
routeMeta({ operation: 'runtime.quests.generate' }),
|
|
asyncHandler(async (request, response) => {
|
|
const payload = questGenerationSchema.parse(
|
|
request.body,
|
|
) as QuestGenerationRequest;
|
|
sendApiResponse(
|
|
response,
|
|
await generateQuestForNpcEncounter(context.llmClient, payload),
|
|
);
|
|
}),
|
|
);
|
|
|
|
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;
|
|
}
|