diff --git a/.env.example b/.env.example
index d6c7c24..066155a 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,2 @@
-# DeepSeek API ключ для генерации историй
-VITE_DEEPSEEK_API_KEY=your_api_key_here
-
# URL бэкенд сервера
VITE_API_URL=http://localhost:3001
diff --git a/README.md b/README.md
index 483262f..89af81a 100644
--- a/README.md
+++ b/README.md
@@ -37,8 +37,8 @@ cd resekai
npm install
cd server && npm install && cd ..
-# Создать .env файл
-echo "VITE_DEEPSEEK_API_KEY=your_api_key" > .env
+# Настроить backend (скопировать .env.example и заполнить)
+# Обязательно указать DEEPSEEK_API_KEY в server/.env
# Запустить в режиме разработки
npm run dev # Frontend (порт 5173)
diff --git a/server/.env.example b/server/.env.example
index b374bb8..c5c5c4f 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -14,3 +14,10 @@ FRONTEND_URL=http://localhost:5174
# Server
PORT=3001
+NODE_ENV=development # 'production' for secure cookies and strict checks
+
+# DeepSeek API (for story generation)
+DEEPSEEK_API_KEY=your_deepseek_api_key
+
+# GeminiGen API (for image generation)
+GEMINIGEN_API_KEY=your_geminigen_api_key
diff --git a/server/index.js b/server/index.js
index c8b0cdf..a6cedaf 100644
--- a/server/index.js
+++ b/server/index.js
@@ -7,6 +7,30 @@ import dotenv from "dotenv";
dotenv.config();
+// Проверка обязательных переменных окружения
+const isProduction = process.env.NODE_ENV === "production";
+const requiredEnvVars = [
+ "MONGODB_URI",
+ "SESSION_SECRET",
+ "DISCORD_CLIENT_ID",
+ "DISCORD_CLIENT_SECRET",
+ "DISCORD_REDIRECT_URI",
+ "FRONTEND_URL",
+];
+
+const missingVars = requiredEnvVars.filter((v) => !process.env[v]);
+if (missingVars.length > 0) {
+ console.error(
+ "❌ Missing required environment variables:",
+ missingVars.join(", "),
+ );
+ if (isProduction) {
+ process.exit(1);
+ } else {
+ console.warn("⚠️ Running in development mode with missing vars");
+ }
+}
+
const app = express();
const PORT = process.env.PORT || 3001;
@@ -21,7 +45,7 @@ async function connectDB() {
}
// Middleware
-app.use(express.json());
+app.use(express.json({ limit: "10mb" }));
app.use(
cors({
origin: process.env.FRONTEND_URL,
@@ -40,8 +64,9 @@ app.use(
collectionName: "sessions",
}),
cookie: {
- secure: false, // true в production с HTTPS
+ secure: isProduction, // true в production с HTTPS
httpOnly: true,
+ sameSite: isProduction ? "strict" : "lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
},
}),
@@ -55,6 +80,192 @@ const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI;
// ============ AUTH ROUTES ============
+// Whitelist полей для безопасного обновления
+const ALLOWED_STORY_FIELDS = [
+ "title",
+ "description",
+ "genre",
+ "setting",
+ "world",
+ "characters",
+ "plot",
+ "firstMessage",
+ "language",
+ "isNsfw",
+ "temperature",
+ "narrativeRules",
+ "protagonist",
+ "coverImage",
+];
+
+const ALLOWED_SESSION_FIELDS = [
+ "name",
+ "messages",
+ "currentState",
+ "storySummary",
+ "keyEvents",
+ "playerId",
+];
+
+const ALLOWED_CHARACTER_FIELDS = ["name", "description", "age", "avatarUrl"];
+
+const ALLOWED_NPC_FIELDS = [
+ "name",
+ "description",
+ "role",
+ "age",
+ "gender",
+ "avatarUrl",
+ "isNsfw",
+];
+
+// Функция для фильтрации полей
+function pickAllowedFields(obj, allowedFields) {
+ // Guard: ensure obj is a plain object
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
+ return {};
+ }
+ const result = {};
+ for (const field of allowedFields) {
+ if (Object.prototype.hasOwnProperty.call(obj, field)) {
+ result[field] = obj[field];
+ }
+ }
+ return result;
+}
+
+// ============ DEEPSEEK RATE LIMITING & VALIDATION ============
+const DEEPSEEK_LIMITS = {
+ MAX_TOKENS_LIMIT: 4096,
+ MAX_MESSAGES: 100,
+ MAX_MESSAGE_LENGTH: 32000, // ~8k tokens per message
+ MAX_TOTAL_LENGTH: 128000, // ~32k tokens total
+ RATE_LIMIT_WINDOW_MS: 60 * 1000, // 1 minute
+ RATE_LIMIT_MAX_REQUESTS: 20, // 20 requests per minute per user
+};
+
+// Simple in-memory rate limiter (per userId)
+const rateLimitStore = new Map();
+
+function checkRateLimit(userId) {
+ const now = Date.now();
+ const windowStart = now - DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS;
+
+ let userRequests = rateLimitStore.get(userId) || [];
+ // Remove old requests outside window
+ userRequests = userRequests.filter((ts) => ts > windowStart);
+
+ if (userRequests.length >= DEEPSEEK_LIMITS.RATE_LIMIT_MAX_REQUESTS) {
+ return {
+ allowed: false,
+ remaining: 0,
+ resetIn: Math.ceil(
+ (userRequests[0] + DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS - now) / 1000,
+ ),
+ };
+ }
+
+ userRequests.push(now);
+ rateLimitStore.set(userId, userRequests);
+ return {
+ allowed: true,
+ remaining: DEEPSEEK_LIMITS.RATE_LIMIT_MAX_REQUESTS - userRequests.length,
+ };
+}
+
+// Cleanup old rate limit entries every 5 minutes
+setInterval(
+ () => {
+ const cutoff = Date.now() - DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS;
+ for (const [userId, requests] of rateLimitStore.entries()) {
+ const filtered = requests.filter((ts) => ts > cutoff);
+ if (filtered.length === 0) {
+ rateLimitStore.delete(userId);
+ } else {
+ rateLimitStore.set(userId, filtered);
+ }
+ }
+ },
+ 5 * 60 * 1000,
+);
+
+function validateDeepSeekRequest(body) {
+ const errors = [];
+ const { messages, temperature, max_tokens } = body;
+
+ // Validate messages
+ if (!Array.isArray(messages)) {
+ errors.push("messages must be an array");
+ } else {
+ if (messages.length === 0) {
+ errors.push("messages cannot be empty");
+ }
+ if (messages.length > DEEPSEEK_LIMITS.MAX_MESSAGES) {
+ errors.push(`too many messages (max ${DEEPSEEK_LIMITS.MAX_MESSAGES})`);
+ }
+
+ let totalLength = 0;
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i];
+ if (!msg || typeof msg !== "object") {
+ errors.push(`messages[${i}] must be an object`);
+ continue;
+ }
+ if (!msg.role || !["system", "user", "assistant"].includes(msg.role)) {
+ errors.push(
+ `messages[${i}].role must be 'system', 'user', or 'assistant'`,
+ );
+ }
+ if (typeof msg.content !== "string") {
+ errors.push(`messages[${i}].content must be a string`);
+ } else {
+ if (msg.content.length > DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH) {
+ errors.push(
+ `messages[${i}].content too long (max ${DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH} chars)`,
+ );
+ }
+ totalLength += msg.content.length;
+ }
+ }
+
+ if (totalLength > DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH) {
+ errors.push(
+ `total message content too long (max ${DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH} chars)`,
+ );
+ }
+ }
+
+ // Validate temperature
+ if (temperature !== undefined) {
+ if (typeof temperature !== "number" || temperature < 0 || temperature > 2) {
+ errors.push("temperature must be a number between 0 and 2");
+ }
+ }
+
+ // Validate max_tokens
+ if (max_tokens !== undefined) {
+ if (
+ typeof max_tokens !== "number" ||
+ !Number.isInteger(max_tokens) ||
+ max_tokens < 1 ||
+ max_tokens > DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT
+ ) {
+ errors.push(
+ `max_tokens must be an integer between 1 and ${DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT}`,
+ );
+ }
+ }
+
+ return errors;
+}
+
+function sanitizeDeepSeekMessages(messages) {
+ return messages.map((msg) => ({
+ role: msg.role,
+ content: String(msg.content),
+ }));
+}
+
// Начало авторизации Discord
app.get("/auth/discord", (req, res) => {
const params = new URLSearchParams({
@@ -241,8 +452,9 @@ app.get("/api/stories/:id", requireAuth, async (req, res) => {
app.post("/api/stories", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
+ const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS);
const newStory = {
- ...req.body,
+ ...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -260,6 +472,8 @@ app.post("/api/stories", requireAuth, async (req, res) => {
app.put("/api/stories/:id", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
+ const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS);
+
const result = await stories.updateOne(
{
_id: new ObjectId(req.params.id),
@@ -267,7 +481,7 @@ app.put("/api/stories/:id", requireAuth, async (req, res) => {
},
{
$set: {
- ...req.body,
+ ...allowedData,
updatedAt: new Date(),
},
},
@@ -416,18 +630,40 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
});
const oldMessageCount = oldSession?.messages?.length || 0;
- // Преобразуем timestamp строки обратно в Date
- const messages = (req.body.messages || []).map((msg) => ({
- ...msg,
- timestamp: new Date(msg.timestamp),
- }));
+ // Фильтруем разрешенные поля
+ const allowedData = pickAllowedFields(req.body, ALLOWED_SESSION_FIELDS);
- // Убираем _id и createdAt из body чтобы не было конфликтов
- const { createdAt, _id, id, ...bodyWithoutMeta } = req.body;
+ // Преобразуем timestamp строки обратно в Date
+ if (allowedData.messages) {
+ allowedData.messages = allowedData.messages.map((msg) => {
+ const sanitized = {
+ id: msg.id,
+ role: msg.role,
+ content: msg.content,
+ timestamp: new Date(msg.timestamp),
+ };
+ // Сохраняем версии для редактирования сообщений
+ if (Array.isArray(msg.versions) && msg.versions.length > 0) {
+ sanitized.versions = msg.versions.map((v) => {
+ const ver = {
+ content: v.content,
+ timestamp: new Date(v.timestamp),
+ };
+ // Сохраняем связанный ответ ИИ для переключения версий
+ if (typeof v.aiResponse === "string") {
+ ver.aiResponse = v.aiResponse;
+ }
+ return ver;
+ });
+ sanitized.activeVersion =
+ typeof msg.activeVersion === "number" ? msg.activeVersion : 0;
+ }
+ return sanitized;
+ });
+ }
const sessionData = {
- ...bodyWithoutMeta,
- messages,
+ ...allowedData,
updatedAt: new Date(),
};
@@ -445,6 +681,7 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => {
}
// Логируем новые токены (только для новых сообщений)
+ const messages = allowedData.messages || [];
const newMessages = messages.slice(oldMessageCount);
if (newMessages.length > 0) {
const tokenUsage = db.collection("token_usage");
@@ -539,8 +776,9 @@ app.get("/api/characters/:id", requireAuth, async (req, res) => {
app.post("/api/characters", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
+ const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS);
const newCharacter = {
- ...req.body,
+ ...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -558,6 +796,8 @@ app.post("/api/characters", requireAuth, async (req, res) => {
app.put("/api/characters/:id", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
+ const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS);
+
const result = await characters.updateOne(
{
_id: new ObjectId(req.params.id),
@@ -565,7 +805,7 @@ app.put("/api/characters/:id", requireAuth, async (req, res) => {
},
{
$set: {
- ...req.body,
+ ...allowedData,
updatedAt: new Date(),
},
},
@@ -644,8 +884,9 @@ app.get("/api/npc/:id", requireAuth, async (req, res) => {
app.post("/api/npc", requireAuth, async (req, res) => {
try {
const npcCharacters = db.collection("npc_characters");
+ const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS);
const newNPC = {
- ...req.body,
+ ...allowedData,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -663,6 +904,8 @@ app.post("/api/npc", requireAuth, async (req, res) => {
app.put("/api/npc/:id", requireAuth, async (req, res) => {
try {
const npcCharacters = db.collection("npc_characters");
+ const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS);
+
const result = await npcCharacters.updateOne(
{
_id: new ObjectId(req.params.id),
@@ -670,7 +913,7 @@ app.put("/api/npc/:id", requireAuth, async (req, res) => {
},
{
$set: {
- ...req.body,
+ ...allowedData,
updatedAt: new Date(),
},
},
@@ -930,9 +1173,233 @@ app.get("/api/generate-image/status/:uuid", requireAuth, async (req, res) => {
}
});
+// ============ DEEPSEEK PROXY ============
+const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
+
+// Regular chat completion
+app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
+ try {
+ // Rate limiting
+ const rateCheck = checkRateLimit(req.session.userId);
+ if (!rateCheck.allowed) {
+ return res.status(429).json({
+ error: "Rate limit exceeded",
+ retryAfter: rateCheck.resetIn,
+ });
+ }
+
+ // Validation
+ const validationErrors = validateDeepSeekRequest(req.body);
+ if (validationErrors.length > 0) {
+ return res
+ .status(400)
+ .json({ error: "Validation failed", details: validationErrors });
+ }
+
+ const apiKey = process.env.DEEPSEEK_API_KEY;
+ if (!apiKey) {
+ return res.status(500).json({ error: "DeepSeek API key not configured" });
+ }
+
+ const { messages, temperature = 0.8, max_tokens = 1000 } = req.body;
+ const sanitizedMessages = sanitizeDeepSeekMessages(messages);
+ const clampedMaxTokens = Math.min(
+ max_tokens,
+ DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT,
+ );
+
+ const response = await fetch(DEEPSEEK_API_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model: "deepseek-chat",
+ messages: sanitizedMessages,
+ temperature,
+ max_tokens: clampedMaxTokens,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ console.error("DeepSeek API error:", response.status, error);
+ return res.status(response.status).json({ error: "DeepSeek API error" });
+ }
+
+ const data = await response.json();
+ res.json(data);
+ } catch (error) {
+ console.error("DeepSeek proxy error:", error);
+ res.status(500).json({ error: "Failed to call DeepSeek API" });
+ }
+});
+
+// Streaming chat completion
+app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
+ try {
+ // Rate limiting
+ const rateCheck = checkRateLimit(req.session.userId);
+ if (!rateCheck.allowed) {
+ return res.status(429).json({
+ error: "Rate limit exceeded",
+ retryAfter: rateCheck.resetIn,
+ });
+ }
+
+ // Validation
+ const validationErrors = validateDeepSeekRequest(req.body);
+ if (validationErrors.length > 0) {
+ return res
+ .status(400)
+ .json({ error: "Validation failed", details: validationErrors });
+ }
+
+ const apiKey = process.env.DEEPSEEK_API_KEY;
+ if (!apiKey) {
+ return res.status(500).json({ error: "DeepSeek API key not configured" });
+ }
+
+ const { messages, temperature = 0.8, max_tokens = 1000 } = req.body;
+ const sanitizedMessages = sanitizeDeepSeekMessages(messages);
+ const clampedMaxTokens = Math.min(
+ max_tokens,
+ DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT,
+ );
+
+ const response = await fetch(DEEPSEEK_API_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model: "deepseek-chat",
+ messages: sanitizedMessages,
+ temperature,
+ max_tokens: clampedMaxTokens,
+ stream: true,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ console.error("DeepSeek stream error:", response.status, error);
+ return res.status(response.status).json({ error: "DeepSeek API error" });
+ }
+
+ // Set headers for SSE
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+
+ // Pipe the stream
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ const pump = async () => {
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ res.end();
+ break;
+ }
+ const chunk = decoder.decode(value, { stream: true });
+ res.write(chunk);
+ }
+ } catch (error) {
+ console.error("Stream pump error:", error);
+ res.end();
+ }
+ };
+
+ // Handle client disconnect
+ req.on("close", () => {
+ reader.cancel();
+ });
+
+ pump();
+ } catch (error) {
+ console.error("DeepSeek stream proxy error:", error);
+ res.status(500).json({ error: "Failed to call DeepSeek API" });
+ }
+});
+
+// Translation endpoint (for image prompts)
+app.post("/api/deepseek/translate", requireAuth, async (req, res) => {
+ try {
+ // Rate limiting (shares limit with chat endpoints)
+ const rateCheck = checkRateLimit(req.session.userId);
+ if (!rateCheck.allowed) {
+ return res.status(429).json({
+ error: "Rate limit exceeded",
+ retryAfter: rateCheck.resetIn,
+ });
+ }
+
+ // Validate text
+ const { text } = req.body;
+ if (typeof text !== "string") {
+ return res.status(400).json({ error: "text must be a string" });
+ }
+ if (text.length === 0) {
+ return res.status(400).json({ error: "text cannot be empty" });
+ }
+ if (text.length > 2000) {
+ return res.status(400).json({ error: "text too long (max 2000 chars)" });
+ }
+
+ const apiKey = process.env.DEEPSEEK_API_KEY;
+ if (!apiKey) {
+ return res.status(500).json({ error: "DeepSeek API key not configured" });
+ }
+
+ const response = await fetch(DEEPSEEK_API_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model: "deepseek-chat",
+ messages: [
+ {
+ role: "system",
+ content:
+ "Translate to English for image generation. Output ONLY the translation, nothing else. Keep character names as-is. Be concise.",
+ },
+ {
+ role: "user",
+ content: text,
+ },
+ ],
+ temperature: 0.1,
+ max_tokens: 150,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ console.error("DeepSeek translate error:", response.status, error);
+ return res.status(response.status).json({ error: "Translation failed" });
+ }
+
+ const data = await response.json();
+ const translated = data.choices?.[0]?.message?.content?.trim() || text;
+ res.json({ translated });
+ } catch (error) {
+ console.error("Translation proxy error:", error);
+ res.status(500).json({ error: "Failed to translate" });
+ }
+});
+
// Запуск сервера
connectDB().then(() => {
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
+ console.log(` Mode: ${isProduction ? "production" : "development"}`);
+ console.log(` Secure cookies: ${isProduction}`);
});
});
diff --git a/src/components/game/CharacterPanel.tsx b/src/components/game/CharacterPanel.tsx
new file mode 100644
index 0000000..0774d2f
--- /dev/null
+++ b/src/components/game/CharacterPanel.tsx
@@ -0,0 +1,19 @@
+import type { Character } from "../../types";
+
+interface CharacterPanelProps {
+ character: Character | null;
+}
+
+export function CharacterPanel({ character }: CharacterPanelProps) {
+ if (!character?.avatarUrl) return null;
+
+ return (
+
+

+
+ {character.name}
+ {character.role}
+
+
+ );
+}
diff --git a/src/components/game/ChatInput.tsx b/src/components/game/ChatInput.tsx
new file mode 100644
index 0000000..46f9630
--- /dev/null
+++ b/src/components/game/ChatInput.tsx
@@ -0,0 +1,88 @@
+import React, { useRef, useEffect } from "react";
+
+interface ChatInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onSend: () => void;
+ onStop: () => void;
+ isLoading: boolean;
+ disabled?: boolean;
+ placeholder?: string;
+}
+
+export function ChatInput({
+ value,
+ onChange,
+ onSend,
+ onStop,
+ isLoading,
+ disabled = false,
+ placeholder = "Что ты хочешь сделать?...",
+}: ChatInputProps) {
+ const inputRef = useRef(null);
+
+ // Focus input after loading completes
+ useEffect(() => {
+ if (!isLoading) {
+ inputRef.current?.focus();
+ }
+ }, [isLoading]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ onSend();
+ }
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ onChange(e.target.value);
+ // Auto-resize
+ e.target.style.height = "auto";
+ e.target.style.height = Math.min(e.target.scrollHeight, 150) + "px";
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSend();
+ };
+
+ // Reset textarea height when value is cleared
+ useEffect(() => {
+ if (!value && inputRef.current) {
+ inputRef.current.style.height = "auto";
+ }
+ }, [value]);
+
+ return (
+
+ );
+}
diff --git a/src/components/game/MessageList.tsx b/src/components/game/MessageList.tsx
new file mode 100644
index 0000000..f144131
--- /dev/null
+++ b/src/components/game/MessageList.tsx
@@ -0,0 +1,208 @@
+import React from "react";
+import ReactMarkdown from "react-markdown";
+import type { ChatMessage } from "../../types";
+
+interface MessageItemProps {
+ message: ChatMessage;
+ isEditing: boolean;
+ editContent: string;
+ isLoading: boolean;
+ onEditContentChange: (content: string) => void;
+ onEditMessage: (messageId: string, content: string) => void;
+ onCancelEdit: () => void;
+ onSaveEdit: (messageId: string) => void;
+ onSwitchVersion: (messageId: string, direction: "prev" | "next") => void;
+}
+
+export function MessageItem({
+ message,
+ isEditing,
+ editContent,
+ isLoading,
+ onEditContentChange,
+ onEditMessage,
+ onCancelEdit,
+ onSaveEdit,
+ onSwitchVersion,
+}: MessageItemProps) {
+ if (isEditing) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {message.content}
+
+
+
+ {new Date(message.timestamp).toLocaleTimeString("ru-RU", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ {message.role === "user" && !isLoading && (
+
+ {message.versions && message.versions.length > 1 && (
+
+
+
+ {(message.activeVersion || 0) + 1}/{message.versions.length}
+
+
+
+ )}
+
+
+ )}
+
+
+ );
+}
+
+interface StreamingMessageProps {
+ content: string;
+}
+
+export function StreamingMessage({ content }: StreamingMessageProps) {
+ return (
+
+ );
+}
+
+export function LoadingMessage() {
+ return (
+
+ );
+}
+
+interface ErrorMessageProps {
+ error: string;
+ onDismiss: () => void;
+}
+
+export function ErrorMessage({ error, onDismiss }: ErrorMessageProps) {
+ return (
+
+ ⚠️ {error}
+
+
+ );
+}
+
+interface MessageListProps {
+ messages: ChatMessage[];
+ streamingContent: string;
+ isLoading: boolean;
+ error: string | null;
+ editingMessageId: string | null;
+ editContent: string;
+ onEditContentChange: (content: string) => void;
+ onEditMessage: (messageId: string, content: string) => void;
+ onCancelEdit: () => void;
+ onSaveEdit: (messageId: string) => void;
+ onSwitchVersion: (messageId: string, direction: "prev" | "next") => void;
+ onDismissError: () => void;
+ messagesEndRef: React.RefObject;
+ messagesContainerRef: React.RefObject;
+ onScroll: () => void;
+}
+
+export function MessageList({
+ messages,
+ streamingContent,
+ isLoading,
+ error,
+ editingMessageId,
+ editContent,
+ onEditContentChange,
+ onEditMessage,
+ onCancelEdit,
+ onSaveEdit,
+ onSwitchVersion,
+ onDismissError,
+ messagesEndRef,
+ messagesContainerRef,
+ onScroll,
+}: MessageListProps) {
+ return (
+
+ {messages.map((message) => (
+
+ ))}
+
+ {isLoading && streamingContent && (
+
+ )}
+
+ {isLoading && !streamingContent &&
}
+
+ {error &&
}
+
+
+
+ );
+}
diff --git a/src/components/game/SessionSelector.tsx b/src/components/game/SessionSelector.tsx
new file mode 100644
index 0000000..15479c3
--- /dev/null
+++ b/src/components/game/SessionSelector.tsx
@@ -0,0 +1,94 @@
+import React, { useState } from "react";
+import type { SessionListItem } from "../../services/api";
+
+interface SessionSelectorProps {
+ sessions: SessionListItem[];
+ currentSessionId: string | null;
+ currentSessionName: string;
+ onCreateNew: () => void;
+ onSwitch: (sessionId: string) => void;
+ onDelete: (sessionId: string, sessionName: string) => void;
+}
+
+export function SessionSelector({
+ sessions,
+ currentSessionId,
+ currentSessionName,
+ onCreateNew,
+ onSwitch,
+ onDelete,
+}: SessionSelectorProps) {
+ const [showMenu, setShowMenu] = useState(false);
+
+ const handleSwitch = (sessionId: string) => {
+ setShowMenu(false);
+ onSwitch(sessionId);
+ };
+
+ const handleDelete = (
+ e: React.MouseEvent,
+ sessionId: string,
+ sessionName: string,
+ ) => {
+ e.stopPropagation();
+ onDelete(sessionId, sessionName);
+ };
+
+ const handleCreateNew = () => {
+ setShowMenu(false);
+ onCreateNew();
+ };
+
+ return (
+
+
+
+ {showMenu && (
+
+
+ Сессии
+
+
+
+ {sessions.map((s) => (
+
+
+ {sessions.length > 1 && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/game/index.ts b/src/components/game/index.ts
new file mode 100644
index 0000000..5a0133f
--- /dev/null
+++ b/src/components/game/index.ts
@@ -0,0 +1,10 @@
+export {
+ MessageList,
+ MessageItem,
+ StreamingMessage,
+ LoadingMessage,
+ ErrorMessage,
+} from "./MessageList";
+export { ChatInput } from "./ChatInput";
+export { SessionSelector } from "./SessionSelector";
+export { CharacterPanel } from "./CharacterPanel";
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
index eab4031..376d50c 100644
--- a/src/contexts/AuthContext.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useContext,
@@ -50,6 +51,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (params.get("auth") === "success") {
// Убираем параметр из URL
window.history.replaceState({}, "", window.location.pathname);
+ // Refresh user after OAuth - intentional cascading render
+ // eslint-disable-next-line react-hooks/set-state-in-effect
refreshUser();
}
}, []);
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..0fca018
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1,4 @@
+export { useCharacterDetection } from "./useCharacterDetection";
+export { useStreamingResponse } from "./useStreamingResponse";
+export { useGameSession } from "./useGameSession";
+export { useLazyMessages } from "./useLazyMessages";
diff --git a/src/hooks/useCharacterDetection.ts b/src/hooks/useCharacterDetection.ts
new file mode 100644
index 0000000..79d147f
--- /dev/null
+++ b/src/hooks/useCharacterDetection.ts
@@ -0,0 +1,57 @@
+import { useMemo, useCallback } from "react";
+import type { Character } from "../types";
+
+/**
+ * Hook for detecting active character from message content
+ * Looks for character names in the first 200 characters of text
+ */
+export function useCharacterDetection(
+ characters: Character[] | undefined,
+ lastAssistantContent: string | undefined,
+ streamingContent: string | undefined,
+) {
+ const detectActiveCharacter = useCallback(
+ (content: string): Character | null => {
+ if (!characters || characters.length === 0) return null;
+
+ const contentLower = content.toLowerCase();
+
+ for (const char of characters) {
+ if (!char.name) continue;
+
+ // Split name into parts (e.g., "Princess Lapis" -> ["princess", "lapis"])
+ const nameParts = char.name.toLowerCase().split(/\s+/);
+
+ for (const namePart of nameParts) {
+ if (namePart.length < 3) continue; // Skip short words
+
+ // Create regex to match whole word only
+ const wordBoundary = new RegExp(
+ `(^|[\\s.,!?;:"'«»—\\-])${namePart}([\\s.,!?;:"'«»—\\-]|$)`,
+ "i",
+ );
+
+ // Check if name appears as a whole word in first 200 characters
+ const firstPart = contentLower.substring(0, 200);
+ if (wordBoundary.test(firstPart)) {
+ return char;
+ }
+ }
+ }
+ return null;
+ },
+ [characters],
+ );
+
+ // Compute active character from streaming or last message content
+ const activeCharacter = useMemo(() => {
+ // Priority: streaming content > last assistant message
+ const content = streamingContent || lastAssistantContent;
+ if (!content) return null;
+
+ const detected = detectActiveCharacter(content);
+ return detected?.avatarUrl ? detected : null;
+ }, [streamingContent, lastAssistantContent, detectActiveCharacter]);
+
+ return { activeCharacter, detectActiveCharacter };
+}
diff --git a/src/hooks/useGameSession.ts b/src/hooks/useGameSession.ts
new file mode 100644
index 0000000..a4dcafb
--- /dev/null
+++ b/src/hooks/useGameSession.ts
@@ -0,0 +1,300 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import {
+ getStory,
+ getSessionsList,
+ getSession,
+ createSession,
+ saveSession as apiSaveSession,
+ deleteSession,
+ getPlayerCharacter,
+ getPlayerCharacters,
+ type SessionListItem,
+} from "../services/api";
+import type { Story, GameSession, PlayerCharacter } from "../types";
+
+interface UseGameSessionResult {
+ story: Story | null;
+ session: GameSession | null;
+ sessionsList: SessionListItem[];
+ currentSessionId: string | null;
+ playerCharacter: PlayerCharacter | null;
+ isInitialLoading: boolean;
+
+ // Actions
+ setSession: (session: GameSession | null) => void;
+ saveSession: (session: GameSession) => Promise;
+ createNewSession: (characterId?: string) => Promise;
+ switchSession: (sessionId: string) => Promise;
+ removeSession: (sessionId: string) => Promise;
+ reloadStory: () => Promise;
+
+ // For tracking unsaved changes
+ markUnsaved: () => void;
+ markSaved: () => void;
+}
+
+interface UseGameSessionOptions {
+ storyId: string | undefined;
+ characterIdFromUrl: string | null;
+ isAuthenticated: boolean;
+}
+
+/**
+ * Hook for managing game sessions, loading, saving, and session switching
+ */
+export function useGameSession({
+ storyId,
+ characterIdFromUrl,
+ isAuthenticated,
+}: UseGameSessionOptions): UseGameSessionResult {
+ const [story, setStory] = useState(null);
+ const [sessionsList, setSessionsList] = useState([]);
+ const [currentSessionId, setCurrentSessionId] = useState(null);
+ const [session, setSession] = useState(null);
+ const [playerCharacter, setPlayerCharacter] =
+ useState(null);
+ const [isInitialLoading, setIsInitialLoading] = useState(true);
+
+ const hasUnsavedChangesRef = useRef(false);
+ const sessionRef = useRef(null);
+
+ // Keep sessionRef in sync
+ useEffect(() => {
+ sessionRef.current = session;
+ }, [session]);
+
+ // Mark as unsaved
+ const markUnsaved = useCallback(() => {
+ hasUnsavedChangesRef.current = true;
+ }, []);
+
+ // Mark as saved
+ const markSaved = useCallback(() => {
+ hasUnsavedChangesRef.current = false;
+ }, []);
+
+ // Warn before leaving if there are unsaved changes
+ useEffect(() => {
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (hasUnsavedChangesRef.current) {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ };
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, []);
+
+ // Save session to API
+ const saveSession = useCallback(
+ async (sessionData: GameSession) => {
+ if (!story || !currentSessionId) return;
+ await apiSaveSession(story.id, currentSessionId, sessionData);
+ hasUnsavedChangesRef.current = false;
+ },
+ [story, currentSessionId],
+ );
+
+ // Reload story data (e.g., after editing)
+ const reloadStory = useCallback(async () => {
+ if (!storyId || !isAuthenticated) return;
+ const updatedStory = await getStory(storyId);
+ if (updatedStory) {
+ const storyWithMongoId = updatedStory as Story & { _id?: string };
+ const normalizedStory = {
+ ...updatedStory,
+ id: storyWithMongoId._id || updatedStory.id,
+ };
+ setStory(normalizedStory);
+ }
+ }, [storyId, isAuthenticated]);
+
+ // Create new session
+ const createNewSession = useCallback(
+ async (characterId?: string): Promise => {
+ if (!story || !storyId) return null;
+
+ const charId = characterId || playerCharacter?.id || session?.playerId;
+ if (!charId) return null;
+
+ const name = `Сессия ${sessionsList.length + 1}`;
+ const newSession = await createSession(storyId, name, charId);
+
+ if (newSession) {
+ setSessionsList((prev) => [newSession, ...prev]);
+ setCurrentSessionId(newSession.id);
+
+ const sessionData = await getSession(storyId, newSession.id);
+ if (sessionData) {
+ setSession(sessionData);
+
+ // Load character if not already loaded
+ if (!playerCharacter && charId) {
+ const character = await getPlayerCharacter(charId);
+ setPlayerCharacter(character);
+ }
+ }
+ return newSession;
+ }
+ return null;
+ },
+ [story, storyId, sessionsList, playerCharacter, session?.playerId],
+ );
+
+ // Switch to different session
+ const switchSession = useCallback(
+ async (sessionId: string) => {
+ if (!storyId || sessionId === currentSessionId) return;
+
+ setIsInitialLoading(true);
+ const sessionData = await getSession(storyId, sessionId);
+
+ if (sessionData) {
+ setCurrentSessionId(sessionId);
+ setSession(sessionData);
+
+ // Load session's character
+ if (sessionData.playerId) {
+ const character = await getPlayerCharacter(sessionData.playerId);
+ setPlayerCharacter(character);
+ }
+ }
+ setIsInitialLoading(false);
+ },
+ [storyId, currentSessionId],
+ );
+
+ // Delete session
+ const removeSession = useCallback(
+ async (sessionId: string): Promise => {
+ if (!storyId) return false;
+
+ const success = await deleteSession(storyId, sessionId);
+ if (success) {
+ const newList = sessionsList.filter((s) => s.id !== sessionId);
+ setSessionsList(newList);
+
+ // If deleted current session, switch to first remaining
+ if (sessionId === currentSessionId && newList.length > 0) {
+ await switchSession(newList[0].id);
+ }
+ return true;
+ }
+ return false;
+ },
+ [storyId, sessionsList, currentSessionId, switchSession],
+ );
+
+ // Initial load
+ useEffect(() => {
+ const loadGame = async () => {
+ if (!storyId || !isAuthenticated) {
+ setIsInitialLoading(false);
+ return;
+ }
+
+ const foundStory = await getStory(storyId);
+ if (!foundStory) {
+ setIsInitialLoading(false);
+ return;
+ }
+
+ const storyWithMongoId = foundStory as Story & { _id?: string };
+ const normalizedStory = {
+ ...foundStory,
+ id: storyWithMongoId._id || foundStory.id,
+ };
+ setStory(normalizedStory);
+
+ // Load sessions list
+ const sessions = await getSessionsList(storyId);
+ setSessionsList(sessions);
+
+ // Load character
+ let character: PlayerCharacter | null = null;
+ if (characterIdFromUrl) {
+ character = await getPlayerCharacter(characterIdFromUrl);
+ setPlayerCharacter(character);
+ } else {
+ const characters = await getPlayerCharacters();
+ if (characters.length > 0) {
+ character = characters.find((c) => c.isFavorite) || characters[0];
+ setPlayerCharacter(character);
+ }
+ }
+
+ // Load or create session
+ if (sessions.length > 0) {
+ const latestSession = sessions[0];
+ setCurrentSessionId(latestSession.id);
+ const sessionData = await getSession(storyId, latestSession.id);
+
+ if (sessionData) {
+ setSession(sessionData);
+
+ // Load character from session if not already loaded
+ if (!character && sessionData.playerId) {
+ character = await getPlayerCharacter(sessionData.playerId);
+ setPlayerCharacter(character);
+ }
+
+ // Update session with character if needed
+ if (!sessionData.playerId && character) {
+ const updatedSession = { ...sessionData, playerId: character.id };
+ await apiSaveSession(storyId, latestSession.id, updatedSession);
+ setSession(updatedSession);
+ }
+ }
+ } else if (characterIdFromUrl) {
+ // No sessions and character selected — create new
+ const newSession = await createSession(
+ storyId,
+ undefined,
+ characterIdFromUrl,
+ );
+ if (newSession) {
+ setSessionsList([newSession]);
+ setCurrentSessionId(newSession.id);
+ const sessionData = await getSession(storyId, newSession.id);
+ if (sessionData) {
+ setSession(sessionData);
+ }
+ }
+ }
+
+ setIsInitialLoading(false);
+ };
+
+ loadGame();
+ }, [storyId, isAuthenticated, characterIdFromUrl]);
+
+ // Reload story on focus (after editing)
+ useEffect(() => {
+ const handleFocus = () => {
+ if (!storyId || !isAuthenticated || isInitialLoading) return;
+ reloadStory();
+ };
+
+ window.addEventListener("focus", handleFocus);
+ return () => window.removeEventListener("focus", handleFocus);
+ }, [storyId, isAuthenticated, isInitialLoading, reloadStory]);
+
+ return {
+ story,
+ session,
+ sessionsList,
+ currentSessionId,
+ playerCharacter,
+ isInitialLoading,
+
+ setSession,
+ saveSession,
+ createNewSession,
+ switchSession,
+ removeSession,
+ reloadStory,
+
+ markUnsaved,
+ markSaved,
+ };
+}
diff --git a/src/hooks/useLazyMessages.ts b/src/hooks/useLazyMessages.ts
new file mode 100644
index 0000000..0e0a158
--- /dev/null
+++ b/src/hooks/useLazyMessages.ts
@@ -0,0 +1,121 @@
+import { useState, useCallback, useRef, useMemo } from "react";
+import type { ChatMessage } from "../types";
+
+const INITIAL_VISIBLE_COUNT = 20;
+const LOAD_MORE_COUNT = 20;
+
+interface UseLazyMessagesResult {
+ visibleMessages: ChatMessage[];
+ hasHiddenMessages: boolean;
+ hiddenCount: number;
+ loadMore: () => void;
+ loadAll: () => void;
+ handleScroll: (container: HTMLElement | null) => void;
+ totalCount: number;
+}
+
+/**
+ * Hook for lazy loading messages - shows only recent messages by default,
+ * loads more when scrolling up or clicking "Load more".
+ * Uses sliding window - when new messages arrive, old ones are hidden again.
+ */
+export function useLazyMessages(
+ messages: ChatMessage[] | undefined,
+ sessionId: string | null,
+): UseLazyMessagesResult {
+ // Track how many extra messages to show (beyond INITIAL_VISIBLE_COUNT)
+ const [stateKey, setStateKey] = useState({
+ sessionId,
+ extraVisible: 0,
+ prevTotalCount: 0,
+ });
+ const isLoadingMoreRef = useRef(false);
+
+ const totalCount = messages?.length || 0;
+
+ // Reset extraVisible when session changes OR when new messages arrive
+ let currentExtraVisible = stateKey.extraVisible;
+
+ if (sessionId !== stateKey.sessionId) {
+ // Session changed - reset everything
+ currentExtraVisible = 0;
+ setStateKey({ sessionId, extraVisible: 0, prevTotalCount: totalCount });
+ } else if (
+ totalCount > stateKey.prevTotalCount &&
+ stateKey.prevTotalCount > 0
+ ) {
+ // New message arrived - reset to show only last 20
+ currentExtraVisible = 0;
+ setStateKey((prev) => ({
+ ...prev,
+ extraVisible: 0,
+ prevTotalCount: totalCount,
+ }));
+ } else if (totalCount !== stateKey.prevTotalCount) {
+ // Total count changed (could be initial load) - just update the count
+ setStateKey((prev) => ({ ...prev, prevTotalCount: totalCount }));
+ }
+
+ // Sliding window: always show last INITIAL_VISIBLE_COUNT + extraVisible messages
+ const effectiveVisibleCount = useMemo(() => {
+ return Math.min(INITIAL_VISIBLE_COUNT + currentExtraVisible, totalCount);
+ }, [totalCount, currentExtraVisible]);
+
+ const startIndex = Math.max(0, totalCount - effectiveVisibleCount);
+ const visibleMessages = messages?.slice(startIndex) || [];
+ const hasHiddenMessages = startIndex > 0;
+ const hiddenCount = startIndex;
+
+ const loadMore = useCallback(() => {
+ setStateKey((prev) => ({
+ ...prev,
+ extraVisible: prev.extraVisible + LOAD_MORE_COUNT,
+ }));
+ }, []);
+
+ const loadAll = useCallback(() => {
+ setStateKey((prev) => ({ ...prev, extraVisible: totalCount }));
+ }, [totalCount]);
+
+ // Handle scroll - load more when reaching top (with cooldown to prevent rapid loading)
+ const handleScroll = useCallback(
+ (container: HTMLElement | null) => {
+ if (!container || !hasHiddenMessages || isLoadingMoreRef.current) return;
+
+ const { scrollTop } = container;
+
+ // If scrolled near top (within 100px), load more
+ if (scrollTop < 100) {
+ isLoadingMoreRef.current = true;
+
+ // Remember current scroll position to maintain it after loading
+ const prevScrollHeight = container.scrollHeight;
+
+ loadMore();
+
+ // After DOM update, restore scroll position and add cooldown
+ requestAnimationFrame(() => {
+ const newScrollHeight = container.scrollHeight;
+ const scrollDiff = newScrollHeight - prevScrollHeight;
+ container.scrollTop = scrollTop + scrollDiff;
+
+ // Add 500ms cooldown to prevent rapid loading
+ setTimeout(() => {
+ isLoadingMoreRef.current = false;
+ }, 500);
+ });
+ }
+ },
+ [hasHiddenMessages, loadMore],
+ );
+
+ return {
+ visibleMessages,
+ hasHiddenMessages,
+ hiddenCount,
+ loadMore,
+ loadAll,
+ handleScroll,
+ totalCount,
+ };
+}
diff --git a/src/hooks/useStreamingResponse.ts b/src/hooks/useStreamingResponse.ts
new file mode 100644
index 0000000..3581e5c
--- /dev/null
+++ b/src/hooks/useStreamingResponse.ts
@@ -0,0 +1,101 @@
+import { useState, useRef, useCallback } from "react";
+
+interface UseStreamingResponseResult {
+ streamingContent: string;
+ isStreaming: boolean;
+ updateStreamingContent: (chunk: string) => void;
+ flushStreamingContent: () => void;
+ resetStreaming: () => void;
+ startStreaming: () => AbortSignal;
+ abortController: React.MutableRefObject;
+ abort: () => void;
+ getLatestContent: () => string;
+}
+
+/**
+ * Hook for handling streaming AI responses with throttling
+ * Throttles updates to every 50ms to avoid excessive re-renders
+ */
+export function useStreamingResponse(): UseStreamingResponseResult {
+ const [streamingContent, setStreamingContent] = useState("");
+ const [isStreaming, setIsStreaming] = useState(false);
+
+ const streamingBufferRef = useRef("");
+ const lastUpdateRef = useRef(0);
+ const abortControllerRef = useRef(null);
+
+ // Start streaming - creates new AbortController and returns its signal
+ const startStreaming = useCallback(() => {
+ // Abort any existing request
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ // Create new controller
+ abortControllerRef.current = new AbortController();
+ setIsStreaming(true);
+ streamingBufferRef.current = "";
+ setStreamingContent("");
+ return abortControllerRef.current.signal;
+ }, []);
+
+ // Throttled streaming update (every 50ms instead of every chunk)
+ const updateStreamingContent = useCallback((chunk: string) => {
+ streamingBufferRef.current += chunk;
+ setIsStreaming(true);
+
+ const now = Date.now();
+ if (now - lastUpdateRef.current > 50) {
+ setStreamingContent(streamingBufferRef.current);
+ lastUpdateRef.current = now;
+ }
+ }, []);
+
+ // Flush remaining content
+ const flushStreamingContent = useCallback(() => {
+ if (streamingBufferRef.current) {
+ setStreamingContent(streamingBufferRef.current);
+ }
+ }, []);
+
+ // Reset streaming state
+ const resetStreaming = useCallback(() => {
+ setStreamingContent("");
+ setIsStreaming(false);
+ streamingBufferRef.current = "";
+ lastUpdateRef.current = 0;
+ abortControllerRef.current = null;
+ }, []);
+
+ // Abort streaming
+ const abort = useCallback(() => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ }, []);
+
+ // Get latest content from buffer (avoids stale closure issues)
+ const getLatestContent = useCallback(() => {
+ return streamingBufferRef.current;
+ }, []);
+
+ return {
+ streamingContent,
+ isStreaming,
+ updateStreamingContent,
+ flushStreamingContent,
+ resetStreaming,
+ startStreaming,
+ abortController: abortControllerRef,
+ abort,
+ getLatestContent,
+ };
+}
+
+/**
+ * Get current buffer content (for auto-save during streaming)
+ */
+export function getStreamingBuffer(
+ bufferRef: React.MutableRefObject,
+): string {
+ return bufferRef.current;
+}
diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx
index 15036b7..7372d1d 100644
--- a/src/pages/AdminPage.tsx
+++ b/src/pages/AdminPage.tsx
@@ -43,7 +43,7 @@ export default function AdminPage() {
} else {
setError("Не удалось загрузить статистику");
}
- } catch (err) {
+ } catch {
setError("Ошибка загрузки данных");
} finally {
setIsLoading(false);
diff --git a/src/pages/GamePage.css b/src/pages/GamePage.css
index 37354d5..31a461c 100644
--- a/src/pages/GamePage.css
+++ b/src/pages/GamePage.css
@@ -289,6 +289,28 @@
box-sizing: border-box;
}
+.load-more-messages-btn {
+ align-self: center;
+ padding: 0.5rem 1rem;
+ margin-bottom: 0.5rem;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 20px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.load-more-messages-btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.load-more-messages-btn:active {
+ transform: scale(0.98);
+}
+
.message {
max-width: 88%;
animation: fadeIn 0.25s ease;
@@ -710,6 +732,48 @@
background: #b91c1c;
}
+/* OOC Mode Button */
+.ooc-btn {
+ padding: 0.4rem 0.6rem;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 12px;
+ color: #888;
+ font-size: 0.7rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ flex-shrink: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.ooc-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: #aaa;
+}
+
+.ooc-btn.active {
+ background: #f59e0b;
+ border-color: #f59e0b;
+ color: #000;
+}
+
+.ooc-btn.active:hover {
+ background: #d97706;
+ border-color: #d97706;
+}
+
+/* OOC Mode Input Container */
+.input-container.ooc-mode {
+ border-color: #f59e0b;
+ background: rgba(245, 158, 11, 0.1);
+}
+
+.input-container.ooc-mode textarea::placeholder {
+ color: #f59e0b;
+}
+
/* Streaming message animation */
.message.streaming .message-text {
position: relative;
diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx
index 17aa419..3cd6bd1 100644
--- a/src/pages/GamePage.tsx
+++ b/src/pages/GamePage.tsx
@@ -1,18 +1,8 @@
-import { useState, useEffect, useRef, useCallback } from "react";
+import { useState, useEffect, useRef } from "react";
import { useParams, Link, useSearchParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
-import {
- getStory,
- getSessionsList,
- getSession,
- createSession,
- saveSession as apiSaveSession,
- deleteSession,
- getPlayerCharacter,
- getPlayerCharacters,
- type SessionListItem,
-} from "../services/api";
+import { saveSession as apiSaveSession } from "../services/api";
import {
generateStoryResponseStream,
buildSystemPrompt,
@@ -24,17 +14,22 @@ import type {
Story,
GameSession,
ChatMessage,
- PlayerCharacter,
MessageVersion,
- Character,
+ PlayerCharacter,
} from "../types";
+import {
+ useGameSession,
+ useStreamingResponse,
+ useCharacterDetection,
+ useLazyMessages,
+} from "../hooks";
+import { SessionSelector, CharacterPanel } from "../components/game";
import "./GamePage.css";
function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
-// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
function estimateTokens(messages: ChatMessage[]): number {
if (!messages || messages.length === 0) return 0;
const totalChars = messages.reduce((sum, msg) => sum + msg.content.length, 0);
@@ -51,111 +46,88 @@ export default function GamePage() {
const { id } = useParams<{ id: string }>();
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);
+
+ // Game session hook
+ const {
+ story,
+ session,
+ sessionsList,
+ currentSessionId,
+ playerCharacter,
+ isInitialLoading,
+ setSession,
+ createNewSession,
+ switchSession,
+ removeSession,
+ markUnsaved,
+ } = useGameSession({
+ storyId: id,
+ characterIdFromUrl: searchParams.get("character"),
+ isAuthenticated,
+ });
+
+ // Streaming response hook
+ const {
+ streamingContent,
+ updateStreamingContent,
+ flushStreamingContent,
+ resetStreaming,
+ startStreaming,
+ abortController,
+ abort,
+ getLatestContent,
+ } = useStreamingResponse();
+
+ // Character detection hook
+ const lastAssistantContent = session?.messages
+ ?.slice()
+ .reverse()
+ .find((m) => m.role === "assistant")?.content;
+
+ const { activeCharacter } = useCharacterDetection(
+ story?.characters || [],
+ lastAssistantContent,
+ streamingContent,
+ );
+
+ // Lazy messages hook for performance
+ const {
+ visibleMessages,
+ hasHiddenMessages,
+ hiddenCount,
+ loadMore: loadMoreMessages,
+ handleScroll: handleLazyScroll,
+ totalCount,
+ } = useLazyMessages(session?.messages, currentSessionId);
+
+ // Track previous totalCount to detect new messages (start at 0 to trigger initial scroll)
+ const prevTotalCountRef = useRef(0);
+
+ // Local state
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
- const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState(null);
- const [streamingContent, setStreamingContent] = useState("");
- const [showSessionMenu, setShowSessionMenu] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false);
const [editingMessageId, setEditingMessageId] = useState(null);
const [editContent, setEditContent] = useState("");
- const [activeCharacter, setActiveCharacter] = useState(
- null,
- );
- const abortControllerRef = useRef(null);
+ const [isOocMode, setIsOocMode] = useState(false);
+
+ // Refs
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const inputRef = useRef(null);
- // Refs for throttled streaming updates
- const streamingBufferRef = useRef("");
- const lastUpdateRef = useRef(0);
- // Ref to track latest session for auto-save
const sessionRef = useRef(null);
const pendingMessagesRef = useRef([]);
- const lastAutoSaveRef = useRef(0);
- const hasUnsavedChangesRef = useRef(false);
// Keep sessionRef in sync
useEffect(() => {
sessionRef.current = session;
}, [session]);
- // Determine active character from message content
- const detectActiveCharacter = useCallback(
- (content: string, characters: Character[]): Character | null => {
- if (!characters || characters.length === 0) return null;
-
- const contentLower = content.toLowerCase();
-
- // Look for character name at the beginning of message or in dialogue
- for (const char of characters) {
- if (!char.name) continue;
-
- // Split name into parts and check each (e.g., "Принцеса Лапис" -> ["принцеса", "лапис"])
- const nameParts = char.name.toLowerCase().split(/\s+/);
-
- for (const namePart of nameParts) {
- if (namePart.length < 3) continue; // Skip short words like "и", "в", etc.
-
- // Create regex to match whole word only
- const wordBoundary = new RegExp(
- `(^|[\\s.,!?;:"'«»—\\-])${namePart}([\\s.,!?;:"'«»—\\-]|$)`,
- "i",
- );
-
- // Check if name appears as a whole word in first 200 characters
- const firstPart = contentLower.substring(0, 200);
- if (wordBoundary.test(firstPart)) {
- return char;
- }
- }
- }
- return null;
- },
- [],
- );
-
- // Update active character based on last assistant message
- useEffect(() => {
- if (!story?.characters || !session?.messages) return;
-
- // Find last assistant message
- const lastAssistantMsg = [...session.messages]
- .reverse()
- .find((m) => m.role === "assistant");
-
- if (lastAssistantMsg) {
- const detected = detectActiveCharacter(
- lastAssistantMsg.content,
- story.characters,
- );
- if (detected && detected.avatarUrl) {
- setActiveCharacter(detected);
- }
- }
- }, [session?.messages, story?.characters, detectActiveCharacter]);
-
- // Also update during streaming
- useEffect(() => {
- if (!story?.characters || !streamingContent) return;
-
- const detected = detectActiveCharacter(streamingContent, story.characters);
- if (detected && detected.avatarUrl) {
- setActiveCharacter(detected);
- }
- }, [streamingContent, story?.characters, detectActiveCharacter]);
-
- // Warn before leaving if there are unsaved changes
+ // Warn before leaving during loading
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
- if (hasUnsavedChangesRef.current || isLoading) {
+ if (isLoading) {
e.preventDefault();
e.returnValue = "";
}
@@ -164,145 +136,63 @@ export default function GamePage() {
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isLoading]);
- // Throttled streaming update (every 50ms instead of every chunk)
- const updateStreamingContent = useCallback((chunk: string) => {
- streamingBufferRef.current += chunk;
- const now = Date.now();
- if (now - lastUpdateRef.current > 50) {
- setStreamingContent(streamingBufferRef.current);
- lastUpdateRef.current = now;
- }
- }, []);
-
- // Flush remaining content
- const flushStreamingContent = useCallback(() => {
- if (streamingBufferRef.current) {
- setStreamingContent(streamingBufferRef.current);
- }
- }, []);
-
+ // Initial load effect - start story if needed
useEffect(() => {
- const loadGame = async () => {
- if (!id || !isAuthenticated) {
- setIsInitialLoading(false);
- return;
- }
-
- const foundStory = await getStory(id);
- if (foundStory) {
- const normalizedStory = {
- ...foundStory,
- id: (foundStory as any)._id || foundStory.id,
- };
- setStory(normalizedStory);
-
- // Загружаем список сессий
- const sessions = await getSessionsList(id);
- setSessionsList(sessions);
-
- const characterId = searchParams.get("character");
-
- // Загружаем персонажа
- let character: PlayerCharacter | null = null;
- if (characterId) {
- character = await getPlayerCharacter(characterId);
- setPlayerCharacter(character);
- } else {
- // Если персонаж не указан в URL, загружаем первого доступного (или избранного)
- const characters = await getPlayerCharacters();
- if (characters.length > 0) {
- character = characters.find((c) => c.isFavorite) || characters[0];
- setPlayerCharacter(character);
- }
- }
-
- // Если есть сессии, загружаем последнюю (или создаём новую)
- if (sessions.length > 0) {
- const latestSession = sessions[0];
- setCurrentSessionId(latestSession.id);
- const sessionData = await getSession(id, latestSession.id);
- if (sessionData) {
- setSession(sessionData);
- // Загружаем персонажа: приоритет URL > сессия
- if (!character && sessionData.playerId) {
- character = await getPlayerCharacter(sessionData.playerId);
- setPlayerCharacter(character);
- }
- // Если в сессии нет playerId, но персонаж выбран в URL — обновляем сессию
- if (!sessionData.playerId && character) {
- const updatedSession = { ...sessionData, playerId: character.id };
- await apiSaveSession(id, latestSession.id, updatedSession);
- setSession(updatedSession);
- }
- // Если сессия пустая (нет сообщений) — запускаем историю
- if (sessionData.messages.length === 0 && character) {
- startStory(
- normalizedStory,
- sessionData,
- character,
- latestSession.id,
- );
- }
- }
- } 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);
- };
-
- loadGame();
- }, [id, isAuthenticated, searchParams]);
-
- // Обновляем историю при возврате на страницу (после редактирования)
- useEffect(() => {
- const handleFocus = async () => {
- if (!id || !isAuthenticated || isInitialLoading) return;
-
- const updatedStory = await getStory(id);
- if (updatedStory) {
- const normalizedStory = {
- ...updatedStory,
- id: (updatedStory as any)._id || updatedStory.id,
- };
- setStory(normalizedStory);
+ const initStory = async () => {
+ if (!story || !session || !currentSessionId || !playerCharacter) return;
+ if (session.messages.length === 0) {
+ startStory(story, session, playerCharacter, currentSessionId);
}
};
+ initStory();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [story, session?.messages?.length, currentSessionId, playerCharacter]);
- window.addEventListener("focus", handleFocus);
- return () => window.removeEventListener("focus", handleFocus);
- }, [id, isAuthenticated, isInitialLoading]);
-
+ // Scroll effects - scroll to bottom only when NEW messages are added
useEffect(() => {
- scrollToBottom();
- }, [session?.messages]);
+ // Only scroll if totalCount increased (new message added)
+ // Don't scroll when loading more old messages (which increases visibleMessages but not totalCount)
+ if (totalCount > prevTotalCountRef.current) {
+ const timer = setTimeout(() => scrollToBottom(), 50);
+ prevTotalCountRef.current = totalCount;
+ return () => clearTimeout(timer);
+ }
+ // Don't update ref here - only update when we actually scroll or on session change
+ }, [totalCount, currentSessionId]);
+
+ // Auto-scroll during streaming
+ useEffect(() => {
+ if (isLoading && streamingContent) {
+ // Use instant scroll during streaming to avoid lag
+ messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
+ }
+ }, [isLoading, streamingContent]);
- // Scroll to bottom on session load
useEffect(() => {
if (session && !isInitialLoading) {
- setTimeout(() => scrollToBottom(), 100);
+ // Reset prevTotalCountRef for new session and scroll to bottom
+ prevTotalCountRef.current = session.messages?.length || 0;
+ // Scroll multiple times with delays to ensure DOM is ready
+ const t1 = setTimeout(() => scrollToBottom(true), 100);
+ const t2 = setTimeout(() => scrollToBottom(true), 300);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ };
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSessionId, isInitialLoading]);
- const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ const scrollToBottom = (instant = false) => {
+ // Try scrollIntoView first
+ messagesEndRef.current?.scrollIntoView({
+ behavior: instant ? "auto" : "smooth",
+ });
+ // Also set scrollTop as fallback
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTop =
+ messagesContainerRef.current.scrollHeight;
+ }
};
const handleScroll = () => {
@@ -311,17 +201,19 @@ export default function GamePage() {
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
setShowScrollButton(distanceFromBottom > 200);
+
+ // Lazy load more messages when scrolling up
+ handleLazyScroll(container);
};
const startStory = async (
storyData: Story,
sessionData: GameSession,
- character: PlayerCharacter,
+ character: PlayerCharacter | null,
sessionId: string,
) => {
- // Если есть заготовленное первое сообщение, используем его
- if (storyData.firstMessage && storyData.firstMessage.trim()) {
- // Заменяем {user} на имя персонажа
+ if (!character) return;
+ if (storyData.firstMessage?.trim()) {
const firstMessageContent = storyData.firstMessage.replace(
/\{user\}/gi,
character.name,
@@ -337,13 +229,11 @@ export default function GamePage() {
...sessionData,
messages: [assistantMessage],
};
-
await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession);
return;
}
- // Иначе генерируем через ИИ
setIsLoading(true);
setError(null);
@@ -365,7 +255,6 @@ export default function GamePage() {
...sessionData,
messages: [assistantMessage],
};
-
await apiSaveSession(storyData.id, sessionId, updatedSession);
setSession(updatedSession);
} catch (err) {
@@ -382,7 +271,7 @@ export default function GamePage() {
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
- content: input.trim(),
+ content: isOocMode ? `[OOC: ${input.trim()}]` : input.trim(),
timestamp: new Date(),
};
@@ -390,51 +279,51 @@ export default function GamePage() {
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
pendingMessagesRef.current = updatedMessages;
- hasUnsavedChangesRef.current = true;
+ markUnsaved();
setInput("");
- // Reset textarea height
+
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
+
setIsLoading(true);
setError(null);
- setStreamingContent("");
- streamingBufferRef.current = "";
- lastAutoSaveRef.current = Date.now();
+ const signal = startStreaming();
// Immediately save user message
try {
await apiSaveSession(story.id, currentSessionId, tempSession);
- } catch (e) {
+ } catch {
// Continue even if initial save fails
}
- // Создаём AbortController для возможности отмены
- abortControllerRef.current = new AbortController();
-
// Auto-save interval during streaming
const autoSaveInterval = setInterval(async () => {
- if (streamingBufferRef.current.trim() && story && currentSessionId) {
- const partialMessage: ChatMessage = {
- id: generateId(),
- role: "assistant",
- content: streamingBufferRef.current,
- timestamp: new Date(),
- };
- const partialSession: GameSession = {
- ...sessionRef.current!,
- messages: [...pendingMessagesRef.current, partialMessage],
- };
- try {
- await apiSaveSession(story.id, currentSessionId, partialSession);
- } catch (e) {
- // Ignore save errors during streaming
+ if (abortController.current && story && currentSessionId) {
+ const currentContent =
+ document.querySelector(".message.streaming .message-content")
+ ?.textContent || "";
+ if (currentContent.trim()) {
+ const partialMessage: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: currentContent,
+ timestamp: new Date(),
+ };
+ const partialSession: GameSession = {
+ ...sessionRef.current!,
+ messages: [...pendingMessagesRef.current, partialMessage],
+ };
+ try {
+ await apiSaveSession(story.id, currentSessionId, partialSession);
+ } catch {
+ // Ignore save errors during streaming
+ }
}
}
- }, 5000); // Auto-save every 5 seconds
+ }, 5000);
try {
- // Streaming ответ от AI
const response = await generateStoryResponseStream(
story,
session.messages,
@@ -442,7 +331,7 @@ export default function GamePage() {
updateStreamingContent,
playerCharacter || undefined,
session,
- abortControllerRef.current.signal,
+ signal,
);
flushStreamingContent();
clearInterval(autoSaveInterval);
@@ -456,13 +345,11 @@ export default function GamePage() {
const allMessages = [...updatedMessages, assistantMessage];
- // Обновляем ключевые события
const newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
- // Генерируем сводку каждые 15 сообщений
let newSummary = session.storySummary;
if (allMessages.length % 15 === 0 && allMessages.length > 0) {
newSummary = await generateStorySummary(
@@ -481,16 +368,16 @@ export default function GamePage() {
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
- hasUnsavedChangesRef.current = false;
} catch (err) {
clearInterval(autoSaveInterval);
if (err instanceof Error && err.name === "AbortError") {
- // Пользователь отменил — сохраняем то что получили
- if (streamingBufferRef.current.trim()) {
+ // User cancelled — save what we got (use getLatestContent for fresh buffer)
+ const currentContent = getLatestContent();
+ if (currentContent.trim()) {
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
- content: streamingBufferRef.current,
+ content: currentContent,
timestamp: new Date(),
};
const partialSession: GameSession = {
@@ -499,7 +386,6 @@ export default function GamePage() {
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
- hasUnsavedChangesRef.current = false;
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
@@ -508,17 +394,14 @@ export default function GamePage() {
}
} finally {
setIsLoading(false);
- setStreamingContent("");
- abortControllerRef.current = null;
+ resetStreaming();
pendingMessagesRef.current = [];
inputRef.current?.focus();
}
};
const handleStop = () => {
- if (abortControllerRef.current) {
- abortControllerRef.current.abort();
- }
+ abort();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -528,19 +411,16 @@ export default function GamePage() {
}
};
- // Начать редактирование сообщения
const handleEditMessage = (messageId: string, content: string) => {
setEditingMessageId(messageId);
setEditContent(content);
};
- // Отменить редактирование
const handleCancelEdit = () => {
setEditingMessageId(null);
setEditContent("");
};
- // Сохранить редактирование и регенерировать ответ
const handleSaveEdit = async (messageId: string) => {
if (!session || !story || !currentSessionId || !editContent.trim()) return;
@@ -548,13 +428,10 @@ export default function GamePage() {
if (messageIndex === -1) return;
const message = session.messages[messageIndex];
-
- // Находим следующее сообщение ИИ (если есть)
const nextMessage = session.messages[messageIndex + 1];
const currentAiResponse =
nextMessage?.role === "assistant" ? nextMessage.content : undefined;
- // Инициализируем версии если их нет (первая версия = оригинал с текущим ответом ИИ)
const versions: MessageVersion[] = message.versions || [
{
content: message.content,
@@ -563,7 +440,6 @@ export default function GamePage() {
},
];
- // Если текущая версия не имеет aiResponse, добавляем его
const currentVersionIdx = message.activeVersion || 0;
if (
versions[currentVersionIdx] &&
@@ -576,7 +452,6 @@ export default function GamePage() {
};
}
- // Добавляем новую версию (aiResponse добавится после генерации)
const newVersion: MessageVersion = {
content: editContent.trim(),
timestamp: new Date(),
@@ -584,7 +459,6 @@ export default function GamePage() {
const newVersions: MessageVersion[] = [...versions, newVersion];
const newActiveVersion = newVersions.length - 1;
- // Обновляем сообщение пользователя
const updatedUserMessage: ChatMessage = {
...message,
content: editContent.trim(),
@@ -592,53 +466,49 @@ export default function GamePage() {
activeVersion: newActiveVersion,
};
- // Обрезаем историю до этого сообщения (удаляем последующие)
const messagesUpToEdit = session.messages.slice(0, messageIndex);
const updatedMessages = [...messagesUpToEdit, updatedUserMessage];
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
pendingMessagesRef.current = updatedMessages;
- hasUnsavedChangesRef.current = true;
+ markUnsaved();
setEditingMessageId(null);
setEditContent("");
setIsLoading(true);
setError(null);
- setStreamingContent("");
- streamingBufferRef.current = "";
+ const signal = startStreaming();
- abortControllerRef.current = new AbortController();
-
- // Save immediately
try {
await apiSaveSession(story.id, currentSessionId, tempSession);
- } catch (e) {
- // Continue even if initial save fails
+ } catch {
+ // Continue even if save fails
}
- // Auto-save interval during streaming
const autoSaveInterval = setInterval(async () => {
- if (streamingBufferRef.current.trim() && story && currentSessionId) {
- const partialMessage: ChatMessage = {
- id: generateId(),
- role: "assistant",
- content: streamingBufferRef.current,
- timestamp: new Date(),
- };
- const partialSession: GameSession = {
- ...sessionRef.current!,
- messages: [...pendingMessagesRef.current, partialMessage],
- };
- try {
- await apiSaveSession(story.id, currentSessionId, partialSession);
- } catch (e) {
- // Ignore save errors during streaming
+ if (abortController.current && story && currentSessionId) {
+ const currentContent = getLatestContent();
+ if (currentContent.trim()) {
+ const partialMessage: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: currentContent,
+ timestamp: new Date(),
+ };
+ const partialSession: GameSession = {
+ ...sessionRef.current!,
+ messages: [...pendingMessagesRef.current, partialMessage],
+ };
+ try {
+ await apiSaveSession(story.id, currentSessionId, partialSession);
+ } catch {
+ // Ignore
+ }
}
}
}, 5000);
try {
- // Генерируем новый ответ ИИ
const response = await generateStoryResponseStream(
story,
messagesUpToEdit,
@@ -646,12 +516,11 @@ export default function GamePage() {
updateStreamingContent,
playerCharacter || undefined,
session,
- abortControllerRef.current.signal,
+ signal,
);
flushStreamingContent();
clearInterval(autoSaveInterval);
- // Сохраняем ответ ИИ в текущую версию
const finalVersions: MessageVersion[] = [...newVersions];
finalVersions[newActiveVersion] = {
...finalVersions[newActiveVersion],
@@ -683,16 +552,15 @@ export default function GamePage() {
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
- hasUnsavedChangesRef.current = false;
} catch (err) {
clearInterval(autoSaveInterval);
if (err instanceof Error && err.name === "AbortError") {
- if (streamingBufferRef.current.trim()) {
- // Сохраняем частичный ответ в версию
+ const currentContent = getLatestContent();
+ if (currentContent.trim()) {
const finalVersions: MessageVersion[] = [...newVersions];
finalVersions[newActiveVersion] = {
...finalVersions[newActiveVersion],
- aiResponse: streamingBufferRef.current,
+ aiResponse: currentContent,
};
const finalUserMessage: ChatMessage = {
@@ -703,7 +571,7 @@ export default function GamePage() {
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
- content: streamingBufferRef.current,
+ content: currentContent,
timestamp: new Date(),
};
const partialSession: GameSession = {
@@ -712,20 +580,17 @@ export default function GamePage() {
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
- hasUnsavedChangesRef.current = false;
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
}
} finally {
setIsLoading(false);
- setStreamingContent("");
- abortControllerRef.current = null;
+ resetStreaming();
pendingMessagesRef.current = [];
}
};
- // Переключить версию сообщения
const handleSwitchVersion = async (
messageId: string,
direction: "prev" | "next",
@@ -749,7 +614,6 @@ export default function GamePage() {
currentVersion < message.versions.length - 1 ? currentVersion + 1 : 0;
}
- // Сохраняем текущий ответ ИИ в текущую версию перед переключением
const nextMessage = session.messages[messageIndex + 1];
const currentAiResponse =
nextMessage?.role === "assistant" ? nextMessage.content : undefined;
@@ -771,10 +635,9 @@ export default function GamePage() {
activeVersion: newVersion,
};
- let updatedMessages = [...session.messages];
+ const updatedMessages = [...session.messages];
updatedMessages[messageIndex] = updatedMessage;
- // Если у версии есть сохраненный ответ ИИ, обновляем следующее сообщение
if (selectedVersion.aiResponse && nextMessage?.role === "assistant") {
const updatedAiMessage: ChatMessage = {
...nextMessage,
@@ -786,85 +649,36 @@ export default function GamePage() {
const updatedSession = { ...session, messages: updatedMessages };
setSession(updatedSession);
- // Сохраняем изменения
await apiSaveSession(story.id, currentSessionId, updatedSession);
};
- // Функции управления сессиями
const handleCreateNewSession = async () => {
if (!story || !id) return;
- setShowSessionMenu(false);
- // Используем персонажа из текущей сессии или выбранного
const characterId = playerCharacter?.id || session?.playerId;
if (!characterId) {
- // Перенаправляем на страницу выбора персонажа
window.location.href = `/story/${id}`;
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);
- }
- }
- }
+ // Create new session - the existing effect will start the story
+ // when session state updates with 0 messages
+ await createNewSession(characterId);
};
const handleSwitchSession = async (sessionId: string) => {
- if (!id || sessionId === currentSessionId) {
- setShowSessionMenu(false);
- return;
- }
-
- setShowSessionMenu(false);
- setIsInitialLoading(true);
-
- const sessionData = await getSession(id, sessionId);
- if (sessionData) {
- setCurrentSessionId(sessionId);
- setSession(sessionData);
- // Загружаем персонажа сессии
- if (sessionData.playerId) {
- const character = await getPlayerCharacter(sessionData.playerId);
- setPlayerCharacter(character);
- }
- }
- setIsInitialLoading(false);
+ if (sessionId === currentSessionId) return;
+ await switchSession(sessionId);
};
const handleDeleteSession = async (
sessionId: string,
sessionName: string,
) => {
- if (!id) return;
-
const confirmed = confirm(`Удалить сессию "${sessionName}"?`);
if (!confirmed) return;
- const success = await deleteSession(id, sessionId);
- if (success) {
- const newList = sessionsList.filter((s) => s.id !== sessionId);
- setSessionsList(newList);
-
- // Если удалили текущую сессию — переключаемся на первую оставшуюся
- if (sessionId === currentSessionId && newList.length > 0) {
- handleSwitchSession(newList[0].id);
- }
- }
+ await removeSession(sessionId);
};
const currentSessionName =
@@ -901,58 +715,14 @@ export default function GamePage() {
{story.title}
-
-
- {showSessionMenu && (
-
-
- Сессии
-
-
-
- {sessionsList.map((s) => (
-
-
- {sessionsList.length > 1 && (
-
- )}
-
- ))}
-
-
- )}
-
+
@@ -971,20 +741,7 @@ export default function GamePage() {
: undefined
}
>
- {/* Character panel - desktop only */}
- {activeCharacter?.avatarUrl && (
-
-

-
-
- {activeCharacter.name}
-
-
- {activeCharacter.role}
-
-
-
- )}
+
- {session?.messages.map((message) => (
+ {hasHiddenMessages && (
+
+ )}
+ {visibleMessages.map((message) => (
{editingMessageId === message.id ? (
@@ -1104,47 +869,46 @@ export default function GamePage() {
{showScrollButton && (
-