初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

426
src/services/ai.test.ts Normal file
View File

@@ -0,0 +1,426 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
connectivityError,
fetchMock,
requestChatMessageContentMock,
requestPlainTextCompletionMock,
streamPlainTextCompletionMock,
} = vi.hoisted(() => ({
connectivityError: new Error('LLM unavailable'),
fetchMock: vi.fn(),
requestChatMessageContentMock: vi.fn(),
requestPlainTextCompletionMock: vi.fn(),
streamPlainTextCompletionMock: vi.fn(),
}));
vi.mock('./llmClient', () => ({
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 45000,
isLlmConnectivityError: (error: unknown) => error === connectivityError,
requestChatMessageContent: requestChatMessageContentMock,
requestPlainTextCompletion: requestPlainTextCompletionMock,
streamPlainTextCompletion: streamPlainTextCompletionMock,
}));
import type {
Character,
Encounter,
SceneMonster,
StoryMoment,
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
import {
generateCharacterPanelChatSuggestions,
generateCustomWorldProfile,
generateCustomWorldSceneImage,
generateInitialStory,
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
import {
buildOfflineCharacterPanelChatReply,
buildOfflineCharacterPanelChatSuggestions,
buildOfflineNpcRecruitDialogue,
} from './aiFallbacks';
import type { StoryGenerationContext } from './aiTypes';
import type { CharacterChatTargetStatus } from './characterChatPrompt';
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 'hero',
name: 'Lin',
title: 'Wanderer',
description: 'A cautious traveler.',
backstory: 'Walked out of the northern mountains.',
avatar: '/avatars/lin.png',
portrait: '/portraits/lin.png',
assetFolder: 'lin',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 8,
intelligence: 7,
spirit: 9,
},
personality: 'Calm, observant, and steady.',
skills: [],
adventureOpenings: {},
...overrides,
};
}
function createStoryOption(overrides: Partial<StoryOption> = {}): StoryOption {
return {
functionId: 'rest',
actionText: 'Pause and recover.',
text: 'Pause and recover.',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
...overrides,
};
}
function createContext(
overrides: Partial<StoryGenerationContext> = {},
): StoryGenerationContext {
return {
playerHp: 30,
playerMaxHp: 40,
playerMana: 12,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: null,
sceneName: 'Forest Trail',
sceneDescription: 'A quiet mountain path.',
pendingSceneEncounter: false,
...overrides,
};
}
function createTargetStatus(
overrides: Partial<CharacterChatTargetStatus> = {},
): CharacterChatTargetStatus {
return {
roleLabel: 'Companion',
hp: 18,
maxHp: 20,
mana: 9,
maxMana: 12,
...overrides,
};
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
npcName: 'Lan',
npcDescription: 'A sharp-eyed scout.',
npcAvatar: '/avatars/lan.png',
context: 'Campfire',
...overrides,
};
}
function createPlayableNpc(index: number) {
return {
name: `角色${index + 1}`,
title: `身份${index + 1}`,
description: `角色描述${index + 1}`,
backstory: `角色背景${index + 1}`,
personality: `角色性格${index + 1}`,
combatStyle: `战斗风格${index + 1}`,
tags: [`标签${index + 1}`],
};
}
function createStoryNpc(index: number) {
return {
name: `世界NPC${index + 1}`,
role: `职责${index + 1}`,
description: `世界NPC描述${index + 1}`,
motivation: `世界NPC动机${index + 1}`,
relationshipHooks: [`关系${index + 1}`],
};
}
function createLandmark(index: number) {
return {
name: `场景${index + 1}`,
description: `场景描述${index + 1}`,
dangerLevel: 'high',
};
}
describe('ai orchestration fallbacks', () => {
const playerCharacter = createCharacter();
const targetCharacter = createCharacter({
id: 'ally',
name: 'Lan',
title: 'Scout',
personality: 'Dry, practical, and quietly protective.',
});
const context = createContext();
const targetStatus = createTargetStatus();
const monsters: SceneMonster[] = [];
const storyHistory: StoryMoment[] = [];
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
requestChatMessageContentMock.mockReset();
requestPlainTextCompletionMock.mockReset();
streamPlainTextCompletionMock.mockReset();
});
it('falls back to the offline story response when story generation loses connectivity', async () => {
const availableOptions = [createStoryOption()];
requestChatMessageContentMock.mockRejectedValue(connectivityError);
const response = await generateInitialStory(
WorldType.WUXIA,
playerCharacter,
monsters,
context,
{ availableOptions },
);
expect(response.options).toEqual(availableOptions);
expect(response.options).not.toBe(availableOptions);
expect(response.storyText.length).toBeGreaterThan(0);
});
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);
const suggestions = await generateCharacterPanelChatSuggestions(
WorldType.WUXIA,
playerCharacter,
targetCharacter,
storyHistory,
context,
[],
'',
targetStatus,
);
expect(suggestions).toEqual(
buildOfflineCharacterPanelChatSuggestions(targetCharacter),
);
});
it('streams the offline character chat reply and forwards it to onUpdate when connectivity fails', async () => {
const onUpdate = vi.fn();
const playerMessage = 'Tell me what you are really worried about.';
const conversationSummary = 'Lan has started to trust the player more.';
const fallbackReply = buildOfflineCharacterPanelChatReply(
targetCharacter,
playerMessage,
conversationSummary,
);
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
const reply = await streamCharacterPanelChatReply(
WorldType.WUXIA,
playerCharacter,
targetCharacter,
storyHistory,
context,
[],
conversationSummary,
playerMessage,
targetStatus,
{ onUpdate },
);
expect(reply).toBe(fallbackReply);
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
});
it('uses the extracted NPC recruit fallback when recruit dialogue streaming loses connectivity', async () => {
const onUpdate = vi.fn();
const encounter = createEncounter();
const fallbackReply = buildOfflineNpcRecruitDialogue(encounter);
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
const reply = await streamNpcRecruitDialogue(
WorldType.WUXIA,
playerCharacter,
encounter,
monsters,
storyHistory,
context,
'Join us.',
'The party is ready to travel together.',
{ onUpdate },
);
expect(reply).toBe(fallbackReply);
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
});
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
JSON.stringify({
name: '测试世界',
subtitle: '副标题',
summary: '概述',
tone: '基调',
playerGoal: '目标',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) =>
createPlayableNpc(index),
),
storyNpcs: Array.from({ length: 10 }, (_, index) =>
createStoryNpc(index),
),
landmarks: Array.from({ length: 4 }, (_, index) =>
createLandmark(index),
),
}),
);
await expect(
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
).rejects.toThrow(
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景/i,
);
});
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
JSON.stringify({
name: '测试世界',
subtitle: '副标题',
summary: '概述',
tone: '基调',
playerGoal: '目标',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) =>
createPlayableNpc(index),
),
storyNpcs: Array.from({ length: 25 }, (_, index) =>
createStoryNpc(index),
),
landmarks: Array.from({ length: 10 }, (_, index) =>
createLandmark(index),
),
items: [
{
name: '不应保留的物品',
category: '材料',
rarity: 'rare',
description: '这个字段应该被清空',
tags: ['测试'],
},
],
}),
);
const profile =
await generateCustomWorldProfile('一个需要很多角色和场景的世界');
expect(profile.playableNpcs).toHaveLength(5);
expect(profile.storyNpcs).toHaveLength(25);
expect(profile.landmarks).toHaveLength(10);
expect(profile.items).toEqual([]);
});
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
fetchMock.mockResolvedValue({
ok: true,
text: async () =>
JSON.stringify({
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
assetId: 'custom-scene-1',
model: 'wan2.2-t2i-flash',
size: '1280*720',
taskId: 'task-123',
prompt: '用于测试的提示词',
actualPrompt: '扩写后的提示词',
}),
} as Response);
const result = await generateCustomWorldSceneImage({
profile: {
id: 'custom-world-1',
name: '测试世界',
subtitle: '副标题',
summary: '世界概述',
tone: '世界基调',
playerGoal: '核心目标',
settingText: '原始设定',
},
landmark: {
id: 'landmark-1',
name: '雾潮码头',
description: '被潮雾与旧升降机包围的码头。',
dangerLevel: 'high',
},
prompt: '用于测试的提示词',
negativePrompt: '文字,水印',
size: '1280*720',
});
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith(
'/api/custom-world/scene-image',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
expect(result).toEqual({
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
assetId: 'custom-scene-1',
model: 'wan2.2-t2i-flash',
size: '1280*720',
taskId: 'task-123',
prompt: '用于测试的提示词',
actualPrompt: '扩写后的提示词',
});
});
it('surfaces proxy error messages when scene image generation fails', async () => {
fetchMock.mockResolvedValue({
ok: false,
text: async () =>
JSON.stringify({
error: {
message: 'DashScope API key 无效。',
},
}),
} as Response);
await expect(
generateCustomWorldSceneImage({
profile: {
id: 'custom-world-1',
name: '测试世界',
subtitle: '副标题',
summary: '世界概述',
tone: '世界基调',
playerGoal: '核心目标',
settingText: '原始设定',
},
landmark: {
id: 'landmark-1',
name: '雾潮码头',
description: '被潮雾与旧升降机包围的码头。',
dangerLevel: 'high',
},
}),
).rejects.toThrow('DashScope API key 无效。');
});
});