917 lines
31 KiB
TypeScript
917 lines
31 KiB
TypeScript
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 as apiDeleteSession,
|
||
getPlayerCharacter,
|
||
type SessionListItem,
|
||
} from "../services/api";
|
||
import {
|
||
generateStoryResponseStream,
|
||
buildSystemPrompt,
|
||
sendMessage,
|
||
generateStorySummary,
|
||
extractKeyEvents,
|
||
} from "../services/deepseek";
|
||
import type {
|
||
Story,
|
||
GameSession,
|
||
ChatMessage,
|
||
PlayerCharacter,
|
||
MessageVersion,
|
||
} from "../types";
|
||
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);
|
||
return Math.round(totalChars / 3);
|
||
}
|
||
|
||
function formatTokens(tokens: number): string {
|
||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
|
||
return tokens.toString();
|
||
}
|
||
|
||
export default function GamePage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const [searchParams] = useSearchParams();
|
||
const { isAuthenticated } = useAuth();
|
||
const [story, setStory] = useState<Story | null>(null);
|
||
const [sessionsList, setSessionsList] = useState<SessionListItem[]>([]);
|
||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||
const [session, setSession] = useState<GameSession | null>(null);
|
||
const [playerCharacter, setPlayerCharacter] =
|
||
useState<PlayerCharacter | null>(null);
|
||
const [input, setInput] = useState("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [streamingContent, setStreamingContent] = useState("");
|
||
const [showSessionMenu, setShowSessionMenu] = useState(false);
|
||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||
const [editContent, setEditContent] = useState("");
|
||
const abortControllerRef = useRef<AbortController | null>(null);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
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);
|
||
console.log("[GamePage] Sessions list:", sessions);
|
||
setSessionsList(sessions);
|
||
|
||
const characterId = searchParams.get("character");
|
||
|
||
// Загружаем персонажа
|
||
let character: PlayerCharacter | null = null;
|
||
if (characterId) {
|
||
character = await getPlayerCharacter(characterId);
|
||
setPlayerCharacter(character);
|
||
}
|
||
|
||
// Если есть сессии, загружаем последнюю (или создаём новую)
|
||
if (sessions.length > 0) {
|
||
const latestSession = sessions[0];
|
||
console.log("[GamePage] Loading latest session:", latestSession);
|
||
setCurrentSessionId(latestSession.id);
|
||
const sessionData = await getSession(id, latestSession.id);
|
||
console.log("[GamePage] Session data loaded:", sessionData);
|
||
if (sessionData) {
|
||
setSession(sessionData);
|
||
// Загружаем персонажа из сессии если не выбран
|
||
if (!character && sessionData.playerId) {
|
||
character = await getPlayerCharacter(sessionData.playerId);
|
||
setPlayerCharacter(character);
|
||
}
|
||
}
|
||
} else if (characterId) {
|
||
// Нет сессий и выбран персонаж — создаём новую
|
||
const newSession = await createSession(id, undefined, characterId);
|
||
if (newSession) {
|
||
setSessionsList([newSession]);
|
||
setCurrentSessionId(newSession.id);
|
||
const sessionData = await getSession(id, newSession.id);
|
||
if (sessionData) {
|
||
setSession(sessionData);
|
||
if (character) {
|
||
startStory(
|
||
normalizedStory,
|
||
sessionData,
|
||
character,
|
||
newSession.id,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
setIsInitialLoading(false);
|
||
};
|
||
|
||
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);
|
||
console.log("[GamePage] История обновлена после возврата на страницу");
|
||
}
|
||
};
|
||
|
||
window.addEventListener("focus", handleFocus);
|
||
return () => window.removeEventListener("focus", handleFocus);
|
||
}, [id, isAuthenticated, isInitialLoading]);
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [session?.messages]);
|
||
|
||
// Scroll to bottom on session load
|
||
useEffect(() => {
|
||
if (session && !isInitialLoading) {
|
||
setTimeout(() => scrollToBottom(), 100);
|
||
}
|
||
}, [currentSessionId, isInitialLoading]);
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
};
|
||
|
||
const handleScroll = () => {
|
||
const container = messagesContainerRef.current;
|
||
if (!container) return;
|
||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||
setShowScrollButton(distanceFromBottom > 200);
|
||
};
|
||
|
||
const startStory = async (
|
||
storyData: Story,
|
||
sessionData: GameSession,
|
||
character: PlayerCharacter,
|
||
sessionId: string,
|
||
) => {
|
||
// Если есть заготовленное первое сообщение, используем его
|
||
if (storyData.firstMessage && storyData.firstMessage.trim()) {
|
||
// Заменяем {user} на имя персонажа
|
||
const firstMessageContent = storyData.firstMessage.replace(
|
||
/\{user\}/gi,
|
||
character.name,
|
||
);
|
||
const assistantMessage: ChatMessage = {
|
||
id: generateId(),
|
||
role: "assistant",
|
||
content: firstMessageContent,
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
const updatedSession: GameSession = {
|
||
...sessionData,
|
||
messages: [assistantMessage],
|
||
};
|
||
|
||
await apiSaveSession(storyData.id, sessionId, updatedSession);
|
||
setSession(updatedSession);
|
||
return;
|
||
}
|
||
|
||
// Иначе генерируем через ИИ
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const systemPrompt = buildSystemPrompt(storyData, character);
|
||
const response = await sendMessage([
|
||
{ role: "system", content: systemPrompt },
|
||
{ role: "user", content: "Начни историю." },
|
||
]);
|
||
|
||
const assistantMessage: ChatMessage = {
|
||
id: generateId(),
|
||
role: "assistant",
|
||
content: response,
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
const updatedSession: GameSession = {
|
||
...sessionData,
|
||
messages: [assistantMessage],
|
||
};
|
||
|
||
await apiSaveSession(storyData.id, sessionId, updatedSession);
|
||
setSession(updatedSession);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Произошла ошибка");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSend = async () => {
|
||
if (!input.trim() || !story || !session || !currentSessionId || isLoading)
|
||
return;
|
||
|
||
const userMessage: ChatMessage = {
|
||
id: generateId(),
|
||
role: "user",
|
||
content: input.trim(),
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
const updatedMessages = [...session.messages, userMessage];
|
||
const tempSession = { ...session, messages: updatedMessages };
|
||
setSession(tempSession);
|
||
setInput("");
|
||
// Reset textarea height
|
||
if (inputRef.current) {
|
||
inputRef.current.style.height = "auto";
|
||
}
|
||
setIsLoading(true);
|
||
setError(null);
|
||
setStreamingContent("");
|
||
|
||
// Создаём AbortController для возможности отмены
|
||
abortControllerRef.current = new AbortController();
|
||
|
||
try {
|
||
// Streaming ответ от AI
|
||
const response = await generateStoryResponseStream(
|
||
story,
|
||
session.messages,
|
||
input.trim(),
|
||
(chunk) => {
|
||
setStreamingContent((prev) => prev + chunk);
|
||
},
|
||
playerCharacter || undefined,
|
||
session,
|
||
abortControllerRef.current.signal,
|
||
);
|
||
|
||
const assistantMessage: ChatMessage = {
|
||
id: generateId(),
|
||
role: "assistant",
|
||
content: response,
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
const allMessages = [...updatedMessages, assistantMessage];
|
||
|
||
// Обновляем ключевые события
|
||
const newKeyEvents = await extractKeyEvents(
|
||
response,
|
||
session.keyEvents || [],
|
||
);
|
||
|
||
// Генерируем сводку каждые 20 сообщений
|
||
let newSummary = session.storySummary;
|
||
if (allMessages.length % 20 === 0 && allMessages.length > 0) {
|
||
console.log("[GamePage] Generating story summary...");
|
||
newSummary = await generateStorySummary(
|
||
story,
|
||
allMessages,
|
||
session.storySummary,
|
||
);
|
||
}
|
||
|
||
const finalSession: GameSession = {
|
||
...session,
|
||
messages: allMessages,
|
||
keyEvents: newKeyEvents,
|
||
storySummary: newSummary,
|
||
};
|
||
|
||
const saved = await apiSaveSession(
|
||
story.id,
|
||
currentSessionId,
|
||
finalSession,
|
||
);
|
||
console.log(
|
||
"[GamePage] Session saved:",
|
||
saved,
|
||
"Messages:",
|
||
allMessages.length,
|
||
);
|
||
setSession(finalSession);
|
||
} catch (err) {
|
||
if (err instanceof Error && err.name === "AbortError") {
|
||
// Пользователь отменил — сохраняем то что получили
|
||
if (streamingContent.trim()) {
|
||
const partialMessage: ChatMessage = {
|
||
id: generateId(),
|
||
role: "assistant",
|
||
content: streamingContent,
|
||
timestamp: new Date(),
|
||
};
|
||
const partialSession: GameSession = {
|
||
...session,
|
||
messages: [...updatedMessages, partialMessage],
|
||
};
|
||
await apiSaveSession(story.id, currentSessionId, partialSession);
|
||
setSession(partialSession);
|
||
}
|
||
} else {
|
||
setError(err instanceof Error ? err.message : "Произошла ошибка");
|
||
setSession(session);
|
||
setInput(userMessage.content);
|
||
}
|
||
} finally {
|
||
setIsLoading(false);
|
||
setStreamingContent("");
|
||
abortControllerRef.current = null;
|
||
inputRef.current?.focus();
|
||
}
|
||
};
|
||
|
||
const handleStop = () => {
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
// Начать редактирование сообщения
|
||
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;
|
||
|
||
const messageIndex = session.messages.findIndex((m) => m.id === messageId);
|
||
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,
|
||
timestamp: message.timestamp,
|
||
aiResponse: currentAiResponse
|
||
}];
|
||
|
||
// Если текущая версия не имеет aiResponse, добавляем его
|
||
const currentVersionIdx = message.activeVersion || 0;
|
||
if (versions[currentVersionIdx] && !versions[currentVersionIdx].aiResponse && currentAiResponse) {
|
||
versions[currentVersionIdx] = { ...versions[currentVersionIdx], aiResponse: currentAiResponse };
|
||
}
|
||
|
||
// Добавляем новую версию (aiResponse добавится после генерации)
|
||
const newVersion: MessageVersion = { content: editContent.trim(), timestamp: new Date() };
|
||
const newVersions: MessageVersion[] = [...versions, newVersion];
|
||
const newActiveVersion = newVersions.length - 1;
|
||
|
||
// Обновляем сообщение пользователя
|
||
const updatedUserMessage: ChatMessage = {
|
||
...message,
|
||
content: editContent.trim(),
|
||
versions: newVersions,
|
||
activeVersion: newActiveVersion,
|
||
};
|
||
|
||
// Обрезаем историю до этого сообщения (удаляем последующие)
|
||
const messagesUpToEdit = session.messages.slice(0, messageIndex);
|
||
const updatedMessages = [...messagesUpToEdit, updatedUserMessage];
|
||
|
||
const tempSession = { ...session, messages: updatedMessages };
|
||
setSession(tempSession);
|
||
setEditingMessageId(null);
|
||
setEditContent("");
|
||
setIsLoading(true);
|
||
setError(null);
|
||
setStreamingContent("");
|
||
|
||
abortControllerRef.current = new AbortController();
|
||
|
||
try {
|
||
// Генерируем новый ответ ИИ
|
||
const response = await generateStoryResponseStream(
|
||
story,
|
||
messagesUpToEdit,
|
||
editContent.trim(),
|
||
(chunk) => {
|
||
setStreamingContent((prev) => prev + chunk);
|
||
},
|
||
playerCharacter || undefined,
|
||
session,
|
||
abortControllerRef.current.signal,
|
||
);
|
||
|
||
// Сохраняем ответ ИИ в текущую версию
|
||
const finalVersions: MessageVersion[] = [...newVersions];
|
||
finalVersions[newActiveVersion] = { ...finalVersions[newActiveVersion], aiResponse: response };
|
||
|
||
const finalUserMessage: ChatMessage = {
|
||
...updatedUserMessage,
|
||
versions: finalVersions,
|
||
};
|
||
|
||
const assistantMessage: ChatMessage = {
|
||
id: generateId(),
|
||
role: "assistant",
|
||
content: response,
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
const allMessages = [...messagesUpToEdit, finalUserMessage, assistantMessage];
|
||
|
||
const finalSession: GameSession = {
|
||
...session,
|
||
messages: allMessages,
|
||
};
|
||
|
||
await apiSaveSession(story.id, currentSessionId, finalSession);
|
||
setSession(finalSession);
|
||
} catch (err) {
|
||
if (err instanceof Error && err.name === "AbortError") {
|
||
if (streamingContent.trim()) {
|
||
// Сохраняем частичный ответ в версию
|
||
const finalVersions: MessageVersion[] = [...newVersions];
|
||
finalVersions[newActiveVersion] = { ...finalVersions[newActiveVersion], aiResponse: streamingContent };
|
||
|
||
const finalUserMessage: ChatMessage = {
|
||
...updatedUserMessage,
|
||
versions: finalVersions,
|
||
};
|
||
|
||
const partialMessage: ChatMessage = {
|
||
id: generateId(),
|
||
role: "assistant",
|
||
content: streamingContent,
|
||
timestamp: new Date(),
|
||
};
|
||
const partialSession: GameSession = {
|
||
...session,
|
||
messages: [...messagesUpToEdit, finalUserMessage, partialMessage],
|
||
};
|
||
await apiSaveSession(story.id, currentSessionId, partialSession);
|
||
setSession(partialSession);
|
||
}
|
||
} else {
|
||
setError(err instanceof Error ? err.message : "Произошла ошибка");
|
||
}
|
||
} finally {
|
||
setIsLoading(false);
|
||
setStreamingContent("");
|
||
abortControllerRef.current = null;
|
||
}
|
||
};
|
||
|
||
// Переключить версию сообщения
|
||
const handleSwitchVersion = async (messageId: string, direction: "prev" | "next") => {
|
||
if (!session || !story || !currentSessionId) return;
|
||
|
||
const messageIndex = session.messages.findIndex((m) => m.id === messageId);
|
||
if (messageIndex === -1) return;
|
||
|
||
const message = session.messages[messageIndex];
|
||
if (!message.versions || message.versions.length <= 1) return;
|
||
|
||
const currentVersion = message.activeVersion || 0;
|
||
let newVersion: number;
|
||
|
||
if (direction === "prev") {
|
||
newVersion = currentVersion > 0 ? currentVersion - 1 : message.versions.length - 1;
|
||
} else {
|
||
newVersion = currentVersion < message.versions.length - 1 ? currentVersion + 1 : 0;
|
||
}
|
||
|
||
// Сохраняем текущий ответ ИИ в текущую версию перед переключением
|
||
const nextMessage = session.messages[messageIndex + 1];
|
||
const currentAiResponse = nextMessage?.role === "assistant" ? nextMessage.content : undefined;
|
||
|
||
const updatedVersions: MessageVersion[] = [...message.versions];
|
||
if (currentAiResponse && updatedVersions[currentVersion]) {
|
||
updatedVersions[currentVersion] = { ...updatedVersions[currentVersion], aiResponse: currentAiResponse };
|
||
}
|
||
|
||
const selectedVersion = updatedVersions[newVersion];
|
||
|
||
const updatedMessage: ChatMessage = {
|
||
...message,
|
||
content: selectedVersion.content,
|
||
versions: updatedVersions,
|
||
activeVersion: newVersion,
|
||
};
|
||
|
||
let updatedMessages = [...session.messages];
|
||
updatedMessages[messageIndex] = updatedMessage;
|
||
|
||
// Если у версии есть сохраненный ответ ИИ, обновляем следующее сообщение
|
||
if (selectedVersion.aiResponse && nextMessage?.role === "assistant") {
|
||
const updatedAiMessage: ChatMessage = {
|
||
...nextMessage,
|
||
content: selectedVersion.aiResponse,
|
||
};
|
||
updatedMessages[messageIndex + 1] = updatedAiMessage;
|
||
}
|
||
|
||
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) {
|
||
alert("Не выбран персонаж для игры");
|
||
return;
|
||
}
|
||
|
||
const name = `Сессия ${sessionsList.length + 1}`;
|
||
const newSession = await createSession(id, name, characterId);
|
||
if (newSession) {
|
||
setSessionsList([newSession, ...sessionsList]);
|
||
setCurrentSessionId(newSession.id);
|
||
const sessionData = await getSession(id, newSession.id);
|
||
if (sessionData) {
|
||
setSession(sessionData);
|
||
// Загружаем персонажа если нет
|
||
let character = playerCharacter;
|
||
if (!character && characterId) {
|
||
character = await getPlayerCharacter(characterId);
|
||
setPlayerCharacter(character);
|
||
}
|
||
if (character) {
|
||
startStory(story, sessionData, character, newSession.id);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleSwitchSession = async (sessionId: string) => {
|
||
console.log("[GamePage] Switching to session:", sessionId, "current:", currentSessionId);
|
||
if (!id || sessionId === currentSessionId) {
|
||
setShowSessionMenu(false);
|
||
return;
|
||
}
|
||
|
||
setShowSessionMenu(false);
|
||
setIsInitialLoading(true);
|
||
|
||
const sessionData = await getSession(id, sessionId);
|
||
console.log("[GamePage] Loaded session data:", sessionData);
|
||
if (sessionData) {
|
||
setCurrentSessionId(sessionId);
|
||
setSession(sessionData);
|
||
// Загружаем персонажа сессии
|
||
if (sessionData.playerId) {
|
||
const character = await getPlayerCharacter(sessionData.playerId);
|
||
setPlayerCharacter(character);
|
||
}
|
||
}
|
||
setIsInitialLoading(false);
|
||
};
|
||
|
||
const handleDeleteSession = async (sessionId: string) => {
|
||
if (!id || sessionsList.length <= 1) {
|
||
alert("Нельзя удалить единственную сессию");
|
||
return;
|
||
}
|
||
|
||
if (!confirm("Удалить эту сессию? Это действие нельзя отменить.")) {
|
||
return;
|
||
}
|
||
|
||
const success = await apiDeleteSession(id, sessionId);
|
||
if (success) {
|
||
const updatedList = sessionsList.filter((s) => s.id !== sessionId);
|
||
setSessionsList(updatedList);
|
||
|
||
// Если удалили текущую сессию — переключаемся на другую
|
||
if (sessionId === currentSessionId && updatedList.length > 0) {
|
||
await handleSwitchSession(updatedList[0].id);
|
||
}
|
||
}
|
||
setShowSessionMenu(false);
|
||
};
|
||
|
||
const currentSessionName =
|
||
sessionsList.find((s) => s.id === currentSessionId)?.name || "Сессия";
|
||
|
||
if (isInitialLoading) {
|
||
return (
|
||
<div className="game-page">
|
||
<div className="game-loading">
|
||
<p>Загрузка игры...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!story) {
|
||
return (
|
||
<div className="game-page">
|
||
<div className="game-error">
|
||
<h2>История не найдена</h2>
|
||
<Link to="/" className="back-link">
|
||
← Вернуться к списку
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="game-page">
|
||
<header className="game-header">
|
||
<Link to={`/story/${story.id}`} className="back-btn">
|
||
←
|
||
</Link>
|
||
<div className="header-info">
|
||
<h1>{story.title}</h1>
|
||
<div className="header-session">
|
||
<button
|
||
className="session-selector"
|
||
onClick={() => setShowSessionMenu(!showSessionMenu)}
|
||
>
|
||
📖 {currentSessionName} ▾
|
||
</button>
|
||
{showSessionMenu && (
|
||
<div className="session-menu">
|
||
<div className="session-menu-header">
|
||
<span>Сессии</span>
|
||
<button
|
||
className="new-session-btn"
|
||
onClick={handleCreateNewSession}
|
||
title="Новая сессия"
|
||
>
|
||
➕
|
||
</button>
|
||
</div>
|
||
<div className="session-list">
|
||
{sessionsList.map((s) => (
|
||
<div
|
||
key={s.id}
|
||
className={`session-item ${s.id === currentSessionId ? "active" : ""}`}
|
||
>
|
||
<button
|
||
className="session-name"
|
||
onClick={() => handleSwitchSession(s.id)}
|
||
>
|
||
{s.name}
|
||
<span className="session-messages">
|
||
{s.messagesCount} сообщ.
|
||
</span>
|
||
</button>
|
||
{sessionsList.length > 1 && (
|
||
<button
|
||
className="delete-session-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteSession(s.id);
|
||
}}
|
||
title="Удалить сессию"
|
||
>
|
||
🗑️
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="header-stats">
|
||
<span className="stat-badge tokens">
|
||
🎟️ {formatTokens(estimateTokens(session?.messages || []))}
|
||
</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="game-content">
|
||
<div
|
||
className="messages-container"
|
||
ref={messagesContainerRef}
|
||
onScroll={handleScroll}
|
||
>
|
||
{session?.messages.map((message) => (
|
||
<div key={message.id} className={`message ${message.role}`}>
|
||
{editingMessageId === message.id ? (
|
||
<div className="message-edit-form">
|
||
<textarea
|
||
value={editContent}
|
||
onChange={(e) => setEditContent(e.target.value)}
|
||
className="message-edit-textarea"
|
||
autoFocus
|
||
/>
|
||
<div className="message-edit-actions">
|
||
<button
|
||
className="edit-cancel-btn"
|
||
onClick={handleCancelEdit}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
className="edit-save-btn"
|
||
onClick={() => handleSaveEdit(message.id)}
|
||
>
|
||
Сохранить и переиграть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="message-content">
|
||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||
</div>
|
||
<div className="message-footer">
|
||
<span className="message-time">
|
||
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}
|
||
</span>
|
||
{message.role === "user" && !isLoading && (
|
||
<div className="message-actions">
|
||
{message.versions && message.versions.length > 1 && (
|
||
<div className="version-switcher">
|
||
<button
|
||
className="version-btn"
|
||
onClick={() => handleSwitchVersion(message.id, "prev")}
|
||
>
|
||
‹
|
||
</button>
|
||
<span className="version-indicator">
|
||
{(message.activeVersion || 0) + 1}/{message.versions.length}
|
||
</span>
|
||
<button
|
||
className="version-btn"
|
||
onClick={() => handleSwitchVersion(message.id, "next")}
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
)}
|
||
<button
|
||
className="edit-btn"
|
||
onClick={() => handleEditMessage(message.id, message.content)}
|
||
title="Редактировать"
|
||
>
|
||
✏️
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{isLoading && streamingContent && (
|
||
<div className="message assistant streaming">
|
||
<div className="message-content">
|
||
<ReactMarkdown>{streamingContent}</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isLoading && !streamingContent && (
|
||
<div className="message assistant loading">
|
||
<div className="typing-indicator">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="error-message">
|
||
<span>⚠️ {error}</span>
|
||
<button onClick={() => setError(null)}>✕</button>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{showScrollButton && (
|
||
<button className="scroll-to-bottom-btn" onClick={scrollToBottom}>
|
||
↓
|
||
</button>
|
||
)}
|
||
|
||
{/* RPG кнопки скрыты — раскомментировать при необходимости
|
||
<div className="quick-actions">
|
||
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
|
||
👀 Осмотреться
|
||
</button>
|
||
<button onClick={() => handleQuickAction("Проверить инвентарь")}>
|
||
🎒 Инвентарь
|
||
</button>
|
||
<button onClick={() => handleQuickAction("Поговорить с кем-нибудь")}>
|
||
💬 Говорить
|
||
</button>
|
||
<button onClick={() => handleQuickAction("Идти вперёд")}>
|
||
🚶 Идти
|
||
</button>
|
||
</div>
|
||
*/}
|
||
|
||
<form className="input-container" onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
||
<textarea
|
||
ref={inputRef}
|
||
value={input}
|
||
onChange={(e) => {
|
||
setInput(e.target.value);
|
||
// Auto-resize
|
||
e.target.style.height = "auto";
|
||
e.target.style.height = Math.min(e.target.scrollHeight, 150) + "px";
|
||
}}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Что ты хочешь сделать?..."
|
||
disabled={isLoading}
|
||
rows={1}
|
||
name="chat-input"
|
||
autoComplete="off"
|
||
autoCorrect="off"
|
||
autoCapitalize="off"
|
||
spellCheck={false}
|
||
enterKeyHint="send"
|
||
data-form-type="other"
|
||
data-lpignore="true"
|
||
data-gramm="false"
|
||
/>
|
||
{isLoading ? (
|
||
<button type="button" onClick={handleStop} className="send-btn stop-btn">
|
||
⏹
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="submit"
|
||
disabled={!input.trim()}
|
||
className="send-btn"
|
||
>
|
||
➤
|
||
</button>
|
||
)}
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|