# Conflicts: # docs/technical/README.md # docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md # docs/technical/SPACETIMEDB_TABLE_CATALOG.md # scripts/generate-spacetime-bindings.mjs # server-rs/crates/api-server/src/app.rs # server-rs/crates/api-server/src/assets.rs # server-rs/crates/api-server/src/big_fish.rs # server-rs/crates/api-server/src/custom_world_ai.rs # server-rs/crates/api-server/src/llm.rs # server-rs/crates/api-server/src/main.rs # server-rs/crates/api-server/src/puzzle.rs # server-rs/crates/api-server/src/runtime_profile.rs # server-rs/crates/api-server/src/runtime_story/compat/ai.rs # server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs # server-rs/crates/api-server/src/runtime_story/compat/presentation.rs # server-rs/crates/api-server/src/runtime_story/compat/tests.rs # server-rs/crates/api-server/src/state.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/module-big-fish/src/lib.rs # server-rs/crates/module-custom-world/src/lib.rs # server-rs/crates/module-puzzle/src/lib.rs # server-rs/crates/module-runtime/src/lib.rs # server-rs/crates/spacetime-client/src/big_fish.rs # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs # server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/mod.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # server-rs/crates/spacetime-module/src/custom_world/mod.rs # server-rs/crates/spacetime-module/src/lib.rs # server-rs/crates/spacetime-module/src/migration.rs # server-rs/crates/spacetime-module/src/puzzle.rs # server-rs/crates/spacetime-module/src/runtime/profile.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx # src/services/aiService.ts # src/services/puzzle-runtime/puzzleRuntimeClient.ts
615 lines
16 KiB
TypeScript
615 lines
16 KiB
TypeScript
import type {
|
||
CharacterChatReplyRequest,
|
||
CharacterChatSuggestionsRequest,
|
||
CharacterChatSummaryRequest,
|
||
NpcChatDialogueRequest,
|
||
NpcChatTurnDirective,
|
||
NpcChatTurnRequest,
|
||
NpcChatTurnResult,
|
||
NpcRecruitDialogueRequest,
|
||
PlainTextResponse,
|
||
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
||
import type {
|
||
CustomWorldGenerationProgress,
|
||
GenerateCustomWorldProfileInput,
|
||
GenerateCustomWorldProfileOptions,
|
||
} from '../../packages/shared/src/contracts/runtime';
|
||
import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||
import type {
|
||
AIResponse,
|
||
Character,
|
||
CharacterChatTurn,
|
||
Encounter,
|
||
GameState,
|
||
SceneHostileNpc,
|
||
StoryMoment,
|
||
WorldType,
|
||
} from '../types';
|
||
import type {
|
||
CustomWorldSceneImageResult,
|
||
StoryGenerationContext,
|
||
StoryRequestOptions,
|
||
TextStreamOptions,
|
||
} from './aiTypes';
|
||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||
import { parseLineListContent } from './llmParsers';
|
||
import {
|
||
buildStoryMomentFromRuntimeProjection,
|
||
getStoryRuntimeProjection,
|
||
resolveRuntimeStoryAction,
|
||
} from './rpg-runtime/rpgRuntimeStoryClient';
|
||
|
||
const RUNTIME_API_BASE = '/api/runtime';
|
||
|
||
function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
|
||
return context.runtimeSessionId?.trim() || undefined;
|
||
}
|
||
|
||
function getStorySessionIdFromContext(context: StoryGenerationContext) {
|
||
return context.storySessionId?.trim() || undefined;
|
||
}
|
||
|
||
function runtimeStoryMomentToAiResponse(
|
||
story: StoryMoment | null | undefined,
|
||
fallbackText: string,
|
||
): AIResponse {
|
||
return {
|
||
storyText: story?.text?.trim() || fallbackText,
|
||
options: story?.options ?? [],
|
||
};
|
||
}
|
||
|
||
function getRuntimeSnapshotFromContext(context: StoryGenerationContext) {
|
||
return context.runtimeSnapshot;
|
||
}
|
||
|
||
async function requestPlainText(
|
||
url: string,
|
||
payload: unknown,
|
||
fallbackMessage: string,
|
||
) {
|
||
return requestJson<PlainTextResponse>(
|
||
url,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
fallbackMessage,
|
||
);
|
||
}
|
||
|
||
async function requestPlainTextStream(
|
||
url: string,
|
||
payload: unknown,
|
||
options: TextStreamOptions = {},
|
||
) {
|
||
const response = await fetchWithApiAuth(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const responseText = await response.text();
|
||
throw new Error(parseApiErrorMessage(responseText, '流式请求失败'));
|
||
}
|
||
|
||
if (!response.body) {
|
||
throw new Error('streaming response body is unavailable');
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buffer = '';
|
||
let accumulatedText = '';
|
||
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) {
|
||
break;
|
||
}
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
while (buffer.includes('\n\n')) {
|
||
const boundary = buffer.indexOf('\n\n');
|
||
const eventBlock = buffer.slice(0, boundary);
|
||
buffer = buffer.slice(boundary + 2);
|
||
|
||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||
const line = rawLine.trim();
|
||
if (!line.startsWith('data:')) {
|
||
continue;
|
||
}
|
||
|
||
const data = line.slice(5).trim();
|
||
if (!data || data === '[DONE]') {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||
if (typeof delta === 'string' && delta.length > 0) {
|
||
accumulatedText += delta;
|
||
options.onUpdate?.(accumulatedText);
|
||
}
|
||
} catch {
|
||
// Ignore malformed SSE frames.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return accumulatedText.trim();
|
||
}
|
||
|
||
type ParsedSseEvent = {
|
||
event: string | null;
|
||
data: string;
|
||
};
|
||
|
||
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
||
let eventName: string | null = null;
|
||
const dataLines: string[] = [];
|
||
|
||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||
const line = rawLine.trim();
|
||
if (!line) continue;
|
||
if (line.startsWith('event:')) {
|
||
eventName = line.slice(6).trim() || null;
|
||
continue;
|
||
}
|
||
if (line.startsWith('data:')) {
|
||
dataLines.push(line.slice(5).trim());
|
||
}
|
||
}
|
||
|
||
if (dataLines.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
event: eventName,
|
||
data: dataLines.join('\n'),
|
||
};
|
||
}
|
||
|
||
export async function generateInitialStory(
|
||
world: WorldType,
|
||
character: Character,
|
||
monsters: SceneHostileNpc[],
|
||
context: StoryGenerationContext,
|
||
requestOptions: StoryRequestOptions = {},
|
||
): Promise<AIResponse> {
|
||
void world;
|
||
void character;
|
||
void monsters;
|
||
void requestOptions;
|
||
|
||
const storySessionId = getStorySessionIdFromContext(context);
|
||
if (!storySessionId) {
|
||
throw new Error('运行时故事会话不存在,无法生成开局剧情');
|
||
}
|
||
|
||
const projection = await getStoryRuntimeProjection({
|
||
storySessionId,
|
||
clientVersion: context.runtimeActionVersion,
|
||
});
|
||
const story = buildStoryMomentFromRuntimeProjection({ projection });
|
||
return runtimeStoryMomentToAiResponse(story, '开局剧情已同步。');
|
||
}
|
||
|
||
export async function generateNextStep(
|
||
world: WorldType,
|
||
character: Character,
|
||
monsters: SceneHostileNpc[],
|
||
history: StoryMoment[],
|
||
choice: string,
|
||
context: StoryGenerationContext,
|
||
requestOptions: StoryRequestOptions = {},
|
||
): Promise<AIResponse> {
|
||
void world;
|
||
void character;
|
||
void monsters;
|
||
void history;
|
||
void requestOptions;
|
||
|
||
const storySessionId = getStorySessionIdFromContext(context);
|
||
if (!storySessionId) {
|
||
throw new Error('运行时故事会话不存在,无法续写剧情');
|
||
}
|
||
const functionId = context.lastFunctionId?.trim();
|
||
if (!functionId) {
|
||
throw new Error('运行时动作缺少 functionId,无法续写剧情');
|
||
}
|
||
|
||
const response = await resolveRuntimeStoryAction({
|
||
storySessionId,
|
||
clientVersion: context.runtimeActionVersion,
|
||
option: {
|
||
functionId,
|
||
actionText: choice,
|
||
},
|
||
payload: {
|
||
observeSignsRequested: context.observeSignsRequested,
|
||
recentActionResult: context.recentActionResult,
|
||
},
|
||
});
|
||
return runtimeStoryMomentToAiResponse(
|
||
response.snapshot.currentStory,
|
||
choice,
|
||
);
|
||
}
|
||
|
||
export async function generateCharacterPanelChatSuggestions(
|
||
world: WorldType,
|
||
playerCharacter: Character,
|
||
targetCharacter: Character,
|
||
storyHistory: StoryMoment[],
|
||
context: StoryGenerationContext,
|
||
conversationHistory: CharacterChatTurn[],
|
||
conversationSummary: string,
|
||
targetStatus: CharacterChatTargetStatus,
|
||
) {
|
||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||
const payload = sessionId
|
||
? ({
|
||
sessionId,
|
||
...(snapshot ? { snapshot } : {}),
|
||
targetCharacter,
|
||
conversationHistory,
|
||
conversationSummary,
|
||
targetStatus,
|
||
} satisfies CharacterChatSuggestionsRequest)
|
||
: ({
|
||
worldType: world,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
conversationHistory,
|
||
conversationSummary,
|
||
targetStatus,
|
||
} satisfies CharacterChatSuggestionsRequest);
|
||
|
||
const { text } = await requestPlainText(
|
||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||
payload,
|
||
'角色聊天建议生成失败',
|
||
);
|
||
return parseLineListContent(text, 3);
|
||
}
|
||
|
||
export async function generateCharacterPanelChatSummary(
|
||
world: WorldType,
|
||
playerCharacter: Character,
|
||
targetCharacter: Character,
|
||
storyHistory: StoryMoment[],
|
||
context: StoryGenerationContext,
|
||
conversationHistory: CharacterChatTurn[],
|
||
previousSummary: string,
|
||
targetStatus: CharacterChatTargetStatus,
|
||
) {
|
||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||
const payload = sessionId
|
||
? ({
|
||
sessionId,
|
||
...(snapshot ? { snapshot } : {}),
|
||
targetCharacter,
|
||
conversationHistory,
|
||
previousSummary,
|
||
targetStatus,
|
||
} satisfies CharacterChatSummaryRequest)
|
||
: ({
|
||
worldType: world,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
conversationHistory,
|
||
previousSummary,
|
||
targetStatus,
|
||
} satisfies CharacterChatSummaryRequest);
|
||
|
||
const { text } = await requestPlainText(
|
||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||
payload,
|
||
'角色聊天摘要生成失败',
|
||
);
|
||
return text.trim();
|
||
}
|
||
|
||
export async function streamCharacterPanelChatReply(
|
||
world: WorldType,
|
||
playerCharacter: Character,
|
||
targetCharacter: Character,
|
||
storyHistory: StoryMoment[],
|
||
context: StoryGenerationContext,
|
||
conversationHistory: CharacterChatTurn[],
|
||
conversationSummary: string,
|
||
playerMessage: string,
|
||
targetStatus: CharacterChatTargetStatus,
|
||
options: TextStreamOptions = {},
|
||
) {
|
||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||
const payload = sessionId
|
||
? ({
|
||
sessionId,
|
||
...(snapshot ? { snapshot } : {}),
|
||
targetCharacter,
|
||
conversationHistory,
|
||
conversationSummary,
|
||
playerMessage,
|
||
targetStatus,
|
||
} satisfies CharacterChatReplyRequest)
|
||
: ({
|
||
worldType: world,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
conversationHistory,
|
||
conversationSummary,
|
||
playerMessage,
|
||
targetStatus,
|
||
} satisfies CharacterChatReplyRequest);
|
||
|
||
const reply = await requestPlainTextStream(
|
||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||
payload,
|
||
options,
|
||
);
|
||
return reply.trim();
|
||
}
|
||
|
||
export async function streamNpcChatDialogue(
|
||
world: WorldType,
|
||
character: Character,
|
||
encounter: Encounter,
|
||
monsters: SceneHostileNpc[],
|
||
history: StoryMoment[],
|
||
context: StoryGenerationContext,
|
||
topic: string,
|
||
resultSummary: string,
|
||
options: TextStreamOptions = {},
|
||
) {
|
||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||
const payload = sessionId
|
||
? ({
|
||
sessionId,
|
||
...(snapshot ? { snapshot } : {}),
|
||
encounter,
|
||
topic,
|
||
resultSummary,
|
||
} satisfies NpcChatDialogueRequest)
|
||
: ({
|
||
worldType: world,
|
||
character,
|
||
encounter,
|
||
monsters,
|
||
history,
|
||
context,
|
||
topic,
|
||
resultSummary,
|
||
} satisfies NpcChatDialogueRequest);
|
||
|
||
const dialogue = await requestPlainTextStream(
|
||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||
payload,
|
||
options,
|
||
);
|
||
return dialogue.trim();
|
||
}
|
||
|
||
export async function streamNpcChatTurn(
|
||
world: WorldType,
|
||
character: Character,
|
||
encounter: Encounter,
|
||
monsters: SceneHostileNpc[],
|
||
history: StoryMoment[],
|
||
context: StoryGenerationContext,
|
||
conversationHistory: StoryMoment['dialogue'],
|
||
playerMessage: string,
|
||
npcState: Record<string, unknown>,
|
||
options: {
|
||
onReplyUpdate?: (text: string) => void;
|
||
questOfferContext?: {
|
||
state: GameState;
|
||
turnCount: number;
|
||
} | null;
|
||
combatContext?: {
|
||
summary: string;
|
||
logLines: string[];
|
||
battleOutcome: 'victory' | 'defeat' | 'spar_complete';
|
||
} | null;
|
||
chatDirective?: NpcChatTurnDirective | null;
|
||
npcInitiatesConversation?: boolean;
|
||
} = {},
|
||
) {
|
||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||
const commonChatPayload = {
|
||
encounter,
|
||
conversationHistory: conversationHistory ?? [],
|
||
dialogue: conversationHistory ?? [],
|
||
playerMessage,
|
||
npcState,
|
||
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
|
||
questOfferContext: options.questOfferContext
|
||
? {
|
||
state: sessionId ? {} : options.questOfferContext.state,
|
||
encounter,
|
||
turnCount: options.questOfferContext.turnCount,
|
||
}
|
||
: null,
|
||
combatContext: options.combatContext ?? null,
|
||
chatDirective: options.chatDirective
|
||
? {
|
||
...options.chatDirective,
|
||
functionOptions: options.chatDirective.functionOptions?.map(
|
||
(item) => ({
|
||
...item,
|
||
}),
|
||
),
|
||
}
|
||
: null,
|
||
};
|
||
const payload = sessionId
|
||
? ({
|
||
sessionId,
|
||
...(snapshot ? { snapshot } : {}),
|
||
...commonChatPayload,
|
||
} satisfies NpcChatTurnRequest)
|
||
: ({
|
||
worldType: world,
|
||
character,
|
||
player: character,
|
||
monsters,
|
||
history,
|
||
context,
|
||
...commonChatPayload,
|
||
} satisfies NpcChatTurnRequest);
|
||
|
||
const response = await fetchWithApiAuth(
|
||
`${RUNTIME_API_BASE}/chat/npc/turn/stream`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const responseText = await response.text();
|
||
throw new Error(parseApiErrorMessage(responseText, 'NPC 聊天续写失败'));
|
||
}
|
||
|
||
if (!response.body) {
|
||
throw new Error('streaming response body is unavailable');
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buffer = '';
|
||
let accumulatedReply = '';
|
||
let completedResult: NpcChatTurnResult | null = null;
|
||
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) {
|
||
break;
|
||
}
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
while (buffer.includes('\n\n')) {
|
||
const boundary = buffer.indexOf('\n\n');
|
||
const eventBlock = buffer.slice(0, boundary);
|
||
buffer = buffer.slice(boundary + 2);
|
||
|
||
const parsedEvent = parseSseEventBlock(eventBlock);
|
||
if (!parsedEvent) {
|
||
continue;
|
||
}
|
||
|
||
if (parsedEvent.data === '[DONE]') {
|
||
continue;
|
||
}
|
||
|
||
if (parsedEvent.event === 'reply_delta') {
|
||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
const nextText =
|
||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||
accumulatedReply = nextText;
|
||
options.onReplyUpdate?.(accumulatedReply);
|
||
continue;
|
||
}
|
||
|
||
if (parsedEvent.event === 'complete') {
|
||
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
||
accumulatedReply = completedResult.npcReply;
|
||
options.onReplyUpdate?.(accumulatedReply);
|
||
continue;
|
||
}
|
||
|
||
if (parsedEvent.event === 'error') {
|
||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
throw new Error(
|
||
typeof payloadRecord.message === 'string'
|
||
? payloadRecord.message
|
||
: 'NPC 聊天续写失败',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!completedResult) {
|
||
throw new Error('NPC 聊天续写结果为空');
|
||
}
|
||
|
||
return completedResult;
|
||
}
|
||
|
||
export async function streamNpcRecruitDialogue(
|
||
world: WorldType,
|
||
character: Character,
|
||
encounter: Encounter,
|
||
monsters: SceneHostileNpc[],
|
||
history: StoryMoment[],
|
||
context: StoryGenerationContext,
|
||
invitationText: string,
|
||
recruitSummary: string,
|
||
options: TextStreamOptions = {},
|
||
) {
|
||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||
const payload = sessionId
|
||
? ({
|
||
sessionId,
|
||
...(snapshot ? { snapshot } : {}),
|
||
encounter,
|
||
invitationText,
|
||
recruitSummary,
|
||
} satisfies NpcRecruitDialogueRequest)
|
||
: ({
|
||
worldType: world,
|
||
character,
|
||
encounter,
|
||
monsters,
|
||
history,
|
||
context,
|
||
invitationText,
|
||
recruitSummary,
|
||
} satisfies NpcRecruitDialogueRequest);
|
||
|
||
const dialogue = await requestPlainTextStream(
|
||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||
payload,
|
||
options,
|
||
);
|
||
return dialogue.trim();
|
||
}
|
||
|
||
export type {
|
||
CustomWorldGenerationProgress,
|
||
CustomWorldSceneImageResult,
|
||
GenerateCustomWorldProfileInput,
|
||
GenerateCustomWorldProfileOptions,
|
||
StoryGenerationContext,
|
||
StoryRequestOptions,
|
||
TextStreamOptions,
|
||
};
|