feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -94,18 +94,18 @@ export default function App() {
};
const handleContinueGame = () => {
persistence.continueSavedGame();
void persistence.continueSavedGame();
};
const handleStartNewGame = () => {
persistence.clearSavedGame();
void persistence.clearSavedGame();
storyFlow.resetStoryState();
resetGame();
};
const handleSaveAndExit = () => {
const syncedGameState = syncGameStatePlayTime(gameState);
persistence.saveCurrentGame({
void persistence.saveCurrentGame({
gameState: syncedGameState,
bottomTab,
currentStory: storyFlow.currentStory,

10
src/AuthenticatedApp.tsx Normal file
View File

@@ -0,0 +1,10 @@
import App from './App';
import { AuthGate } from './components/auth/AuthGate';
export default function AuthenticatedApp() {
return (
<AuthGate>
<App />
</AuthGate>
);
}

View File

@@ -19,7 +19,7 @@ import {
import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/ai';
} from '../services/aiService';
import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,

View File

@@ -2,7 +2,7 @@ import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/ai';
} from '../services/aiService';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps {

View File

@@ -0,0 +1,160 @@
import { type ReactNode, useEffect, useState } from 'react';
import {
AUTH_STATE_EVENT,
getStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthUser,
ensureAutoAuthUser,
getCurrentAuthUser,
logoutAuthUser,
} from '../../services/authService';
type AuthGateProps = {
children: ReactNode;
};
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [error, setError] = useState('');
useEffect(() => {
let isActive = true;
const ensureAutoUser = async () => {
if (!isActive) {
return;
}
setStatus('recovering');
try {
const { user: nextUser } = await ensureAutoAuthUser();
if (!isActive) {
return;
}
setUser(nextUser);
setStatus('ready');
setError('');
} catch (autoAuthError) {
if (!isActive) {
return;
}
setUser(null);
setStatus('error');
setError(
autoAuthError instanceof Error
? autoAuthError.message
: '自动登录失败,请稍后再试。',
);
}
};
const hydrate = async () => {
const token = getStoredAccessToken();
if (!token) {
await ensureAutoUser();
return;
}
try {
const nextUser = await getCurrentAuthUser();
if (!isActive) {
return;
}
if (nextUser) {
setUser(nextUser);
setStatus('ready');
setError('');
return;
}
await ensureAutoUser();
} catch {
if (!isActive) {
return;
}
await ensureAutoUser();
}
};
void hydrate();
const handleAuthStateChange = () => {
setStatus('checking');
void hydrate();
};
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
return () => {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
};
}, []);
if (status === 'checking') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
...
</div>
);
}
if (status === 'recovering') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
...
</div>
);
}
if (status !== 'ready' || !user) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
<div className="text-base font-medium text-zinc-50"></div>
<div className="mt-3 text-sm leading-6 text-zinc-300">
{error || '账号恢复失败,请刷新页面后重试。'}
</div>
<button
type="button"
className="mt-5 rounded-full border border-amber-300/30 px-4 py-2 text-sm text-amber-100 transition hover:border-amber-300/60 hover:bg-amber-300/10"
onClick={() => {
window.location.reload();
}}
>
</button>
</div>
</div>
);
}
return (
<div className="relative">
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
<span>{user.username}</span>
<button
type="button"
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
</div>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
type LoginScreenProps = {
loading: boolean;
error: string;
onSubmit: (username: string, password: string) => Promise<void>;
};
export function LoginScreen({
loading,
error,
onSubmit,
}: LoginScreenProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-8 text-zinc-100">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-5xl items-center justify-center">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.15fr_0.85fr]">
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<p className="text-xs uppercase tracking-[0.35em] text-amber-200/70">
Genarrative
</p>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
3 24 线
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
</div>
</div>
</div>
<form
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
void onSubmit(username, password);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="hero_name"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 6 位"
/>
</label>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
<button
type="submit"
disabled={loading}
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? '正在进入...' : '进入游戏'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -4,20 +4,20 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildCustomWorldPlayableCharacters,
} from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { getScenePreset } from '../../data/scenePresets';
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile,
} from '../../services/ai';
} from '../../services/aiService';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import {
listCustomWorldLibrary,
upsertCustomWorldProfile,
} from '../../services/storageService';
import {
type CustomWorldCreatorIntent,
type CustomWorldGenerationMode,
@@ -172,7 +172,7 @@ export function PreGameSelectionFlow({
useState<GameState['customWorldProfile']>(null);
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
CustomWorldProfile[]
>(() => readSavedCustomWorldProfiles());
>([]);
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
() => generateWorldOnlineCounts(),
@@ -280,6 +280,25 @@ export function PreGameSelectionFlow({
},
[],
);
useEffect(() => {
let isActive = true;
void listCustomWorldLibrary()
.then((profiles) => {
if (!isActive) return;
setSavedCustomWorldProfiles(profiles);
})
.catch((error) => {
console.warn(
'[PreGameSelectionFlow] failed to load custom world library',
error,
);
});
return () => {
isActive = false;
};
}, []);
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
@@ -331,18 +350,18 @@ export function PreGameSelectionFlow({
setShowCustomWorldModal(true);
};
const saveGeneratedCustomWorld = () => {
const saveGeneratedCustomWorld = async () => {
if (!generatedCustomWorldProfile) {
return;
}
try {
setSavedCustomWorldProfiles(
upsertSavedCustomWorldProfile(generatedCustomWorldProfile),
await upsertCustomWorldProfile(generatedCustomWorldProfile),
);
} catch (error) {
setCustomWorldError(
error instanceof Error ? error.message : '本地保存自定义世界失败。',
error instanceof Error ? error.message : '保存自定义世界失败。',
);
return;
}
@@ -650,7 +669,7 @@ export function PreGameSelectionFlow({
id: generatedCustomWorldProfile.id,
}
: profile;
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
const savedProfiles = await upsertCustomWorldProfile(persistedProfile);
setSavedCustomWorldProfiles(savedProfiles);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
@@ -1034,7 +1053,9 @@ export function PreGameSelectionFlow({
onRegenerateLandmarkNetwork={() => {
void regenerateLandmarkNetwork();
}}
onSave={saveGeneratedCustomWorld}
onSave={() => {
void saveGeneratedCustomWorld();
}}
/>
</motion.div>
)}

View File

@@ -4,7 +4,7 @@ import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
} from '../../services/ai';
} from '../../services/aiService';
import type {StoryGenerationContext} from '../../services/aiTypes';
import type {
Character,

View File

@@ -16,7 +16,7 @@ import {
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/ai';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';

View File

@@ -39,7 +39,7 @@ import {
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import { generateNextStep, streamNpcChatDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import {

View File

@@ -34,7 +34,7 @@ import {
removeInventoryItem,
syncNpcTradeInventory,
} from '../../data/npcInteractions';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
appendStoryEngineCarrierMemory,

View File

@@ -11,7 +11,7 @@ import {
} from '../../data/sceneEncounterPreviews';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import { generateNextStep } from '../../services/ai';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {

View File

@@ -12,7 +12,12 @@ import { normalizeNpcPersistentState } from '../data/npcInteractions';
import { normalizeQuestLogEntries } from '../data/questFlow';
import { normalizeGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews';
import {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../persistence/gameSaveStorage';
import type { SavedGameSnapshot } from '../persistence/gameSaveStorage';
import {
deleteSaveSnapshot,
getSaveSnapshot,
putSaveSnapshot,
} from '../services/storageService';
import {
applyStoryEngineMigration,
buildSaveMigrationManifest,
@@ -169,9 +174,24 @@ export function useGamePersistence({
resetStoryState: () => void;
}) {
const [hasSavedGame, setHasSavedGame] = useState(false);
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
useEffect(() => {
setHasSavedGame(Boolean(readSavedSnapshot()));
let isActive = true;
void getSaveSnapshot()
.then((snapshot) => {
if (!isActive) return;
setSavedSnapshot(snapshot);
setHasSavedGame(Boolean(snapshot));
})
.catch((error) => {
console.warn('[useGamePersistence] failed to load remote snapshot', error);
});
return () => {
isActive = false;
};
}, []);
useEffect(() => {
@@ -180,21 +200,24 @@ export function useGamePersistence({
if (!canPersist) return;
const timeoutId = window.setTimeout(() => {
const didSave = writeSavedSnapshot({
void putSaveSnapshot({
gameState,
bottomTab,
currentStory,
});
if (didSave) {
setHasSavedGame(true);
}
})
.then((snapshot) => {
setSavedSnapshot(snapshot);
setHasSavedGame(true);
})
.catch((error) => {
console.warn('[useGamePersistence] failed to autosave remote snapshot', error);
});
}, AUTO_SAVE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [bottomTab, currentStory, gameState, isLoading]);
const saveCurrentGame = useCallback((override?: {
const saveCurrentGame = useCallback(async (override?: {
gameState?: GameState;
bottomTab?: BottomTab;
currentStory?: StoryMoment | null;
@@ -207,28 +230,40 @@ export function useGamePersistence({
return false;
}
const didSave = writeSavedSnapshot({
gameState: nextGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
});
if (didSave) {
try {
const snapshot = await putSaveSnapshot({
gameState: nextGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
});
setSavedSnapshot(snapshot);
setHasSavedGame(true);
return true;
} catch (error) {
console.warn('[useGamePersistence] failed to save remote snapshot', error);
return false;
}
return didSave;
}, [bottomTab, currentStory, gameState]);
const clearSavedGame = useCallback(() => {
clearSavedSnapshot();
const clearSavedGame = useCallback(async () => {
try {
await deleteSaveSnapshot();
} catch (error) {
console.warn('[useGamePersistence] failed to delete remote snapshot', error);
}
setSavedSnapshot(null);
setHasSavedGame(false);
}, []);
const continueSavedGame = useCallback(() => {
const snapshot = readSavedSnapshot();
const continueSavedGame = useCallback(async () => {
const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => {
console.warn('[useGamePersistence] failed to refetch remote snapshot', error);
return null;
});
if (!snapshot) {
clearSavedGame();
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
@@ -236,9 +271,10 @@ export function useGamePersistence({
setGameState(normalizeSavedGameState(snapshot.gameState));
setBottomTab(snapshot.bottomTab ?? 'adventure');
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
setSavedSnapshot(snapshot);
setHasSavedGame(true);
return true;
}, [clearSavedGame, hydrateStoryState, resetStoryState, setBottomTab, setGameState]);
}, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]);
return {
hasSavedGame,

View File

@@ -1,13 +1,64 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {clampVolume, readSavedSettings, writeSavedSettings} from '../persistence/gameSettingsStorage';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
} from '../persistence/gameSettingsStorage';
import { getSettings, putSettings } from '../services/storageService';
export function useGameSettings() {
const [musicVolume, setMusicVolumeState] = useState(() => readSavedSettings().musicVolume);
const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME);
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
useEffect(() => {
writeSavedSettings({musicVolume});
}, [musicVolume]);
let isActive = true;
void getSettings()
.then((settings) => {
if (!isActive) return;
const nextVolume = clampVolume(settings.musicVolume);
lastSyncedVolumeRef.current = nextVolume;
setMusicVolumeState(nextVolume);
})
.catch((error) => {
console.warn('[useGameSettings] failed to load remote settings', error);
})
.finally(() => {
if (isActive) {
setHasHydratedSettings(true);
}
});
return () => {
isActive = false;
};
}, []);
useEffect(() => {
if (!hasHydratedSettings) {
return;
}
if (lastSyncedVolumeRef.current === musicVolume) {
return;
}
let isActive = true;
void putSettings({musicVolume})
.then((settings) => {
if (!isActive) return;
lastSyncedVolumeRef.current = clampVolume(settings.musicVolume);
})
.catch((error) => {
console.warn('[useGameSettings] failed to persist remote settings', error);
});
return () => {
isActive = false;
};
}, [hasHydratedSettings, musicVolume]);
const setMusicVolume = useCallback((value: number) => {
setMusicVolumeState(clampVolume(value));

View File

@@ -42,7 +42,7 @@ import {
sortStoryOptionsByPriority,
} from '../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
import { generateInitialStory, generateNextStep } from '../services/aiService';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,

View File

@@ -25,7 +25,7 @@ export type ResolvedAppRoute = {
componentProps?: Record<string, unknown>;
};
const GameApp = lazy(() => import('../App')) as AppRouteComponent;
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const PresetEditorApp = lazy(async () => {
const module = await import('../components/PresetEditor');

View File

@@ -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
View 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
View 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);
}

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

View File

@@ -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({

View File

@@ -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: {
);
}
}

View File

@@ -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),

View 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 : [];
}