diff --git a/server/index.js b/server/index.js index a6cedaf..aaf04b6 100644 --- a/server/index.js +++ b/server/index.js @@ -45,6 +45,11 @@ async function connectDB() { } // Middleware +// Trust proxy in production (needed for secure cookies behind nginx) +if (isProduction) { + app.set("trust proxy", 1); +} + app.use(express.json({ limit: "10mb" })); app.use( cors({ @@ -66,7 +71,7 @@ app.use( cookie: { secure: isProduction, // true в production с HTTPS httpOnly: true, - sameSite: isProduction ? "strict" : "lax", + sameSite: "lax", // "lax" needed for OAuth redirects from Discord maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней }, }), diff --git a/src/pages/GamePage.backup.tsx.bak b/src/pages/GamePage.backup.tsx.bak new file mode 100644 index 0000000..b842b4b --- /dev/null +++ b/src/pages/GamePage.backup.tsx.bak @@ -0,0 +1,1174 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useParams, Link, useSearchParams } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; +import { saveSession as apiSaveSession, getPlayerCharacter } from "../services/api"; +import { + generateStoryResponseStream, + buildSystemPrompt, + sendMessage, + generateStorySummary, + extractKeyEvents, +} from "../services/deepseek"; +import type { Story, GameSession, ChatMessage, MessageVersion } from "../types"; +import { useGameSession, useStreamingResponse, useCharacterDetection } from "../hooks"; +import { MessageList, ChatInput, SessionSelector, CharacterPanel } 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, + saveSession, + createNewSession, + switchSession, + removeSession, + markUnsaved, + } = useGameSession({ + storyId: id, + characterIdFromUrl: searchParams.get("character"), + isAuthenticated, + }); + + // Streaming response hook + const { + streamingContent, + isStreaming, + updateStreamingContent, + flushStreamingContent, + resetStreaming, + abortController, + abort, + } = useStreamingResponse(); + + // Character detection hook + const lastAssistantContent = session?.messages + ?.slice() + .reverse() + .find((m) => m.role === "assistant")?.content; + + const activeCharacter = useCharacterDetection( + story?.characters || [], + lastAssistantContent, + streamingContent + ); + + // Local state + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [showScrollButton, setShowScrollButton] = useState(false); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editContent, setEditContent] = useState(""); + + // Refs + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const sessionRef = useRef(null); + const pendingMessagesRef = useRef([]); + + // 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(); + }, [story, session, currentSessionId, playerCharacter]); + + // Scroll effects + useEffect(() => { + scrollToBottom(); + }, [session?.messages]); + + 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: { id: string; name: string }, + sessionId: string + ) => { + 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); + } + }; + setSessionsList(sessions); + + const characterId = searchParams.get("character"); + + // Загружаем персонажа + let character: PlayerCharacter | null = null; + if (characterId) { + character = await getPlayerCharacter(characterId); + setPlayerCharacter(character); + } else { + // Если персонаж не указан в URL, загружаем первого доступного (или избранного) + const characters = await getPlayerCharacters(); + if (characters.length > 0) { + character = characters.find((c) => c.isFavorite) || characters[0]; + setPlayerCharacter(character); + } + } + + // Если есть сессии, загружаем последнюю (или создаём новую) + if (sessions.length > 0) { + const latestSession = sessions[0]; + setCurrentSessionId(latestSession.id); + const sessionData = await getSession(id, latestSession.id); + if (sessionData) { + setSession(sessionData); + // Загружаем персонажа: приоритет URL > сессия + if (!character && sessionData.playerId) { + character = await getPlayerCharacter(sessionData.playerId); + setPlayerCharacter(character); + } + // Если в сессии нет playerId, но персонаж выбран в URL — обновляем сессию + if (!sessionData.playerId && character) { + const updatedSession = { ...sessionData, playerId: character.id }; + await apiSaveSession(id, latestSession.id, updatedSession); + setSession(updatedSession); + } + // Если сессия пустая (нет сообщений) — запускаем историю + if (sessionData.messages.length === 0 && character) { + startStory( + normalizedStory, + sessionData, + character, + latestSession.id, + ); + } + } + } else if (characterId) { + // Нет сессий и выбран персонаж — создаём новую + const newSession = await createSession(id, undefined, characterId); + if (newSession) { + setSessionsList([newSession]); + setCurrentSessionId(newSession.id); + const sessionData = await getSession(id, newSession.id); + if (sessionData) { + setSession(sessionData); + if (character) { + startStory( + normalizedStory, + sessionData, + character, + newSession.id, + ); + } + } + } + } + } + setIsInitialLoading(false); + }; + + loadGame(); + }, [id, isAuthenticated, searchParams]); + + // Обновляем историю при возврате на страницу (после редактирования) + useEffect(() => { + const handleFocus = async () => { + if (!id || !isAuthenticated || isInitialLoading) return; + + const updatedStory = await getStory(id); + if (updatedStory) { + const normalizedStory = { + ...updatedStory, + id: (updatedStory as any)._id || updatedStory.id, + }; + setStory(normalizedStory); + } + }; + + 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); + pendingMessagesRef.current = updatedMessages; + hasUnsavedChangesRef.current = true; + setInput(""); + // Reset textarea height + if (inputRef.current) { + inputRef.current.style.height = "auto"; + } + setIsLoading(true); + setError(null); + setStreamingContent(""); + streamingBufferRef.current = ""; + lastAutoSaveRef.current = Date.now(); + + // Immediately save user message + try { + await apiSaveSession(story.id, currentSessionId, tempSession); + } catch (e) { + // Continue even if initial save fails + } + + // Создаём AbortController для возможности отмены + abortControllerRef.current = new AbortController(); + + // Auto-save interval during streaming + const autoSaveInterval = setInterval(async () => { + if (streamingBufferRef.current.trim() && story && currentSessionId) { + const partialMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: streamingBufferRef.current, + timestamp: new Date(), + }; + const partialSession: GameSession = { + ...sessionRef.current!, + messages: [...pendingMessagesRef.current, partialMessage], + }; + try { + await apiSaveSession(story.id, currentSessionId, partialSession); + } catch (e) { + // Ignore save errors during streaming + } + } + }, 5000); // Auto-save every 5 seconds + + try { + // Streaming ответ от AI + const response = await generateStoryResponseStream( + story, + session.messages, + input.trim(), + updateStreamingContent, + playerCharacter || undefined, + session, + abortControllerRef.current.signal, + ); + flushStreamingContent(); + clearInterval(autoSaveInterval); + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: response, + timestamp: new Date(), + }; + + const allMessages = [...updatedMessages, assistantMessage]; + + // Обновляем ключевые события + const newKeyEvents = await extractKeyEvents( + response, + session.keyEvents || [], + ); + + // Генерируем сводку каждые 15 сообщений + let newSummary = session.storySummary; + if (allMessages.length % 15 === 0 && allMessages.length > 0) { + newSummary = await generateStorySummary( + story, + allMessages, + session.storySummary, + ); + } + + const finalSession: GameSession = { + ...session, + messages: allMessages, + keyEvents: newKeyEvents, + storySummary: newSummary, + }; + + await apiSaveSession(story.id, currentSessionId, finalSession); + setSession(finalSession); + hasUnsavedChangesRef.current = false; + } catch (err) { + clearInterval(autoSaveInterval); + if (err instanceof Error && err.name === "AbortError") { + // Пользователь отменил — сохраняем то что получили + if (streamingBufferRef.current.trim()) { + const partialMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: streamingBufferRef.current, + timestamp: new Date(), + }; + const partialSession: GameSession = { + ...session, + messages: [...updatedMessages, partialMessage], + }; + await apiSaveSession(story.id, currentSessionId, partialSession); + setSession(partialSession); + hasUnsavedChangesRef.current = false; + } + } else { + setError(err instanceof Error ? err.message : "Произошла ошибка"); + setSession(session); + setInput(userMessage.content); + } + } finally { + setIsLoading(false); + setStreamingContent(""); + abortControllerRef.current = null; + pendingMessagesRef.current = []; + 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); + pendingMessagesRef.current = updatedMessages; + hasUnsavedChangesRef.current = true; + setEditingMessageId(null); + setEditContent(""); + setIsLoading(true); + setError(null); + setStreamingContent(""); + streamingBufferRef.current = ""; + + abortControllerRef.current = new AbortController(); + + // Save immediately + try { + await apiSaveSession(story.id, currentSessionId, tempSession); + } catch (e) { + // Continue even if initial save fails + } + + // Auto-save interval during streaming + const autoSaveInterval = setInterval(async () => { + if (streamingBufferRef.current.trim() && story && currentSessionId) { + const partialMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: streamingBufferRef.current, + timestamp: new Date(), + }; + const partialSession: GameSession = { + ...sessionRef.current!, + messages: [...pendingMessagesRef.current, partialMessage], + }; + try { + await apiSaveSession(story.id, currentSessionId, partialSession); + } catch (e) { + // Ignore save errors during streaming + } + } + }, 5000); + + try { + // Генерируем новый ответ ИИ + const response = await generateStoryResponseStream( + story, + messagesUpToEdit, + editContent.trim(), + updateStreamingContent, + playerCharacter || undefined, + session, + abortControllerRef.current.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, + ]; + + const finalSession: GameSession = { + ...session, + messages: allMessages, + }; + + await apiSaveSession(story.id, currentSessionId, finalSession); + setSession(finalSession); + hasUnsavedChangesRef.current = false; + } catch (err) { + clearInterval(autoSaveInterval); + if (err instanceof Error && err.name === "AbortError") { + if (streamingBufferRef.current.trim()) { + // Сохраняем частичный ответ в версию + const finalVersions: MessageVersion[] = [...newVersions]; + finalVersions[newActiveVersion] = { + ...finalVersions[newActiveVersion], + aiResponse: streamingBufferRef.current, + }; + + const finalUserMessage: ChatMessage = { + ...updatedUserMessage, + versions: finalVersions, + }; + + const partialMessage: ChatMessage = { + id: generateId(), + role: "assistant", + content: streamingBufferRef.current, + timestamp: new Date(), + }; + const partialSession: GameSession = { + ...session, + messages: [...messagesUpToEdit, finalUserMessage, partialMessage], + }; + await apiSaveSession(story.id, currentSessionId, partialSession); + setSession(partialSession); + hasUnsavedChangesRef.current = false; + } + } else { + setError(err instanceof Error ? err.message : "Произошла ошибка"); + } + } finally { + setIsLoading(false); + setStreamingContent(""); + abortControllerRef.current = null; + 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, + }; + + 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) { + // Перенаправляем на страницу выбора персонажа + window.location.href = `/story/${id}`; + return; + } + + const name = `Сессия ${sessionsList.length + 1}`; + const newSession = await createSession(id, name, characterId); + if (newSession) { + setSessionsList([newSession, ...sessionsList]); + setCurrentSessionId(newSession.id); + const sessionData = await getSession(id, newSession.id); + if (sessionData) { + setSession(sessionData); + // Загружаем персонажа если нет + let character = playerCharacter; + if (!character && characterId) { + character = await getPlayerCharacter(characterId); + setPlayerCharacter(character); + } + if (character) { + startStory(story, sessionData, character, newSession.id); + } + } + } + }; + + const handleSwitchSession = async (sessionId: string) => { + if (!id || sessionId === currentSessionId) { + setShowSessionMenu(false); + return; + } + + setShowSessionMenu(false); + setIsInitialLoading(true); + + const sessionData = await getSession(id, sessionId); + if (sessionData) { + setCurrentSessionId(sessionId); + setSession(sessionData); + // Загружаем персонажа сессии + if (sessionData.playerId) { + const character = await getPlayerCharacter(sessionData.playerId); + setPlayerCharacter(character); + } + } + setIsInitialLoading(false); + }; + + const handleDeleteSession = async ( + sessionId: string, + sessionName: string, + ) => { + if (!id) return; + + const confirmed = confirm(`Удалить сессию "${sessionName}"?`); + if (!confirmed) return; + + const success = await deleteSession(id, sessionId); + if (success) { + const newList = sessionsList.filter((s) => s.id !== sessionId); + setSessionsList(newList); + + // Если удалили текущую сессию — переключаемся на первую оставшуюся + if (sessionId === currentSessionId && newList.length > 0) { + handleSwitchSession(newList[0].id); + } + } + }; + + const currentSessionName = + sessionsList.find((s) => s.id === currentSessionId)?.name || "Сессия"; + + if (isInitialLoading) { + return ( +
+
+

Загрузка игры...

+
+
+ ); + } + + if (!story) { + return ( +
+
+

История не найдена

+ + ← Вернуться к списку + +
+
+ ); + } + + return ( +
+
+ + ← + +
+

{story.title}

+
+ + {showSessionMenu && ( +
+
+ Сессии + +
+
+ {sessionsList.map((s) => ( +
+ + {sessionsList.length > 1 && ( + + )} +
+ ))} +
+
+ )} +
+
+
+ + 🎟️ {formatTokens(estimateTokens(session?.messages || []))} + +
+
+ +
+ {/* Character panel - desktop only */} + {activeCharacter?.avatarUrl && ( +
+ {activeCharacter.name} +
+ + {activeCharacter.name} + + + {activeCharacter.role} + +
+
+ )} + +
+
+ {session?.messages.map((message) => ( +
+ {editingMessageId === message.id ? ( +
+