feat: message editing with version history and AI regeneration

This commit is contained in:
Alexej Wolff
2026-02-11 16:34:25 +01:00
parent dae3c88020
commit a0827caabd
3 changed files with 362 additions and 16 deletions
+146 -7
View File
@@ -307,10 +307,10 @@
}
.message.assistant .message-content {
background: #1a1a1a;
background: transparent;
color: #e5e5e5;
border: none;
border-bottom-left-radius: 6px;
padding: 0.5rem 0;
}
.message-content p {
@@ -364,19 +364,144 @@
margin: 0.75rem 0;
}
.message-time {
display: block;
font-size: 0.65rem;
color: #444;
.message-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.25rem;
padding: 0 0.4rem;
}
.message-time {
font-size: 0.65rem;
color: #444;
}
.message.user .message-time {
text-align: right;
color: rgba(255, 255, 255, 0.4);
}
.message.user .message-footer {
flex-direction: row-reverse;
}
.message-actions {
display: flex;
align-items: center;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.2s;
}
.message:hover .message-actions {
opacity: 1;
}
.edit-btn {
background: none;
border: none;
font-size: 0.75rem;
cursor: pointer;
padding: 0.25rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.edit-btn:hover {
opacity: 1;
}
.version-switcher {
display: flex;
align-items: center;
gap: 0.25rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.15rem 0.35rem;
}
.version-btn {
background: none;
border: none;
color: #888;
font-size: 0.9rem;
cursor: pointer;
padding: 0 0.25rem;
line-height: 1;
transition: color 0.2s;
}
.version-btn:hover {
color: #fff;
}
.version-indicator {
font-size: 0.7rem;
color: #666;
min-width: 2rem;
text-align: center;
}
/* Message editing */
.message-edit-form {
width: 100%;
}
.message-edit-textarea {
width: 100%;
min-height: 60px;
padding: 0.75rem;
background: #1a1a1a;
border: 1px solid rgba(37, 99, 235, 0.5);
border-radius: 12px;
color: white;
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
line-height: 1.4;
}
.message-edit-textarea:focus {
outline: none;
border-color: #2563eb;
}
.message-edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.edit-cancel-btn,
.edit-save-btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 8px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.edit-cancel-btn {
background: #333;
color: #aaa;
}
.edit-cancel-btn:hover {
background: #444;
color: #fff;
}
.edit-save-btn {
background: #2563eb;
color: white;
}
.edit-save-btn:hover {
background: #1d4ed8;
}
.message.loading .message-content {
padding: 1rem;
}
@@ -664,4 +789,18 @@
right: 0.75rem;
font-size: 1rem;
}
.message-actions {
opacity: 1;
}
.message-edit-textarea {
font-size: 0.9rem;
}
.edit-cancel-btn,
.edit-save-btn {
font-size: 0.75rem;
padding: 0.35rem 0.6rem;
}
}
+209 -9
View File
@@ -61,6 +61,8 @@ export default function GamePage() {
const [streamingContent, setStreamingContent] = useState("");
const [showSessionMenu, setShowSessionMenu] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -370,6 +372,145 @@ export default function GamePage() {
}
};
// Начать редактирование сообщения
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 versions = message.versions || [{ content: message.content, timestamp: message.timestamp }];
const currentVersionIndex = message.activeVersion || 0;
// Добавляем новую версию
const newVersion = { content: editContent.trim(), timestamp: new Date() };
const newVersions = [...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);
setEditingMessageId(null);
setEditContent("");
setIsLoading(true);
setError(null);
setStreamingContent("");
abortControllerRef.current = new AbortController();
try {
// Генерируем новый ответ ИИ
const response = await generateStoryResponseStream(
story,
messagesUpToEdit,
editContent.trim(),
(chunk) => {
setStreamingContent((prev) => prev + chunk);
},
playerCharacter || undefined,
session,
abortControllerRef.current.signal,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const allMessages = [...updatedMessages, assistantMessage];
const finalSession: GameSession = {
...session,
messages: allMessages,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
if (streamingContent.trim()) {
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: streamingContent,
timestamp: new Date(),
};
const partialSession: GameSession = {
...session,
messages: [...updatedMessages, partialMessage],
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
}
} finally {
setIsLoading(false);
setStreamingContent("");
abortControllerRef.current = null;
}
};
// Переключить версию сообщения
const handleSwitchVersion = (messageId: string, direction: "prev" | "next") => {
if (!session) 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 updatedMessage: ChatMessage = {
...message,
content: message.versions[newVersion].content,
activeVersion: newVersion,
};
const updatedMessages = [...session.messages];
updatedMessages[messageIndex] = updatedMessage;
setSession({ ...session, messages: updatedMessages });
};
// Функции управления сессиями
const handleCreateNewSession = async () => {
if (!story || !id) return;
@@ -552,15 +693,74 @@ export default function GamePage() {
>
{session?.messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{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>
))}