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
+20 -4
View File
@@ -2,7 +2,10 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>ReSekai</title> <title>ReSekai</title>
<!-- PWA --> <!-- PWA -->
@@ -11,13 +14,26 @@
<!-- iOS PWA --> <!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="ReSekai" /> <meta name="apple-mobile-web-app-title" content="ReSekai" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" /> <link
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" /> rel="icon"
type="image/png"
sizes="32x32"
href="/icons/favicon-32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/icons/favicon-16.png"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+116 -13
View File
@@ -310,24 +310,102 @@ app.delete("/api/stories/:id", requireAuth, async (req, res) => {
// ============ GAME SESSIONS ROUTES ============ // ============ GAME SESSIONS ROUTES ============
// Получить сессию игры // Получить список сессий для истории
app.get("/api/sessions/:storyId", requireAuth, async (req, res) => { app.get("/api/sessions/:storyId", requireAuth, async (req, res) => {
try { try {
const sessions = db.collection("game_sessions"); 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({ const session = await sessions.findOne({
_id: new ObjectId(req.params.sessionId),
storyId: req.params.storyId, storyId: req.params.storyId,
userId: req.session.userId, 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) { } catch (error) {
console.error("Get session error:", error); console.error("Get session error:", error);
res.status(500).json({ error: "Failed to get session" }); res.status(500).json({ error: "Failed to get session" });
} }
}); });
// Сохранить сессию игры // Создать новую сессию
app.post("/api/sessions/:storyId", requireAuth, async (req, res) => { 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 { try {
const sessions = db.collection("game_sessions"); const sessions = db.collection("game_sessions");
@@ -343,30 +421,55 @@ app.post("/api/sessions/:storyId", requireAuth, async (req, res) => {
const sessionData = { const sessionData = {
...bodyWithoutMeta, ...bodyWithoutMeta,
messages, messages,
storyId: req.params.storyId,
userId: req.session.userId,
updatedAt: new Date(), updatedAt: new Date(),
}; };
await sessions.updateOne( const result = await sessions.updateOne(
{ {
_id: new ObjectId(req.params.sessionId),
storyId: req.params.storyId, storyId: req.params.storyId,
userId: req.session.userId, userId: req.session.userId,
}, },
{ { $set: sessionData },
$set: sessionData,
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true },
); );
if (result.matchedCount === 0) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error("Save session error:", error); console.error("Update session error:", error);
res.status(500).json({ error: "Failed to save session" }); 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 ============ // ============ PLAYER CHARACTERS ROUTES ============
// Получить всех персонажей пользователя // Получить всех персонажей пользователя
+125 -3
View File
@@ -56,6 +56,122 @@
color: #fff; 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 { .header-protagonist {
font-size: 0.85rem; font-size: 0.85rem;
color: #888; color: #888;
@@ -375,14 +491,20 @@
} }
.message.streaming .message-text::after { .message.streaming .message-text::after {
content: '▋'; content: "▋";
animation: blink 1s infinite; animation: blink 1s infinite;
margin-left: 2px; margin-left: 2px;
} }
@keyframes blink { @keyframes blink {
0%, 50% { opacity: 1; } 0%,
51%, 100% { opacity: 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 { useAuth } from "../contexts/AuthContext";
import { import {
getStory, getStory,
getSessionsList,
getSession, getSession,
createSession,
saveSession as apiSaveSession, saveSession as apiSaveSession,
deleteSession as apiDeleteSession,
getPlayerCharacter, getPlayerCharacter,
type SessionListItem,
} from "../services/api"; } from "../services/api";
import { import {
generateStoryResponseStream, generateStoryResponseStream,
@@ -45,6 +49,8 @@ export default function GamePage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null); 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 [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] = const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null); useState<PlayerCharacter | null>(null);
@@ -53,6 +59,7 @@ export default function GamePage() {
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [streamingContent, setStreamingContent] = useState(""); const [streamingContent, setStreamingContent] = useState("");
const [showSessionMenu, setShowSessionMenu] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -72,12 +79,11 @@ export default function GamePage() {
}; };
setStory(normalizedStory); setStory(normalizedStory);
let existingSession = await getSession(id); // Загружаем список сессий
console.log( const sessions = await getSessionsList(id);
"[GamePage] Loaded session:", console.log("[GamePage] Sessions list:", sessions);
existingSession?.messages?.length || 0, setSessionsList(sessions);
"messages",
);
const characterId = searchParams.get("character"); const characterId = searchParams.get("character");
// Загружаем персонажа // Загружаем персонажа
@@ -85,51 +91,42 @@ export default function GamePage() {
if (characterId) { if (characterId) {
character = await getPlayerCharacter(characterId); character = await getPlayerCharacter(characterId);
setPlayerCharacter(character); setPlayerCharacter(character);
} else if (existingSession?.playerId) {
character = await getPlayerCharacter(existingSession.playerId);
setPlayerCharacter(character);
} }
if (!existingSession) { // Если есть сессии, загружаем последнюю (или создаём новую)
console.log("[GamePage] No existing session, creating new"); if (sessions.length > 0) {
existingSession = { const latestSession = sessions[0];
storyId: id, console.log("[GamePage] Loading latest session:", latestSession);
playerId: characterId || undefined, setCurrentSessionId(latestSession.id);
messages: [], const sessionData = await getSession(id, latestSession.id);
currentState: { console.log("[GamePage] Session data loaded:", sessionData);
location: "Неизвестно", if (sessionData) {
health: 100, setSession(sessionData);
inventory: [], // Загружаем персонажа из сессии если не выбран
questProgress: {}, if (!character && sessionData.playerId) {
}, character = await getPlayerCharacter(sessionData.playerId);
}; setPlayerCharacter(character);
} else if (characterId && existingSession.playerId !== characterId) { }
// Новый персонаж — новая сессия }
console.log("[GamePage] New character selected, resetting session"); } else if (characterId) {
existingSession = { // Нет сессий и выбран персонаж — создаём новую
storyId: id, const newSession = await createSession(id, undefined, characterId);
playerId: characterId, if (newSession) {
messages: [], setSessionsList([newSession]);
currentState: { setCurrentSessionId(newSession.id);
location: "Неизвестно", const sessionData = await getSession(id, newSession.id);
health: 100, if (sessionData) {
inventory: [], setSession(sessionData);
questProgress: {}, if (character) {
}, startStory(
}; normalizedStory,
} else { sessionData,
console.log( character,
"[GamePage] Using existing session with", newSession.id,
existingSession.messages.length, );
"messages", }
); }
} }
setSession(existingSession);
// Начинаем историю если это новая сессия
if (existingSession.messages.length === 0 && character) {
startStory(normalizedStory, existingSession, character);
} }
} }
setIsInitialLoading(false); setIsInitialLoading(false);
@@ -170,6 +167,7 @@ export default function GamePage() {
storyData: Story, storyData: Story,
sessionData: GameSession, sessionData: GameSession,
character: PlayerCharacter, character: PlayerCharacter,
sessionId: string,
) => { ) => {
// Если есть заготовленное первое сообщение, используем его // Если есть заготовленное первое сообщение, используем его
if (storyData.firstMessage && storyData.firstMessage.trim()) { if (storyData.firstMessage && storyData.firstMessage.trim()) {
@@ -190,7 +188,7 @@ export default function GamePage() {
messages: [assistantMessage], messages: [assistantMessage],
}; };
await apiSaveSession(storyData.id, updatedSession); await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession); setSession(updatedSession);
return; return;
} }
@@ -218,7 +216,7 @@ export default function GamePage() {
messages: [assistantMessage], messages: [assistantMessage],
}; };
await apiSaveSession(storyData.id, updatedSession); await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession); setSession(updatedSession);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка"); setError(err instanceof Error ? err.message : "Произошла ошибка");
@@ -228,7 +226,8 @@ export default function GamePage() {
}; };
const handleSend = async () => { const handleSend = async () => {
if (!input.trim() || !story || !session || isLoading) return; if (!input.trim() || !story || !session || !currentSessionId || isLoading)
return;
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
id: generateId(), id: generateId(),
@@ -295,7 +294,11 @@ export default function GamePage() {
storySummary: newSummary, storySummary: newSummary,
}; };
const saved = await apiSaveSession(story.id, finalSession); const saved = await apiSaveSession(
story.id,
currentSessionId,
finalSession,
);
console.log( console.log(
"[GamePage] Session saved:", "[GamePage] Session saved:",
saved, saved,
@@ -317,7 +320,7 @@ export default function GamePage() {
...session, ...session,
messages: [...updatedMessages, partialMessage], messages: [...updatedMessages, partialMessage],
}; };
await apiSaveSession(story.id, partialSession); await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession); setSession(partialSession);
} }
} else { } 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) { if (isInitialLoading) {
return ( return (
<div className="game-page"> <div className="game-page">
@@ -377,9 +463,58 @@ export default function GamePage() {
</Link> </Link>
<div className="header-info"> <div className="header-info">
<h1>{story.title}</h1> <h1>{story.title}</h1>
<span className="header-protagonist"> <div className="header-session">
👤 {playerCharacter?.name || "Герой"} <button
</span> 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>
<div className="header-stats"> <div className="header-stats">
<span className="stat-badge tokens"> <span className="stat-badge tokens">
+23 -27
View File
@@ -4,18 +4,23 @@ import { useAuth } from "../contexts/AuthContext";
import { import {
getStory, getStory,
deleteStory as apiDeleteStory, deleteStory as apiDeleteStory,
getSession, getSessionsList,
getPlayerCharacters, getPlayerCharacters,
} from "../services/api"; } from "../services/api";
import type { Story, GameSession, PlayerCharacter } from "../types"; import type { Story, PlayerCharacter } from "../types";
import "./StoryDetailPage.css"; import "./StoryDetailPage.css";
interface SessionsInfo {
count: number;
totalMessages: number;
}
export default function StoryDetailPage() { export default function StoryDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null); 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[]>( const [playerCharacters, setPlayerCharacters] = useState<PlayerCharacter[]>(
[], [],
); );
@@ -39,10 +44,13 @@ export default function StoryDetailPage() {
...foundStory, ...foundStory,
id: (foundStory as any)._id || foundStory.id, id: (foundStory as any)._id || foundStory.id,
}); });
const existingSession = await getSession(id); const sessions = await getSessionsList(id);
if (existingSession) { if (sessions.length > 0) {
setSession(existingSession); const totalMessages = sessions.reduce((sum, s) => sum + s.messagesCount, 0);
setSelectedCharacter(existingSession.playerId || null); setSessionsInfo({
count: sessions.length,
totalMessages,
});
} }
} }
@@ -81,7 +89,7 @@ export default function StoryDetailPage() {
return; return;
} }
if (!session) { if (!sessionsInfo) {
// Новая игра — показываем выбор персонажа // Новая игра — показываем выбор персонажа
setShowCharacterSelect(true); setShowCharacterSelect(true);
} else { } else {
@@ -98,16 +106,6 @@ export default function StoryDetailPage() {
navigate(`/play/${story!.id}?character=${selectedCharacter}`); 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) => { const formatTokens = (tokens: number) => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`; if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`; if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
@@ -302,24 +300,22 @@ export default function StoryDetailPage() {
)} )}
</section> </section>
{session && ( {sessionsInfo && (
<section className="detail-section session-info"> <section className="detail-section session-info">
<h2>🎮 Текущий прогресс</h2> <h2>🎮 Текущий прогресс</h2>
<div className="session-stats"> <div className="session-stats">
<div className="stat"> <div className="stat">
<span className="stat-label">Сообщений</span> <span className="stat-label">Сессий</span>
<span className="stat-value">{session.messages.length}</span> <span className="stat-value">{sessionsInfo.count}</span>
</div> </div>
<div className="stat"> <div className="stat">
<span className="stat-label">Локация</span> <span className="stat-label">Сообщений</span>
<span className="stat-value"> <span className="stat-value">{sessionsInfo.totalMessages}</span>
{session.currentState.location || "Неизвестно"}
</span>
</div> </div>
<div className="stat"> <div className="stat">
<span className="stat-label"> Токенов</span> <span className="stat-label"> Токенов</span>
<span className="stat-value"> <span className="stat-value">
{formatTokens(estimateTokens(session.messages))} {formatTokens(sessionsInfo.totalMessages * 50)}
</span> </span>
</div> </div>
</div> </div>
@@ -328,7 +324,7 @@ export default function StoryDetailPage() {
<div className="story-actions"> <div className="story-actions">
<button onClick={handleStartGame} className="action-btn play-btn"> <button onClick={handleStartGame} className="action-btn play-btn">
{session ? "🎮 Продолжить игру" : "🎮 Начать приключение"} {sessionsInfo ? "🎮 Продолжить игру" : "🎮 Начать приключение"}
</button> </button>
<Link to={`/edit/${story.id}`} className="action-btn edit-btn"> <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"; 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 { try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, { const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
credentials: "include", 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) { if (!response.ok) {
return null; 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, storyId: string,
session: Omit<GameSession, "storyId">, name?: string,
): Promise<boolean> { playerId?: string,
): Promise<SessionListItem | null> {
try { try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, { const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
method: "POST", method: "POST",
@@ -185,9 +222,39 @@ export async function saveSession(
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", 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; return response.ok;
} catch (error) { } catch (error) {
console.error("Failed to save session:", 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 ============ // ============ PLAYER CHARACTERS ============
import type { PlayerCharacter } from "../types"; import type { PlayerCharacter } from "../types";
+1
View File
@@ -53,6 +53,7 @@ export interface GameSession {
id?: string; id?: string;
storyId: string; storyId: string;
playerId?: string; // ID выбранного персонажа игрока playerId?: string; // ID выбранного персонажа игрока
name?: string; // Название сессии (например "Попытка 1", "Злой путь")
messages: ChatMessage[]; messages: ChatMessage[];
currentState: { currentState: {
location: string; location: string;