From e8cd01c693786b3ab1c7eb1455de98c57bfeb979 Mon Sep 17 00:00:00 2001 From: Alexej Wolff Date: Sat, 9 May 2026 06:39:10 +0200 Subject: [PATCH] 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 --- server/index.js | 71 ++- src/components/game/StreamingText.tsx | 91 ++++ src/components/game/index.ts | 1 + src/pages/CreateStoryPage.css | 117 +++++ src/pages/CreateStoryPage.tsx | 99 +++- src/pages/GamePage.css | 334 ++++++++++-- src/pages/GamePage.tsx | 710 +++++++++++++++++++------- src/services/deepseek.ts | 204 +++++++- src/services/imageGen.ts | 131 ++++- 9 files changed, 1498 insertions(+), 260 deletions(-) create mode 100644 src/components/game/StreamingText.tsx diff --git a/server/index.js b/server/index.js index 73f3a25..e0e3004 100644 --- a/server/index.js +++ b/server/index.js @@ -198,18 +198,16 @@ function validateDeepSeekRequest(body) { const errors = []; const { messages, temperature, max_tokens } = body; - // Validate messages + // Validate messages - only check critical errors, truncation handled in sanitize if (!Array.isArray(messages)) { errors.push("messages must be an array"); } else { if (messages.length === 0) { errors.push("messages cannot be empty"); } - if (messages.length > DEEPSEEK_LIMITS.MAX_MESSAGES) { - errors.push(`too many messages (max ${DEEPSEEK_LIMITS.MAX_MESSAGES})`); - } + // Don't error on too many messages - we'll truncate them + // Don't error on too long messages - we'll truncate them - let totalLength = 0; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (!msg || typeof msg !== "object") { @@ -223,21 +221,8 @@ function validateDeepSeekRequest(body) { } if (typeof msg.content !== "string") { errors.push(`messages[${i}].content must be a string`); - } else { - if (msg.content.length > DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH) { - errors.push( - `messages[${i}].content too long (max ${DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH} chars)`, - ); - } - totalLength += msg.content.length; } } - - if (totalLength > DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH) { - errors.push( - `total message content too long (max ${DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH} chars)`, - ); - } } // Validate temperature @@ -264,8 +249,37 @@ function validateDeepSeekRequest(body) { return errors; } +/** + * Truncate ONLY individual messages that are too long. + * Does NOT remove messages - that would lose context. + * If total is too long, client should handle via summary generation. + */ +function truncateMessagesToFit(messages) { + const MAX_PER_MSG = DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH; + + // Only truncate individual messages that are too long - keep the END (recent context) + return messages.map((msg, idx) => { + if (msg.content && msg.content.length > MAX_PER_MSG) { + console.log( + `Truncating message ${idx} (${msg.role}) from ${msg.content.length} to ${MAX_PER_MSG} chars - keeping END`, + ); + // Keep end of message (most recent context is more important) + return { + ...msg, + content: + "[earlier content truncated]...\n\n" + + msg.content.slice(-MAX_PER_MSG + 50), + }; + } + return msg; + }); +} + function sanitizeDeepSeekMessages(messages) { - return messages.map((msg) => ({ + // First truncate to fit limits + const truncatedMessages = truncateMessagesToFit(messages); + + return truncatedMessages.map((msg) => ({ role: msg.role, content: String(msg.content), })); @@ -1059,7 +1073,7 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => { // Прокси для генерации изображений через Grok (обход CORS) app.post("/api/generate-image", requireAuth, async (req, res) => { try { - const { prompt } = req.body; + const { prompt, orientation = "portrait", refHistory } = req.body; const apiKey = process.env.GEMINIGEN_API_KEY; if (!apiKey) { @@ -1068,14 +1082,27 @@ app.post("/api/generate-image", requireAuth, async (req, res) => { .json({ error: "GeminiGen API key not configured" }); } - console.log("Generating image with Grok, prompt:", prompt); + console.log( + "Generating image with Grok, prompt:", + prompt, + "orientation:", + orientation, + "refHistory:", + refHistory, + ); // Используем FormData для multipart/form-data const formData = new FormData(); formData.append("prompt", prompt); - formData.append("orientation", "portrait"); // 9:16 + formData.append("orientation", orientation); // portrait (9:16) or landscape (16:9) formData.append("num_result", "1"); + // Добавляем референс истории если есть (для консистентности персонажей) + if (refHistory) { + formData.append("ref_history", refHistory); + console.log("Using ref_history:", refHistory); + } + const response = await fetch( "https://api.geminigen.ai/uapi/v1/imagen/grok", { diff --git a/src/components/game/StreamingText.tsx b/src/components/game/StreamingText.tsx new file mode 100644 index 0000000..45d5459 --- /dev/null +++ b/src/components/game/StreamingText.tsx @@ -0,0 +1,91 @@ +import { useRef, useEffect, useState, useCallback } from "react"; +import ReactMarkdown from "react-markdown"; + +interface StreamingTextProps { + content: string; + isStreaming?: boolean; +} + +/** + * Component that renders text with typewriter-like fade-in animation during streaming. + * Characters appear gradually with a soft fade effect. + */ +export default function StreamingText({ + content, + isStreaming = false, +}: StreamingTextProps) { + const [visibleChars, setVisibleChars] = useState(content.length); + const targetCharsRef = useRef(content.length); + const animationFrameRef = useRef(null); + + const animate = useCallback(() => { + setVisibleChars((prev) => { + const target = targetCharsRef.current; + if (prev >= target) { + animationFrameRef.current = null; + return prev; + } + // Reveal 3-5 characters per frame for smooth but fast animation + const step = Math.min(5, Math.max(3, Math.ceil((target - prev) / 10))); + const next = Math.min(prev + step, target); + + // Continue animation + animationFrameRef.current = requestAnimationFrame(animate); + return next; + }); + }, []); + + useEffect(() => { + if (!isStreaming) { + // Not streaming - show all immediately + setVisibleChars(content.length); + targetCharsRef.current = content.length; + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + return; + } + + // Content changed + targetCharsRef.current = content.length; + + // Start animation if not running + if (!animationFrameRef.current && visibleChars < content.length) { + animationFrameRef.current = requestAnimationFrame(animate); + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + }, [content, isStreaming, visibleChars, animate]); + + // Reset when content is cleared (new message) + useEffect(() => { + if (content.length === 0 || content.length < visibleChars - 100) { + setVisibleChars(0); + } + }, [content.length, visibleChars]); + + // Not streaming - render markdown normally + if (!isStreaming) { + return ( +
+ {content} +
+ ); + } + + // Streaming - show only visible characters with typewriter effect + const visibleText = content.slice(0, visibleChars); + + return ( +
+ {visibleText} + +
+ ); +} diff --git a/src/components/game/index.ts b/src/components/game/index.ts index 5a0133f..f5a446e 100644 --- a/src/components/game/index.ts +++ b/src/components/game/index.ts @@ -8,3 +8,4 @@ export { export { ChatInput } from "./ChatInput"; export { SessionSelector } from "./SessionSelector"; export { CharacterPanel } from "./CharacterPanel"; +export { default as StreamingText } from "./StreamingText"; diff --git a/src/pages/CreateStoryPage.css b/src/pages/CreateStoryPage.css index 8fcfcaf..fda92aa 100644 --- a/src/pages/CreateStoryPage.css +++ b/src/pages/CreateStoryPage.css @@ -1134,3 +1134,120 @@ justify-content: center; } } + +/* Banner/Cover Image */ +.banner-generator { + display: flex; + gap: 1.5rem; + align-items: flex-start; +} + +.banner-preview { + flex-shrink: 0; + width: 280px; + aspect-ratio: 16 / 9; + border-radius: 12px; + overflow: hidden; + background: #252525; + border: 2px dashed #444; +} + +.banner-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.banner-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #666; + font-size: 1rem; +} + +.banner-controls { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.btn-generate-banner { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border: none; + border-radius: 8px; + color: white; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + width: fit-content; +} + +.btn-generate-banner:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +.btn-generate-banner:disabled { + opacity: 0.6; + cursor: wait; +} + +.banner-url-input { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.banner-url-input .url-input { + flex: 1; + padding: 0.6rem 0.8rem; + background: #252525; + border: 1px solid #444; + border-radius: 8px; + color: #e5e5e5; + font-size: 0.85rem; +} + +.banner-url-input .url-input:focus { + outline: none; + border-color: #667eea; +} + +.clear-url-btn { + padding: 0.5rem 0.75rem; + background: #3a3a3a; + border: 1px solid #555; + border-radius: 6px; + color: #999; + cursor: pointer; + transition: all 0.2s; +} + +.clear-url-btn:hover { + background: #ef4444; + border-color: #ef4444; + color: white; +} + +@media (max-width: 600px) { + .banner-generator { + flex-direction: column; + } + + .banner-preview { + width: 100%; + } + + .btn-generate-banner { + width: 100%; + justify-content: center; + } +} diff --git a/src/pages/CreateStoryPage.tsx b/src/pages/CreateStoryPage.tsx index 23c56d0..2300208 100644 --- a/src/pages/CreateStoryPage.tsx +++ b/src/pages/CreateStoryPage.tsx @@ -9,7 +9,7 @@ import { getNPCCharacters, createNPCCharacter, } from "../services/api"; -import { generateAvatarUrl } from "../services/imageGen"; +import { generateAvatarUrl, generateBannerUrl } from "../services/imageGen"; import { useStoryGeneration } from "../hooks/useStoryGeneration"; import type { Character, @@ -92,6 +92,8 @@ export default function CreateStoryPage() { const [generatingAvatarIndex, setGeneratingAvatarIndex] = useState< number | null >(null); + const [generatingBanner, setGeneratingBanner] = useState(false); + const [coverImage, setCoverImage] = useState(""); const [savedNPCs, setSavedNPCs] = useState([]); const [showNPCSelector, setShowNPCSelector] = useState(false); @@ -154,6 +156,7 @@ export default function CreateStoryPage() { const story = await getStory(id); if (story) { + setCoverImage(story.coverImage || ""); setForm({ title: story.title, description: story.description || "", @@ -382,6 +385,37 @@ export default function CreateStoryPage() { } }; + // Генерация баннера/обложки + const handleGenerateBanner = async () => { + if (!form.title && !form.description && !form.summary) { + alert("Заполните название или описание для генерации баннера"); + return; + } + + setGeneratingBanner(true); + try { + // Передаём персонажей для поиска референса + const charactersWithAvatars = form.characters + .filter((c) => c.name && c.avatarUrl) + .map((c) => ({ name: c.name, avatarUrl: c.avatarUrl })); + + const url = await generateBannerUrl({ + title: form.title, + description: form.description || form.summary, + genre: form.genres, + setting: form.settings, + isNsfw: form.isNsfw, + characters: charactersWithAvatars, + }); + setCoverImage(url); + } catch (error) { + console.error("Failed to generate banner:", error); + alert("Ошибка при генерации баннера"); + } finally { + setGeneratingBanner(false); + } + }; + // Правила мира const handleRuleChange = (index: number, value: string) => { const newRules = [...form.worldRules]; @@ -430,7 +464,7 @@ export default function CreateStoryPage() { const storyData = { title: form.title, description: form.description || `Исекай история: ${form.title}`, - coverImage: "", + coverImage: coverImage, language: LANGUAGES.find((l) => l.code === form.language)?.name.split(" ")[1] || "Русский", @@ -622,9 +656,10 @@ export default function CreateStoryPage() {

{[ + { value: 0.75, label: "🎯 Точный", desc: "0.75" }, { value: 0.9, label: "⚖️ Сбалансированный", desc: "0.9" }, { value: 0.95, label: "✨ Живой", desc: "0.95" }, - { value: 1.05, label: "🎨 Креативный", desc: "1.05" }, + { value: 1.0, label: "🎨 Креативный", desc: "1.0" }, ].map((opt) => ( +

+ ИИ создаст обложку на основе названия, описания и жанров + истории. +

+
+ setCoverImage(e.target.value)} + placeholder="Или вставьте URL изображения..." + className="url-input" + /> + {coverImage && ( + + )} +
+
+ + + + {/* Первое сообщение */}

diff --git a/src/pages/GamePage.css b/src/pages/GamePage.css index 31a461c..6cc3a2d 100644 --- a/src/pages/GamePage.css +++ b/src/pages/GamePage.css @@ -14,6 +14,39 @@ touch-action: none; } +/* Streaming text animation - typewriter effect */ +@keyframes cursorBlink { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } +} + +.streaming-text { + line-height: 1.6; +} + +.streaming-text.is-streaming { + white-space: pre-wrap; + word-wrap: break-word; +} + +.streaming-text .visible-text { + display: inline; +} + +.streaming-text .streaming-cursor { + display: inline; + animation: cursorBlink 0.8s infinite; + color: #667eea; + margin-left: 1px; + font-weight: normal; +} + .game-page * { touch-action: pan-y; } @@ -243,6 +276,7 @@ overflow: hidden; position: relative; width: 100%; + align-items: center; } .scroll-to-bottom-btn { @@ -278,12 +312,13 @@ overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; - padding: 1rem; - padding-left: max(1rem, env(safe-area-inset-left)); - padding-right: max(1rem, env(safe-area-inset-right)); + padding: 1.5rem 2rem; + padding-left: max(2rem, env(safe-area-inset-left)); + padding-right: max(2rem, env(safe-area-inset-right)); padding-bottom: 1rem; display: flex; flex-direction: column; + align-items: center; gap: 0.75rem; width: 100%; box-sizing: border-box; @@ -312,10 +347,11 @@ } .message { - max-width: 88%; + max-width: 750px; animation: fadeIn 0.25s ease; word-wrap: break-word; overflow-wrap: break-word; + width: 100%; } @keyframes fadeIn { @@ -330,31 +366,49 @@ } .message.user { - align-self: flex-end; + /* No align-self - inherit center from parent */ } .message.assistant { - align-self: flex-start; + /* No align-self - inherit center from parent */ } .message-content { - padding: 0.875rem 1rem; - border-radius: 18px; - line-height: 1.7; - font-size: 1.1rem; + padding: 0.5rem 0; + line-height: 1.8; + font-size: 1.05rem; + text-align: justify; + text-justify: inter-word; } @media (max-width: 768px) { .message-content { - font-size: 22px; - line-height: 1.8; + font-size: 1rem; + line-height: 1.75; + text-align: left; + } + + .messages-container { + padding: 1rem; + gap: 0.5rem; + } + + .messages-container > * { + max-width: 100%; } } +.message.user { + /* Modern minimal card style */ +} + .message.user .message-content { - background: #2563eb; - color: white; - border-bottom-right-radius: 6px; + background: rgba(255, 255, 255, 0.03); + color: #9ca3af; + border-left: 2px solid #3b82f6; + padding: 0.75rem 1rem 0.75rem 1rem; + text-align: left; + border-radius: 0 8px 8px 0; } .message.assistant .message-content { @@ -364,12 +418,18 @@ padding: 0.5rem 0; } +/* Loading text */ +.loading-text { + color: #9ca3af; + font-style: italic; +} + .message-content p { margin: 0; } .message-content p + p { - margin-top: 0.75rem; + margin-top: 0.25rem; } /* Markdown стили */ @@ -418,9 +478,10 @@ .message-footer { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; margin-top: 0.25rem; - padding: 0 0.4rem; + padding: 0; + gap: 1rem; } .message-time { @@ -429,11 +490,13 @@ } .message.user .message-time { - color: rgba(255, 255, 255, 0.4); + color: #6b7280; } .message.user .message-footer { - flex-direction: row-reverse; + flex-direction: row; + padding-left: 1.75rem; + margin-top: 0.35rem; } .message-actions { @@ -639,19 +702,122 @@ border-color: #667eea; } +/* Anime-style loading indicator */ +.anime-loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + gap: 1rem; + max-width: 750px; + width: 100%; +} + +.loader-ring { + width: 48px; + height: 48px; + position: relative; + animation: loader-rotate 2s linear infinite; +} + +.loader-ring::before, +.loader-ring::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + border: 2px solid transparent; +} + +.loader-ring::before { + border-top-color: #8b5cf6; + border-right-color: #8b5cf6; + animation: loader-pulse 1.5s ease-in-out infinite; +} + +.loader-ring::after { + border-bottom-color: #ec4899; + border-left-color: #ec4899; + animation: loader-pulse 1.5s ease-in-out infinite reverse; +} + +.loader-ring-inner { + position: absolute; + inset: 6px; + border-radius: 50%; + border: 2px solid transparent; + border-top-color: #06b6d4; + border-bottom-color: #06b6d4; + animation: loader-rotate 1s linear infinite reverse; +} + +.loader-text { + color: #6b7280; + font-size: 0.85rem; + letter-spacing: 0.05em; + animation: loader-text-fade 1.5s ease-in-out infinite; +} + +.stop-generation-btn { + margin-top: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 8px; + color: #f87171; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s; +} + +.stop-generation-btn:hover { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.5); +} + +@keyframes loader-rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes loader-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes loader-text-fade { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + .input-container { flex-shrink: 0; display: flex; align-items: flex-end; gap: 0.5rem; - margin: 0.5rem; - margin-left: max(0.5rem, env(safe-area-inset-left)); - margin-right: max(0.5rem, env(safe-area-inset-right)); + margin: 0.5rem auto; margin-bottom: max(0.5rem, env(safe-area-inset-bottom)); padding: 0.5rem; background: #1a1a1a; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; + max-width: 750px; + width: calc(100% - 1rem); } .input-container textarea { @@ -774,15 +940,64 @@ color: #f59e0b; } -/* Streaming message animation */ -.message.streaming .message-text { +/* Streaming message animation - simple fade effect */ +.message.streaming .message-content { position: relative; } -.message.streaming .message-text::after { - content: "▋"; - animation: blink 1s infinite; - margin-left: 2px; +/* Simple fade-in for streaming text */ +.message.streaming .message-content, +.message.continuing .message-content { + animation: simpleFadeIn 0.3s ease-out; +} + +@keyframes simpleFadeIn { + from { + opacity: 0.7; + } + to { + opacity: 1; + } +} + +/* Message continuing animation */ +.message.continuing .message-content { + position: relative; +} + +/* Small loader for Continue mode */ +.continue-loader { + display: flex; + justify-content: center; + padding: 0.5rem; + color: #6b7280; +} + +.continue-loader-dots span { + animation: continueDot 1.4s infinite ease-in-out both; + font-size: 1.5rem; + line-height: 1; +} + +.continue-loader-dots span:nth-child(1) { + animation-delay: 0s; +} +.continue-loader-dots span:nth-child(2) { + animation-delay: 0.2s; +} +.continue-loader-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes continueDot { + 0%, + 80%, + 100% { + opacity: 0.3; + } + 40% { + opacity: 1; + } } /* Game Layout with Character Panel */ @@ -1012,3 +1227,64 @@ padding: 0.35rem 0.6rem; } } + +/* Chat Action Buttons (Retry, Erase) */ +.chat-actions { + display: flex; + gap: 0.5rem; + padding: 0.5rem 1rem; + justify-content: center; + flex-shrink: 0; + max-width: 750px; + margin: 0 auto; + width: 100%; +} + +.action-btn { + padding: 0.4rem 0.8rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + color: #888; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.3rem; +} + +.action-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #aaa; + border-color: rgba(255, 255, 255, 0.25); +} + +.action-btn.retry-btn:hover { + background: rgba(59, 130, 246, 0.15); + border-color: rgba(59, 130, 246, 0.4); + color: #60a5fa; +} + +.action-btn.erase-btn:hover { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.4); + color: #f87171; +} + +.action-btn.continue-btn:hover { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.4); + color: #4ade80; +} + +@media (max-width: 768px) { + .chat-actions { + padding: 0.4rem 0.75rem; + } + + .action-btn { + padding: 0.35rem 0.6rem; + font-size: 0.7rem; + } +} diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index e202165..0489477 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -1,6 +1,7 @@ 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 { @@ -18,6 +19,7 @@ import type { ChatMessage, MessageVersion, PlayerCharacter, + WorldState, } from "../types"; import { useGameSession, @@ -25,7 +27,11 @@ import { useCharacterDetection, useLazyMessages, } from "../hooks"; -import { SessionSelector, CharacterPanel } from "../components/game"; +import { + SessionSelector, + CharacterPanel, + StreamingText, +} from "../components/game"; import "./GamePage.css"; function generateId(): string { @@ -76,7 +82,7 @@ export default function GamePage() { resetStreaming, startStreaming, abortController, - abort, + // abort - не используется, т.к. кнопка "Остановить" закомментирована getLatestContent, } = useStreamingResponse(); @@ -108,6 +114,7 @@ export default function GamePage() { // Local state const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isContinuing, setIsContinuing] = useState(false); const [error, setError] = useState(null); const [showScrollButton, setShowScrollButton] = useState(false); const [editingMessageId, setEditingMessageId] = useState(null); @@ -329,7 +336,7 @@ export default function GamePage() { const response = await generateStoryResponseStream( story, session.messages, - input.trim(), + userMessage.content, // Use userMessage.content to include [OOC: ...] wrapper updateStreamingContent, playerCharacter || undefined, session, @@ -347,31 +354,51 @@ export default function GamePage() { const allMessages = [...updatedMessages, assistantMessage]; - const newKeyEvents = await extractKeyEvents( - response, - session.keyEvents || [], - ); - + // Skip state updates for OOC messages - they are meta-conversations + let newKeyEvents = session.keyEvents || []; let newSummary = session.storySummary; - if (allMessages.length % 15 === 0 && allMessages.length > 0) { - newSummary = await generateStorySummary( - story, - allMessages, - session.storySummary, - ); - } - - // Update world state for better character tracking let newWorldState = session.worldState; - if (shouldUpdateWorldState(allMessages.length, session.worldState)) { - try { - newWorldState = await updateWorldState( + + 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.worldState, + session.storySummary, ); - } catch (e) { - console.warn("Failed to update world state:", e); + } + + // 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); + } } } @@ -417,8 +444,243 @@ export default function GamePage() { } }; - const handleStop = () => { - abort(); + // Закомментирована кнопка остановки - функция сохранена на будущее + // 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) => { @@ -525,6 +787,25 @@ export default function GamePage() { } }, 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, @@ -532,7 +813,7 @@ export default function GamePage() { editContent.trim(), updateStreamingContent, playerCharacter || undefined, - session, + sessionForEdit, signal, ); flushStreamingContent(); @@ -562,14 +843,14 @@ export default function GamePage() { assistantMessage, ]; - // Update world state after edit regeneration - let newWorldState = session.worldState; - if (shouldUpdateWorldState(allMessages.length, session.worldState)) { + // Update world state after edit regeneration (based on recalculated state) + let newWorldState = recalculatedWorldState; + if (shouldUpdateWorldState(allMessages.length, recalculatedWorldState)) { try { newWorldState = await updateWorldState( story, allMessages, - session.worldState, + recalculatedWorldState, ); } catch (e) { console.warn("Failed to update world state:", e); @@ -789,107 +1070,149 @@ export default function GamePage() { ↑ Загрузить ещё {hiddenCount} сообщений )} - {visibleMessages.map((message) => ( -
- {editingMessageId === message.id ? ( -
-