feat: multiple sessions per story with streaming AI responses

This commit is contained in:
Alexej Wolff
2026-02-11 01:47:24 +01:00
parent 161ecd661e
commit 8c6d6591f8
7 changed files with 573 additions and 112 deletions
+192 -57
View File
@@ -4,9 +4,13 @@ import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
import {
getStory,
getSessionsList,
getSession,
createSession,
saveSession as apiSaveSession,
deleteSession as apiDeleteSession,
getPlayerCharacter,
type SessionListItem,
} from "../services/api";
import {
generateStoryResponseStream,
@@ -45,6 +49,8 @@ export default function GamePage() {
const [searchParams] = useSearchParams();
const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null);
const [sessionsList, setSessionsList] = useState<SessionListItem[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null);
@@ -53,6 +59,7 @@ export default function GamePage() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [streamingContent, setStreamingContent] = useState("");
const [showSessionMenu, setShowSessionMenu] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -72,12 +79,11 @@ export default function GamePage() {
};
setStory(normalizedStory);
let existingSession = await getSession(id);
console.log(
"[GamePage] Loaded session:",
existingSession?.messages?.length || 0,
"messages",
);
// Загружаем список сессий
const sessions = await getSessionsList(id);
console.log("[GamePage] Sessions list:", sessions);
setSessionsList(sessions);
const characterId = searchParams.get("character");
// Загружаем персонажа
@@ -85,51 +91,42 @@ export default function GamePage() {
if (characterId) {
character = await getPlayerCharacter(characterId);
setPlayerCharacter(character);
} else if (existingSession?.playerId) {
character = await getPlayerCharacter(existingSession.playerId);
setPlayerCharacter(character);
}
if (!existingSession) {
console.log("[GamePage] No existing session, creating new");
existingSession = {
storyId: id,
playerId: characterId || undefined,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else if (characterId && existingSession.playerId !== characterId) {
// Новый персонаж — новая сессия
console.log("[GamePage] New character selected, resetting session");
existingSession = {
storyId: id,
playerId: characterId,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else {
console.log(
"[GamePage] Using existing session with",
existingSession.messages.length,
"messages",
);
}
setSession(existingSession);
// Начинаем историю если это новая сессия
if (existingSession.messages.length === 0 && character) {
startStory(normalizedStory, existingSession, character);
// Если есть сессии, загружаем последнюю (или создаём новую)
if (sessions.length > 0) {
const latestSession = sessions[0];
console.log("[GamePage] Loading latest session:", latestSession);
setCurrentSessionId(latestSession.id);
const sessionData = await getSession(id, latestSession.id);
console.log("[GamePage] Session data loaded:", sessionData);
if (sessionData) {
setSession(sessionData);
// Загружаем персонажа из сессии если не выбран
if (!character && sessionData.playerId) {
character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
}
} else if (characterId) {
// Нет сессий и выбран персонаж — создаём новую
const newSession = await createSession(id, undefined, characterId);
if (newSession) {
setSessionsList([newSession]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(id, newSession.id);
if (sessionData) {
setSession(sessionData);
if (character) {
startStory(
normalizedStory,
sessionData,
character,
newSession.id,
);
}
}
}
}
}
setIsInitialLoading(false);
@@ -170,6 +167,7 @@ export default function GamePage() {
storyData: Story,
sessionData: GameSession,
character: PlayerCharacter,
sessionId: string,
) => {
// Если есть заготовленное первое сообщение, используем его
if (storyData.firstMessage && storyData.firstMessage.trim()) {
@@ -190,7 +188,7 @@ export default function GamePage() {
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession);
return;
}
@@ -218,7 +216,7 @@ export default function GamePage() {
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
@@ -228,7 +226,8 @@ export default function GamePage() {
};
const handleSend = async () => {
if (!input.trim() || !story || !session || isLoading) return;
if (!input.trim() || !story || !session || !currentSessionId || isLoading)
return;
const userMessage: ChatMessage = {
id: generateId(),
@@ -295,7 +294,11 @@ export default function GamePage() {
storySummary: newSummary,
};
const saved = await apiSaveSession(story.id, finalSession);
const saved = await apiSaveSession(
story.id,
currentSessionId,
finalSession,
);
console.log(
"[GamePage] Session saved:",
saved,
@@ -317,7 +320,7 @@ export default function GamePage() {
...session,
messages: [...updatedMessages, partialMessage],
};
await apiSaveSession(story.id, partialSession);
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
@@ -346,6 +349,89 @@ export default function GamePage() {
}
};
// Функции управления сессиями
const handleCreateNewSession = async () => {
if (!story || !id) return;
setShowSessionMenu(false);
// Используем персонажа из текущей сессии или выбранного
const characterId = playerCharacter?.id || session?.playerId;
if (!characterId) {
alert("Не выбран персонаж для игры");
return;
}
const name = `Сессия ${sessionsList.length + 1}`;
const newSession = await createSession(id, name, characterId);
if (newSession) {
setSessionsList([newSession, ...sessionsList]);
setCurrentSessionId(newSession.id);
const sessionData = await getSession(id, newSession.id);
if (sessionData) {
setSession(sessionData);
// Загружаем персонажа если нет
let character = playerCharacter;
if (!character && characterId) {
character = await getPlayerCharacter(characterId);
setPlayerCharacter(character);
}
if (character) {
startStory(story, sessionData, character, newSession.id);
}
}
}
};
const handleSwitchSession = async (sessionId: string) => {
console.log("[GamePage] Switching to session:", sessionId, "current:", currentSessionId);
if (!id || sessionId === currentSessionId) {
setShowSessionMenu(false);
return;
}
setShowSessionMenu(false);
setIsInitialLoading(true);
const sessionData = await getSession(id, sessionId);
console.log("[GamePage] Loaded session data:", sessionData);
if (sessionData) {
setCurrentSessionId(sessionId);
setSession(sessionData);
// Загружаем персонажа сессии
if (sessionData.playerId) {
const character = await getPlayerCharacter(sessionData.playerId);
setPlayerCharacter(character);
}
}
setIsInitialLoading(false);
};
const handleDeleteSession = async (sessionId: string) => {
if (!id || sessionsList.length <= 1) {
alert("Нельзя удалить единственную сессию");
return;
}
if (!confirm("Удалить эту сессию? Это действие нельзя отменить.")) {
return;
}
const success = await apiDeleteSession(id, sessionId);
if (success) {
const updatedList = sessionsList.filter((s) => s.id !== sessionId);
setSessionsList(updatedList);
// Если удалили текущую сессию — переключаемся на другую
if (sessionId === currentSessionId && updatedList.length > 0) {
await handleSwitchSession(updatedList[0].id);
}
}
setShowSessionMenu(false);
};
const currentSessionName =
sessionsList.find((s) => s.id === currentSessionId)?.name || "Сессия";
if (isInitialLoading) {
return (
<div className="game-page">
@@ -377,9 +463,58 @@ export default function GamePage() {
</Link>
<div className="header-info">
<h1>{story.title}</h1>
<span className="header-protagonist">
👤 {playerCharacter?.name || "Герой"}
</span>
<div className="header-session">
<button
className="session-selector"
onClick={() => setShowSessionMenu(!showSessionMenu)}
>
📖 {currentSessionName}
</button>
{showSessionMenu && (
<div className="session-menu">
<div className="session-menu-header">
<span>Сессии</span>
<button
className="new-session-btn"
onClick={handleCreateNewSession}
title="Новая сессия"
>
</button>
</div>
<div className="session-list">
{sessionsList.map((s) => (
<div
key={s.id}
className={`session-item ${s.id === currentSessionId ? "active" : ""}`}
>
<button
className="session-name"
onClick={() => handleSwitchSession(s.id)}
>
{s.name}
<span className="session-messages">
{s.messagesCount} сообщ.
</span>
</button>
{sessionsList.length > 1 && (
<button
className="delete-session-btn"
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(s.id);
}}
title="Удалить сессию"
>
🗑
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="header-stats">
<span className="stat-badge tokens">