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:
+529
-181
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user