init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

615
src/services/aiService.ts Normal file
View File

@@ -0,0 +1,615 @@
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 './characterChatPrompt';
import { parseLineListContent } from './llmParsers';
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 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> {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
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(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
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(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
conversationSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
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,
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,
) {
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,
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 = {},
) {
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,
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 = {},
) {
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,
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' | 'spar_complete';
} | null;
chatDirective?: NpcChatTurnDirective | null;
npcInitiatesConversation?: boolean;
} = {},
) {
const payload = {
worldType: world,
character,
player: character,
encounter,
monsters,
history,
context,
conversationHistory: conversationHistory ?? [],
dialogue: conversationHistory ?? [],
playerMessage,
npcState,
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
questOfferContext: options.questOfferContext
? {
state: 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,
} 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 = {},
) {
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,
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,
};