feat: added conversation history feature
This commit is contained in:
+119
-18
@@ -176,6 +176,125 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chatbox-history-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chatbox-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.chatbox-history-header h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chatbox-history-cancel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9fc7ea;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chatbox-history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chatbox-history-item {
|
||||
width: 100%;
|
||||
background: #262626;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chatbox-history-item:hover {
|
||||
background: #2d2d2d;
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.chatbox-history-item-toprow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chatbox-history-open-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chatbox-history-open-btn-body {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chatbox-history-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chatbox-history-item-meta {
|
||||
font-size: 10px;
|
||||
color: #9a9a9a;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chatbox-history-delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b7b7b7;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chatbox-history-delete-btn:hover {
|
||||
color: #e17070;
|
||||
}
|
||||
|
||||
.chatbox-history-item-preview {
|
||||
font-size: 11px;
|
||||
color: #c8c8c8;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatbox-history-empty {
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
padding: 16px 6px;
|
||||
}
|
||||
|
||||
.chatbox-todo-card {
|
||||
background: linear-gradient(180deg, #252525 0%, #202020 100%);
|
||||
border: 1px solid #3a3a3a;
|
||||
@@ -441,24 +560,6 @@
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.message-tool-confirmation-btn-always.dialog-btn-primary {
|
||||
background-color: #5aa36a;
|
||||
}
|
||||
|
||||
.message-tool-confirmation-btn-always.dialog-btn-primary:hover {
|
||||
background-color: #4a935a;
|
||||
box-shadow: 0 4px 12px rgba(90, 163, 106, 0.3);
|
||||
}
|
||||
|
||||
.message-tool-confirmation-btn-deny.dialog-btn-primary {
|
||||
background-color: #c96a6a;
|
||||
}
|
||||
|
||||
.message-tool-confirmation-btn-deny.dialog-btn-primary:hover {
|
||||
background-color: #b85b5b;
|
||||
box-shadow: 0 4px 12px rgba(201, 106, 106, 0.3);
|
||||
}
|
||||
|
||||
.message-tool-summary-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const {
|
||||
streamProcessorCallbacks,
|
||||
clearChatHistoryAndUIMock,
|
||||
projectStoreState,
|
||||
conversationStorageMock,
|
||||
showConfirmMock,
|
||||
} = vi.hoisted(() => ({
|
||||
agentCoreMock: {
|
||||
setLLMProvider: vi.fn(),
|
||||
@@ -21,9 +23,12 @@ const {
|
||||
abortCurrentRequest: vi.fn(),
|
||||
getAgentState: vi.fn(() => ({
|
||||
getMessages: vi.fn(() => []),
|
||||
getFullMessages: vi.fn(() => []),
|
||||
getConversationId: vi.fn(() => 'conv_test'),
|
||||
getTodos: vi.fn(() => []),
|
||||
subscribeTodoChanges: vi.fn(() => () => undefined),
|
||||
})),
|
||||
restoreConversation: vi.fn(),
|
||||
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
|
||||
shouldCompactBeforeNextTurn: vi.fn(async () => false),
|
||||
},
|
||||
@@ -31,11 +36,20 @@ const {
|
||||
processStreamMock: vi.fn(async () => ''),
|
||||
clearChatHistoryAndUIMock: vi.fn(),
|
||||
projectStoreState: {
|
||||
projectName: 'Test Project',
|
||||
toolFastForwardEnabled: false,
|
||||
setStatus: vi.fn(),
|
||||
setToolFastForwardEnabled: vi.fn(),
|
||||
toggleToolFastForwardEnabled: vi.fn(),
|
||||
},
|
||||
conversationStorageMock: {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
saveConversation: vi.fn(async () => undefined),
|
||||
loadConversation: vi.fn(async () => null),
|
||||
listConversations: vi.fn(async () => []),
|
||||
deleteConversation: vi.fn(async () => undefined),
|
||||
},
|
||||
showConfirmMock: vi.fn(async () => true),
|
||||
streamProcessorCallbacks: {
|
||||
onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined,
|
||||
onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined,
|
||||
@@ -181,6 +195,20 @@ vi.mock('../util/localLLMConfig', () => ({
|
||||
LOCAL_LLM_PROVIDER_KEY: 'local_browser',
|
||||
}));
|
||||
|
||||
vi.mock('../core/io/KGConversationStorage', () => ({
|
||||
KGConversationStorage: {
|
||||
getInstance: () => conversationStorageMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../util/dialogUtil', async () => {
|
||||
const actual = await vi.importActual('../util/dialogUtil');
|
||||
return {
|
||||
...actual,
|
||||
showConfirm: showConfirmMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./common/KGDropdown', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
@@ -209,16 +237,32 @@ describe('ChatBox', () => {
|
||||
processUserMessageMock.mockReset();
|
||||
processStreamMock.mockClear();
|
||||
clearChatHistoryAndUIMock.mockClear();
|
||||
conversationStorageMock.initialize.mockClear();
|
||||
conversationStorageMock.saveConversation.mockClear();
|
||||
conversationStorageMock.loadConversation.mockClear();
|
||||
conversationStorageMock.listConversations.mockClear();
|
||||
conversationStorageMock.deleteConversation.mockClear();
|
||||
showConfirmMock.mockClear();
|
||||
showConfirmMock.mockResolvedValue(true);
|
||||
agentCoreMock.compactConversation.mockClear();
|
||||
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
|
||||
agentCoreMock.restoreConversation.mockClear();
|
||||
streamProcessorCallbacks.onMessageAdd = undefined;
|
||||
streamProcessorCallbacks.onMessageUpdate = undefined;
|
||||
streamProcessorCallbacks.onMessageRemove = undefined;
|
||||
streamProcessorCallbacks.onProcessingChange = undefined;
|
||||
projectStoreState.projectName = 'Test Project';
|
||||
projectStoreState.toolFastForwardEnabled = false;
|
||||
projectStoreState.setStatus.mockClear();
|
||||
projectStoreState.setToolFastForwardEnabled.mockClear();
|
||||
projectStoreState.toggleToolFastForwardEnabled.mockClear();
|
||||
agentCoreMock.getAgentState.mockReturnValue({
|
||||
getMessages: vi.fn(() => []),
|
||||
getFullMessages: vi.fn(() => []),
|
||||
getConversationId: vi.fn(() => 'conv_test'),
|
||||
getTodos: vi.fn(() => []),
|
||||
subscribeTodoChanges: vi.fn(() => () => undefined),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the English assistant title under en_us', () => {
|
||||
@@ -436,7 +480,7 @@ describe('ChatBox', () => {
|
||||
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('resets fast-forward through the shared new chat clear path', () => {
|
||||
it('resets fast-forward through the shared new chat clear path', async () => {
|
||||
projectStoreState.toolFastForwardEnabled = true;
|
||||
clearChatHistoryAndUIMock.mockImplementation(() => {
|
||||
projectStoreState.setToolFastForwardEnabled(false);
|
||||
@@ -458,7 +502,151 @@ describe('ChatBox', () => {
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
expect(clearChatHistoryAndUIMock).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(clearChatHistoryAndUIMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: 'en_us',
|
||||
resolvedLocale: 'en_us',
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, 'en_us'),
|
||||
}}
|
||||
>
|
||||
<ChatBox isVisible={true} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('autosaves a completed conversation after sending', async () => {
|
||||
agentCoreMock.getAgentState.mockReturnValue({
|
||||
getMessages: vi.fn(() => [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }]),
|
||||
getFullMessages: vi.fn(() => [
|
||||
{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 },
|
||||
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 2 },
|
||||
]),
|
||||
getConversationId: vi.fn(() => 'conv_saved'),
|
||||
getTodos: vi.fn(() => []),
|
||||
subscribeTodoChanges: vi.fn(() => () => undefined),
|
||||
});
|
||||
processUserMessageMock.mockResolvedValue({
|
||||
displayUserMessage: true,
|
||||
sendToLLM: true,
|
||||
finalMessageForLLM: 'hello',
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: null,
|
||||
});
|
||||
processStreamMock.mockImplementation(async () => {
|
||||
streamProcessorCallbacks.onMessageAdd?.({ id: 'assistant-1', role: 'assistant', content: 'world' });
|
||||
return 'world';
|
||||
});
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(conversationStorageMock.saveConversation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const persistedDocument = ((conversationStorageMock.saveConversation.mock.calls[0] as unknown) as [string, { displayTranscript: ChatMessage[] }])[1];
|
||||
expect(persistedDocument.displayTranscript).toEqual([
|
||||
expect.objectContaining({ role: 'user', content: 'hello' }),
|
||||
expect.objectContaining({ role: 'assistant', content: 'world' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads and restores a selected saved conversation from history', async () => {
|
||||
conversationStorageMock.listConversations.mockResolvedValue([
|
||||
{
|
||||
conversationId: 'conv_old',
|
||||
title: 'Earlier conversation',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastTurnAt: 2,
|
||||
messageCount: 2,
|
||||
preview: 'Preview',
|
||||
},
|
||||
] as never);
|
||||
conversationStorageMock.loadConversation.mockResolvedValue({
|
||||
meta: {
|
||||
conversationId: 'conv_old',
|
||||
title: 'Earlier conversation',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastTurnAt: 2,
|
||||
messageCount: 2,
|
||||
preview: 'Preview',
|
||||
},
|
||||
document: {
|
||||
version: 1,
|
||||
conversationId: 'conv_old',
|
||||
continuationState: {
|
||||
messages: [{ id: 'a', role: 'user', content: 'prompt', timestamp: 1 }],
|
||||
todos: [],
|
||||
},
|
||||
fullHistory: {
|
||||
messages: [
|
||||
{ id: 'a', role: 'user', content: 'prompt', timestamp: 1 },
|
||||
{ id: 'b', role: 'assistant', content: 'reply', timestamp: 2 },
|
||||
],
|
||||
},
|
||||
displayTranscript: [
|
||||
{ id: 'display-a', role: 'user', content: 'prompt' },
|
||||
{ id: 'display-b', role: 'assistant', content: 'reply' },
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Conversation history'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Earlier conversation')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Earlier conversation'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(agentCoreMock.restoreConversation).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('prompt')).toBeTruthy();
|
||||
expect(screen.getByText('reply')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a saved conversation after confirmation', async () => {
|
||||
conversationStorageMock.listConversations.mockResolvedValue([
|
||||
{
|
||||
conversationId: 'conv_old',
|
||||
title: 'Earlier conversation',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastTurnAt: 2,
|
||||
messageCount: 2,
|
||||
preview: 'Preview',
|
||||
},
|
||||
] as never);
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Conversation history'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Earlier conversation')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showConfirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(conversationStorageMock.deleteConversation).toHaveBeenCalledWith('Test Project', 'conv_old');
|
||||
expect(screen.queryByText('Earlier conversation')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+367
-38
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||
import './ChatBox.css';
|
||||
import { FaPlus, FaDownload, FaForward } from 'react-icons/fa';
|
||||
import { FaPlus, FaDownload, FaForward, FaHistory, FaTrash } from 'react-icons/fa';
|
||||
import { UserMessage, AssistantMessage } from './chat';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { summarizeTodoCounts } from '../agent/core/todo';
|
||||
@@ -19,12 +19,18 @@ import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMM
|
||||
import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../util/localLLMConfig';
|
||||
import KGDropdown from './common/KGDropdown';
|
||||
import { useI18n } from '../i18n/useI18n';
|
||||
import { KGConversationStorage } from '../core/io/KGConversationStorage';
|
||||
import { SAVED_CONVERSATION_VERSION, type SavedConversationDocument, type SavedConversationMeta } from '../types/conversationTypes';
|
||||
import type { Message } from '../agent/core/AgentState';
|
||||
import { showConfirm } from '../util/dialogUtil';
|
||||
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
|
||||
// Module-level guard to avoid duplicate welcome in React StrictMode dev remounts
|
||||
let hasShownWelcomeOnceInRuntime = false;
|
||||
const TODO_TOOL_NAME = 'update_todo_list';
|
||||
const HISTORY_TITLE_MAX_LENGTH = 48;
|
||||
const HISTORY_PREVIEW_MAX_LENGTH = 96;
|
||||
|
||||
const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => {
|
||||
if (message.toolName !== TODO_TOOL_NAME || !Array.isArray(message.todoSnapshot)) {
|
||||
@@ -35,6 +41,77 @@ const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => {
|
||||
return counts.total > 0 && counts.completed === counts.total;
|
||||
};
|
||||
|
||||
const truncateWithEllipsis = (value: string, maxLength: number): string => {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||||
};
|
||||
|
||||
const stripDisplayText = (value: string | null | undefined): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/[`*_>#-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const extractFirstLine = (value: string | null | undefined): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [firstLine] = value.split(/\r?\n/, 1);
|
||||
return stripDisplayText(firstLine);
|
||||
};
|
||||
|
||||
const toDurableChatMessage = (message: ChatMessage): ChatMessage | null => {
|
||||
if (message.isStreaming || message.toolConfirmation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { onToolConfirmationDecision, ...persisted } = message;
|
||||
return persisted;
|
||||
};
|
||||
|
||||
const buildConversationMeta = (
|
||||
conversationId: string,
|
||||
fullHistoryMessages: Message[],
|
||||
displayTranscript: ChatMessage[],
|
||||
): SavedConversationMeta => {
|
||||
const firstDisplayUserMessage = displayTranscript.find((message) => (
|
||||
message.role === 'user' && stripDisplayText(message.content)
|
||||
));
|
||||
const firstUserMessage = firstDisplayUserMessage
|
||||
?? fullHistoryMessages.find((message) => message.role === 'user' && stripDisplayText(message.content));
|
||||
const previewSource = [...displayTranscript]
|
||||
.reverse()
|
||||
.map((message) => stripDisplayText(message.content))
|
||||
.find(Boolean)
|
||||
|| [...fullHistoryMessages]
|
||||
.reverse()
|
||||
.map((message) => stripDisplayText(message.content))
|
||||
.find(Boolean)
|
||||
|| '';
|
||||
const createdAt = fullHistoryMessages[0]?.timestamp ?? Date.now();
|
||||
const lastTurnAt = fullHistoryMessages[fullHistoryMessages.length - 1]?.timestamp ?? createdAt;
|
||||
|
||||
return {
|
||||
conversationId,
|
||||
title: truncateWithEllipsis(extractFirstLine(firstUserMessage?.content) || 'Untitled conversation', HISTORY_TITLE_MAX_LENGTH),
|
||||
createdAt,
|
||||
updatedAt: Date.now(),
|
||||
lastTurnAt,
|
||||
messageCount: fullHistoryMessages.length,
|
||||
preview: truncateWithEllipsis(previewSource, HISTORY_PREVIEW_MAX_LENGTH),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the LLM provider from current configuration
|
||||
*/
|
||||
@@ -78,6 +155,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const { t } = useI18n();
|
||||
const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled);
|
||||
const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled);
|
||||
const projectName = useProjectStore((state) => state.projectName);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -93,10 +171,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
// Export dropdown state and options
|
||||
const [showExportDropdown, setShowExportDropdown] = useState(false);
|
||||
const [showHistoryPanel, setShowHistoryPanel] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [conversationHistory, setConversationHistory] = useState<SavedConversationMeta[]>([]);
|
||||
const exportOptions = [
|
||||
'Export conversation as JSON',
|
||||
'Export conversation as Markdown'
|
||||
];
|
||||
const lastPersistedConversationKeyRef = useRef<string | null>(null);
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
|
||||
const handleExportOptionSelect = (option: string) => {
|
||||
if (option === 'Export conversation as JSON') {
|
||||
@@ -160,7 +243,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
// Message update callbacks for stream processor
|
||||
const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setMessages(prev => prev.map(msg => msg.id === messageId ? updater(msg) : msg));
|
||||
setMessages(prev => {
|
||||
const next = prev.map(msg => msg.id === messageId ? updater(msg) : msg);
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMessageAdd = useCallback((message: ChatMessage) => {
|
||||
@@ -171,15 +258,23 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|| !Array.isArray(existingMessage.todoSnapshot)
|
||||
|| isCompletedTodoSnapshotMessage(existingMessage)
|
||||
));
|
||||
return [...preservedMessages, message];
|
||||
const next = [...preservedMessages, message];
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
return [...prev, message];
|
||||
const next = [...prev, message];
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMessageRemove = useCallback((messageId: string) => {
|
||||
setMessages(prev => prev.filter(msg => msg.id !== messageId));
|
||||
setMessages(prev => {
|
||||
const next = prev.filter(msg => msg.id !== messageId);
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleProcessingChange = useCallback((processing: boolean) => {
|
||||
@@ -195,15 +290,151 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
});
|
||||
|
||||
const clearChatUI = useCallback(async () => {
|
||||
messagesRef.current = [];
|
||||
setMessages([]);
|
||||
setIsFirstMessage(true);
|
||||
setShowHistoryPanel(false);
|
||||
|
||||
const welcomeMessage = await addWelcomeMessage();
|
||||
if (welcomeMessage) {
|
||||
messagesRef.current = [welcomeMessage];
|
||||
setMessages([welcomeMessage]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const buildSavedConversationDocument = useCallback((displayMessages: ChatMessage[]): SavedConversationDocument | null => {
|
||||
const agentState = AgentCore.instance().getAgentState();
|
||||
const fullHistoryMessages = agentState.getFullMessages();
|
||||
if (fullHistoryMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durableTranscript = displayMessages
|
||||
.map(toDurableChatMessage)
|
||||
.filter((message): message is ChatMessage => message !== null);
|
||||
const transcriptToPersist = durableTranscript.length > 0
|
||||
? durableTranscript
|
||||
: fullHistoryMessages
|
||||
.filter((message): message is Message & { role: 'user' | 'assistant' } => (
|
||||
message.role === 'user' || message.role === 'assistant'
|
||||
))
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content ?? '',
|
||||
}));
|
||||
|
||||
return {
|
||||
version: SAVED_CONVERSATION_VERSION,
|
||||
conversationId: agentState.getConversationId(),
|
||||
continuationState: {
|
||||
messages: agentState.getMessages(),
|
||||
todos: agentState.getTodos(),
|
||||
},
|
||||
fullHistory: {
|
||||
messages: fullHistoryMessages,
|
||||
},
|
||||
displayTranscript: transcriptToPersist,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistConversationDocument = useCallback(async (displayMessages: ChatMessage[]) => {
|
||||
if (!projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = buildSavedConversationDocument(displayMessages);
|
||||
if (!document) {
|
||||
lastPersistedConversationKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprint = JSON.stringify(document);
|
||||
if (lastPersistedConversationKeyRef.current === fingerprint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = buildConversationMeta(
|
||||
document.conversationId,
|
||||
document.fullHistory.messages,
|
||||
document.displayTranscript,
|
||||
);
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
await storage.saveConversation(projectName, document, meta);
|
||||
lastPersistedConversationKeyRef.current = fingerprint;
|
||||
}, [buildSavedConversationDocument, projectName]);
|
||||
|
||||
const loadConversationHistory = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
setConversationHistory([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
setConversationHistory(await storage.listConversations(projectName));
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
const handleOpenHistoryPanel = useCallback(async () => {
|
||||
await loadConversationHistory();
|
||||
setShowHistoryPanel(true);
|
||||
}, [loadConversationHistory]);
|
||||
|
||||
const handleRestoreConversation = useCallback(async (conversationId: string) => {
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
|
||||
if (!projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
const savedConversation = await storage.loadConversation(projectName, conversationId);
|
||||
if (!savedConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
AgentCore.instance().restoreConversation(savedConversation.document);
|
||||
messagesRef.current = savedConversation.document.displayTranscript;
|
||||
setMessages(savedConversation.document.displayTranscript);
|
||||
setInputValue('');
|
||||
setLastUserMessage('');
|
||||
setIsFirstMessage(savedConversation.document.fullHistory.messages.length === 0);
|
||||
setShowHistoryPanel(false);
|
||||
lastPersistedConversationKeyRef.current = JSON.stringify(savedConversation.document);
|
||||
}, [persistConversationDocument, projectName]);
|
||||
|
||||
const handleDeleteConversation = useCallback(async (conversationId: string, title: string) => {
|
||||
const confirmed = await showConfirm(
|
||||
t('chatbox.history.deleteConfirm', { title }),
|
||||
{
|
||||
confirmLabel: t('chatbox.history.delete'),
|
||||
cancelLabel: t('chatbox.history.cancel'),
|
||||
},
|
||||
);
|
||||
if (!confirmed || !projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
await storage.deleteConversation(projectName, conversationId);
|
||||
setConversationHistory((prev) => prev.filter((conversation) => conversation.conversationId !== conversationId));
|
||||
}, [projectName, t]);
|
||||
|
||||
const handleStartNewChat = useCallback(async () => {
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
clearChatHistoryAndUI(setStatus);
|
||||
lastPersistedConversationKeyRef.current = null;
|
||||
}, [persistConversationDocument]);
|
||||
|
||||
// Initialize AgentCore with configured provider and register clear UI callback
|
||||
useEffect(() => {
|
||||
const initializeProvider = async () => {
|
||||
@@ -250,6 +481,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
const welcomeMessage = await addWelcomeMessage();
|
||||
if (welcomeMessage) {
|
||||
messagesRef.current = [welcomeMessage];
|
||||
setMessages([welcomeMessage]);
|
||||
}
|
||||
})();
|
||||
@@ -262,6 +494,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
};
|
||||
}, [clearChatUI]);
|
||||
|
||||
useEffect(() => {
|
||||
lastPersistedConversationKeyRef.current = null;
|
||||
}, [projectName]);
|
||||
|
||||
const handleAbort = () => {
|
||||
const controller = streamProcessor.abortController;
|
||||
if (controller) {
|
||||
@@ -270,17 +506,16 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const agentCore = AgentCore.instance();
|
||||
const userMessageContent = agentCore.abortCurrentRequest();
|
||||
|
||||
setMessages(prev => prev.slice(0, -2));
|
||||
setMessages(prev => {
|
||||
const next = prev.slice(0, -2);
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setInputValue(userMessageContent || lastUserMessage);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCommand = () => {
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
clearChatHistoryAndUI(setStatus);
|
||||
};
|
||||
|
||||
const runCompactionWithStatus = useCallback(async (
|
||||
trigger: 'manual' | 'auto',
|
||||
focus?: string,
|
||||
@@ -313,6 +548,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet',
|
||||
}));
|
||||
handleMessageRemove(progressMessage.id);
|
||||
if (result.changed) {
|
||||
const nextMessages = messages
|
||||
.filter((message) => message.id !== progressMessage.id)
|
||||
.map((message) => message.id === statusMessage.id
|
||||
? { ...message, content: 'Conversation Compacted' }
|
||||
: message);
|
||||
await persistConversationDocument(nextMessages);
|
||||
}
|
||||
return result.changed;
|
||||
} catch (error) {
|
||||
console.error('Conversation compaction failed:', error);
|
||||
@@ -325,7 +568,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
} finally {
|
||||
setIsCompacting(false);
|
||||
}
|
||||
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate]);
|
||||
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate, persistConversationDocument]);
|
||||
|
||||
const sendWithCompactionRecovery = useCallback(async (
|
||||
llmInput: string,
|
||||
@@ -357,6 +600,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
setLastUserMessage(userMessage);
|
||||
setInputValue('');
|
||||
|
||||
if (/^\/clear(\s|$)/i.test(userMessage)) {
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
}
|
||||
|
||||
const filterResult = await processUserMessage(userMessage);
|
||||
|
||||
if (filterResult.displayUserMessage) {
|
||||
@@ -404,6 +651,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
}
|
||||
|
||||
await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage);
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -457,6 +705,22 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
<div className="chatbox-header">
|
||||
<h3>{t('assistant.displayName')}</h3>
|
||||
<div className="chatbox-actions">
|
||||
<button
|
||||
type="button"
|
||||
title={t('chatbox.history.title')}
|
||||
aria-pressed={showHistoryPanel}
|
||||
onClick={() => {
|
||||
if (showHistoryPanel) {
|
||||
setShowHistoryPanel(false);
|
||||
} else {
|
||||
void handleOpenHistoryPanel();
|
||||
}
|
||||
}}
|
||||
className={`chatbox-action-btn chatbox-toggle-btn ${showHistoryPanel ? 'is-active' : ''}`}
|
||||
disabled={isProcessing || isCompacting}
|
||||
>
|
||||
<FaHistory />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('chatbox.fastForward.title')}
|
||||
@@ -491,8 +755,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
<button
|
||||
type="button"
|
||||
title="New Chat"
|
||||
onClick={handleClearCommand}
|
||||
onClick={() => { void handleStartNewChat(); }}
|
||||
className="chatbox-action-btn"
|
||||
disabled={isProcessing || isCompacting}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
@@ -538,31 +803,95 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chatbox-messages">
|
||||
{messages.map((message) => (
|
||||
message.role === 'user' ? (
|
||||
<UserMessage key={message.id} content={message.content} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={message.id}
|
||||
content={message.content}
|
||||
isStreaming={message.isStreaming}
|
||||
performanceInfo={message.performanceInfo}
|
||||
toolName={message.toolName}
|
||||
toolSuccess={message.toolSuccess}
|
||||
toolRawResult={message.toolRawResult}
|
||||
toolResultDisplayContent={message.toolResultDisplayContent}
|
||||
toolConfirmation={message.toolConfirmation}
|
||||
toolDenied={message.toolDenied}
|
||||
onToolConfirmationDecision={message.onToolConfirmationDecision}
|
||||
todoSnapshot={message.todoSnapshot}
|
||||
isToolCallMessage={message.isToolCallMessage}
|
||||
onAbort={message.isStreaming ? handleAbort : undefined}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{showHistoryPanel ? (
|
||||
<div className="chatbox-history-panel">
|
||||
<div className="chatbox-history-header">
|
||||
<h4>{t('chatbox.history.heading')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-cancel"
|
||||
onClick={() => setShowHistoryPanel(false)}
|
||||
>
|
||||
{t('chatbox.history.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="chatbox-history-list">
|
||||
{isLoadingHistory && (
|
||||
<div className="chatbox-history-empty">{t('chatbox.history.loading')}</div>
|
||||
)}
|
||||
{!isLoadingHistory && conversationHistory.length === 0 && (
|
||||
<div className="chatbox-history-empty">{t('chatbox.history.empty')}</div>
|
||||
)}
|
||||
{!isLoadingHistory && conversationHistory.map((conversation) => (
|
||||
<div
|
||||
key={conversation.conversationId}
|
||||
className="chatbox-history-item"
|
||||
>
|
||||
<div className="chatbox-history-item-toprow">
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-open-btn"
|
||||
onClick={() => { void handleRestoreConversation(conversation.conversationId); }}
|
||||
>
|
||||
<div className="chatbox-history-item-title" title={conversation.title}>{conversation.title}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-delete-btn"
|
||||
title={t('chatbox.history.delete')}
|
||||
aria-label={t('chatbox.history.delete')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDeleteConversation(conversation.conversationId, conversation.title);
|
||||
}}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-open-btn chatbox-history-open-btn-body"
|
||||
onClick={() => { void handleRestoreConversation(conversation.conversationId); }}
|
||||
>
|
||||
<div className="chatbox-history-item-meta">
|
||||
{formatLocalDateTime(new Date(conversation.lastTurnAt))}
|
||||
</div>
|
||||
{conversation.preview && (
|
||||
<div className="chatbox-history-item-preview">{conversation.preview}</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chatbox-messages">
|
||||
{messages.map((message) => (
|
||||
message.role === 'user' ? (
|
||||
<UserMessage key={message.id} content={message.content} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={message.id}
|
||||
content={message.content}
|
||||
isStreaming={message.isStreaming}
|
||||
performanceInfo={message.performanceInfo}
|
||||
toolName={message.toolName}
|
||||
toolSuccess={message.toolSuccess}
|
||||
toolRawResult={message.toolRawResult}
|
||||
toolResultDisplayContent={message.toolResultDisplayContent}
|
||||
toolConfirmation={message.toolConfirmation}
|
||||
toolDenied={message.toolDenied}
|
||||
onToolConfirmationDecision={message.onToolConfirmationDecision}
|
||||
todoSnapshot={message.todoSnapshot}
|
||||
isToolCallMessage={message.isToolCallMessage}
|
||||
onAbort={message.isStreaming ? handleAbort : undefined}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isProcessing && !isCompacting && (
|
||||
<div className="chatbox-input-area">
|
||||
|
||||
@@ -279,14 +279,14 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="message-tool-confirmation-btn message-tool-confirmation-btn-always dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||
className="message-tool-confirmation-btn dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||
onClick={() => onToolConfirmationDecision('always_allow')}
|
||||
>
|
||||
{t('chatbox.tool.confirmation.alwaysAllow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="message-tool-confirmation-btn message-tool-confirmation-btn-deny dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||
className="message-tool-confirmation-btn dialog-btn dialog-btn-cancel kgone-btn-generate"
|
||||
onClick={() => onToolConfirmationDecision('deny')}
|
||||
>
|
||||
{t('chatbox.tool.confirmation.deny')}
|
||||
|
||||
Reference in New Issue
Block a user