Files
ReSekai/src/pages/GamePage.tsx
T
Alexej Wolff e8cd01c693 feat: banner generation, improved memory system, streaming text animation
- Add banner/cover generation for stories with character reference support
- Improve summary system: generate every 8 msgs or when context large
- Enhance summary prompt to preserve critical story info (promises, relationships)
- Add typewriter text animation during AI streaming
- Increase context to 20 messages, lower summary temperature to 0.1
- Server: auto-truncate long messages instead of rejecting
2026-05-09 06:39:10 +02:00

1327 lines
42 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { saveSession as apiSaveSession } from "../services/api";
import {
generateStoryResponseStream,
buildSystemPrompt,
sendMessage,
generateStorySummary,
extractKeyEvents,
updateWorldState,
shouldUpdateWorldState,
} from "../services/deepseek";
import type {
Story,
GameSession,
ChatMessage,
MessageVersion,
PlayerCharacter,
WorldState,
} from "../types";
import {
useGameSession,
useStreamingResponse,
useCharacterDetection,
useLazyMessages,
} from "../hooks";
import {
SessionSelector,
CharacterPanel,
StreamingText,
} from "../components/game";
import "./GamePage.css";
function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
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();
// 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 [isContinuing, setIsContinuing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [isOocMode, setIsOocMode] = useState(false);
// Refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const sessionRef = useRef<GameSession | null>(null);
const pendingMessagesRef = useRef<ChatMessage[]>([]);
// Keep sessionRef in sync
useEffect(() => {
sessionRef.current = session;
}, [session]);
// Warn before leaving during loading
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isLoading) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isLoading]);
// Initial load effect - start story if needed
useEffect(() => {
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]);
// Scroll effects - scroll to bottom only when NEW messages are added
useEffect(() => {
// 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]);
useEffect(() => {
if (session && !isInitialLoading) {
// 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 = (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 = () => {
const container = messagesContainerRef.current;
if (!container) return;
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 | null,
sessionId: string,
) => {
if (!character) return;
if (storyData.firstMessage?.trim()) {
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: isOocMode ? `[OOC: ${input.trim()}]` : input.trim(),
timestamp: new Date(),
};
const updatedMessages = [...session.messages, userMessage];
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
pendingMessagesRef.current = updatedMessages;
markUnsaved();
setInput("");
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
setIsLoading(true);
setError(null);
const signal = startStreaming();
// Immediately save user message
try {
await apiSaveSession(story.id, currentSessionId, tempSession);
} catch {
// Continue even if initial save fails
}
// Auto-save interval during streaming
const autoSaveInterval = setInterval(async () => {
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);
try {
const response = await generateStoryResponseStream(
story,
session.messages,
userMessage.content, // Use userMessage.content to include [OOC: ...] wrapper
updateStreamingContent,
playerCharacter || undefined,
session,
signal,
);
flushStreamingContent();
clearInterval(autoSaveInterval);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const allMessages = [...updatedMessages, assistantMessage];
// Skip state updates for OOC messages - they are meta-conversations
let newKeyEvents = session.keyEvents || [];
let newSummary = session.storySummary;
let newWorldState = session.worldState;
if (!isOocMode) {
newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
// Calculate total context size
const totalContextSize = allMessages.reduce(
(sum, m) => sum + m.content.length,
0,
);
const contextThreshold = 100000; // Generate summary when context gets large
// Generate summary more frequently (every 8 messages) OR when context is large
const shouldGenerateSummary =
(allMessages.length % 8 === 0 && allMessages.length > 0) ||
(totalContextSize > contextThreshold && allMessages.length > 10);
if (shouldGenerateSummary) {
console.log(
`Generating summary: ${allMessages.length} messages, ${totalContextSize} chars`,
);
newSummary = await generateStorySummary(
story,
allMessages,
session.storySummary,
);
}
// Update world state for better character tracking
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
try {
newWorldState = await updateWorldState(
story,
allMessages,
session.worldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
}
}
const finalSession: GameSession = {
...session,
messages: allMessages,
keyEvents: newKeyEvents,
storySummary: newSummary,
worldState: newWorldState,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
clearInterval(autoSaveInterval);
if (err instanceof Error && err.name === "AbortError") {
// 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: currentContent,
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);
resetStreaming();
pendingMessagesRef.current = [];
inputRef.current?.focus();
}
};
// Закомментирована кнопка остановки - функция сохранена на будущее
// const handleStop = () => {
// abort();
// };
// Retry: regenerate last AI response
const handleRetry = async () => {
if (!story || !session || !currentSessionId || isLoading) return;
if (session.messages.length < 2) return;
// Find last assistant message and corresponding user message
const messages = session.messages;
let lastAssistantIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIndex = i;
break;
}
}
if (lastAssistantIndex < 1) return; // Need at least user + assistant
const lastUserMessage = messages[lastAssistantIndex - 1];
if (lastUserMessage.role !== "user") return;
// Remove last assistant message, keep everything before it
const messagesWithoutLast = messages.slice(0, lastAssistantIndex);
const tempSession = { ...session, messages: messagesWithoutLast };
setSession(tempSession);
pendingMessagesRef.current = messagesWithoutLast;
markUnsaved();
setIsLoading(true);
setError(null);
const signal = startStreaming();
try {
const response = await generateStoryResponseStream(
story,
messagesWithoutLast.slice(0, -1), // History without the user message we're regenerating for
lastUserMessage.content,
updateStreamingContent,
playerCharacter || undefined,
tempSession,
signal,
);
flushStreamingContent();
const newAssistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const finalMessages = [...messagesWithoutLast, newAssistantMessage];
// Skip state updates for OOC
const isOoc = lastUserMessage.content.startsWith("[OOC:");
let newWorldState = session.worldState;
if (
!isOoc &&
shouldUpdateWorldState(finalMessages.length, session.worldState)
) {
try {
newWorldState = await updateWorldState(
story,
finalMessages,
session.worldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
}
const finalSession: GameSession = {
...session,
messages: finalMessages,
worldState: newWorldState,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
const currentContent = getLatestContent();
if (currentContent.trim()) {
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: currentContent,
timestamp: new Date(),
};
const partialSession: GameSession = {
...session,
messages: [...messagesWithoutLast, partialMessage],
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
setSession(session); // Restore original
}
} finally {
setIsLoading(false);
resetStreaming();
pendingMessagesRef.current = [];
}
};
// Erase: remove last message pair (user + assistant)
const handleErase = async () => {
if (!story || !session || !currentSessionId || isLoading) return;
if (session.messages.length < 2) return;
const messages = session.messages;
let removeCount = 0;
// Remove last assistant if exists
if (messages[messages.length - 1].role === "assistant") {
removeCount++;
// Also remove the user message before it
if (
messages.length >= 2 &&
messages[messages.length - 2].role === "user"
) {
removeCount++;
}
} else if (messages[messages.length - 1].role === "user") {
// Just remove the user message
removeCount = 1;
}
if (removeCount === 0) return;
const newMessages = messages.slice(0, -removeCount);
const newSession: GameSession = {
...session,
messages: newMessages,
};
try {
await apiSaveSession(story.id, currentSessionId, newSession);
setSession(newSession);
markUnsaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка удаления");
}
};
// Continue: продолжить генерацию истории (дополняет последнее сообщение ИИ)
const handleContinue = async () => {
if (isLoading || !session || !story || !currentSessionId) return;
if (session.messages.length === 0) return;
// Find last assistant message
const messages = session.messages;
let lastAssistantIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIndex = i;
break;
}
}
if (lastAssistantIndex === -1) return; // No assistant message to continue
const lastAssistantMessage = messages[lastAssistantIndex];
setIsLoading(true);
setIsContinuing(true);
setError(null);
const signal = startStreaming();
try {
// Generate continuation without adding user message
// Pass special instruction to continue from where AI left off
const response = await generateStoryResponseStream(
story,
messages.slice(0, lastAssistantIndex + 1), // History INCLUDING the message we're continuing
"[CONTINUE]", // Special marker for continuation
updateStreamingContent,
playerCharacter || undefined,
{ ...session, messages: messages.slice(0, lastAssistantIndex + 1) },
signal,
);
flushStreamingContent();
// Append to existing assistant message
const updatedAssistantMessage: ChatMessage = {
...lastAssistantMessage,
content: lastAssistantMessage.content + "\n\n" + response,
};
const finalMessages = [
...messages.slice(0, lastAssistantIndex),
updatedAssistantMessage,
...messages.slice(lastAssistantIndex + 1),
];
const finalSession: GameSession = {
...session,
messages: finalMessages,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
const currentContent = getLatestContent();
if (currentContent.trim()) {
// Still append partial content
const updatedAssistantMessage: ChatMessage = {
...lastAssistantMessage,
content: lastAssistantMessage.content + "\n\n" + currentContent,
};
const partialMessages = [
...messages.slice(0, lastAssistantIndex),
updatedAssistantMessage,
...messages.slice(lastAssistantIndex + 1),
];
const partialSession: GameSession = {
...session,
messages: partialMessages,
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
}
} finally {
setIsLoading(false);
setIsContinuing(false);
resetStreaming();
}
};
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,
},
];
const currentVersionIdx = message.activeVersion || 0;
if (
versions[currentVersionIdx] &&
!versions[currentVersionIdx].aiResponse &&
currentAiResponse
) {
versions[currentVersionIdx] = {
...versions[currentVersionIdx],
aiResponse: currentAiResponse,
};
}
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);
pendingMessagesRef.current = updatedMessages;
markUnsaved();
setEditingMessageId(null);
setEditContent("");
setIsLoading(true);
setError(null);
const signal = startStreaming();
try {
await apiSaveSession(story.id, currentSessionId, tempSession);
} catch {
// Continue even if save fails
}
const autoSaveInterval = setInterval(async () => {
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);
// Reset worldState — it's now stale (was calculated from future messages)
let recalculatedWorldState: WorldState | undefined = undefined;
try {
recalculatedWorldState = await updateWorldState(
story,
messagesUpToEdit,
undefined, // pass undefined — force fresh calculation, ignore old state
);
} catch (e) {
console.warn("WorldState recalculation failed on edit:", e);
// Continue without worldState — better than using stale future state
}
const sessionForEdit: GameSession = {
...session,
messages: messagesUpToEdit,
worldState: recalculatedWorldState,
};
try {
const response = await generateStoryResponseStream(
story,
messagesUpToEdit,
editContent.trim(),
updateStreamingContent,
playerCharacter || undefined,
sessionForEdit,
signal,
);
flushStreamingContent();
clearInterval(autoSaveInterval);
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,
];
// Update world state after edit regeneration (based on recalculated state)
let newWorldState = recalculatedWorldState;
if (shouldUpdateWorldState(allMessages.length, recalculatedWorldState)) {
try {
newWorldState = await updateWorldState(
story,
allMessages,
recalculatedWorldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
}
const finalSession: GameSession = {
...session,
messages: allMessages,
worldState: newWorldState,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
clearInterval(autoSaveInterval);
if (err instanceof Error && err.name === "AbortError") {
const currentContent = getLatestContent();
if (currentContent.trim()) {
const finalVersions: MessageVersion[] = [...newVersions];
finalVersions[newActiveVersion] = {
...finalVersions[newActiveVersion],
aiResponse: currentContent,
};
const finalUserMessage: ChatMessage = {
...updatedUserMessage,
versions: finalVersions,
};
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: currentContent,
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);
resetStreaming();
pendingMessagesRef.current = [];
}
};
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,
};
const 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;
const characterId = playerCharacter?.id || session?.playerId;
if (!characterId) {
window.location.href = `/story/${id}`;
return;
}
// 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 (sessionId === currentSessionId) return;
await switchSession(sessionId);
};
const handleDeleteSession = async (
sessionId: string,
sessionName: string,
) => {
const confirmed = confirm(`Удалить сессию "${sessionName}"?`);
if (!confirmed) return;
await removeSession(sessionId);
};
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>
<SessionSelector
sessions={sessionsList}
currentSessionId={currentSessionId}
currentSessionName={currentSessionName}
onCreateNew={handleCreateNewSession}
onSwitch={handleSwitchSession}
onDelete={handleDeleteSession}
/>
</div>
<div className="header-stats">
<span className="stat-badge tokens">
🎟 {formatTokens(estimateTokens(session?.messages || []))}
</span>
</div>
</header>
<div
className={`game-layout ${activeCharacter?.avatarUrl ? "has-character" : ""}`}
style={
activeCharacter?.avatarUrl
? ({
"--character-bg": `url(${activeCharacter.avatarUrl})`,
} as React.CSSProperties)
: undefined
}
>
<CharacterPanel character={activeCharacter} />
<div className="game-content">
<div
className="messages-container"
ref={messagesContainerRef}
onScroll={handleScroll}
>
{hasHiddenMessages && (
<button
className="load-more-messages-btn"
onClick={loadMoreMessages}
>
Загрузить ещё {hiddenCount} сообщений
</button>
)}
{/* Filter out pending user message until streaming starts, but NOT during Continue */}
{(isLoading && !streamingContent && !isContinuing
? visibleMessages.slice(0, -1)
: visibleMessages
).map((message, index) => {
// Check if this is the last assistant message
const isLastAssistant =
message.role === "assistant" &&
index === visibleMessages.length - 1;
const isContinuingThis =
isLastAssistant && isContinuing && streamingContent;
return (
<div
key={message.id}
className={`message ${message.role}${isLastAssistant ? " last-assistant" : ""}${isContinuingThis ? " continuing" : ""}`}
>
{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">
{message.role === "assistant" ? (
<StreamingText
content={
isContinuingThis && streamingContent
? message.content + "\n\n" + streamingContent
: message.content
}
isStreaming={
!!(isContinuingThis && streamingContent)
}
/>
) : (
<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 && !isContinuing && (
<div className="message assistant streaming">
<div className="message-content">
<StreamingText
content={streamingContent}
isStreaming={true}
/>
</div>
</div>
)}
{isLoading && !streamingContent && !isContinuing && (
<div className="message assistant streaming">
<div className="message-content">
<span className="loading-text">Генерация...</span>
</div>
</div>
)}
{/* Small loader for Continue mode */}
{isLoading && !streamingContent && isContinuing && (
<div className="continue-loader">
<span className="continue-loader-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</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>
)}
{/* Hide input during loading */}
{!isLoading && (
<>
{/* Action buttons: Retry, Erase */}
{session && session.messages.length >= 2 && (
<div className="chat-actions">
<button
type="button"
className="action-btn continue-btn"
onClick={handleContinue}
title="Продолжить историю"
>
Continue
</button>
<button
type="button"
className="action-btn retry-btn"
onClick={handleRetry}
title="Перегенерировать последний ответ"
>
🔄 Retry
</button>
<button
type="button"
className="action-btn erase-btn"
onClick={handleErase}
title="Удалить последнюю пару сообщений"
>
🗑 Erase
</button>
</div>
)}
<form
className={`input-container${isOocMode ? " ooc-mode" : ""}`}
onSubmit={(e) => {
e.preventDefault();
handleSend();
}}
>
<button
type="button"
className={`ooc-btn${isOocMode ? " active" : ""}`}
onClick={() => setIsOocMode(!isOocMode)}
title={
isOocMode ? "Вернуться в игру" : "OOC: написать напрямую ИИ"
}
>
OOC
</button>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height =
Math.min(e.target.scrollHeight, 150) + "px";
}}
onKeyDown={handleKeyDown}
placeholder={
isOocMode
? "[OOC] Напиши инструкцию для ИИ..."
: "Что ты хочешь сделать?..."
}
disabled={false}
rows={1}
name="chat-input"
autoComplete="off"
autoCorrect="on"
autoCapitalize="sentences"
spellCheck={true}
enterKeyHint="send"
data-form-type="other"
data-lpignore="true"
data-gramm="false"
/>
<button
type="submit"
disabled={!input.trim()}
className="send-btn"
>
</button>
</form>
</>
)}
</div>
</div>
</div>
);
}