Files
Genarrative/src/services/aiService.ts

780 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
CreateCustomWorldAgentSessionRequest,
CreateCustomWorldAgentSessionResponse,
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
GetCustomWorldAgentCardDetailResponse,
ListCustomWorldWorksResponse,
SendCustomWorldAgentMessageRequest,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
CustomWorldGenerationProgress,
CustomWorldSessionRecord,
CustomWorldSessionSummary,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
} from '../../packages/shared/src/contracts/runtime';
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
PlainTextResponse,
} from '../../packages/shared/src/contracts/story';
import { parseApiErrorMessage } from '../../packages/shared/src/http';
import type {
AIResponse,
Character,
CharacterChatTurn,
CustomWorldProfile,
Encounter,
SceneHostileNpc,
StoryMoment,
WorldType,
} from '../types';
import type {
CustomWorldSceneImageRequest,
CustomWorldSceneImageResult,
StoryGenerationContext,
StoryRequestOptions,
TextStreamOptions,
} from './ai';
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 requestPostJson<T>(
url: string,
payload: unknown,
fallbackMessage: string,
) {
return requestJson<T>(
url,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
fallbackMessage,
);
}
async function requestPlainText(
url: string,
payload: unknown,
fallbackMessage: string,
) {
return requestPostJson<PlainTextResponse>(url, 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();
}
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 generateCustomWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const normalizedInput =
typeof input === 'string'
? {
settingText: input,
creatorIntent: null,
generationMode: 'full' as const,
}
: {
settingText: input.settingText,
creatorIntent: input.creatorIntent ?? null,
generationMode: input.generationMode === 'fast' ? 'fast' as const : 'full' as const,
};
const session = await createCustomWorldSession({
settingText: normalizedInput.settingText,
creatorIntent:
normalizedInput.creatorIntent as Record<string, unknown> | null,
generationMode: normalizedInput.generationMode,
});
const fallbackAnswerMap: Record<string, string> = {
world_hook:
typeof normalizedInput.creatorIntent?.worldHook === 'string' &&
normalizedInput.creatorIntent.worldHook.trim()
? normalizedInput.creatorIntent.worldHook.trim()
: normalizedInput.settingText.trim().slice(0, 120) || '这是一个围绕失衡秩序展开的世界。',
player_premise:
typeof normalizedInput.creatorIntent?.playerPremise === 'string' &&
normalizedInput.creatorIntent.playerPremise.trim()
? normalizedInput.creatorIntent.playerPremise.trim()
: '玩家是一名被卷入局势中心的行动者。',
opening_situation:
typeof normalizedInput.creatorIntent?.openingSituation === 'string' &&
normalizedInput.creatorIntent.openingSituation.trim()
? normalizedInput.creatorIntent.openingSituation.trim()
: '故事开局时,玩家正身处风暴边缘,必须立刻判断立场与风险。',
core_conflict:
Array.isArray(normalizedInput.creatorIntent?.coreConflicts) &&
normalizedInput.creatorIntent.coreConflicts.length > 0
? normalizedInput.creatorIntent.coreConflicts
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.join('')
: '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。',
};
for (const question of session.questions ?? []) {
if (question.answer?.trim()) {
continue;
}
const answer = fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
await answerCustomWorldSessionQuestion(session.sessionId, {
questionId: question.id,
answer,
});
}
return streamCustomWorldSessionGeneration(session.sessionId, options);
}
export async function streamCustomWorldSessionGeneration(
sessionId: string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
{
method: 'GET',
signal: options.signal,
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, '生成自定义世界失败'));
}
if (!response.body) {
throw new Error('自定义世界生成流不可用');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let latestProfile: Record<string, unknown> | 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);
let eventName = '';
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();
continue;
}
if (!line.startsWith('data:')) {
continue;
}
const payloadText = line.slice(5).trim();
if (!payloadText) {
continue;
}
const payload = JSON.parse(payloadText) as Record<string, unknown>;
if (eventName === 'progress') {
if (
typeof payload.phaseId === 'string'
&& typeof payload.phaseLabel === 'string'
&& typeof payload.phaseDetail === 'string'
&& typeof payload.overallProgress === 'number'
&& Array.isArray(payload.steps)
) {
options.onProgress?.(payload as unknown as CustomWorldGenerationProgress);
} else {
options.onProgress?.({
phaseId: 'finalize',
phaseLabel: typeof payload.phase === 'string' ? payload.phase : 'generating',
phaseDetail: typeof payload.phase === 'string' ? payload.phase : 'generating',
overallProgress:
typeof payload.progress === 'number' ? payload.progress / 100 : 0,
completedWeight:
typeof payload.progress === 'number' ? payload.progress : 0,
totalWeight: 100,
elapsedMs: 0,
estimatedRemainingMs: null,
activeStepIndex: 0,
steps: [],
});
}
}
if (eventName === 'result' && payload.profile && typeof payload.profile === 'object') {
latestProfile = payload.profile as Record<string, unknown>;
}
if (eventName === 'error') {
throw new Error(
typeof payload.message === 'string'
? payload.message
: '生成自定义世界失败',
);
}
}
}
}
if (!latestProfile) {
throw new Error('自定义世界生成未返回结果');
}
return latestProfile as unknown as CustomWorldProfile;
}
export async function generateCustomWorldSceneImage(
...args: [CustomWorldSceneImageRequest]
) {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldSceneImage(...args);
}
export async function createCustomWorldSession(payload: {
settingText: string;
creatorIntent?: Record<string, unknown> | null;
generationMode: 'fast' | 'full';
}) {
return requestJson<CustomWorldSessionSummary>(
`${RUNTIME_API_BASE}/custom-world/sessions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
},
'创建自定义世界会话失败',
);
}
export async function listCustomWorldWorks() {
const response = await requestJson<ListCustomWorldWorksResponse>(
`${RUNTIME_API_BASE}/custom-world/works`,
{
method: 'GET',
},
'读取创作作品列表失败',
);
return Array.isArray(response?.items) ? response.items : [];
}
export async function createCustomWorldAgentSession(
payload: CreateCustomWorldAgentSessionRequest,
) {
return requestJson<CreateCustomWorldAgentSessionResponse>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建世界共创会话失败',
);
}
export async function getCustomWorldAgentSession(sessionId: string) {
return requestJson<CustomWorldAgentSessionSnapshot>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取世界共创会话失败',
);
}
export async function sendCustomWorldAgentMessage(
sessionId: string,
payload: SendCustomWorldAgentMessageRequest,
) {
return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发送共创消息失败',
);
}
export async function executeCustomWorldAgentAction(
sessionId: string,
payload: CustomWorldAgentActionRequest,
) {
return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/actions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'执行共创操作失败',
);
}
export async function getCustomWorldAgentOperation(
sessionId: string,
operationId: string,
): Promise<CustomWorldAgentOperationRecord> {
const response = await requestJson<{
operation?: CustomWorldAgentOperationRecord;
data?: CustomWorldAgentOperationRecord;
} & Partial<CustomWorldAgentOperationRecord>>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
{
method: 'GET',
},
'读取共创操作状态失败',
);
return (response.operation ?? response.data ?? response) as CustomWorldAgentOperationRecord;
}
export async function getCustomWorldAgentCardDetail(
sessionId: string,
cardId: string,
) {
const response = await requestJson<GetCustomWorldAgentCardDetailResponse>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`,
{
method: 'GET',
},
'读取草稿卡详情失败',
);
return response.card as CustomWorldDraftCardDetail;
}
export async function getCustomWorldSession(sessionId: string) {
return requestJson<CustomWorldSessionRecord>(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取自定义世界会话失败',
);
}
export async function answerCustomWorldSessionQuestion(
sessionId: string,
payload: { questionId: string; answer: string },
) {
return requestJson<CustomWorldSessionSummary>(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest),
},
'提交自定义世界补充设定失败',
);
}
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 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,
};