e8cd01c693
- 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
1327 lines
42 KiB
TypeScript
1327 lines
42 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 { 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>
|
||
);
|
||
}
|