Chat management
This commit is contained in:
parent
aad0d06798
commit
21ad47a67b
|
|
@ -32,6 +32,82 @@
|
||||||
border-radius: 4px;
|
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"] {
|
.message[data-role="user"] {
|
||||||
background: var(--bg-active);
|
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 {
|
.stopButton {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks"
|
||||||
import LLM from "../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
import Prompt from "../utils/prompt";
|
import Prompt from "../utils/prompt";
|
||||||
import { Tools } from "../utils/tools";
|
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 clsx from "clsx";
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
|
|
||||||
|
|
@ -54,6 +54,10 @@ export const ChatPanel = () => {
|
||||||
const messagesRef = useRef<HTMLDivElement>(null);
|
const messagesRef = useRef<HTMLDivElement>(null);
|
||||||
const abortControllerRef = useRef<AbortController>(new AbortController());
|
const abortControllerRef = useRef<AbortController>(new AbortController());
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||||
|
const [editingContent, setEditingContent] = useInputState('');
|
||||||
|
|
||||||
const appStateRef = useRef(appState);
|
const appStateRef = useRef(appState);
|
||||||
appStateRef.current = 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;
|
const isDisabled = !currentStory || !connection || !model || isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -322,10 +389,74 @@ export const ChatPanel = () => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class={styles.messages} ref={messagesRef}>
|
<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 key={message.id} class={styles.message} data-role={message.role}>
|
||||||
|
<div class={styles.messageHeader}>
|
||||||
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
<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 && (
|
{message.role === 'assistant' && message.reasoning_content && (
|
||||||
<div class={styles.reasoningContent}>
|
<div class={styles.reasoningContent}>
|
||||||
{message.reasoning_content}
|
{message.reasoning_content}
|
||||||
|
|
@ -346,8 +477,11 @@ export const ChatPanel = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{error && (
|
{error && (
|
||||||
<div class={clsx(styles.message, styles.errorMessage)} data-role="assistant">
|
<div class={clsx(styles.message, styles.errorMessage)} data-role="assistant">
|
||||||
<div class={styles.role}>error</div>
|
<div class={styles.role}>error</div>
|
||||||
|
|
@ -388,7 +522,7 @@ export const ChatPanel = () => {
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
autoLines
|
autoLines
|
||||||
class={styles.input}
|
class={styles.input}
|
||||||
value={input}
|
value={highlight(input)}
|
||||||
onInput={setInput}
|
onInput={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
|
|
@ -425,6 +559,16 @@ export const ChatPanel = () => {
|
||||||
<ChevronsRight size={14} />
|
<ChevronsRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{lastMessage?.role === 'assistant' && (
|
||||||
|
<button
|
||||||
|
class={styles.regenerateButton}
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
disabled={isDisabled}
|
||||||
|
title="Regenerate last response"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,8 @@ type Action =
|
||||||
// Chat
|
// Chat
|
||||||
| { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage }
|
| { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage }
|
||||||
| { type: 'CLEAR_CHAT'; worldId: string; storyId: string }
|
| { 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
|
// Connection
|
||||||
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
|
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
|
||||||
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
|
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
|
||||||
|
|
@ -254,7 +256,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
worlds: [...state.worlds, world],
|
worlds: [...state.worlds, world],
|
||||||
currentWorldId: world.id,
|
currentWorldId: world.id,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: action.chatOnly ? 'system' : 'lore',
|
currentTab: 'menu',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'RENAME_WORLD': {
|
case 'RENAME_WORLD': {
|
||||||
|
|
@ -271,12 +273,11 @@ function reducer(state: IState, action: Action): IState {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'SELECT_WORLD': {
|
case 'SELECT_WORLD': {
|
||||||
const world = state.worlds.find(w => w.id === action.worldId);
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentWorldId: action.worldId,
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: world?.chatOnly ? 'system' : 'lore',
|
currentTab: 'menu',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'CREATE_STORY': {
|
case 'CREATE_STORY': {
|
||||||
|
|
@ -297,7 +298,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
|
||||||
currentWorldId: action.worldId,
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: story.id,
|
currentStoryId: story.id,
|
||||||
currentTab: world?.chatOnly ? 'chat' : 'story',
|
currentTab: 'menu',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'RENAME_STORY': {
|
case 'RENAME_STORY': {
|
||||||
|
|
@ -350,7 +351,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
return {
|
return {
|
||||||
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
|
||||||
currentStoryId: newStory.id,
|
currentStoryId: newStory.id,
|
||||||
currentTab: world?.chatOnly ? 'chat' : 'story',
|
currentTab: 'menu',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'ADD_LORE_ENTRY': {
|
case 'ADD_LORE_ENTRY': {
|
||||||
|
|
@ -406,6 +407,21 @@ function reducer(state: IState, action: Action): IState {
|
||||||
case 'CLEAR_CHAT': {
|
case 'CLEAR_CHAT': {
|
||||||
return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] }));
|
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': {
|
case 'SET_CONNECTION': {
|
||||||
return { ...state, connection: action.connection };
|
return { ...state, connection: action.connection };
|
||||||
}
|
}
|
||||||
|
|
@ -530,7 +546,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
worlds: [...state.worlds, world],
|
worlds: [...state.worlds, world],
|
||||||
currentWorldId: world.id,
|
currentWorldId: world.id,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: world.chatOnly ? 'system' : 'lore',
|
currentTab: 'menu',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue