feat: migrate runtime backend to node server
This commit is contained in:
@@ -38,6 +38,7 @@ import type {
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
@@ -129,6 +130,8 @@ export type {
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
type RawOptionItem = {
|
||||
functionId: string;
|
||||
actionText?: string;
|
||||
@@ -139,7 +142,7 @@ type MergeableCustomWorldRoleEntry = {
|
||||
};
|
||||
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||||
import.meta.env.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
'/api/custom-world/scene-image';
|
||||
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
@@ -155,7 +158,7 @@ const CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE = 3;
|
||||
const CUSTOM_WORLD_PLAYABLE_BATCH_SIZE = 3;
|
||||
const CUSTOM_WORLD_STORY_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => {
|
||||
const rawValue = Number(import.meta.env.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
const rawValue = Number(ENV.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000;
|
||||
})();
|
||||
|
||||
@@ -1776,6 +1779,60 @@ async function requestCompletion(
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateInitialStoryStrict(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
return requestCompletion(
|
||||
buildUserPrompt(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
[],
|
||||
context,
|
||||
undefined,
|
||||
requestOptions.availableOptions,
|
||||
requestOptions.optionCatalog,
|
||||
),
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateNextStepStrict(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
return requestCompletion(
|
||||
buildUserPrompt(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
choice,
|
||||
requestOptions.availableOptions,
|
||||
requestOptions.optionCatalog,
|
||||
),
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage({
|
||||
profile,
|
||||
landmark,
|
||||
@@ -1794,7 +1851,7 @@ export async function generateCustomWorldSceneImage({
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL, {
|
||||
const response = await fetchWithApiAuth(CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
825
src/services/aiService.ts
Normal file
825
src/services/aiService.ts
Normal file
@@ -0,0 +1,825 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSceneImageResult,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './ai';
|
||||
import * as aiClient from './ai';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply,
|
||||
buildOfflineCharacterPanelChatSuggestions,
|
||||
buildOfflineCharacterPanelChatSummary,
|
||||
buildOfflineNpcChatDialogue,
|
||||
buildOfflineNpcRecruitDialogue,
|
||||
} from './aiFallbacks';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
buildCharacterPanelChatSummaryPrompt,
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
type CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
import {
|
||||
buildNpcRecruitDialoguePrompt,
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
} from './prompt';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
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: { systemPrompt: string; userPrompt: string },
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestPostJson<{ text: string }>(url, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
async function requestPlainTextStream(
|
||||
url: string,
|
||||
payload: { systemPrompt: string; userPrompt: string },
|
||||
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();
|
||||
}
|
||||
|
||||
function buildCharacterChatPromptContext(context: StoryGenerationContext) {
|
||||
return {
|
||||
playerHp: context.playerHp,
|
||||
playerMaxHp: context.playerMaxHp,
|
||||
playerMana: context.playerMana,
|
||||
playerMaxMana: context.playerMaxMana,
|
||||
inBattle: context.inBattle,
|
||||
playerFacing: context.playerFacing,
|
||||
playerAnimation: context.playerAnimation,
|
||||
sceneName: context.sceneName ?? null,
|
||||
sceneDescription: context.sceneDescription ?? null,
|
||||
customWorldProfile: context.customWorldProfile ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateInitialStory(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/initial`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情开局生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] story/initial fell back to frontend implementation', error);
|
||||
return aiClient.generateInitialStory(
|
||||
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') {
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await 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,
|
||||
}),
|
||||
},
|
||||
'剧情续写失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] story/continue fell back to frontend implementation', error);
|
||||
return aiClient.generateNextStep(
|
||||
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,
|
||||
) {
|
||||
const fallbackSuggestions =
|
||||
buildOfflineCharacterPanelChatSuggestions(targetCharacter);
|
||||
const userPrompt = buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
});
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
'角色聊天建议生成失败',
|
||||
);
|
||||
const parsedSuggestions = parseLineListContent(text, 3);
|
||||
return parsedSuggestions.length > 0
|
||||
? [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3)
|
||||
: fallbackSuggestions;
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character suggestions fell back to frontend implementation', error);
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateCharacterPanelChatSummary(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
targetCharacter: Character,
|
||||
storyHistory: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const fallbackSummary = buildOfflineCharacterPanelChatSummary(
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
);
|
||||
const userPrompt = buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
});
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
'角色聊天摘要生成失败',
|
||||
);
|
||||
return text.trim() || fallbackSummary;
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character summary fell back to frontend implementation', error);
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(session.sessionId)}/generate/stream`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
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 Awaited<
|
||||
ReturnType<typeof aiClient.generateCustomWorldProfile>
|
||||
>;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
...args: Parameters<typeof aiClient.generateCustomWorldSceneImage>
|
||||
) {
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
}
|
||||
|
||||
export async function createCustomWorldSession(payload: {
|
||||
settingText: string;
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
}) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
}>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCustomWorldSession(sessionId: string) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
settingText: string;
|
||||
generationMode: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
result?: Record<string, unknown>;
|
||||
lastError?: string;
|
||||
}>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function answerCustomWorldSessionQuestion(
|
||||
sessionId: string,
|
||||
payload: { questionId: string; answer: string },
|
||||
) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
}>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
}
|
||||
|
||||
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 userPrompt = buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
});
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return (
|
||||
reply.trim() ||
|
||||
buildOfflineCharacterPanelChatReply(
|
||||
targetCharacter,
|
||||
playerMessage,
|
||||
conversationSummary,
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character reply stream fell back to frontend implementation', error);
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamNpcChatDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
resultSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildStrictNpcChatDialoguePrompt(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||||
{
|
||||
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return dialogue.trim() || buildOfflineNpcChatDialogue(encounter, topic);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] npc dialogue stream fell back to frontend implementation', error);
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
invitationText: string,
|
||||
recruitSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildNpcRecruitDialoguePrompt(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||||
{
|
||||
systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return dialogue.trim() || buildOfflineNpcRecruitDialogue(encounter);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] npc recruit stream fell back to frontend implementation', error);
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSceneImageResult,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
};
|
||||
143
src/services/apiClient.ts
Normal file
143
src/services/apiClient.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function emitAuthStateChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||||
}
|
||||
|
||||
export function getStoredAccessToken() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(token: string) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextToken = token.trim();
|
||||
if (nextToken) {
|
||||
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
export function clearStoredAccessToken() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
export function getStoredAutoAuthCredentials() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
|
||||
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
export function setStoredAutoAuthCredentials(credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
|
||||
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
|
||||
}
|
||||
|
||||
export function clearStoredAutoAuthCredentials() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
|
||||
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
} else if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (token) {
|
||||
nextHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
export async function fetchWithApiAuth(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
) {
|
||||
const response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: withAuthorizationHeaders(init.headers),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
clearStoredAccessToken();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
): Promise<T> {
|
||||
const response = await fetchWithApiAuth(url, init);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
return responseText ? (JSON.parse(responseText) as T) : (null as T);
|
||||
}
|
||||
113
src/services/authService.test.ts
Normal file
113
src/services/authService.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
} from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
createAutoAuthCredentials,
|
||||
ensureAutoAuthUser,
|
||||
} from './authService';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('authService auto auth', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
const credentials = createAutoAuthCredentials();
|
||||
|
||||
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('stores jwt and auto credentials after auth entry', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-token-value',
|
||||
user: {
|
||||
id: 'user_1',
|
||||
username: 'guest_abc123abc123',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await authEntryWithStoredCredentials({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(getStoredAccessToken()).toBe('jwt-token-value');
|
||||
expect(getStoredAutoAuthCredentials()).toEqual({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
|
||||
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-restored',
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
username: 'guest_saveduser01',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
|
||||
expect(result.user.username).toBe('guest_saveduser01');
|
||||
expect(result.credentials).toEqual({
|
||||
username: 'guest_saveduser01',
|
||||
password: 'auto_saved_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
101
src/services/authService.ts
Normal file
101
src/services/authService.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAutoAuthCredentials,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
setStoredAutoAuthCredentials,
|
||||
} from './apiClient';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type AutoAuthCredentials = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
||||
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
|
||||
}
|
||||
|
||||
export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
return {
|
||||
username: `guest_${buildRandomSegment(12)}`,
|
||||
password: `auto_${buildRandomSegment(24)}_${buildRandomSegment(8)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const response = await requestJson<{
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}>(
|
||||
'/api/auth/entry',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function authEntryWithStoredCredentials(
|
||||
credentials: AutoAuthCredentials,
|
||||
) {
|
||||
const user = await authEntry(credentials.username, credentials.password);
|
||||
setStoredAutoAuthCredentials(credentials);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function ensureAutoAuthUser() {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
|
||||
return {
|
||||
user,
|
||||
credentials,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser() {
|
||||
const response = await requestJson<{
|
||||
user: AuthUser | null;
|
||||
}>(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取当前用户失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function logoutAuthUser() {
|
||||
try {
|
||||
await requestJson<{ ok: true }>(
|
||||
'/api/auth/logout',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出登录失败',
|
||||
);
|
||||
} finally {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,66 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
const API_BASE_URL = ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm';
|
||||
const MODEL = ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
|
||||
const ENABLE_LLM_DEBUG_LOG = Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true';
|
||||
type NodeProcessLike = {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function getNodeEnv() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return (
|
||||
(globalThis as typeof globalThis & {process?: NodeProcessLike}).process?.env
|
||||
?? {}
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
return value.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function coerceBoolean(value: string | undefined) {
|
||||
return value?.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function resolveHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
} else if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
const NODE_ENV = getNodeEnv();
|
||||
const IS_SERVER_RUNTIME = typeof window === 'undefined';
|
||||
const SERVER_API_KEY =
|
||||
NODE_ENV.LLM_API_KEY || NODE_ENV.ARK_API_KEY || NODE_ENV.VITE_LLM_API_KEY || '';
|
||||
const API_BASE_URL = IS_SERVER_RUNTIME
|
||||
? normalizeBaseUrl(
|
||||
NODE_ENV.LLM_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
)
|
||||
: (ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm');
|
||||
const MODEL = IS_SERVER_RUNTIME
|
||||
? (NODE_ENV.LLM_MODEL
|
||||
|| NODE_ENV.VITE_LLM_MODEL
|
||||
|| 'doubao-1-5-pro-32k-character-250715')
|
||||
: (ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715');
|
||||
const ENABLE_LLM_DEBUG_LOG = IS_SERVER_RUNTIME
|
||||
? coerceBoolean(NODE_ENV.LLM_DEBUG_LOG)
|
||||
: (Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true');
|
||||
|
||||
export interface PlainTextCompletionOptions {
|
||||
timeoutMs?: number;
|
||||
@@ -31,9 +87,16 @@ export function resolveTimeoutMs(rawValue: string | undefined, fallback: number)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
IS_SERVER_RUNTIME
|
||||
? (NODE_ENV.LLM_REQUEST_TIMEOUT_MS || NODE_ENV.VITE_LLM_REQUEST_TIMEOUT_MS)
|
||||
: ENV.VITE_LLM_REQUEST_TIMEOUT_MS,
|
||||
15000,
|
||||
);
|
||||
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
IS_SERVER_RUNTIME
|
||||
? (NODE_ENV.LLM_CUSTOM_WORLD_TIMEOUT_MS || NODE_ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS)
|
||||
: ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
Math.max(REQUEST_TIMEOUT_MS, 120000),
|
||||
);
|
||||
|
||||
@@ -57,6 +120,22 @@ function normalizeLlmError(error: unknown): never {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function requestLlmEndpoint(input: string, init: RequestInit = {}) {
|
||||
const headers = resolveHeaders(init.headers);
|
||||
if (IS_SERVER_RUNTIME && SERVER_API_KEY.trim()) {
|
||||
headers.Authorization = `Bearer ${SERVER_API_KEY.trim()}`;
|
||||
}
|
||||
|
||||
const nextInit = {
|
||||
...init,
|
||||
headers,
|
||||
} satisfies RequestInit;
|
||||
|
||||
return IS_SERVER_RUNTIME
|
||||
? fetch(input, nextInit)
|
||||
: fetchWithApiAuth(input, nextInit);
|
||||
}
|
||||
|
||||
export function isLlmConnectivityError(error: unknown): error is LlmConnectivityError {
|
||||
return error instanceof LlmConnectivityError;
|
||||
}
|
||||
@@ -99,7 +178,7 @@ async function requestMessageContent(
|
||||
try {
|
||||
logLlmDebug(`[LLM:${debugLabel}] prompt text`, rawPromptText);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/chat/completions`, {
|
||||
const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(requestBody),
|
||||
@@ -175,7 +254,7 @@ export async function streamPlainTextCompletion(
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/chat/completions`, {
|
||||
const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
QuestLogEntry,
|
||||
} from '../types';
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
import { requestJson } from './apiClient';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
|
||||
@@ -204,6 +205,22 @@ export async function generateQuestForNpcEncounter(params: {
|
||||
|
||||
const fallbackIntent = buildFallbackQuestIntent(request);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
return await requestJson<QuestLogEntry | null>(
|
||||
'/api/runtime/quests/generate',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'任务生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[QuestDirector] backend quest generation failed, falling back', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await requestChatMessageContent(
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
@@ -237,4 +254,3 @@ export async function generateQuestForNpcEncounter(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../types';
|
||||
import { requestJson } from './apiClient';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {
|
||||
@@ -88,6 +89,28 @@ export async function generateRuntimeItemAiIntents(params: {
|
||||
buildRuntimeItemAiIntent(params.context, plan),
|
||||
);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const response = await requestJson<{
|
||||
intents?: unknown[];
|
||||
}>(
|
||||
'/api/runtime/items/runtime-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'运行时物品意图生成失败',
|
||||
);
|
||||
const rawIntents = Array.isArray(response.intents) ? response.intents : [];
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[runtimeItemAiDirector] backend intent generation failed, falling back', error);
|
||||
}
|
||||
}
|
||||
|
||||
const content = await requestChatMessageContent(
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
buildRuntimeItemIntentPrompt(params),
|
||||
|
||||
97
src/services/storageService.ts
Normal file
97
src/services/storageService.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
SavedGameSnapshot,
|
||||
SavedGameSnapshotInput,
|
||||
} from '../persistence/gameSaveStorage';
|
||||
import type { SavedGameSettings } from '../persistence/gameSettingsStorage';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
type CustomWorldLibraryResponse = {
|
||||
profiles?: CustomWorldProfile[];
|
||||
};
|
||||
|
||||
export async function getSaveSnapshot() {
|
||||
return requestJson<SavedGameSnapshot | null>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(snapshot: SavedGameSnapshotInput) {
|
||||
return requestJson<SavedGameSnapshot>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSaveSnapshot() {
|
||||
return requestJson<{ ok: true }>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
return requestJson<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSettings(settings: SavedGameSettings) {
|
||||
return requestJson<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary() {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library`,
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export async function upsertCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export async function deleteCustomWorldProfile(profileId: string) {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
Reference in New Issue
Block a user