feat: migrate runtime backend to node server
This commit is contained in:
6
server-node/src/services/chatService.ts
Normal file
6
server-node/src/services/chatService.ts
Normal 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),
|
||||
});
|
||||
29
server-node/src/services/customWorldGenerationService.ts
Normal file
29
server-node/src/services/customWorldGenerationService.ts
Normal 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>;
|
||||
}
|
||||
174
server-node/src/services/customWorldSessionStore.ts
Normal file
174
server-node/src/services/customWorldSessionStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
169
server-node/src/services/llmClient.ts
Normal file
169
server-node/src/services/llmClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
140
server-node/src/services/questService.ts
Normal file
140
server-node/src/services/questService.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
104
server-node/src/services/runtimeItemService.ts
Normal file
104
server-node/src/services/runtimeItemService.ts
Normal 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]!),
|
||||
);
|
||||
}
|
||||
193
server-node/src/services/sceneImageService.ts
Normal file
193
server-node/src/services/sceneImageService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
74
server-node/src/services/storyService.ts
Normal file
74
server-node/src/services/storyService.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user