This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

@@ -45,6 +45,7 @@ import {
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
import { streamNpcChatTurn } from './aiService';
import type { StoryGenerationContext } from './aiTypes';
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
@@ -457,6 +458,50 @@ function createSseResponse(text: string) {
} as Response;
}
function createNpcChatTurnSseResponse(reply: string) {
const encoder = new TextEncoder();
const completePayload = {
npcReply: reply,
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
suggestions: [],
functionSuggestions: [],
pendingQuestOffer: null,
chatDirective: null,
};
const chunks = [
encoder.encode(
`event: reply_delta\ndata: ${JSON.stringify({ text: reply })}\n\n`,
),
encoder.encode(
`event: complete\ndata: ${JSON.stringify(completePayload)}\n\n`,
),
encoder.encode('data: [DONE]\n\n'),
];
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader() {
return {
async read() {
if (index >= chunks.length) {
return { done: true, value: undefined };
}
const value = chunks[index];
index += 1;
return { done: false, value };
},
};
},
},
text: async () => '',
} as Response;
}
describe('ai runtime client orchestration', () => {
const playerCharacter = createCharacter();
const targetCharacter = createCharacter({
@@ -466,6 +511,17 @@ describe('ai runtime client orchestration', () => {
personality: 'Dry, practical, and quietly protective.',
});
const context = createContext();
const transientSnapshot: NonNullable<
StoryGenerationContext['runtimeSnapshot']
> = {
bottomTab: 'adventure',
gameState: {
worldType: WorldType.WUXIA,
runtimeSessionId: 'runtime-preview',
runtimePersistenceDisabled: true,
} as NonNullable<StoryGenerationContext['runtimeSnapshot']>['gameState'],
currentStory: null,
};
const targetStatus = createTargetStatus();
const monsters: SceneHostileNpc[] = [];
const storyHistory: StoryMoment[] = [];
@@ -633,6 +689,86 @@ describe('ai runtime client orchestration', () => {
);
});
it('attaches transient snapshot to session based chat requests only when provided', async () => {
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
text: '先确认眼下的局势。\n问清对方的真实目的。\n保持距离继续观察。',
}),
);
await generateCharacterPanelChatSuggestions(
WorldType.WUXIA,
playerCharacter,
targetCharacter,
storyHistory,
createContext({
runtimeSessionId: 'runtime-preview',
runtimeSnapshot: transientSnapshot,
}),
[],
'',
targetStatus,
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/suggestions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-preview',
snapshot: transientSnapshot,
targetCharacter,
conversationHistory: [],
conversationSummary: '',
targetStatus,
}),
}),
);
});
it('attaches transient snapshot to npc chat turn session requests', async () => {
const encounter = createEncounter();
fetchMock.mockResolvedValue(
createNpcChatTurnSseResponse('先把眼前的事说清楚。'),
);
const result = await streamNpcChatTurn(
WorldType.WUXIA,
playerCharacter,
encounter,
monsters,
storyHistory,
createContext({
runtimeSessionId: 'runtime-preview',
runtimeSnapshot: transientSnapshot,
}),
[],
'你刚才看见了什么?',
{ chattedCount: 0 },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/npc/turn/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-preview',
snapshot: transientSnapshot,
encounter,
conversationHistory: [],
dialogue: [],
playerMessage: '你刚才看见了什么?',
npcState: { chattedCount: 0 },
npcInitiatesConversation: false,
questOfferContext: null,
combatContext: null,
chatDirective: null,
}),
}),
);
expect(result.npcReply).toBe('先把眼前的事说清楚。');
});
it('streams npc recruit dialogue from the runtime api server', async () => {
const onUpdate = vi.fn();
const encounter = createEncounter();