From 21ad47a67bbf4b49b0a9f2903e16aeb6cb747dfd Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 7 Apr 2026 11:32:41 +0000 Subject: [PATCH] Chat management --- .../assets/chat-sidebar.module.css | 99 +++++++++ .../storywriter/components/chat-sidebar.tsx | 192 +++++++++++++++--- src/games/storywriter/contexts/state.tsx | 28 ++- 3 files changed, 289 insertions(+), 30 deletions(-) diff --git a/src/games/storywriter/assets/chat-sidebar.module.css b/src/games/storywriter/assets/chat-sidebar.module.css index f3770f7..5f46386 100644 --- a/src/games/storywriter/assets/chat-sidebar.module.css +++ b/src/games/storywriter/assets/chat-sidebar.module.css @@ -32,6 +32,82 @@ border-radius: 4px; } +.messageHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.messageActions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.message:hover .messageActions { + opacity: 1; +} + +.iconButton { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(:disabled) { + color: var(--text); + background: var(--bg-hover); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.saveButton:hover:not(:disabled) { + color: var(--accent); +} + +.cancelButton:hover:not(:disabled) { + color: var(--error, #f44336); +} + +.editContainer { + display: flex; + flex-direction: column; + gap: 8px; +} + +.editTextarea { + width: 100%; + min-height: 80px; + padding: 8px; + font-size: 13px; + font-family: inherit; + background: var(--bg-panel); + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--text); + outline: none; + resize: vertical; + white-space: pre-wrap; + word-wrap: break-word; +} + +.editActions { + display: flex; + gap: 4px; +} + .message[data-role="user"] { background: var(--bg-active); } @@ -244,6 +320,29 @@ } } +.regenerateButton { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + color: var(--text-muted); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + height: 32px; + + &:hover:not(:disabled) { + color: var(--text); + border-color: var(--text-muted); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + .stopButton { padding: 8px 16px; font-size: 13px; diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index d7d2f52..7ee3ca5 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -9,7 +9,7 @@ import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks" import LLM from "../utils/llm"; import Prompt from "../utils/prompt"; import { Tools } from "../utils/tools"; -import { Sparkles, ChevronsRight } from "lucide-preact"; +import { Sparkles, ChevronsRight, Trash2, Edit2, Check, X, RefreshCw } from "lucide-preact"; import clsx from "clsx"; import { ContentEditable } from "@common/components/ContentEditable"; @@ -54,6 +54,10 @@ export const ChatPanel = () => { const messagesRef = useRef(null); const abortControllerRef = useRef(new AbortController()); + // Edit state + const [editingMessageId, setEditingMessageId] = useState(null); + const [editingContent, setEditingContent] = useInputState(''); + const appStateRef = useRef(appState); appStateRef.current = appState; @@ -304,6 +308,69 @@ export const ChatPanel = () => { }); }; + const handleDeleteMessage = useCallback((messageId: string) => { + if (!currentStory || !currentWorld) return; + dispatch({ + type: 'DELETE_CHAT_MESSAGES_FROM', + worldId: currentWorld.id, + storyId: currentStory.id, + messageId, + }); + }, [currentStory, currentWorld, dispatch]); + + const handleStartEdit = useCallback((message: ChatMessage) => { + setEditingMessageId(message.id); + setEditingContent(message.content); + }, [setEditingContent]); + + const handleSaveEdit = useCallback(() => { + if (!currentStory || !currentWorld || !editingMessageId) return; + + dispatch({ + type: 'EDIT_CHAT_MESSAGE', + worldId: currentWorld.id, + storyId: currentStory.id, + messageId: editingMessageId, + content: editingContent.trim(), + }); + + setEditingMessageId(null); + setEditingContent(''); + }, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]); + + const handleCancelEdit = useCallback(() => { + setEditingMessageId(null); + setEditingContent(''); + }, [setEditingContent]); + + const handleRegenerate = useCallback(async () => { + if (!currentStory || !connection || !model || isLoading) return; + + // Only regenerate if last message is assistant + const lastMessage = currentStory.chatMessages.at(-1); + if (!lastMessage || lastMessage.role !== 'assistant') return; + + setIsLoading(true); + setError(null); + abortControllerRef.current = new AbortController(); + + try { + // Delete the last assistant message and regenerate + dispatch({ + type: 'DELETE_CHAT_MESSAGES_FROM', + worldId: currentWorld!.id, + storyId: currentStory.id, + messageId: lastMessage.id, + }); + + // Find the message history before the deleted assistant message + const messages = currentStory.chatMessages.slice(0, -1); + await sendMessage(messages); + } finally { + setIsLoading(false); + } + }, [currentStory, currentWorld, connection, model, isLoading, dispatch, sendMessage]); + const isDisabled = !currentStory || !connection || !model || isLoading; return ( @@ -322,32 +389,99 @@ export const ChatPanel = () => { ) : (
- {currentStory.chatMessages.map((message) => ( -
- + {currentStory.chatMessages.map((message, index) => { + const isEditing = editingMessageId === message.id; + const canEdit = message.role === 'user' || message.role === 'assistant'; + const canDelete = index < currentStory.chatMessages.length - 1 || message.role === 'user' || message.role === 'assistant'; - {message.role === 'assistant' && message.reasoning_content && ( -
- {message.reasoning_content} + return ( +
+
+ + + {!isLoading && canEdit && ( +
+ + +
+ )}
- )} -
+ {isEditing ? ( +
+ { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + handleSaveEdit(); + } + if (e.key === 'Escape') { + e.preventDefault(); + handleCancelEdit(); + } + }} + /> +
+ + +
+
+ ) : ( + <> + {message.role === 'assistant' && message.reasoning_content && ( +
+ {message.reasoning_content} +
+ )} - {message.role === 'assistant' && message.tool_calls && ( -
- {message.tool_calls.map((tool) => ( - - {tool.function.name} - - ))} -
- )} -
- ))} +
+ + {message.role === 'assistant' && message.tool_calls && ( +
+ {message.tool_calls.map((tool) => ( + + {tool.function.name} + + ))} +
+ )} + + )} +
+ ); + })} {error && (
error
@@ -388,7 +522,7 @@ export const ChatPanel = () => { { )} + {lastMessage?.role === 'assistant' && ( + + )}
)}
diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index dca4582..da92cb9 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -148,6 +148,8 @@ type Action = // Chat | { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage } | { type: 'CLEAR_CHAT'; worldId: string; storyId: string } + | { type: 'DELETE_CHAT_MESSAGES_FROM'; worldId: string; storyId: string; messageId: string } + | { type: 'EDIT_CHAT_MESSAGE'; worldId: string; storyId: string; messageId: string; content: string } // Connection | { type: 'SET_CONNECTION'; connection: LLM.Connection | null } | { type: 'SET_MODEL'; model: LLM.ModelInfo | null } @@ -254,7 +256,7 @@ function reducer(state: IState, action: Action): IState { worlds: [...state.worlds, world], currentWorldId: world.id, currentStoryId: null, - currentTab: action.chatOnly ? 'system' : 'lore', + currentTab: 'menu', }; } case 'RENAME_WORLD': { @@ -271,12 +273,11 @@ function reducer(state: IState, action: Action): IState { }; } case 'SELECT_WORLD': { - const world = state.worlds.find(w => w.id === action.worldId); return { ...state, currentWorldId: action.worldId, currentStoryId: null, - currentTab: world?.chatOnly ? 'system' : 'lore', + currentTab: 'menu', }; } case 'CREATE_STORY': { @@ -297,7 +298,7 @@ function reducer(state: IState, action: Action): IState { ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })), currentWorldId: action.worldId, currentStoryId: story.id, - currentTab: world?.chatOnly ? 'chat' : 'story', + currentTab: 'menu', }; } case 'RENAME_STORY': { @@ -350,7 +351,7 @@ function reducer(state: IState, action: Action): IState { return { ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })), currentStoryId: newStory.id, - currentTab: world?.chatOnly ? 'chat' : 'story', + currentTab: 'menu', }; } case 'ADD_LORE_ENTRY': { @@ -406,6 +407,21 @@ function reducer(state: IState, action: Action): IState { case 'CLEAR_CHAT': { return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] })); } + case 'DELETE_CHAT_MESSAGES_FROM': { + return updateStory(state, action.worldId, action.storyId, s => { + const messageIndex = s.chatMessages.findIndex(m => m.id === action.messageId); + if (messageIndex === -1) return s; + return { ...s, chatMessages: s.chatMessages.slice(0, messageIndex) }; + }); + } + case 'EDIT_CHAT_MESSAGE': { + return updateStory(state, action.worldId, action.storyId, s => ({ + ...s, + chatMessages: s.chatMessages.map(m => + m.id === action.messageId ? { ...m, content: action.content } : m + ), + })); + } case 'SET_CONNECTION': { return { ...state, connection: action.connection }; } @@ -530,7 +546,7 @@ function reducer(state: IState, action: Action): IState { worlds: [...state.worlds, world], currentWorldId: world.id, currentStoryId: null, - currentTab: world.chatOnly ? 'system' : 'lore', + currentTab: 'menu', }; } }