1
0
Fork 0

Chat management

This commit is contained in:
Pabloader 2026-04-07 11:32:41 +00:00
parent aad0d06798
commit 21ad47a67b
3 changed files with 289 additions and 30 deletions

View File

@ -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;

View File

@ -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,32 +389,99 @@ export const ChatPanel = () => {
</div>
) : (
<div class={styles.messages} ref={messagesRef}>
{currentStory.chatMessages.map((message) => (
<div key={message.id} class={styles.message} data-role={message.role}>
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
{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 && (
<div class={styles.reasoningContent}>
{message.reasoning_content}
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>
)}
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: highlight(message.content, false).trim() }}
/>
{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}
</div>
)}
{message.role === 'assistant' && message.tool_calls && (
<div class={styles.toolCalls}>
{message.tool_calls.map((tool) => (
<span key={tool.id} class={styles.toolBadge}>
{tool.function.name}
</span>
))}
</div>
)}
</div>
))}
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: highlight(message.content, false).trim() }}
/>
{message.role === 'assistant' && message.tool_calls && (
<div class={styles.toolCalls}>
{message.tool_calls.map((tool) => (
<span key={tool.id} class={styles.toolBadge}>
{tool.function.name}
</span>
))}
</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>

View File

@ -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',
};
}
}