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
+125 -3
View File
@@ -56,6 +56,122 @@
color: #fff;
}
.header-session {
position: relative;
}
.session-selector {
background: rgba(102, 126, 234, 0.2);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 8px;
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
color: #a0b4ff;
cursor: pointer;
transition: all 0.2s;
}
.session-selector:hover {
background: rgba(102, 126, 234, 0.3);
}
.session-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
background: #1e1e2e;
border: 1px solid #333;
border-radius: 12px;
min-width: 220px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 100;
overflow: hidden;
}
.session-menu-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid #333;
font-size: 0.85rem;
color: #888;
}
.new-session-btn {
background: rgba(80, 200, 120, 0.2);
border: none;
border-radius: 6px;
padding: 0.3rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.new-session-btn:hover {
background: rgba(80, 200, 120, 0.4);
}
.session-list {
max-height: 250px;
overflow-y: auto;
}
.session-item {
display: flex;
align-items: center;
border-bottom: 1px solid #2a2a3a;
}
.session-item:last-child {
border-bottom: none;
}
.session-item.active {
background: rgba(102, 126, 234, 0.15);
}
.session-name {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
padding: 0.75rem 1rem;
background: none;
border: none;
color: #fff;
font-size: 0.9rem;
cursor: pointer;
text-align: left;
transition: background 0.2s;
}
.session-name:hover {
background: rgba(255, 255, 255, 0.05);
}
.session-messages {
font-size: 0.75rem;
color: #666;
}
.delete-session-btn {
background: none;
border: none;
padding: 0.5rem;
margin-right: 0.5rem;
font-size: 0.85rem;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
}
.delete-session-btn:hover {
opacity: 1;
}
.header-protagonist {
font-size: 0.85rem;
color: #888;
@@ -375,14 +491,20 @@
}
.message.streaming .message-text::after {
content: '▋';
content: "▋";
animation: blink 1s infinite;
margin-left: 2px;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
/* Скроллбар */
+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">
+23 -27
View File
@@ -4,18 +4,23 @@ import { useAuth } from "../contexts/AuthContext";
import {
getStory,
deleteStory as apiDeleteStory,
getSession,
getSessionsList,
getPlayerCharacters,
} from "../services/api";
import type { Story, GameSession, PlayerCharacter } from "../types";
import type { Story, PlayerCharacter } from "../types";
import "./StoryDetailPage.css";
interface SessionsInfo {
count: number;
totalMessages: number;
}
export default function StoryDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [sessionsInfo, setSessionsInfo] = useState<SessionsInfo | null>(null);
const [playerCharacters, setPlayerCharacters] = useState<PlayerCharacter[]>(
[],
);
@@ -39,10 +44,13 @@ export default function StoryDetailPage() {
...foundStory,
id: (foundStory as any)._id || foundStory.id,
});
const existingSession = await getSession(id);
if (existingSession) {
setSession(existingSession);
setSelectedCharacter(existingSession.playerId || null);
const sessions = await getSessionsList(id);
if (sessions.length > 0) {
const totalMessages = sessions.reduce((sum, s) => sum + s.messagesCount, 0);
setSessionsInfo({
count: sessions.length,
totalMessages,
});
}
}
@@ -81,7 +89,7 @@ export default function StoryDetailPage() {
return;
}
if (!session) {
if (!sessionsInfo) {
// Новая игра — показываем выбор персонажа
setShowCharacterSelect(true);
} else {
@@ -98,16 +106,6 @@ export default function StoryDetailPage() {
navigate(`/play/${story!.id}?character=${selectedCharacter}`);
};
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского текста)
const estimateTokens = (messages: GameSession["messages"] | undefined) => {
if (!messages || messages.length === 0) return 0;
const totalChars = messages.reduce(
(sum, msg) => sum + msg.content.length,
0,
);
return Math.round(totalChars / 3);
};
const formatTokens = (tokens: number) => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
@@ -302,24 +300,22 @@ export default function StoryDetailPage() {
)}
</section>
{session && (
{sessionsInfo && (
<section className="detail-section session-info">
<h2>🎮 Текущий прогресс</h2>
<div className="session-stats">
<div className="stat">
<span className="stat-label">Сообщений</span>
<span className="stat-value">{session.messages.length}</span>
<span className="stat-label">Сессий</span>
<span className="stat-value">{sessionsInfo.count}</span>
</div>
<div className="stat">
<span className="stat-label">Локация</span>
<span className="stat-value">
{session.currentState.location || "Неизвестно"}
</span>
<span className="stat-label">Сообщений</span>
<span className="stat-value">{sessionsInfo.totalMessages}</span>
</div>
<div className="stat">
<span className="stat-label"> Токенов</span>
<span className="stat-value">
{formatTokens(estimateTokens(session.messages))}
{formatTokens(sessionsInfo.totalMessages * 50)}
</span>
</div>
</div>
@@ -328,7 +324,7 @@ export default function StoryDetailPage() {
<div className="story-actions">
<button onClick={handleStartGame} className="action-btn play-btn">
{session ? "🎮 Продолжить игру" : "🎮 Начать приключение"}
{sessionsInfo ? "🎮 Продолжить игру" : "🎮 Начать приключение"}
</button>
<Link to={`/edit/${story.id}`} className="action-btn edit-btn">
Редактировать
+93 -5
View File
@@ -157,12 +157,47 @@ export async function deleteStory(id: string): Promise<boolean> {
import type { GameSession } from "../types";
export async function getSession(storyId: string): Promise<GameSession | null> {
export interface SessionListItem {
id: string;
name: string;
messagesCount: number;
createdAt: Date;
updatedAt: Date;
}
// Получить список сессий для истории
export async function getSessionsList(
storyId: string,
): Promise<SessionListItem[]> {
try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
credentials: "include",
});
if (!response.ok) {
return [];
}
return await response.json();
} catch (error) {
console.error("Failed to get sessions list:", error);
return [];
}
}
// Получить конкретную сессию
export async function getSession(
storyId: string,
sessionId: string,
): Promise<GameSession | null> {
try {
const response = await fetch(
`${API_URL}/api/sessions/${storyId}/${sessionId}`,
{
credentials: "include",
},
);
if (!response.ok) {
return null;
}
@@ -174,10 +209,12 @@ export async function getSession(storyId: string): Promise<GameSession | null> {
}
}
export async function saveSession(
// Создать новую сессию
export async function createSession(
storyId: string,
session: Omit<GameSession, "storyId">,
): Promise<boolean> {
name?: string,
playerId?: string,
): Promise<SessionListItem | null> {
try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
method: "POST",
@@ -185,9 +222,39 @@ export async function saveSession(
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(session),
body: JSON.stringify({ name, playerId }),
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to create session:", error);
return null;
}
}
// Сохранить/обновить сессию
export async function saveSession(
storyId: string,
sessionId: string,
session: Omit<GameSession, "storyId">,
): Promise<boolean> {
try {
const response = await fetch(
`${API_URL}/api/sessions/${storyId}/${sessionId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(session),
},
);
return response.ok;
} catch (error) {
console.error("Failed to save session:", error);
@@ -195,6 +262,27 @@ export async function saveSession(
}
}
// Удалить сессию
export async function deleteSession(
storyId: string,
sessionId: string,
): Promise<boolean> {
try {
const response = await fetch(
`${API_URL}/api/sessions/${storyId}/${sessionId}`,
{
method: "DELETE",
credentials: "include",
},
);
return response.ok;
} catch (error) {
console.error("Failed to delete session:", error);
return false;
}
}
// ============ PLAYER CHARACTERS ============
import type { PlayerCharacter } from "../types";
+1
View File
@@ -53,6 +53,7 @@ export interface GameSession {
id?: string;
storyId: string;
playerId?: string; // ID выбранного персонажа игрока
name?: string; // Название сессии (например "Попытка 1", "Злой путь")
messages: ChatMessage[];
currentState: {
location: string;