Chat management
This commit is contained in:
parent
aad0d06798
commit
21ad47a67b
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const abortControllerRef = useRef<AbortController>(new AbortController());
|
||||
|
||||
// Edit state
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(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,10 +389,74 @@ export const ChatPanel = () => {
|
|||
</div>
|
||||
) : (
|
||||
<div class={styles.messages} ref={messagesRef}>
|
||||
{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';
|
||||
|
||||
return (
|
||||
<div key={message.id} class={styles.message} data-role={message.role}>
|
||||
<div class={styles.messageHeader}>
|
||||
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
||||
|
||||
{!isLoading && canEdit && (
|
||||
<div class={styles.messageActions}>
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleStartEdit(message)}
|
||||
title="Edit message"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleDeleteMessage(message.id)}
|
||||
title="Delete this and all following messages"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div class={styles.editContainer}>
|
||||
<ContentEditable
|
||||
autoLines
|
||||
class={styles.editTextarea}
|
||||
value={highlight(editingContent)}
|
||||
onInput={setEditingContent}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class={styles.editActions}>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.saveButton)}
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!editingContent.trim()}
|
||||
title="Save (Ctrl+Enter)"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.cancelButton)}
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message.role === 'assistant' && message.reasoning_content && (
|
||||
<div class={styles.reasoningContent}>
|
||||
{message.reasoning_content}
|
||||
|
|
@ -346,8 +477,11 @@ export const ChatPanel = () => {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div class={clsx(styles.message, styles.errorMessage)} data-role="assistant">
|
||||
<div class={styles.role}>error</div>
|
||||
|
|
@ -388,7 +522,7 @@ export const ChatPanel = () => {
|
|||
<ContentEditable
|
||||
autoLines
|
||||
class={styles.input}
|
||||
value={input}
|
||||
value={highlight(input)}
|
||||
onInput={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
|
|
@ -425,6 +559,16 @@ export const ChatPanel = () => {
|
|||
<ChevronsRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
{lastMessage?.role === 'assistant' && (
|
||||
<button
|
||||
class={styles.regenerateButton}
|
||||
onClick={handleRegenerate}
|
||||
disabled={isDisabled}
|
||||
title="Regenerate last response"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue