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();

View File

@@ -42,6 +42,10 @@ function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
return context.runtimeSessionId?.trim() || undefined;
}
function getRuntimeSnapshotFromContext(context: StoryGenerationContext) {
return context.runtimeSnapshot;
}
async function requestPlainText(
url: string,
payload: unknown,
@@ -240,9 +244,11 @@ export async function generateCharacterPanelChatSuggestions(
targetStatus: CharacterChatTargetStatus,
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
conversationSummary,
@@ -278,9 +284,11 @@ export async function generateCharacterPanelChatSummary(
targetStatus: CharacterChatTargetStatus,
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
previousSummary,
@@ -318,9 +326,11 @@ export async function streamCharacterPanelChatReply(
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
conversationSummary,
@@ -359,9 +369,11 @@ export async function streamNpcChatDialogue(
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
encounter,
topic,
resultSummary,
@@ -411,6 +423,7 @@ export async function streamNpcChatTurn(
} = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const commonChatPayload = {
encounter,
conversationHistory: conversationHistory ?? [],
@@ -429,15 +442,18 @@ export async function streamNpcChatTurn(
chatDirective: options.chatDirective
? {
...options.chatDirective,
functionOptions: options.chatDirective.functionOptions?.map((item) => ({
...item,
})),
functionOptions: options.chatDirective.functionOptions?.map(
(item) => ({
...item,
}),
),
}
: null,
};
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
...commonChatPayload,
} satisfies NpcChatTurnRequest)
: ({
@@ -548,9 +564,11 @@ export async function streamNpcRecruitDialogue(
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
encounter,
invitationText,
recruitSummary,

View File

@@ -21,6 +21,7 @@ import type {
EquipmentLoadout,
FacingDirection,
FactionTensionState,
GameState,
GoalStackState,
InventoryItem,
JourneyBeat,
@@ -43,6 +44,7 @@ import type {
WorldMutation,
WorldType,
} from '../types';
import type { SavedGameSnapshotInput } from '../../packages/shared/src/contracts/runtime';
import type { ConversationPressure, ConversationSituation } from '../types';
export interface StoryRequestOptions {
@@ -90,6 +92,7 @@ export interface CustomWorldSceneImageResult {
export interface StoryGenerationContext {
runtimeSessionId?: string | null;
runtimeActionVersion?: number;
runtimeSnapshot?: SavedGameSnapshotInput<GameState, string, StoryMoment>;
playerHp: number;
playerMaxHp: number;
playerMana: number;

View File

@@ -1,3 +1,4 @@
import type { BigFishSessionResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import { ApiClientError, type ApiRetryOptions, requestJson } from '../apiClient';
@@ -36,6 +37,20 @@ export async function listBigFishGallery() {
}
}
/**
* 将公开大鱼吃小鱼作品复制为当前用户草稿。
*/
export async function remixBigFishGalleryWork(sessionId: string) {
return requestJson<BigFishSessionResponse>(
`${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/remix`,
{
method: 'POST',
},
'Remix 大鱼吃小鱼作品失败',
);
}
export const bigFishGalleryClient = {
list: listBigFishGallery,
remix: remixBigFishGalleryWork,
};

View File

@@ -1,4 +1,5 @@
export {
bigFishGalleryClient,
listBigFishGallery,
remixBigFishGalleryWork,
} from './bigFishGalleryClient';

View File

@@ -2,4 +2,5 @@ export {
getPuzzleGalleryDetail,
listPuzzleGallery,
puzzleGalleryClient,
remixPuzzleGalleryWork,
} from './puzzleGalleryClient';

View File

@@ -2,6 +2,7 @@ import type {
PuzzleWorksResponse,
PuzzleWorkSummary,
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_GALLERY_API_BASE = '/api/runtime/puzzle/gallery';
@@ -43,7 +44,21 @@ export async function getPuzzleGalleryDetail(profileId: string) {
);
}
/**
* 将公开拼图作品复制为当前用户的草稿。
*/
export async function remixPuzzleGalleryWork(profileId: string) {
return requestJson<{ session: PuzzleAgentSessionSnapshot }>(
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}/remix`,
{
method: 'POST',
},
'Remix 拼图作品失败',
);
}
export const puzzleGalleryClient = {
getDetail: getPuzzleGalleryDetail,
list: listPuzzleGallery,
remix: remixPuzzleGalleryWork,
};

View File

@@ -26,6 +26,7 @@ const baseWork: PuzzleWorkSummary = {
updatedAt: '2026-04-25T00:00:00.000Z',
publishedAt: '2026-04-25T00:00:00.000Z',
playCount: 0,
likeCount: 0,
publishReady: true,
};

View File

@@ -4,6 +4,8 @@ export {
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
publishRpgEntryWorldProfile,
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
rpgEntryLibraryClient,
type RuntimeRequestOptions,
unpublishRpgEntryWorldProfile,

View File

@@ -78,6 +78,43 @@ export async function getRpgEntryWorldGalleryDetailByCode(
return response.entry;
}
export async function remixRpgEntryWorldGallery(
ownerUserId: string,
profileId: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<
CustomWorldLibraryMutationResponse<CustomWorldProfile>
>(
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/remix`,
{ method: 'POST' },
'Remix 作品失败',
options,
);
return {
entry: response.entry,
entries: Array.isArray(response?.entries) ? response.entries : [],
};
}
export async function recordRpgEntryWorldGalleryPlay(
ownerUserId: string,
profileId: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<
CustomWorldGalleryDetailResponse<CustomWorldProfile>
>(
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/play`,
{ method: 'POST' },
'记录作品游玩失败',
options,
);
return response.entry;
}
export async function upsertRpgEntryWorldProfile(
profile: CustomWorldProfile,
options: RuntimeRequestOptions = {},
@@ -162,6 +199,8 @@ export const rpgEntryLibraryClient = {
listWorldGallery: listRpgEntryWorldGallery,
getWorldGalleryDetail: getRpgEntryWorldGalleryDetail,
getWorldGalleryDetailByCode: getRpgEntryWorldGalleryDetailByCode,
remixWorldGallery: remixRpgEntryWorldGallery,
recordWorldGalleryPlay: recordRpgEntryWorldGalleryPlay,
upsertWorldProfile: upsertRpgEntryWorldProfile,
deleteWorldProfile: deleteRpgEntryWorldProfile,
publishWorldProfile: publishRpgEntryWorldProfile,