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

@@ -0,0 +1,97 @@
import crypto from 'node:crypto';
import type { AuthCaptchaChallenge } from '../../../packages/shared/src/contracts/auth.js';
type CaptchaChallengeRecord = {
challengeId: string;
scopeKey: string;
answer: string;
createdAt: string;
expiresAt: string;
imageDataUrl: string;
};
function buildCaptchaSvgDataUrl(text: string) {
const lines = Array.from({ length: 4 }, (_, index) => {
const x1 = 8 + index * 18;
const x2 = 150 - index * 16;
const y1 = 12 + index * 8;
const y2 = 46 - index * 6;
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="rgba(245,158,11,0.38)" stroke-width="1.4" />`;
}).join('');
const noise = Array.from(text).map((char, index) => {
const x = 24 + index * 24;
const y = 30 + ((index % 2) * 6 - 3);
const rotate = index % 2 === 0 ? -8 : 7;
return `<text x="${x}" y="${y}" fill="#f8fafc" font-size="22" font-family="monospace" transform="rotate(${rotate} ${x} ${y})">${char}</text>`;
}).join('');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="56" viewBox="0 0 160 56">
<rect width="160" height="56" rx="14" fill="#11131a"/>
<rect x="1" y="1" width="158" height="54" rx="13" fill="none" stroke="rgba(255,255,255,0.08)"/>
${lines}
${noise}
</svg>`;
return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`;
}
function normalizeCaptchaAnswer(answer: string) {
return answer.trim().toLowerCase();
}
function buildCaptchaText() {
return crypto.randomBytes(3).toString('hex').slice(0, 5).toUpperCase();
}
export class CaptchaChallengeStore {
private readonly challenges = new Map<string, CaptchaChallengeRecord>();
create(scopeKey: string, expiresInSeconds: number): AuthCaptchaChallenge {
const text = buildCaptchaText();
const challengeId = `captcha_${crypto.randomBytes(16).toString('hex')}`;
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + expiresInSeconds * 1000);
this.challenges.set(challengeId, {
challengeId,
scopeKey,
answer: normalizeCaptchaAnswer(text),
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
imageDataUrl: buildCaptchaSvgDataUrl(text),
});
return {
challengeId,
promptText: '请输入图中的验证码后再获取短信验证码',
imageDataUrl: buildCaptchaSvgDataUrl(text),
expiresInSeconds,
};
}
verify(params: {
challengeId: string;
scopeKey: string;
answer: string;
}) {
const record = this.challenges.get(params.challengeId);
if (!record) {
return false;
}
if (record.scopeKey !== params.scopeKey) {
this.challenges.delete(params.challengeId);
return false;
}
if (new Date(record.expiresAt).getTime() <= Date.now()) {
this.challenges.delete(params.challengeId);
return false;
}
const isValid =
record.answer === normalizeCaptchaAnswer(params.answer);
this.challenges.delete(params.challengeId);
return isValid;
}
}

View File

@@ -1,6 +1,56 @@
import { z } from 'zod';
export const plainTextRequestSchema = z.object({
systemPrompt: z.string().trim().min(1),
userPrompt: z.string().trim().min(1),
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
const baseCharacterChatSchema = z.object({
worldType: z.string().trim().min(1),
playerCharacter: jsonObjectSchema,
targetCharacter: jsonObjectSchema,
storyHistory: z.array(jsonObjectSchema).default([]),
context: jsonObjectSchema,
conversationHistory: z.array(jsonObjectSchema).default([]),
targetStatus: jsonObjectSchema,
});
const baseNpcChatSchema = z.object({
worldType: z.string().trim().min(1),
character: jsonObjectSchema,
encounter: jsonObjectSchema,
monsters: z.array(jsonObjectSchema).default([]),
history: z.array(jsonObjectSchema).default([]),
context: jsonObjectSchema,
});
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
conversationSummary: z.string().optional().default(''),
playerMessage: z.string().trim().min(1),
}) satisfies z.ZodType<CharacterChatReplyRequest>;
export const characterChatSuggestionsRequestSchema =
baseCharacterChatSchema.extend({
conversationSummary: z.string().optional().default(''),
}) satisfies z.ZodType<CharacterChatSuggestionsRequest>;
export const characterChatSummaryRequestSchema = baseCharacterChatSchema.extend(
{
previousSummary: z.string().optional().default(''),
},
) satisfies z.ZodType<CharacterChatSummaryRequest>;
export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
topic: z.string().trim().min(1),
resultSummary: z.string().optional().default(''),
}) satisfies z.ZodType<NpcChatDialogueRequest>;
export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({
invitationText: z.string().trim().min(1),
recruitSummary: z.string().optional().default(''),
}) satisfies z.ZodType<NpcRecruitDialogueRequest>;

View File

@@ -1,8 +1,8 @@
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile as generateCustomWorldProfileFromAi,
type GenerateCustomWorldProfileInput,
} from '../../../src/services/ai.js';
generateCustomWorldProfileFromOrchestrator,
} from '../modules/ai/customWorldOrchestrator.js';
import type { AppContext } from '../context.js';
import type { CustomWorldSession } from './customWorldSessionStore.js';
@@ -20,7 +20,7 @@ export async function generateCustomWorldProfile(
generationMode: session.generationMode,
} satisfies GenerateCustomWorldProfileInput;
const profile = await generateCustomWorldProfileFromAi(input, {
const profile = await generateCustomWorldProfileFromOrchestrator(input, {
onProgress: options.onProgress,
signal: options.signal,
});

View File

@@ -1,28 +1,21 @@
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;
};
import type { JsonObject } from '../../../packages/shared/src/contracts/common.js';
import type {
CustomWorldGenerationMode,
CustomWorldQuestion,
CustomWorldSessionStatus,
} from '../../../packages/shared/src/contracts/runtime.js';
export type CustomWorldSession = {
sessionId: string;
userId: string;
status: CustomWorldSessionStatus;
settingText: string;
creatorIntent: Record<string, unknown> | null;
generationMode: 'fast' | 'full';
creatorIntent: JsonObject | null;
generationMode: CustomWorldGenerationMode;
questions: CustomWorldQuestion[];
result?: Record<string, unknown>;
result?: JsonObject;
lastError?: string;
createdAt: string;
updatedAt: string;
@@ -38,7 +31,7 @@ function hasPendingQuestion(questions: CustomWorldQuestion[]) {
function buildClarificationQuestions(
settingText: string,
creatorIntent: Record<string, unknown> | null,
creatorIntent: JsonObject | null,
) {
const questions: CustomWorldQuestion[] = [];
const worldHook =
@@ -91,8 +84,8 @@ export class CustomWorldSessionStore {
create(
userId: string,
settingText: string,
creatorIntent: Record<string, unknown> | null,
generationMode: 'fast' | 'full',
creatorIntent: JsonObject | null,
generationMode: CustomWorldGenerationMode,
) {
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
@@ -159,7 +152,7 @@ export class CustomWorldSessionStore {
return cloneSession(session);
}
setResult(userId: string, sessionId: string, result: Record<string, unknown>) {
setResult(userId: string, sessionId: string, result: JsonObject) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
@@ -167,7 +160,7 @@ export class CustomWorldSessionStore {
session.status = 'completed';
session.lastError = undefined;
session.result = JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}

View File

@@ -1,11 +1,18 @@
import { Readable } from 'node:stream';
import type { Response as ExpressResponse } from 'express';
import type {
Request as ExpressRequest,
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';
import { HttpError, upstreamError } from '../errors.js';
import {
extractApiErrorMessage,
prepareApiResponse,
prepareEventStreamResponse,
} from '../http.js';
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
@@ -18,6 +25,14 @@ type CompletionRequest = {
messages: ChatMessage[];
};
type RequestExecutionOptions = {
signal?: AbortSignal;
timeoutMs?: number;
debugLabel?: string;
};
const DEFAULT_LLM_REQUEST_TIMEOUT_MS = 30000;
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/u, '');
}
@@ -26,11 +41,69 @@ function buildCompletionUrl(baseUrl: string) {
return `${normalizeBaseUrl(baseUrl)}/chat/completions`;
}
function isAbortLikeError(error: unknown) {
return (
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
);
}
function readTimeoutMs(config: AppConfig) {
const parsed = Number(config.rawEnv.LLM_REQUEST_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0
? Math.round(parsed)
: DEFAULT_LLM_REQUEST_TIMEOUT_MS;
}
export class UpstreamLlmTimeoutError extends HttpError {
constructor(message = 'LLM 上游请求超时') {
super(502, message, {
code: 'UPSTREAM_TIMEOUT',
});
this.name = 'UpstreamLlmTimeoutError';
}
}
export class UpstreamLlmConnectivityError extends HttpError {
constructor(message = '无法连接 LLM 上游服务') {
super(502, message, {
code: 'UPSTREAM_CONNECTIVITY',
});
this.name = 'UpstreamLlmConnectivityError';
}
}
export function isUpstreamLlmTimeoutError(
error: unknown,
): error is UpstreamLlmTimeoutError {
return (
error instanceof UpstreamLlmTimeoutError ||
(error instanceof HttpError && error.code === 'UPSTREAM_TIMEOUT')
);
}
export function isUpstreamLlmConnectivityError(
error: unknown,
): error is UpstreamLlmConnectivityError {
return (
error instanceof UpstreamLlmConnectivityError ||
(error instanceof HttpError && error.code === 'UPSTREAM_CONNECTIVITY')
);
}
export class UpstreamLlmClient {
readonly logger: Logger;
private readonly requestTimeoutMs: number;
constructor(
private readonly config: AppConfig,
private readonly logger: Logger,
) {}
logger: Logger,
) {
this.logger = logger;
this.requestTimeoutMs = readTimeoutMs(config);
}
private resolveModel(model?: string) {
return model?.trim() || this.config.llm.model;
@@ -47,24 +120,128 @@ export class UpstreamLlmClient {
};
}
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,
});
private createRequestSignal(
externalSignal?: AbortSignal,
timeoutMs = this.requestTimeoutMs,
) {
const controller = new AbortController();
let timedOut = false;
const handleAbort = () => controller.abort(externalSignal?.reason);
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
if (externalSignal) {
if (externalSignal.aborted) {
handleAbort();
} else {
externalSignal.addEventListener('abort', handleAbort, {
once: true,
});
}
}
return {
signal: controller.signal,
didTimeout() {
return timedOut;
},
cleanup() {
clearTimeout(timeout);
externalSignal?.removeEventListener('abort', handleAbort);
},
};
}
private attachRequestAbort(request: ExpressRequest) {
const controller = new AbortController();
const handleClose = () => controller.abort();
request.on('close', handleClose);
return {
signal: controller.signal,
cleanup() {
request.removeListener('close', handleClose);
},
};
}
async requestCompletion(
body: CompletionRequest,
options: RequestExecutionOptions = {},
) {
const timeoutMs =
typeof options.timeoutMs === 'number' && options.timeoutMs > 0
? Math.round(options.timeoutMs)
: this.requestTimeoutMs;
const requestSignal = this.createRequestSignal(options.signal, timeoutMs);
const model = this.resolveModel(body.model);
const debugLabel =
typeof options.debugLabel === 'string' && options.debugLabel.trim()
? options.debugLabel.trim()
: undefined;
this.logger.debug(
{
llm_model: model,
llm_stream: body.stream === true,
llm_timeout_ms: timeoutMs,
llm_debug_label: debugLabel,
},
'llm upstream request started',
);
let response: globalThis.Response;
try {
response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model,
}),
signal: requestSignal.signal,
});
} catch (error) {
requestSignal.cleanup();
if (requestSignal.didTimeout() && isAbortLikeError(error)) {
throw new UpstreamLlmTimeoutError();
}
if (error instanceof TypeError) {
throw new UpstreamLlmConnectivityError();
}
this.logger.warn(
{
err: error,
llm_model: model,
llm_stream: body.stream === true,
llm_debug_label: debugLabel,
},
'llm upstream request failed',
);
throw error;
}
requestSignal.cleanup();
if (!response.ok) {
const rawText = await response.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败'));
}
this.logger.debug(
{
llm_model: model,
llm_stream: body.stream === true,
llm_status: response.status,
llm_debug_label: debugLabel,
},
'llm upstream request succeeded',
);
return response;
}
@@ -73,6 +250,8 @@ export class UpstreamLlmClient {
userPrompt: string;
model?: string;
signal?: AbortSignal;
timeoutMs?: number;
debugLabel?: string;
}) {
const response = await this.requestCompletion(
{
@@ -82,7 +261,11 @@ export class UpstreamLlmClient {
{ role: 'user', content: params.userPrompt },
],
},
params.signal,
{
signal: params.signal,
timeoutMs: params.timeoutMs,
debugLabel: params.debugLabel,
},
);
const rawText = await response.text();
const parsed = JSON.parse(rawText) as {
@@ -101,69 +284,116 @@ export class UpstreamLlmClient {
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,
}),
});
async forwardCompletion(
request: ExpressRequest,
body: Record<string, unknown>,
response: ExpressResponse,
) {
const requestAbort = this.attachRequestAbort(request);
let upstreamResponse: globalThis.Response;
if (!upstreamResponse.ok) {
const rawText = await upstreamResponse.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
try {
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,
}),
signal: requestAbort.signal,
});
} catch (error) {
requestAbort.cleanup();
if (requestAbort.signal.aborted && response.writableEnded) {
return;
}
throw error;
}
response.status(upstreamResponse.status);
response.setHeader(
'Content-Type',
upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8',
);
if (!upstreamResponse.ok) {
requestAbort.cleanup();
const rawText = await upstreamResponse.text();
throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败'));
}
prepareApiResponse(request, response, {
statusCode: upstreamResponse.status,
headers: {
'Content-Type':
upstreamResponse.headers.get('content-type') ||
'application/json; charset=utf-8',
},
});
if (!upstreamResponse.body) {
requestAbort.cleanup();
response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
try {
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
} finally {
requestAbort.cleanup();
}
}
async forwardSseText(params: {
request: ExpressRequest;
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 },
],
const requestAbort = this.attachRequestAbort(params.request);
let upstreamResponse: globalThis.Response;
try {
upstreamResponse = await this.requestCompletion(
{
model: params.model,
stream: true,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
},
{
signal: requestAbort.signal,
},
);
} catch (error) {
requestAbort.cleanup();
if (requestAbort.signal.aborted && params.response.writableEnded) {
return;
}
throw error;
}
prepareEventStreamResponse(params.request, params.response, {
statusCode: upstreamResponse.status,
headers: {
'Content-Type':
upstreamResponse.headers.get('content-type') ||
'text/event-stream; charset=utf-8',
},
});
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) {
requestAbort.cleanup();
params.response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response);
try {
await Readable.fromWeb(upstreamResponse.body as never).pipe(
params.response,
);
} finally {
requestAbort.cleanup();
}
}
}

View File

@@ -1,17 +1,29 @@
import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js';
import {
QUEST_INTIMACY_LEVELS,
QUEST_NARRATIVE_TYPES,
QUEST_OBJECTIVE_KINDS,
QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS,
} from '../../../packages/shared/src/contracts/story.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
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';
buildQuestGenerationContextFromState,
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from '../bridges/legacyQuestRuntimeBridge.js';
import type { UpstreamLlmClient } from './llmClient.js';
type QuestPreviewRequest = Parameters<typeof evaluateQuestOpportunity>[0];
type QuestIntent = ReturnType<typeof buildFallbackQuestIntent>;
type QuestGenerationInput = Parameters<typeof buildQuestGenerationContextFromState>[0];
type QuestGenerationState = QuestGenerationInput['state'];
type QuestGenerationEncounter = QuestGenerationInput['encounter'];
type QuestLogEntry = ReturnType<typeof compileQuestIntentToQuest>;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
@@ -41,7 +53,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
summary: coerceString(intent.summary, fallback.summary),
narrativeType:
typeof intent.narrativeType === 'string' &&
['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
QUEST_NARRATIVE_TYPES.includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
@@ -51,29 +63,20 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
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'],
).filter((kind) => QUEST_OBJECTIVE_KINDS.includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
QUEST_URGENCY_LEVELS.includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
QUEST_INTIMACY_LEVELS.includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
QUEST_REWARD_THEMES.includes(intent.rewardTheme)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
@@ -82,10 +85,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
export async function generateQuestForNpcEncounter(
llmClient: UpstreamLlmClient,
params: {
state: GameState;
encounter: Encounter;
},
params: QuestGenerationRequest<QuestGenerationState, QuestGenerationEncounter>,
): Promise<QuestLogEntry | null> {
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
@@ -95,7 +95,7 @@ export async function generateQuestForNpcEncounter(
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: state.quests.map((quest: QuestLogEntry) => ({
currentQuests: state.quests.map((quest) => ({
id: quest.id,
issuerNpcId: quest.issuerNpcId,
status: quest.status,

View File

@@ -1,16 +1,22 @@
import { buildRuntimeItemAiIntent } from '../../../src/data/runtimeItemNarrative.js';
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../packages/shared/src/contracts/story.js';
import type {
RuntimeItemIntentRequest,
} from '../../../packages/shared/src/contracts/story.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
buildRuntimeItemAiIntent,
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from '../../../src/services/runtimeItemAiPrompt.js';
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../../../src/types/runtimeItem.js';
} from '../bridges/legacyRuntimeItemBridge.js';
import type { UpstreamLlmClient } from './llmClient.js';
type RuntimeItemGenerationContext = Parameters<typeof buildRuntimeItemAiIntent>[0];
type RuntimeItemPlan = Parameters<typeof buildRuntimeItemAiIntent>[1];
type RuntimeItemAiIntent = ReturnType<typeof buildRuntimeItemAiIntent>;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
@@ -45,7 +51,7 @@ function sanitizeRuntimeItemAiIntent(
(
item,
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES.includes(item),
);
const tone = coerceString(intent.tone, fallback.tone);
@@ -59,7 +65,7 @@ function sanitizeRuntimeItemAiIntent(
desiredFunctionalBias.length > 0
? desiredFunctionalBias
: fallback.desiredFunctionalBias,
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
tone: RUNTIME_ITEM_TONE_VALUES.includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
@@ -80,10 +86,7 @@ function sanitizeRuntimeItemAiIntent(
export async function generateRuntimeItemIntents(
llmClient: UpstreamLlmClient,
params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
},
params: RuntimeItemIntentRequest<RuntimeItemGenerationContext, RuntimeItemPlan>,
) {
const fallbackIntents = params.plans.map((plan) =>
buildRuntimeItemAiIntent(params.context, plan),

View File

@@ -0,0 +1,239 @@
import crypto from 'node:crypto';
import DypnsClient, {
CheckSmsVerifyCodeRequest,
SendSmsVerifyCodeRequest,
} from '@alicloud/dypnsapi20170525';
import OpenApiClient from '@alicloud/openapi-client';
import type { Logger } from 'pino';
import type { NormalizedPhoneNumber } from '../auth/phoneNumber.js';
import type { AppConfig } from '../config.js';
import {
badRequest,
unauthorized,
upstreamError,
} from '../errors.js';
export type SendLoginCodeResult = {
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
};
export type SmsVerificationService = {
sendLoginCode(phoneNumber: NormalizedPhoneNumber): Promise<SendLoginCodeResult>;
verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
): Promise<void>;
};
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
return !config.accessKeyId || !config.accessKeySecret;
}
function buildProviderErrorMessage(prefix: string, message: string) {
const normalizedMessage = message.trim();
return normalizedMessage ? `${prefix}${normalizedMessage}` : prefix;
}
class AliyunSmsVerificationService implements SmsVerificationService {
private readonly client: DypnsClient;
constructor(
private readonly config: AppConfig['smsAuth'],
private readonly logger: Logger,
) {
if (isAliyunConfigMissing(config)) {
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
}
const clientConfig = new OpenApiClient.Config({
accessKeyId: config.accessKeyId,
accessKeySecret: config.accessKeySecret,
endpoint: config.endpoint,
protocol: 'HTTPS',
});
this.client = new DypnsClient(clientConfig);
}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
const templateParam = JSON.stringify({
[this.config.templateParamKey]: '##code##',
});
const request = new SendSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
signName: this.config.signName,
templateCode: this.config.templateCode,
templateParam,
codeLength: this.config.codeLength,
codeType: this.config.codeType,
validTime: this.config.validTimeSeconds,
interval: this.config.intervalSeconds,
duplicatePolicy: this.config.duplicatePolicy,
returnVerifyCode: this.config.returnVerifyCode,
schemeName: this.config.schemeName || undefined,
outId: `login_${crypto.randomBytes(12).toString('hex')}`,
});
try {
const response = await this.client.sendSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'短信验证码发送失败',
body?.message ?? '',
body?.code ?? '',
);
}
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: body.requestId ?? body.model?.requestId ?? null,
} satisfies SendLoginCodeResult;
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms send failed',
);
throw upstreamError(
buildProviderErrorMessage(
'短信验证码发送失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const request = new CheckSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
verifyCode,
caseAuthPolicy: this.config.caseAuthPolicy,
schemeName: this.config.schemeName || undefined,
});
try {
const response = await this.client.checkSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'验证码校验失败',
body?.message ?? '',
body?.code ?? '',
);
}
if (body.model?.verifyResult !== 'PASS') {
throw unauthorized('验证码错误或已失效');
}
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms verify failed',
);
throw upstreamError(
buildProviderErrorMessage(
'验证码校验失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
private resolveAliyunRequestError(
fallbackMessage: string,
providerMessage: string,
providerCode: string,
) {
const normalizedCode = providerCode.trim().toUpperCase();
if (
normalizedCode.includes('MOBILE') ||
normalizedCode.includes('PHONE') ||
normalizedCode.includes('TEMPLATE') ||
normalizedCode.includes('SIGN')
) {
return badRequest(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
return upstreamError(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
}
class MockSmsVerificationService implements SmsVerificationService {
private readonly sentCodes = new Map<string, string>();
constructor(private readonly config: AppConfig['smsAuth']) {}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
this.sentCodes.set(phoneNumber.e164, this.config.mockVerifyCode);
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: 'mock-request-id',
} satisfies SendLoginCodeResult;
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const expectedCode = this.sentCodes.get(phoneNumber.e164);
if (!expectedCode || expectedCode !== verifyCode) {
throw unauthorized('验证码错误或已失效');
}
}
}
export function createSmsVerificationService(
config: AppConfig,
logger: Logger,
): SmsVerificationService {
if (!config.smsAuth.enabled) {
return {
async sendLoginCode() {
throw badRequest('短信验证码登录未启用');
},
async verifyLoginCode() {
throw badRequest('短信验证码登录未启用');
},
};
}
if (config.smsAuth.provider === 'mock') {
return new MockSmsVerificationService(config.smsAuth);
}
return new AliyunSmsVerificationService(config.smsAuth, logger);
}

View File

@@ -1,26 +1,24 @@
import { z } from 'zod';
import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/story.js';
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';
generateInitialStoryFromOrchestrator,
generateNextStoryFromOrchestrator,
} from '../modules/ai/storyOrchestrator.js';
import type { UpstreamLlmClient } from './llmClient.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
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([]),
character: jsonObjectSchema,
monsters: z.array(jsonObjectSchema).default([]),
history: z.array(jsonObjectSchema).default([]),
choice: z.string().optional().default(''),
context: z.record(z.string(), z.unknown()),
context: jsonObjectSchema,
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([]),
availableOptions: z.array(jsonObjectSchema).optional().default([]),
optionCatalog: z.array(jsonObjectSchema).optional().default([]),
}).optional().default({
availableOptions: [],
optionCatalog: [],
@@ -28,28 +26,30 @@ const storyRequestSchema = z.object({
});
export function parseStoryRequest(body: unknown) {
return storyRequestSchema.parse(body);
return storyRequestSchema.parse(body) as StoryRequestPayload;
}
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[],
worldType: request.worldType,
character: request.character,
monsters: request.monsters,
history: request.history,
choice: request.choice.trim(),
context: request.context as unknown as StoryGenerationContext,
requestOptions: request.requestOptions as unknown as StoryRequestOptions,
context: request.context,
requestOptions: request.requestOptions,
};
}
export async function generateHighQualityInitialStory(
llmClient: UpstreamLlmClient,
request: ReturnType<typeof parseStoryRequest>,
) {
const params = toTypedStoryParams(request);
return generateInitialStoryFromAi(
return generateInitialStoryFromOrchestrator(
llmClient,
params.worldType,
params.character,
params.monsters,
@@ -59,10 +59,12 @@ export async function generateHighQualityInitialStory(
}
export async function generateHighQualityNextStory(
llmClient: UpstreamLlmClient,
request: ReturnType<typeof parseStoryRequest>,
) {
const params = toTypedStoryParams(request);
return generateNextStepFromAi(
return generateNextStoryFromOrchestrator(
llmClient,
params.worldType,
params.character,
params.monsters,

View File

@@ -0,0 +1,182 @@
import type { Logger } from 'pino';
import type { AppConfig } from '../config.js';
import { badRequest, upstreamError } from '../errors.js';
export type WechatIdentityProfile = {
providerUid: string;
providerUnionId: string | null;
displayName: string | null;
avatarUrl: string | null;
metaJson: Record<string, unknown> | null;
};
export type WechatAuthService = {
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
}): string;
resolveCallbackProfile(params: {
code?: string | null;
mockCode?: string | null;
}): Promise<WechatIdentityProfile>;
};
class MockWechatAuthService implements WechatAuthService {
constructor(private readonly config: AppConfig['wechatAuth']) {}
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
}) {
const callbackUrl = new URL(params.callbackUrl);
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
callbackUrl.searchParams.set('state', params.state);
return callbackUrl.toString();
}
async resolveCallbackProfile(params: {
mockCode?: string | null;
}) {
const mockCode = params.mockCode?.trim() || this.config.mockUserId;
return {
providerUid: mockCode,
providerUnionId: this.config.mockUnionId || null,
displayName: this.config.mockDisplayName || '微信旅人',
avatarUrl: this.config.mockAvatarUrl || null,
metaJson: {
mockCode,
},
} satisfies WechatIdentityProfile;
}
}
class RealWechatAuthService implements WechatAuthService {
constructor(
private readonly config: AppConfig['wechatAuth'],
private readonly logger: Logger,
) {
if (!config.appId || !config.appSecret) {
throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置');
}
}
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
}) {
const url = new URL(this.config.authorizeEndpoint);
url.searchParams.set('appid', this.config.appId);
url.searchParams.set('redirect_uri', params.callbackUrl);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'snsapi_login');
url.searchParams.set('state', params.state);
return `${url.toString()}#wechat_redirect`;
}
async resolveCallbackProfile(params: {
code?: string | null;
}) {
const code = params.code?.trim();
if (!code) {
throw badRequest('缺少微信授权 code');
}
try {
const accessTokenUrl = new URL(this.config.accessTokenEndpoint);
accessTokenUrl.searchParams.set('appid', this.config.appId);
accessTokenUrl.searchParams.set('secret', this.config.appSecret);
accessTokenUrl.searchParams.set('code', code);
accessTokenUrl.searchParams.set('grant_type', 'authorization_code');
const accessTokenResponse = await fetch(accessTokenUrl.toString());
const accessTokenPayload =
(await accessTokenResponse.json()) as Record<string, unknown>;
if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') {
throw new Error(
typeof accessTokenPayload.errmsg === 'string'
? accessTokenPayload.errmsg
: 'failed to exchange code',
);
}
const accessToken =
typeof accessTokenPayload.access_token === 'string'
? accessTokenPayload.access_token
: '';
const openId = accessTokenPayload.openid;
const fallbackUnionId =
typeof accessTokenPayload.unionid === 'string'
? accessTokenPayload.unionid
: null;
if (!accessToken) {
throw new Error('missing access_token');
}
const userInfoUrl = new URL(this.config.userInfoEndpoint);
userInfoUrl.searchParams.set('access_token', accessToken);
userInfoUrl.searchParams.set('openid', openId);
userInfoUrl.searchParams.set('lang', 'zh_CN');
const userInfoResponse = await fetch(userInfoUrl.toString());
const userInfoPayload =
(await userInfoResponse.json()) as Record<string, unknown>;
if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') {
throw new Error(
typeof userInfoPayload.errmsg === 'string'
? userInfoPayload.errmsg
: 'failed to fetch user info',
);
}
return {
providerUid: userInfoPayload.openid,
providerUnionId:
typeof userInfoPayload.unionid === 'string'
? userInfoPayload.unionid
: fallbackUnionId,
displayName:
typeof userInfoPayload.nickname === 'string'
? userInfoPayload.nickname
: null,
avatarUrl:
typeof userInfoPayload.headimgurl === 'string'
? userInfoPayload.headimgurl
: null,
metaJson: userInfoPayload,
} satisfies WechatIdentityProfile;
} catch (error) {
this.logger.error({ err: error }, 'wechat auth callback failed');
throw upstreamError(
error instanceof Error
? `微信登录失败:${error.message}`
: '微信登录失败',
);
}
}
}
export function createWechatAuthService(
config: AppConfig,
logger: Logger,
): WechatAuthService {
if (!config.wechatAuth.enabled) {
return {
buildAuthorizationUrl() {
throw badRequest('微信登录暂未启用');
},
async resolveCallbackProfile() {
throw badRequest('微信登录暂未启用');
},
};
}
if (config.wechatAuth.provider === 'mock') {
return new MockWechatAuthService(config.wechatAuth);
}
return new RealWechatAuthService(config.wechatAuth, logger);
}

View File

@@ -0,0 +1,32 @@
import crypto from 'node:crypto';
export type WechatAuthStateRecord = {
state: string;
redirectPath: string;
createdAt: string;
};
export class WechatAuthStateStore {
private readonly states = new Map<string, WechatAuthStateRecord>();
create(redirectPath: string) {
const state = crypto.randomBytes(18).toString('hex');
const record: WechatAuthStateRecord = {
state,
redirectPath,
createdAt: new Date().toISOString(),
};
this.states.set(state, record);
return record;
}
consume(state: string) {
const record = this.states.get(state) ?? null;
if (!record) {
return null;
}
this.states.delete(state);
return record;
}
}