From 8c6d6591f8c28c6cb287d75d7d76a0e63e9cf053 Mon Sep 17 00:00:00 2001 From: Alexej Wolff Date: Wed, 11 Feb 2026 01:47:24 +0100 Subject: [PATCH] feat: multiple sessions per story with streaming AI responses --- index.html | 30 +++- server/index.js | 129 ++++++++++++++++-- src/pages/GamePage.css | 128 ++++++++++++++++- src/pages/GamePage.tsx | 249 ++++++++++++++++++++++++++-------- src/pages/StoryDetailPage.tsx | 50 ++++--- src/services/api.ts | 98 ++++++++++++- src/types/index.ts | 1 + 7 files changed, 573 insertions(+), 112 deletions(-) diff --git a/index.html b/index.html index d4d5549..07150b0 100644 --- a/index.html +++ b/index.html @@ -2,22 +2,38 @@ - + ReSekai - + - + - + - + - - + +
diff --git a/server/index.js b/server/index.js index 832bacb..fc5a439 100644 --- a/server/index.js +++ b/server/index.js @@ -310,24 +310,102 @@ app.delete("/api/stories/:id", requireAuth, async (req, res) => { // ============ GAME SESSIONS ROUTES ============ -// Получить сессию игры +// Получить список сессий для истории app.get("/api/sessions/:storyId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); + const userSessions = await sessions + .find({ + storyId: req.params.storyId, + userId: req.session.userId, + }) + .sort({ updatedAt: -1 }) + .toArray(); + + // Возвращаем список с базовой инфой (без полных сообщений) + const sessionsList = userSessions.map((s) => ({ + id: s._id.toString(), + name: s.name || "Сессия", + messagesCount: s.messages?.length || 0, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + })); + + res.json(sessionsList); + } catch (error) { + console.error("Get sessions list error:", error); + res.status(500).json({ error: "Failed to get sessions" }); + } +}); + +// Получить конкретную сессию +app.get("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { + try { + const sessions = db.collection("game_sessions"); + const session = await sessions.findOne({ + _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }); - res.json(session); + if (!session) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ ...session, id: session._id.toString() }); } catch (error) { console.error("Get session error:", error); res.status(500).json({ error: "Failed to get session" }); } }); -// Сохранить сессию игры +// Создать новую сессию app.post("/api/sessions/:storyId", requireAuth, async (req, res) => { + try { + const sessions = db.collection("game_sessions"); + + // Считаем существующие сессии для нумерации + const existingCount = await sessions.countDocuments({ + storyId: req.params.storyId, + userId: req.session.userId, + }); + + const sessionData = { + storyId: req.params.storyId, + userId: req.session.userId, + name: req.body.name || `Сессия ${existingCount + 1}`, + playerId: req.body.playerId || null, + messages: [], + currentState: { + location: "start", + health: 100, + inventory: [], + questProgress: {}, + }, + storySummary: "", + keyEvents: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await sessions.insertOne(sessionData); + + res.json({ + id: result.insertedId.toString(), + name: sessionData.name, + messagesCount: 0, + createdAt: sessionData.createdAt, + updatedAt: sessionData.updatedAt, + }); + } catch (error) { + console.error("Create session error:", error); + res.status(500).json({ error: "Failed to create session" }); + } +}); + +// Обновить сессию +app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); @@ -343,30 +421,55 @@ app.post("/api/sessions/:storyId", requireAuth, async (req, res) => { const sessionData = { ...bodyWithoutMeta, messages, - storyId: req.params.storyId, - userId: req.session.userId, updatedAt: new Date(), }; - await sessions.updateOne( + const result = await sessions.updateOne( { + _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }, - { - $set: sessionData, - $setOnInsert: { createdAt: new Date() }, - }, - { upsert: true }, + { $set: sessionData }, ); + if (result.matchedCount === 0) { + return res.status(404).json({ error: "Session not found" }); + } + res.json({ success: true }); } catch (error) { - console.error("Save session error:", error); - res.status(500).json({ error: "Failed to save session" }); + console.error("Update session error:", error); + res.status(500).json({ error: "Failed to update session" }); } }); +// Удалить сессию +app.delete( + "/api/sessions/:storyId/:sessionId", + requireAuth, + async (req, res) => { + try { + const sessions = db.collection("game_sessions"); + + const result = await sessions.deleteOne({ + _id: new ObjectId(req.params.sessionId), + storyId: req.params.storyId, + userId: req.session.userId, + }); + + if (result.deletedCount === 0) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ success: true }); + } catch (error) { + console.error("Delete session error:", error); + res.status(500).json({ error: "Failed to delete session" }); + } + }, +); + // ============ PLAYER CHARACTERS ROUTES ============ // Получить всех персонажей пользователя diff --git a/src/pages/GamePage.css b/src/pages/GamePage.css index 5ce174d..832cc56 100644 --- a/src/pages/GamePage.css +++ b/src/pages/GamePage.css @@ -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; + } } /* Скроллбар */ diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index 21017fe..6bfd437 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -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(null); + const [sessionsList, setSessionsList] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); const [session, setSession] = useState(null); const [playerCharacter, setPlayerCharacter] = useState(null); @@ -53,6 +59,7 @@ export default function GamePage() { const [isInitialLoading, setIsInitialLoading] = useState(true); const [error, setError] = useState(null); const [streamingContent, setStreamingContent] = useState(""); + const [showSessionMenu, setShowSessionMenu] = useState(false); const abortControllerRef = useRef(null); const messagesEndRef = useRef(null); const inputRef = useRef(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 (
@@ -377,9 +463,58 @@ export default function GamePage() {

{story.title}

- - 👤 {playerCharacter?.name || "Герой"} - +
+ + {showSessionMenu && ( +
+
+ Сессии + +
+
+ {sessionsList.map((s) => ( +
+ + {sessionsList.length > 1 && ( + + )} +
+ ))} +
+
+ )} +
diff --git a/src/pages/StoryDetailPage.tsx b/src/pages/StoryDetailPage.tsx index 088db19..692427c 100644 --- a/src/pages/StoryDetailPage.tsx +++ b/src/pages/StoryDetailPage.tsx @@ -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(null); - const [session, setSession] = useState(null); + const [sessionsInfo, setSessionsInfo] = useState(null); const [playerCharacters, setPlayerCharacters] = useState( [], ); @@ -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() { )} - {session && ( + {sessionsInfo && (

🎮 Текущий прогресс

- Сообщений - {session.messages.length} + Сессий + {sessionsInfo.count}
- Локация - - {session.currentState.location || "Неизвестно"} - + Сообщений + {sessionsInfo.totalMessages}
≈ Токенов - {formatTokens(estimateTokens(session.messages))} + {formatTokens(sessionsInfo.totalMessages * 50)}
@@ -328,7 +324,7 @@ export default function StoryDetailPage() {
✏️ Редактировать diff --git a/src/services/api.ts b/src/services/api.ts index 3bc39a9..0bf03cc 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -157,12 +157,47 @@ export async function deleteStory(id: string): Promise { import type { GameSession } from "../types"; -export async function getSession(storyId: string): Promise { +export interface SessionListItem { + id: string; + name: string; + messagesCount: number; + createdAt: Date; + updatedAt: Date; +} + +// Получить список сессий для истории +export async function getSessionsList( + storyId: string, +): Promise { 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 { + 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 { } } -export async function saveSession( +// Создать новую сессию +export async function createSession( storyId: string, - session: Omit, -): Promise { + name?: string, + playerId?: string, +): Promise { 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, +): Promise { + 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 { + 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"; diff --git a/src/types/index.ts b/src/types/index.ts index 90e55c1..12e3379 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -53,6 +53,7 @@ export interface GameSession { id?: string; storyId: string; playerId?: string; // ID выбранного персонажа игрока + name?: string; // Название сессии (например "Попытка 1", "Злой путь") messages: ChatMessage[]; currentState: { location: string;