feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const plainTextRequestSchema = z.object({
systemPrompt: z.string().trim().min(1),
userPrompt: z.string().trim().min(1),
});

View File

@@ -0,0 +1,29 @@
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile as generateCustomWorldProfileFromAi,
type GenerateCustomWorldProfileInput,
} from '../../../src/services/ai.js';
import type { AppContext } from '../context.js';
import type { CustomWorldSession } from './customWorldSessionStore.js';
export async function generateCustomWorldProfile(
_context: AppContext,
session: CustomWorldSession,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
} = {},
) {
const input = {
settingText: session.settingText,
creatorIntent: session.creatorIntent,
generationMode: session.generationMode,
} satisfies GenerateCustomWorldProfileInput;
const profile = await generateCustomWorldProfileFromAi(input, {
onProgress: options.onProgress,
signal: options.signal,
});
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
}

View File

@@ -0,0 +1,174 @@
import crypto from 'node:crypto';
export type CustomWorldSessionStatus =
| 'clarifying'
| 'ready_to_generate'
| 'generating'
| 'completed'
| 'generation_error';
export type CustomWorldQuestion = {
id: string;
label: string;
question: string;
answer?: string;
};
export type CustomWorldSession = {
sessionId: string;
userId: string;
status: CustomWorldSessionStatus;
settingText: string;
creatorIntent: Record<string, unknown> | null;
generationMode: 'fast' | 'full';
questions: CustomWorldQuestion[];
result?: Record<string, unknown>;
lastError?: string;
createdAt: string;
updatedAt: string;
};
function cloneSession(session: CustomWorldSession) {
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
}
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
return questions.some((question) => !question.answer?.trim());
}
function buildClarificationQuestions(
settingText: string,
creatorIntent: Record<string, unknown> | null,
) {
const questions: CustomWorldQuestion[] = [];
const worldHook =
typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : '';
const playerPremise =
typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : '';
const openingSituation =
typeof creatorIntent?.openingSituation === 'string'
? creatorIntent.openingSituation.trim()
: '';
const coreConflicts = Array.isArray(creatorIntent?.coreConflicts)
? creatorIntent.coreConflicts
: [];
if (!worldHook && settingText.trim().length < 24) {
questions.push({
id: 'world_hook',
label: '世界核心',
question: '请用一句话补充这个世界最核心的命题或独特卖点。',
});
}
if (!playerPremise) {
questions.push({
id: 'player_premise',
label: '玩家身份',
question: '玩家在这个世界里是什么身份、立场或来历?',
});
}
if (!openingSituation) {
questions.push({
id: 'opening_situation',
label: '开局处境',
question: '故事开局时,玩家正处于什么局面?',
});
}
if (coreConflicts.length === 0) {
questions.push({
id: 'core_conflict',
label: '核心冲突',
question: '这个世界当前最核心的冲突、危机或悬念是什么?',
});
}
return questions;
}
export class CustomWorldSessionStore {
private readonly sessions = new Map<string, Map<string, CustomWorldSession>>();
create(
userId: string,
settingText: string,
creatorIntent: Record<string, unknown> | null,
generationMode: 'fast' | 'full',
) {
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
const session: CustomWorldSession = {
sessionId,
userId,
status: 'ready_to_generate',
settingText,
creatorIntent,
generationMode,
questions: buildClarificationQuestions(settingText, creatorIntent),
createdAt: now,
updatedAt: now,
};
if (hasPendingQuestion(session.questions)) {
session.status = 'clarifying';
}
const userSessions = this.sessions.get(userId) ?? new Map<string, CustomWorldSession>();
userSessions.set(sessionId, session);
this.sessions.set(userId, userSessions);
return cloneSession(session);
}
get(userId: string, sessionId: string) {
const session = this.sessions.get(userId)?.get(sessionId);
return session ? cloneSession(session) : null;
}
answer(userId: string, sessionId: string, questionId: string, answer: string) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
}
const question = session.questions.find((item) => item.id === questionId);
if (!question) {
return null;
}
question.answer = answer;
session.status = hasPendingQuestion(session.questions)
? 'clarifying'
: 'ready_to_generate';
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}
updateStatus(
userId: string,
sessionId: string,
status: CustomWorldSessionStatus,
lastError = '',
) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
}
session.status = status;
session.lastError = lastError || undefined;
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}
setResult(userId: string, sessionId: string, result: Record<string, unknown>) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
}
session.status = 'completed';
session.lastError = undefined;
session.result = JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}
}

View File

@@ -0,0 +1,169 @@
import { Readable } from 'node:stream';
import type { Response as ExpressResponse } from 'express';
import type { Logger } from 'pino';
import type { AppConfig } from '../config.js';
import { upstreamError } from '../errors.js';
import { extractApiErrorMessage } from '../http.js';
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
};
type CompletionRequest = {
model?: string;
stream?: boolean;
messages: ChatMessage[];
};
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/u, '');
}
function buildCompletionUrl(baseUrl: string) {
return `${normalizeBaseUrl(baseUrl)}/chat/completions`;
}
export class UpstreamLlmClient {
constructor(
private readonly config: AppConfig,
private readonly logger: Logger,
) {}
private resolveModel(model?: string) {
return model?.trim() || this.config.llm.model;
}
private buildHeaders() {
if (!this.config.llm.apiKey) {
throw upstreamError('服务端缺少 LLM_API_KEY');
}
return {
Authorization: `Bearer ${this.config.llm.apiKey}`,
'Content-Type': 'application/json',
};
}
async requestCompletion(body: CompletionRequest, signal?: AbortSignal) {
const response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model: this.resolveModel(body.model),
}),
signal,
});
if (!response.ok) {
const rawText = await response.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
}
return response;
}
async requestMessageContent(params: {
systemPrompt: string;
userPrompt: string;
model?: string;
signal?: AbortSignal;
}) {
const response = await this.requestCompletion(
{
model: params.model,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
},
params.signal,
);
const rawText = await response.text();
const parsed = JSON.parse(rawText) as {
choices?: Array<{
message?: {
content?: string;
};
}>;
};
const content = parsed.choices?.[0]?.message?.content?.trim();
if (!content) {
throw upstreamError('LLM 返回内容为空');
}
return content;
}
async forwardCompletion(body: Record<string, unknown>, response: ExpressResponse) {
const upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model:
typeof body.model === 'string' && body.model.trim()
? body.model
: this.config.llm.model,
}),
});
if (!upstreamResponse.ok) {
const rawText = await upstreamResponse.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
}
response.status(upstreamResponse.status);
response.setHeader(
'Content-Type',
upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8',
);
if (!upstreamResponse.body) {
response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
}
async forwardSseText(params: {
systemPrompt: string;
userPrompt: string;
response: ExpressResponse;
model?: string;
}) {
const upstreamResponse = await this.requestCompletion({
model: params.model,
stream: true,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
});
params.response.status(upstreamResponse.status);
params.response.setHeader(
'Content-Type',
upstreamResponse.headers.get('content-type') || 'text/event-stream; charset=utf-8',
);
params.response.setHeader('Cache-Control', 'no-cache');
params.response.setHeader('Connection', 'keep-alive');
params.response.setHeader('X-Accel-Buffering', 'no');
if (!upstreamResponse.body) {
params.response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response);
}
}

View File

@@ -0,0 +1,140 @@
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
} from '../../../src/data/questFlow.js';
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
import { buildQuestGenerationContextFromState } from '../../../src/services/questDirector.js';
import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT } from '../../../src/services/questPrompt.js';
import type { QuestIntent, QuestPreviewRequest } from '../../../src/services/questTypes.js';
import type { GameState } from '../../../src/types/game.js';
import type { Encounter } from '../../../src/types/scene.js';
import type { QuestLogEntry } from '../../../src/types/story.js';
import type { UpstreamLlmClient } from './llmClient.js';
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
}
const items = value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
return items.length > 0 ? items : fallback;
}
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
return {
title: coerceString(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType:
typeof intent.narrativeType === 'string' &&
['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
playerHook: coerceString(intent.playerHook, fallback.playerHook),
worldReason: coerceString(intent.worldReason, fallback.worldReason),
recommendedObjectiveKinds: coerceStringArray(
intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
].includes(kind),
) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
};
}
export async function generateQuestForNpcEncounter(
llmClient: UpstreamLlmClient,
params: {
state: GameState;
encounter: Encounter;
},
): Promise<QuestLogEntry | null> {
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const request: QuestPreviewRequest = {
issuerNpcId,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: state.quests.map((quest: QuestLogEntry) => ({
id: quest.id,
issuerNpcId: quest.issuerNpcId,
status: quest.status,
})),
context: buildQuestGenerationContextFromState({ state, encounter }),
origin: 'ai_compiled',
};
const opportunity = evaluateQuestOpportunity(request);
if (!opportunity.shouldOffer) {
return null;
}
const fallbackIntent = buildFallbackQuestIntent(request);
try {
const content = await llmClient.requestMessageContent({
systemPrompt: QUEST_INTENT_SYSTEM_PROMPT,
userPrompt: buildQuestIntentPrompt({
context: request.context!,
scene: request.scene,
opportunity,
}),
});
const parsed = parseJsonResponseText(content) as { intent?: unknown };
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
return compileQuestIntentToQuest(
{
...request,
origin: 'ai_compiled',
},
intent,
);
} catch {
return compileQuestIntentToQuest(
{
...request,
origin: 'fallback_builder',
},
fallbackIntent,
);
}
}

View File

@@ -0,0 +1,104 @@
import { buildRuntimeItemAiIntent } from '../../../src/data/runtimeItemNarrative.js';
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
import {
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from '../../../src/services/runtimeItemAiPrompt.js';
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../../../src/types/runtimeItem.js';
import type { UpstreamLlmClient } from './llmClient.js';
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, limit);
return normalized.length > 0 ? normalized : fallback;
}
function sanitizeRuntimeItemAiIntent(
rawIntent: unknown,
fallback: RuntimeItemAiIntent,
): RuntimeItemAiIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
const desiredFunctionalBias = coerceStringArray(
intent.desiredFunctionalBias,
fallback.desiredFunctionalBias,
2,
).filter(
(
item,
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
);
const tone = coerceString(intent.tone, fallback.tone);
return {
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
desiredFunctionalBias:
desiredFunctionalBias.length > 0
? desiredFunctionalBias
: fallback.desiredFunctionalBias,
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
unfinishedBusiness: coerceString(
intent.unfinishedBusiness,
fallback.unfinishedBusiness ?? '',
),
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
reactionHooks: coerceStringArray(
intent.reactionHooks,
fallback.reactionHooks ?? [],
4,
),
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
};
}
export async function generateRuntimeItemIntents(
llmClient: UpstreamLlmClient,
params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
},
) {
const fallbackIntents = params.plans.map((plan) =>
buildRuntimeItemAiIntent(params.context, plan),
);
const content = await llmClient.requestMessageContent({
systemPrompt: RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
userPrompt: buildRuntimeItemIntentPrompt(params),
});
const parsed = parseJsonResponseText(content) as {
intents?: unknown[];
};
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
}

View File

@@ -0,0 +1,193 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { AppContext } from '../context.js';
import { badRequest } from '../errors.js';
import { extractApiErrorMessage } from '../http.js';
export const sceneImageSchema = z.object({
prompt: z.string().trim().min(1),
negativePrompt: z.string().trim().optional().default(''),
size: z.string().trim().optional().default('1280*720'),
model: z.string().trim().optional().default(''),
worldName: z.string().trim().optional().default(''),
profileId: z.string().trim().optional().default(''),
landmarkName: z.string().trim().optional().default(''),
landmarkId: z.string().trim().optional().default(''),
});
function ensurePayload(
payload: z.infer<typeof sceneImageSchema>,
defaultModel: string,
) {
if (!payload.landmarkName && !payload.landmarkId) {
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
}
return {
...payload,
model: payload.model || defaultModel,
};
}
export async function generateSceneImage(
context: AppContext,
input: z.infer<typeof sceneImageSchema>,
) {
const payload = ensurePayload(input, context.config.dashScope.imageModel);
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
const createResponse = await fetch(
`${baseUrl}/services/aigc/text2image/image-synthesis`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
},
body: JSON.stringify({
model: payload.model,
input: {
prompt: payload.prompt,
...(payload.negativePrompt
? { negative_prompt: payload.negativePrompt }
: {}),
},
parameters: {
n: 1,
size: payload.size,
prompt_extend: true,
watermark: false,
},
}),
},
);
const createText = await createResponse.text();
if (!createResponse.ok) {
throw badRequest(
extractApiErrorMessage(createText, '创建场景图片生成任务失败'),
);
}
const createPayload = JSON.parse(createText) as {
output?: {
task_id?: string;
};
};
const taskId = createPayload.output?.task_id?.trim();
if (!taskId) {
throw badRequest('场景图片生成任务未返回 task_id');
}
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
let imageUrl = '';
let actualPrompt = '';
while (Date.now() < deadline) {
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
headers: {
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
},
});
const pollText = await pollResponse.text();
if (!pollResponse.ok) {
throw badRequest(
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
);
}
const pollPayload = JSON.parse(pollText) as {
output?: {
task_status?: string;
results?: Array<{
url?: string;
actual_prompt?: string;
}>;
};
};
const status = pollPayload.output?.task_status?.trim();
if (status === 'SUCCEEDED') {
imageUrl =
pollPayload.output?.results?.find((item) => item.url?.trim())?.url?.trim() || '';
actualPrompt =
pollPayload.output?.results?.find((item) => item.url?.trim())?.actual_prompt?.trim() || '';
break;
}
if (status === 'FAILED' || status === 'UNKNOWN') {
throw badRequest(
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!imageUrl) {
throw badRequest('场景图片生成超时或未返回图片地址');
}
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw badRequest('下载生成图片失败');
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
const contentType = imageResponse.headers.get('content-type') || '';
const extension = contentType.includes('png')
? 'png'
: contentType.includes('webp')
? 'webp'
: 'jpg';
const assetId = `custom-scene-${Date.now()}`;
const worldSegment = (payload.profileId || payload.worldName || 'world')
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
.slice(0, 48);
const landmarkSegment = (payload.landmarkId || payload.landmarkName || 'landmark')
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
.slice(0, 48);
const relativeDir = path.join(
'generated-custom-world-scenes',
worldSegment || 'world',
landmarkSegment || 'landmark',
assetId,
);
const outputDir = path.join(context.config.publicDir, relativeDir);
fs.mkdirSync(outputDir, { recursive: true });
const fileName = `scene.${extension}`;
fs.writeFileSync(path.join(outputDir, fileName), imageBuffer);
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
fs.writeFileSync(
path.join(outputDir, 'manifest.json'),
`${JSON.stringify(
{
assetId,
taskId,
model: payload.model,
size: payload.size,
prompt: payload.prompt,
negativePrompt: payload.negativePrompt,
actualPrompt,
imageSrc,
worldName: payload.worldName,
landmarkName: payload.landmarkName,
createdAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
return {
ok: true,
imageSrc,
assetId,
taskId,
model: payload.model,
size: payload.size,
prompt: payload.prompt,
actualPrompt,
};
}

View File

@@ -0,0 +1,74 @@
import { z } from 'zod';
import {
generateInitialStoryStrict as generateInitialStoryFromAi,
generateNextStepStrict as generateNextStepFromAi,
type StoryGenerationContext,
type StoryRequestOptions,
} from '../../../src/services/ai.js';
import type { Character } from '../../../src/types/characters.js';
import type { WorldType } from '../../../src/types/core.js';
import type { SceneHostileNpc } from '../../../src/types/scene.js';
import type { StoryMoment } from '../../../src/types/story.js';
const storyRequestSchema = z.object({
worldType: z.string().trim().min(1),
character: z.record(z.string(), z.unknown()),
monsters: z.array(z.record(z.string(), z.unknown())).default([]),
history: z.array(z.record(z.string(), z.unknown())).default([]),
choice: z.string().optional().default(''),
context: z.record(z.string(), z.unknown()),
requestOptions: z.object({
availableOptions: z.array(z.record(z.string(), z.unknown())).optional().default([]),
optionCatalog: z.array(z.record(z.string(), z.unknown())).optional().default([]),
}).optional().default({
availableOptions: [],
optionCatalog: [],
}),
});
export function parseStoryRequest(body: unknown) {
return storyRequestSchema.parse(body);
}
function toTypedStoryParams(
request: ReturnType<typeof parseStoryRequest>,
) {
return {
worldType: request.worldType as WorldType,
character: request.character as unknown as Character,
monsters: request.monsters as unknown as SceneHostileNpc[],
history: request.history as unknown as StoryMoment[],
choice: request.choice.trim(),
context: request.context as unknown as StoryGenerationContext,
requestOptions: request.requestOptions as unknown as StoryRequestOptions,
};
}
export async function generateHighQualityInitialStory(
request: ReturnType<typeof parseStoryRequest>,
) {
const params = toTypedStoryParams(request);
return generateInitialStoryFromAi(
params.worldType,
params.character,
params.monsters,
params.context,
params.requestOptions,
);
}
export async function generateHighQualityNextStory(
request: ReturnType<typeof parseStoryRequest>,
) {
const params = toTypedStoryParams(request);
return generateNextStepFromAi(
params.worldType,
params.character,
params.monsters,
params.history,
params.choice,
params.context,
params.requestOptions,
);
}