1
This commit is contained in:
@@ -935,7 +935,9 @@ describe('ai orchestration fallbacks', () => {
|
||||
'/api/custom-world/scene-image',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldGenerationStep,
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback,
|
||||
buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback,
|
||||
@@ -125,6 +131,12 @@ import {
|
||||
normalizeWorldStoryGraph,
|
||||
} from './storyEngine/worldStoryGraph';
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
@@ -358,40 +370,6 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
export type CustomWorldGenerationStageId =
|
||||
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
|
||||
|
||||
export interface CustomWorldGenerationStep {
|
||||
id: CustomWorldGenerationStageId;
|
||||
label: string;
|
||||
detail: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
status: 'pending' | 'active' | 'completed';
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationProgress {
|
||||
phaseId: CustomWorldGenerationStageId;
|
||||
phaseLabel: string;
|
||||
phaseDetail: string;
|
||||
batchLabel?: string;
|
||||
overallProgress: number;
|
||||
completedWeight: number;
|
||||
totalWeight: number;
|
||||
elapsedMs: number;
|
||||
estimatedRemainingMs: number | null;
|
||||
activeStepIndex: number;
|
||||
steps: CustomWorldGenerationStep[];
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldProfileOptions {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldProfileInput {
|
||||
settingText: string;
|
||||
creatorIntent?: CustomWorldCreatorIntent | null;
|
||||
generationMode?: CustomWorldGenerationMode;
|
||||
}
|
||||
|
||||
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
|
||||
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
|
||||
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
|
||||
@@ -450,7 +428,9 @@ function resolveCustomWorldGenerationInput(
|
||||
}
|
||||
|
||||
const normalizedSettingText = input.settingText.trim();
|
||||
const creatorIntent = input.creatorIntent ?? null;
|
||||
const creatorIntent =
|
||||
(input.creatorIntent as CustomWorldCreatorIntent | null | undefined) ??
|
||||
null;
|
||||
const generationSeedText =
|
||||
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
|
||||
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
|
||||
|
||||
@@ -1,50 +1,56 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionSummary,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
PlainTextResponse,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './ai';
|
||||
import * as aiClient from './ai';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply,
|
||||
buildOfflineCharacterPanelChatSuggestions,
|
||||
buildOfflineCharacterPanelChatSummary,
|
||||
buildOfflineNpcChatDialogue,
|
||||
buildOfflineNpcRecruitDialogue,
|
||||
} from './aiFallbacks';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
buildCharacterPanelChatSummaryPrompt,
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
type CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
import {
|
||||
buildNpcRecruitDialoguePrompt,
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
} from './prompt';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
type LegacyAiModule = typeof import('./ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('./ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
async function requestPostJson<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
@@ -63,15 +69,15 @@ async function requestPostJson<T>(
|
||||
|
||||
async function requestPlainText(
|
||||
url: string,
|
||||
payload: { systemPrompt: string; userPrompt: string },
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestPostJson<{ text: string }>(url, payload, fallbackMessage);
|
||||
return requestPostJson<PlainTextResponse>(url, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
async function requestPlainTextStream(
|
||||
url: string,
|
||||
payload: { systemPrompt: string; userPrompt: string },
|
||||
payload: unknown,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
@@ -135,21 +141,6 @@ async function requestPlainTextStream(
|
||||
return accumulatedText.trim();
|
||||
}
|
||||
|
||||
function buildCharacterChatPromptContext(context: StoryGenerationContext) {
|
||||
return {
|
||||
playerHp: context.playerHp,
|
||||
playerMaxHp: context.playerMaxHp,
|
||||
playerMana: context.playerMana,
|
||||
playerMaxMana: context.playerMaxMana,
|
||||
inBattle: context.inBattle,
|
||||
playerFacing: context.playerFacing,
|
||||
playerAnimation: context.playerAnimation,
|
||||
sceneName: context.sceneName ?? null,
|
||||
sceneDescription: context.sceneDescription ?? null,
|
||||
customWorldProfile: context.customWorldProfile ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateInitialStory(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
@@ -158,6 +149,7 @@ export async function generateInitialStory(
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
@@ -167,32 +159,21 @@ export async function generateInitialStory(
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/initial`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情开局生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] story/initial fell back to frontend implementation', error);
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/initial`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情开局生成失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateNextStep(
|
||||
@@ -205,6 +186,7 @@ export async function generateNextStep(
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
@@ -216,36 +198,23 @@ export async function generateNextStep(
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/continue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情续写失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] story/continue fell back to frontend implementation', error);
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/continue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情续写失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateCharacterPanelChatSuggestions(
|
||||
@@ -258,58 +227,37 @@ export async function generateCharacterPanelChatSuggestions(
|
||||
conversationSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const fallbackSuggestions =
|
||||
buildOfflineCharacterPanelChatSuggestions(targetCharacter);
|
||||
const userPrompt = buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
});
|
||||
} satisfies CharacterChatSuggestionsRequest;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
'角色聊天建议生成失败',
|
||||
);
|
||||
const parsedSuggestions = parseLineListContent(text, 3);
|
||||
return parsedSuggestions.length > 0
|
||||
? [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3)
|
||||
: fallbackSuggestions;
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character suggestions fell back to frontend implementation', error);
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||||
payload,
|
||||
'角色聊天建议生成失败',
|
||||
);
|
||||
return parseLineListContent(text, 3);
|
||||
}
|
||||
|
||||
export async function generateCharacterPanelChatSummary(
|
||||
@@ -322,64 +270,43 @@ export async function generateCharacterPanelChatSummary(
|
||||
previousSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const fallbackSummary = buildOfflineCharacterPanelChatSummary(
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
);
|
||||
const userPrompt = buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
});
|
||||
} satisfies CharacterChatSummaryRequest;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
'角色聊天摘要生成失败',
|
||||
);
|
||||
return text.trim() || fallbackSummary;
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character summary fell back to frontend implementation', error);
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||||
payload,
|
||||
'角色聊天摘要生成失败',
|
||||
);
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
) {
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
@@ -534,14 +461,13 @@ export async function generateCustomWorldProfile(
|
||||
throw new Error('自定义世界生成未返回结果');
|
||||
}
|
||||
|
||||
return latestProfile as unknown as Awaited<
|
||||
ReturnType<typeof aiClient.generateCustomWorldProfile>
|
||||
>;
|
||||
return latestProfile as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
...args: Parameters<typeof aiClient.generateCustomWorldSceneImage>
|
||||
...args: [CustomWorldSceneImageRequest]
|
||||
) {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
}
|
||||
|
||||
@@ -550,41 +476,19 @@ export async function createCustomWorldSession(payload: {
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
}) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
}>(
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
||||
},
|
||||
'创建自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCustomWorldSession(sessionId: string) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
settingText: string;
|
||||
generationMode: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
result?: Record<string, unknown>;
|
||||
lastError?: string;
|
||||
}>(
|
||||
return requestJson<CustomWorldSessionRecord>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -597,21 +501,12 @@ export async function answerCustomWorldSessionQuestion(
|
||||
sessionId: string,
|
||||
payload: { questionId: string; answer: string },
|
||||
) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
}>(
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
@@ -629,65 +524,40 @@ export async function streamCharacterPanelChatReply(
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
});
|
||||
} satisfies CharacterChatReplyRequest;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return (
|
||||
reply.trim() ||
|
||||
buildOfflineCharacterPanelChatReply(
|
||||
targetCharacter,
|
||||
playerMessage,
|
||||
conversationSummary,
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character reply stream fell back to frontend implementation', error);
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
const reply = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
return reply.trim();
|
||||
}
|
||||
|
||||
export async function streamNpcChatDialogue(
|
||||
@@ -701,8 +571,23 @@ export async function streamNpcChatDialogue(
|
||||
resultSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildStrictNpcChatDialoguePrompt(
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
@@ -710,46 +595,14 @@ export async function streamNpcChatDialogue(
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
} satisfies NpcChatDialogueRequest;
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||||
{
|
||||
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return dialogue.trim() || buildOfflineNpcChatDialogue(encounter, topic);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] npc dialogue stream fell back to frontend implementation', error);
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
return dialogue.trim();
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
@@ -763,8 +616,23 @@ export async function streamNpcRecruitDialogue(
|
||||
recruitSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildNpcRecruitDialoguePrompt(
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
@@ -772,46 +640,14 @@ export async function streamNpcRecruitDialogue(
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
} satisfies NpcRecruitDialogueRequest;
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||||
{
|
||||
systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return dialogue.trim() || buildOfflineNpcRecruitDialogue(encounter);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] npc recruit stream fell back to frontend implementation', error);
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
return dialogue.trim();
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
243
src/services/apiClient.test.ts
Normal file
243
src/services/apiClient.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
fetchWithApiAuth,
|
||||
getStoredAccessToken,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResponseMock(params: {
|
||||
status: number;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
}) {
|
||||
const headers = new Map(
|
||||
Object.entries(params.headers ?? {}).map(([key, value]) => [
|
||||
key.toLowerCase(),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
status: params.status,
|
||||
ok: params.status >= 200 && params.status < 300,
|
||||
headers: {
|
||||
get(name: string) {
|
||||
return headers.get(name.toLowerCase()) ?? null;
|
||||
},
|
||||
},
|
||||
text: vi.fn(async () => params.body ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apiClient', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
});
|
||||
|
||||
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => {
|
||||
setStoredAccessToken('jwt-token');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/protected', { method: 'GET' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer jwt-token',
|
||||
'x-genarrative-response-envelope': 'v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('refreshes the access token once and retries the original request', async () => {
|
||||
setStoredAccessToken('expired-token');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 7,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 7 });
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries transient get requests before unwrapping the response envelope', async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new TypeError('network unavailable'))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 42,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it('surfaces response metadata through ApiClientError', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 503,
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'UPSTREAM_ERROR',
|
||||
message: '上游暂不可用',
|
||||
details: {
|
||||
scope: 'runtime',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
requestId: 'req-body',
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': 'req-header',
|
||||
'x-route-version': 'runtime.v2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedError: unknown;
|
||||
try {
|
||||
await requestJson(
|
||||
'/api/runtime/story/initial',
|
||||
{ method: 'POST' },
|
||||
'剧情生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
capturedError = error;
|
||||
}
|
||||
|
||||
expect(capturedError).toBeInstanceOf(ApiClientError);
|
||||
expect(capturedError).toMatchObject({
|
||||
status: 503,
|
||||
code: 'UPSTREAM_ERROR',
|
||||
details: {
|
||||
scope: 'runtime',
|
||||
},
|
||||
meta: {
|
||||
requestId: 'req-body',
|
||||
routeVersion: 'runtime.v2',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,317 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
API_RESPONSE_ENVELOPE_HEADER,
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
API_VERSION,
|
||||
type ApiErrorPayload,
|
||||
type ApiMeta,
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../packages/shared/src/http';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
const ROUTE_VERSION_HEADER = 'x-route-version';
|
||||
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
|
||||
const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export type ApiRetryOptions = {
|
||||
maxRetries?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
retryableStatusCodes?: number[];
|
||||
retryUnsafeMethods?: boolean;
|
||||
allowRetryMethods?: string[];
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
omitEnvelopeHeader?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedRetryOptions = {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
retryableStatusCodes: Set<number>;
|
||||
retryUnsafeMethods: boolean;
|
||||
allowRetryMethods: Set<string>;
|
||||
method: string;
|
||||
};
|
||||
|
||||
type ParsedApiErrorShape = {
|
||||
code: string;
|
||||
details: Record<string, unknown> | null;
|
||||
meta: Partial<ApiMeta>;
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function coerceMeta(value: unknown): Partial<ApiMeta> {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion:
|
||||
typeof value.apiVersion === 'string' && value.apiVersion.trim()
|
||||
? value.apiVersion.trim()
|
||||
: undefined,
|
||||
requestId:
|
||||
typeof value.requestId === 'string' && value.requestId.trim()
|
||||
? value.requestId.trim()
|
||||
: undefined,
|
||||
routeVersion:
|
||||
typeof value.routeVersion === 'string' && value.routeVersion.trim()
|
||||
? value.routeVersion.trim()
|
||||
: undefined,
|
||||
operation:
|
||||
typeof value.operation === 'string' && value.operation.trim()
|
||||
? value.operation.trim()
|
||||
: value.operation === null
|
||||
? null
|
||||
: undefined,
|
||||
latencyMs:
|
||||
typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs)
|
||||
? value.latencyMs
|
||||
: undefined,
|
||||
timestamp:
|
||||
typeof value.timestamp === 'string' && value.timestamp.trim()
|
||||
? value.timestamp.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null {
|
||||
if (!rawText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as
|
||||
| {
|
||||
error?: ApiErrorPayload;
|
||||
meta?: Partial<ApiMeta>;
|
||||
code?: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
}
|
||||
| Record<string, unknown>;
|
||||
|
||||
if (isRecord(parsed.error)) {
|
||||
return {
|
||||
code:
|
||||
typeof parsed.error.code === 'string' && parsed.error.code.trim()
|
||||
? parsed.error.code.trim()
|
||||
: 'HTTP_ERROR',
|
||||
details:
|
||||
isRecord(parsed.error.details) || parsed.error.details === null
|
||||
? (parsed.error.details as Record<string, unknown> | null)
|
||||
: null,
|
||||
meta: coerceMeta(parsed.meta),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return {
|
||||
code: parsed.code.trim(),
|
||||
details:
|
||||
isRecord(parsed.details) || parsed.details === null
|
||||
? (parsed.details as Record<string, unknown> | null)
|
||||
: null,
|
||||
meta: coerceMeta(parsed.meta),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed json responses.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createAbortError() {
|
||||
if (typeof DOMException !== 'undefined') {
|
||||
return new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
const error = new Error('The operation was aborted.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
async function waitForRetry(ms: number, signal?: AbortSignal) {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(signal?.reason ?? createAbortError());
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
cleanup();
|
||||
reject(signal.reason ?? createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRetryOptions(
|
||||
method: string,
|
||||
retry?: ApiRetryOptions,
|
||||
): ResolvedRetryOptions {
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod)
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
return {
|
||||
maxRetries:
|
||||
typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0
|
||||
? Math.floor(retry.maxRetries)
|
||||
: defaultMaxRetries,
|
||||
baseDelayMs:
|
||||
typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0
|
||||
? retry.baseDelayMs
|
||||
: 250,
|
||||
maxDelayMs:
|
||||
typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0
|
||||
? retry.maxDelayMs
|
||||
: 1500,
|
||||
retryableStatusCodes: new Set(
|
||||
retry?.retryableStatusCodes?.length
|
||||
? retry.retryableStatusCodes
|
||||
: DEFAULT_RETRYABLE_STATUS_CODES,
|
||||
),
|
||||
retryUnsafeMethods: retry?.retryUnsafeMethods === true,
|
||||
allowRetryMethods: new Set(
|
||||
(retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()),
|
||||
),
|
||||
method: normalizedMethod,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRetryResponse(
|
||||
status: number,
|
||||
attempt: number,
|
||||
retry: ResolvedRetryOptions,
|
||||
) {
|
||||
if (attempt >= retry.maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!retry.retryableStatusCodes.has(status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
retry.retryUnsafeMethods ||
|
||||
DEFAULT_SAFE_RETRY_METHODS.has(retry.method) ||
|
||||
retry.allowRetryMethods.has(retry.method)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' ||
|
||||
(typeof DOMException !== 'undefined' &&
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError'))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
|
||||
if (attempt >= retry.maxRetries || isAbortError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error instanceof TypeError;
|
||||
}
|
||||
|
||||
function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) {
|
||||
return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt));
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details: Record<string, unknown> | null;
|
||||
meta: ApiMeta;
|
||||
responseText: string;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
status: number;
|
||||
code: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
meta?: Partial<ApiMeta>;
|
||||
responseText?: string;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = 'ApiClientError';
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.details = params.details ?? null;
|
||||
this.meta = {
|
||||
apiVersion: params.meta?.apiVersion ?? API_VERSION,
|
||||
requestId: params.meta?.requestId,
|
||||
routeVersion: params.meta?.routeVersion,
|
||||
operation: params.meta?.operation,
|
||||
latencyMs: params.meta?.latencyMs,
|
||||
timestamp: params.meta?.timestamp,
|
||||
};
|
||||
this.responseText = params.responseText ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
@@ -14,7 +322,14 @@ function emitAuthStateChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||||
if (typeof CustomEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof Event === 'function') {
|
||||
window.dispatchEvent(new Event(AUTH_STATE_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAccessToken() {
|
||||
@@ -25,7 +340,12 @@ export function getStoredAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(token: string) {
|
||||
export function setStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
@@ -36,16 +356,24 @@ export function setStoredAccessToken(token: string) {
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
emitAuthStateChange();
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAccessToken() {
|
||||
export function clearStoredAccessToken(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
emitAuthStateChange();
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAutoAuthCredentials() {
|
||||
@@ -88,56 +416,165 @@ export function clearStoredAutoAuthCredentials() {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
} else if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
) {
|
||||
const nextHeaders = normalizeHeaders(headers);
|
||||
const token = getStoredAccessToken();
|
||||
if (token) {
|
||||
if (token && !options.skipAuth) {
|
||||
nextHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (!options.omitEnvelopeHeader) {
|
||||
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
}
|
||||
|
||||
refreshAccessTokenPromise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearStoredAccessToken();
|
||||
throw await buildApiClientError(response, '刷新登录状态失败');
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const payload = responseText
|
||||
? unwrapApiResponse<RefreshTokenResponse>(
|
||||
JSON.parse(responseText) as RefreshTokenResponse,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!payload?.token?.trim()) {
|
||||
clearStoredAccessToken();
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
setStoredAccessToken(payload.token, { emit: false });
|
||||
return payload.token;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await refreshAccessTokenPromise;
|
||||
} finally {
|
||||
refreshAccessTokenPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithApiAuth(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
options: ApiRequestOptions = {},
|
||||
) {
|
||||
const response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: withAuthorizationHeaders(init.headers),
|
||||
});
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
if (response.status === 401) {
|
||||
clearStoredAccessToken();
|
||||
for (;;) {
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: withAuthorizationHeaders(init.headers, options),
|
||||
});
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!options.skipAuth &&
|
||||
!options.skipRefresh &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
refreshAttempted = true;
|
||||
continue;
|
||||
} catch {
|
||||
clearStoredAccessToken();
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
clearStoredAccessToken();
|
||||
}
|
||||
|
||||
if (!shouldRetryResponse(response.status, attempt, retry)) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!shouldRetryError(error, attempt, retry)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await waitForRetry(
|
||||
buildRetryDelayMs(attempt, retry),
|
||||
init.signal ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
async function buildApiClientError(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const responseText = await response.text();
|
||||
const parsedError = parseApiErrorShape(responseText);
|
||||
|
||||
return new ApiClientError({
|
||||
message: parseApiErrorMessage(responseText, fallbackMessage),
|
||||
status: response.status,
|
||||
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
|
||||
details: parsedError?.details ?? null,
|
||||
meta: {
|
||||
apiVersion:
|
||||
parsedError?.meta.apiVersion ??
|
||||
response.headers.get(API_VERSION_HEADER) ??
|
||||
API_VERSION,
|
||||
requestId:
|
||||
parsedError?.meta.requestId ??
|
||||
response.headers.get(REQUEST_ID_HEADER) ??
|
||||
undefined,
|
||||
routeVersion:
|
||||
parsedError?.meta.routeVersion ??
|
||||
response.headers.get(ROUTE_VERSION_HEADER) ??
|
||||
undefined,
|
||||
operation: parsedError?.meta.operation,
|
||||
latencyMs: parsedError?.meta.latencyMs,
|
||||
timestamp: parsedError?.meta.timestamp,
|
||||
},
|
||||
responseText,
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await fetchWithApiAuth(url, init);
|
||||
const responseText = await response.text();
|
||||
const response = await fetchWithApiAuth(url, init, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
throw await buildApiClientError(response, fallbackMessage);
|
||||
}
|
||||
|
||||
return responseText ? (JSON.parse(responseText) as T) : (null as T);
|
||||
const responseText = await response.text();
|
||||
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: (null as T);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,30 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
createAutoAuthCredentials,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from './authService';
|
||||
|
||||
function createMemoryStorage() {
|
||||
@@ -68,12 +83,17 @@ describe('authService auto auth', () => {
|
||||
user: {
|
||||
id: 'user_1',
|
||||
username: 'guest_abc123abc123',
|
||||
displayName: 'guest_abc123abc123',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await authEntryWithStoredCredentials({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
username: ' guest_abc123abc123 ',
|
||||
password: ' auto_secret_password ',
|
||||
});
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
@@ -82,6 +102,16 @@ describe('authService auto auth', () => {
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
@@ -92,6 +122,11 @@ describe('authService auto auth', () => {
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
username: 'guest_saveduser01',
|
||||
displayName: 'guest_saveduser01',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,4 +145,354 @@ describe('authService auto auth', () => {
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
scene: 'login',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone change code with the correct scene', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
await sendPhoneLoginCode('13900139000', 'change_phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
scene: 'change_phone',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts captcha challenge details from api errors', () => {
|
||||
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
|
||||
|
||||
const captchaError = new ApiClientError({
|
||||
message: '需要完成人机校验',
|
||||
status: 403,
|
||||
code: 'CAPTCHA_REQUIRED',
|
||||
details: {
|
||||
captchaChallenge: {
|
||||
challengeId: 'captcha_1',
|
||||
promptText: '请输入图中的验证码后再获取短信验证码',
|
||||
imageDataUrl: 'data:image/svg+xml;base64,abc',
|
||||
expiresInSeconds: 180,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getCaptchaChallengeFromError(captchaError)).toEqual({
|
||||
challengeId: 'captcha_1',
|
||||
promptText: '请输入图中的验证码后再获取短信验证码',
|
||||
imageDataUrl: 'data:image/svg+xml;base64,abc',
|
||||
expiresInSeconds: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores jwt after phone login', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'phone-jwt-token',
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(getStoredAccessToken()).toBe('phone-jwt-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('binds wechat phone and stores jwt after activation', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'wechat-bind-token',
|
||||
user: {
|
||||
id: 'user_wechat',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('wechat-bind-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'绑定手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('changes phone number without replacing the stored access token', async () => {
|
||||
setStoredAccessToken('active-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '139****9000',
|
||||
displayName: '139****9000',
|
||||
phoneNumberMasked: '139****9000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(getStoredAccessToken()).toBe('active-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/change',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'更换手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts wechat login by navigating to backend authorization url', async () => {
|
||||
const assignMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockResolvedValue({
|
||||
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/start?redirectPath=%2F',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
expect(assignMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and stores token', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
});
|
||||
|
||||
const result = consumeAuthCallbackResult();
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: 'wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('wx-token');
|
||||
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
|
||||
});
|
||||
|
||||
it('loads auth sessions from account center endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
clientType: 'browser',
|
||||
clientLabel: '网页端浏览器',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
createdAt: '2026-04-09T10:00:00.000Z',
|
||||
lastSeenAt: '2026-04-09T10:30:00.000Z',
|
||||
expiresAt: '2026-05-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'audit_1',
|
||||
eventType: 'phone_login',
|
||||
title: '手机号登录',
|
||||
detail: '使用手机号 138****8000 完成登录',
|
||||
ipMasked: '127.0.*.*',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
createdAt: '2026-04-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logs = await getAuthAuditLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/audit-logs',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads current risk blocks', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
blocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
title: '手机号保护中',
|
||||
detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试',
|
||||
expiresAt: '2026-04-09T11:00:00.000Z',
|
||||
remainingSeconds: 1800,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const blocks = await getAuthRiskBlocks();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取安全状态失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('lifts a risk block by scope type', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await liftAuthRiskBlock('phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks/phone/lift',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'解除保护失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes a remote auth session by id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await revokeAuthSession('usess_123');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_123/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears local auth state after logout all sessions', async () => {
|
||||
setStoredAccessToken('stale-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await logoutAllAuthSessions();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'退出全部设备失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthAuditLogsResponse,
|
||||
AuthCaptchaChallenge,
|
||||
AuthEntryResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
AuthRevokeSessionResponse,
|
||||
AuthRiskBlocksResponse,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAutoAuthCredentials,
|
||||
@@ -7,19 +29,77 @@ import {
|
||||
setStoredAutoAuthCredentials,
|
||||
} from './apiClient';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
|
||||
export type AutoAuthCredentials = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type AuthSessionSnapshot = {
|
||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
};
|
||||
export type { AuthSessionSummary };
|
||||
export type { AuthCaptchaChallenge };
|
||||
export type { AuthAuditLogEntry };
|
||||
export type { AuthRiskBlockSummary };
|
||||
|
||||
export type ConsumedAuthCallback = {
|
||||
provider: 'wechat' | 'unknown';
|
||||
bindingStatus: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
|
||||
export function getCaptchaChallengeFromError(
|
||||
error: unknown,
|
||||
): AuthCaptchaChallenge | null {
|
||||
if (
|
||||
error instanceof ApiClientError &&
|
||||
error.code === 'CAPTCHA_REQUIRED' &&
|
||||
error.details &&
|
||||
typeof error.details === 'object' &&
|
||||
'captchaChallenge' in error.details
|
||||
) {
|
||||
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
|
||||
if (
|
||||
challenge &&
|
||||
typeof challenge === 'object' &&
|
||||
'challengeId' in challenge &&
|
||||
'promptText' in challenge &&
|
||||
'imageDataUrl' in challenge &&
|
||||
'expiresInSeconds' in challenge
|
||||
) {
|
||||
return challenge as AuthCaptchaChallenge;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials {
|
||||
return {
|
||||
username: credentials.username.trim(),
|
||||
password: credentials.password.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
||||
const cryptoApi = globalThis.crypto;
|
||||
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
return Array.from(
|
||||
{length},
|
||||
() => alphabet[Math.floor(Math.random() * alphabet.length)],
|
||||
).join('');
|
||||
}
|
||||
|
||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
|
||||
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
|
||||
}
|
||||
@@ -31,20 +111,111 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
};
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const response = await requestJson<{
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}>(
|
||||
'/api/auth/entry',
|
||||
export function clearAuthSession() {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
}
|
||||
|
||||
export async function sendPhoneLoginCode(
|
||||
phone: string,
|
||||
scene: 'login' | 'bind_phone' | 'change_phone' = 'login',
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) {
|
||||
const response = await requestJson<AuthPhoneSendCodeResponse>(
|
||||
'/api/auth/phone/send-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
phone: normalizePhoneInput(phone),
|
||||
scene,
|
||||
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
|
||||
captchaAnswer: captcha?.answer?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
'发送验证码失败',
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneLoginResponse>(
|
||||
'/api/auth/phone/login',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function changePhoneNumber(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneChangeResponse>(
|
||||
'/api/auth/phone/change',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'更换手机号失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function startWechatLogin() {
|
||||
const response = await requestJson<AuthWechatStartResponse>(
|
||||
`/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
|
||||
window.location.assign(response.authorizationUrl);
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const credentials = normalizeCredentials({ username, password });
|
||||
const response = await requestJson<AuthEntryResponse>(
|
||||
'/api/auth/entry',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
@@ -55,8 +226,12 @@ export async function authEntry(username: string, password: string) {
|
||||
export async function authEntryWithStoredCredentials(
|
||||
credentials: AutoAuthCredentials,
|
||||
) {
|
||||
const user = await authEntry(credentials.username, credentials.password);
|
||||
setStoredAutoAuthCredentials(credentials);
|
||||
const normalizedCredentials = normalizeCredentials(credentials);
|
||||
const user = await authEntry(
|
||||
normalizedCredentials.username,
|
||||
normalizedCredentials.password,
|
||||
);
|
||||
setStoredAutoAuthCredentials(normalizedCredentials);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -71,10 +246,49 @@ export async function ensureAutoAuthUser() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser() {
|
||||
const response = await requestJson<{
|
||||
user: AuthUser | null;
|
||||
}>(
|
||||
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
if (!hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
const authToken = params.get('auth_token');
|
||||
const authError = params.get('auth_error');
|
||||
const providerValue = params.get('auth_provider');
|
||||
const bindingStatus = params.get('auth_binding_status');
|
||||
|
||||
if (!authToken && !authError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
setStoredAccessToken(authToken);
|
||||
}
|
||||
|
||||
if (typeof window.history?.replaceState === 'function') {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: providerValue === 'wechat' ? 'wechat' : 'unknown',
|
||||
bindingStatus,
|
||||
error: authError,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
|
||||
const response = await requestJson<AuthMeResponse>(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -82,12 +296,71 @@ export async function getCurrentAuthUser() {
|
||||
'读取当前用户失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
return {
|
||||
user: response.user,
|
||||
availableLoginMethods: response.availableLoginMethods,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAuthSessions() {
|
||||
const response = await requestJson<AuthSessionsResponse>(
|
||||
'/api/auth/sessions',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录设备失败',
|
||||
);
|
||||
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId: string) {
|
||||
await requestJson<AuthRevokeSessionResponse>(
|
||||
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'移除登录设备失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
|
||||
return response.logs;
|
||||
}
|
||||
|
||||
export async function getAuthRiskBlocks() {
|
||||
const response = await requestJson<AuthRiskBlocksResponse>(
|
||||
'/api/auth/risk-blocks',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取安全状态失败',
|
||||
);
|
||||
|
||||
return response.blocks;
|
||||
}
|
||||
|
||||
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
|
||||
await requestJson<AuthLiftRiskBlockResponse>(
|
||||
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'解除保护失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function logoutAuthUser() {
|
||||
try {
|
||||
await requestJson<{ ok: true }>(
|
||||
await requestJson<LogoutResponse>(
|
||||
'/api/auth/logout',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -95,7 +368,20 @@ export async function logoutAuthUser() {
|
||||
'退出登录失败',
|
||||
);
|
||||
} finally {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutAllAuthSessions() {
|
||||
try {
|
||||
await requestJson<AuthLogoutAllResponse>(
|
||||
'/api/auth/logout-all',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出全部设备失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function coerceBoolean(value: string | undefined) {
|
||||
function resolveHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
export function parseJsonResponseText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('LLM returned an empty response.');
|
||||
}
|
||||
|
||||
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
|
||||
if (fencedMatch?.[1]) {
|
||||
return JSON.parse(fencedMatch[1].trim());
|
||||
}
|
||||
|
||||
const firstBrace = trimmed.indexOf('{');
|
||||
const lastBrace = trimmed.lastIndexOf('}');
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
|
||||
}
|
||||
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
export function parseLineListContent(text: string, maxItems = 3) {
|
||||
return text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim().replace(/^[-*\d.)\s]+/u, '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, maxItems);
|
||||
}
|
||||
export {
|
||||
parseJsonResponseText,
|
||||
parseLineListContent,
|
||||
} from '../../packages/shared/src/llm/parsers';
|
||||
|
||||
@@ -1,68 +1,4 @@
|
||||
const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu;
|
||||
const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g;
|
||||
const LATIN_FRAGMENT_PATTERN =
|
||||
/[A-Za-z][A-Za-z0-9'"“”‘’()\-,:;!?/]*(?:\s+[A-Za-z0-9'"“”‘’()\-,:;!?/]+)+/gu;
|
||||
const SAFE_LATIN_TOKENS = new Set([
|
||||
'act',
|
||||
'ai',
|
||||
'boss',
|
||||
'cd',
|
||||
'hp',
|
||||
'json',
|
||||
'llm',
|
||||
'mp',
|
||||
'npc',
|
||||
'qa',
|
||||
'rpg',
|
||||
]);
|
||||
|
||||
function getCjkCharCount(text: string) {
|
||||
return text.match(CJK_CHAR_PATTERN)?.length ?? 0;
|
||||
}
|
||||
|
||||
function getSignificantLatinWords(text: string) {
|
||||
return (text.match(LATIN_WORD_PATTERN) ?? [])
|
||||
.map((word) => word.toLowerCase())
|
||||
.filter(
|
||||
(word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMixedNarrativeLanguage(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cjkCharCount = getCjkCharCount(trimmed);
|
||||
const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? [])
|
||||
.map((fragment) => fragment.trim())
|
||||
.filter((fragment) => fragment.split(/\s+/u).length >= 2);
|
||||
const significantLatinWords = getSignificantLatinWords(trimmed);
|
||||
|
||||
if (latinSentenceFragments.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cjkCharCount > 0 && significantLatinWords.length >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return cjkCharCount === 0 && significantLatinWords.length >= 3;
|
||||
}
|
||||
|
||||
export function sanitizePromptNarrativeText(
|
||||
text: string | null | undefined,
|
||||
fallback: string | null = null,
|
||||
) {
|
||||
if (typeof text !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed;
|
||||
}
|
||||
export {
|
||||
hasMixedNarrativeLanguage,
|
||||
sanitizePromptNarrativeText,
|
||||
} from '../../packages/shared/src/llm/narrativeLanguage';
|
||||
|
||||
249
src/services/runtimeStoryService.test.ts
Normal file
249
src/services/runtimeStoryService.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeSessionId,
|
||||
isServerRuntimeFunctionId,
|
||||
isTask5RuntimeFunctionId,
|
||||
resolveRuntimeStoryAction,
|
||||
shouldUseServerRuntimeOptions,
|
||||
} from './runtimeStoryService';
|
||||
import { AnimationState } from '../types';
|
||||
|
||||
describe('runtimeStoryService', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('builds runtime action requests against the dedicated story endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 2,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '后端已结算',
|
||||
storyText: '后端已结算',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
option: {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_chat',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '继续交谈',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('merges custom runtime payload fields into the action request body', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 3,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '使用凝神灵液',
|
||||
resultText: '后端已结算物品使用',
|
||||
storyText: '后端已结算物品使用',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 3,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
option: {
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用凝神灵液',
|
||||
},
|
||||
payload: {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: undefined,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'inventory_use',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '使用凝神灵液',
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters disabled runtime options when rebuilding a story moment', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
{
|
||||
functionId: 'npc_recruit',
|
||||
actionText: '邀请加入队伍',
|
||||
scope: 'npc',
|
||||
disabled: true,
|
||||
reason: '队伍已满',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回的新故事');
|
||||
expect(story.options).toHaveLength(1);
|
||||
expect(story.options[0]?.functionId).toBe('npc_chat');
|
||||
});
|
||||
|
||||
it('recognizes server-runtime option pools for server-side legality checks', () => {
|
||||
expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true);
|
||||
expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false);
|
||||
expect(isServerRuntimeFunctionId('npc_trade')).toBe(true);
|
||||
expect(isServerRuntimeFunctionId('unknown_action')).toBe(false);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
{
|
||||
functionId: 'unknown_action',
|
||||
actionText: '未知动作',
|
||||
text: '未知动作',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(getRuntimeSessionId({ runtimeSessionId: '' })).toBe('runtime-main');
|
||||
expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('hydrates runtime option interaction metadata from the current encounter', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
gameState: {
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc',
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
} as never,
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.options[0]?.interaction).toEqual({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/services/runtimeStoryService.ts
Normal file
218
src/services/runtimeStoryService.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryOptionView,
|
||||
ServerRuntimeFunctionId,
|
||||
Task5RuntimeFunctionId,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
import {
|
||||
SERVER_RUNTIME_FUNCTION_IDS,
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
import type {
|
||||
HydratedGameState,
|
||||
HydratedSavedGameSnapshot,
|
||||
} from '../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../types';
|
||||
import { AnimationState } from '../types';
|
||||
import { requestJson, type ApiRetryOptions } from './apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
const RUNTIME_STORY_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 220,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
const TASK5_RUNTIME_FUNCTION_ID_SET = new Set<string>(
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
);
|
||||
const SERVER_RUNTIME_FUNCTION_ID_SET = new Set<string>([
|
||||
...SERVER_RUNTIME_FUNCTION_IDS,
|
||||
]);
|
||||
|
||||
export type RuntimeStoryServiceOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
||||
HydratedGameState,
|
||||
StoryMoment
|
||||
>;
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
|
||||
function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_STORY_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
function buildRuntimeOptionInteraction(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption['interaction'] {
|
||||
const encounter = gameState?.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = encounter.id ?? encounter.npcName;
|
||||
const npcActionMap: Record<string, StoryOption['interaction']> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[option.functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, StoryOption['interaction']> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[option.functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption {
|
||||
const detailParts = [option.detailText, option.disabled ? option.reason : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: detailParts || undefined,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: buildRuntimeOptionInteraction(option, gameState),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeSessionId(gameState: Pick<GameState, 'runtimeSessionId'>) {
|
||||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
}
|
||||
|
||||
export function getRuntimeClientVersion(
|
||||
gameState: Pick<GameState, 'runtimeActionVersion'>,
|
||||
) {
|
||||
return typeof gameState.runtimeActionVersion === 'number'
|
||||
? gameState.runtimeActionVersion
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isTask5RuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is Task5RuntimeFunctionId {
|
||||
return TASK5_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function isServerRuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is ServerRuntimeFunctionId {
|
||||
return SERVER_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) {
|
||||
return Boolean(
|
||||
options?.length &&
|
||||
options.every((option) => isServerRuntimeFunctionId(option.functionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
storyText: string;
|
||||
options: RuntimeStoryOptionView[];
|
||||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||||
}) {
|
||||
return {
|
||||
text: params.storyText,
|
||||
options: params.options
|
||||
.filter((option) => !option.disabled)
|
||||
.map((option) => createRuntimeStoryOption(option, params.gameState)),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(
|
||||
params: {
|
||||
sessionId?: string;
|
||||
clientVersion?: number;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||||
targetId?: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
},
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: params.sessionId || DEFAULT_SESSION_ID,
|
||||
clientVersion: params.clientVersion,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: params.option.functionId,
|
||||
targetId: params.targetId,
|
||||
payload: {
|
||||
optionText: params.option.actionText,
|
||||
...(params.payload ?? {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return response.snapshot as HydratedSavedGameSnapshot;
|
||||
}
|
||||
@@ -1,78 +1,131 @@
|
||||
import type {
|
||||
SavedGameSnapshot,
|
||||
BasicOkResult,
|
||||
CustomWorldLibraryResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
SavedGameSnapshotInput,
|
||||
} from '../persistence/gameSaveStorage';
|
||||
import type { SavedGameSettings } from '../persistence/gameSettingsStorage';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { requestJson } from './apiClient';
|
||||
import { type ApiRetryOptions,requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
type CustomWorldLibraryResponse = {
|
||||
profiles?: CustomWorldProfile[];
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export async function getSaveSnapshot() {
|
||||
return requestJson<SavedGameSnapshot | null>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
function requestRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(snapshot: SavedGameSnapshotInput) {
|
||||
return requestJson<SavedGameSnapshot>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSaveSnapshot() {
|
||||
return requestJson<{ ok: true }>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
return requestJson<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
export async function getSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSettings(settings: SavedGameSettings) {
|
||||
return requestJson<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
export async function putSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary() {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library`,
|
||||
export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export async function upsertCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
export async function upsertCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -81,17 +134,33 @@ export async function upsertCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export async function deleteCustomWorldProfile(profileId: string) {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
export async function deleteCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export const runtimeStorageClient = {
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
deleteSaveSnapshot,
|
||||
getSettings,
|
||||
putSettings,
|
||||
listCustomWorldLibrary,
|
||||
upsertCustomWorldProfile,
|
||||
deleteCustomWorldProfile,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user