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
This commit is contained in:
Alexej Wolff
2026-05-09 06:39:10 +02:00
parent f6fffc1561
commit e8cd01c693
9 changed files with 1498 additions and 260 deletions
+529 -181
View File
@@ -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<string | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(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} сообщений
</button>
)}
{visibleMessages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
{editingMessageId === message.id ? (
<div className="message-edit-form">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="message-edit-textarea"
autoFocus
/>
<div className="message-edit-actions">
<button
className="edit-cancel-btn"
onClick={handleCancelEdit}
>
Отмена
</button>
<button
className="edit-save-btn"
onClick={() => handleSaveEdit(message.id)}
>
Сохранить и переиграть
</button>
</div>
</div>
) : (
<>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<div className="message-footer">
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString(
"ru-RU",
{
hour: "2-digit",
minute: "2-digit",
},
)}
</span>
{message.role === "user" && !isLoading && (
<div className="message-actions">
{message.versions && message.versions.length > 1 && (
<div className="version-switcher">
<button
className="version-btn"
onClick={() =>
handleSwitchVersion(message.id, "prev")
}
>
</button>
<span className="version-indicator">
{(message.activeVersion || 0) + 1}/
{message.versions.length}
</span>
<button
className="version-btn"
onClick={() =>
handleSwitchVersion(message.id, "next")
}
>
</button>
</div>
)}
<button
className="edit-btn"
onClick={() =>
handleEditMessage(message.id, message.content)
}
title="Редактировать"
>
</button>
</div>
)}
</div>
</>
)}
</div>
))}
{/* 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;
{isLoading && 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">
<ReactMarkdown>{streamingContent}</ReactMarkdown>
<StreamingText
content={streamingContent}
isStreaming={true}
/>
</div>
</div>
)}
{isLoading && !streamingContent && (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
{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>
@@ -909,68 +1232,93 @@ export default function GamePage() {
</button>
)}
<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={isLoading}
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"
/>
{isLoading ? (
<button
type="button"
onClick={handleStop}
className="send-btn stop-btn"
{/* 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>
) : (
<button
type="submit"
disabled={!input.trim()}
className="send-btn"
>
</button>
)}
</form>
<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>