feat: added conversation history feature

This commit is contained in:
Xiaohan-Tian
2026-06-03 22:56:05 -07:00
parent 5c6e1d69c5
commit a3a0f2f4ad
19 changed files with 1271 additions and 90 deletions
+29
View File
@@ -182,4 +182,33 @@ describe('AgentCore todo integration', () => {
expect(provider.calls).toHaveLength(1); expect(provider.calls).toHaveLength(1);
expect(AgentCore.instance().getAgentState().getMessages().at(-1)?.role).toBe('tool'); expect(AgentCore.instance().getAgentState().getMessages().at(-1)?.role).toBe('tool');
}); });
it('restores a saved conversation document into the agent state', () => {
AgentCore.instance().restoreConversation({
version: 1,
conversationId: 'conv_saved',
continuationState: {
messages: [
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2 },
],
todos: [
{ id: 'todo-1', text: 'Continue work', status: 'in_progress', updatedAt: 3 },
],
},
fullHistory: {
messages: [
{ id: 'm1', role: 'user', content: 'prompt', timestamp: 1 },
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2 },
],
},
displayTranscript: [],
});
expect(AgentCore.instance().getAgentState().getConversationId()).toBe('conv_saved');
expect(AgentCore.instance().getAgentState().getMessages()).toHaveLength(1);
expect(AgentCore.instance().getAgentState().getFullMessages()).toHaveLength(2);
expect(AgentCore.instance().getAgentState().getTodos()).toEqual([
{ id: 'todo-1', text: 'Continue work', status: 'in_progress', updatedAt: 3 },
]);
});
}); });
+24
View File
@@ -9,6 +9,7 @@ import type { OpenAIToolDefinition } from '../tools/BaseTool';
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor'; import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
import { ConfigManager } from '../../core/config/ConfigManager'; import { ConfigManager } from '../../core/config/ConfigManager';
import { buildTodoContext } from './todo'; import { buildTodoContext } from './todo';
import type { SavedConversationDocument } from '../../types/conversationTypes';
export interface CompactConversationOptions { export interface CompactConversationOptions {
trigger: 'manual' | 'auto'; trigger: 'manual' | 'auto';
@@ -261,6 +262,29 @@ export class AgentCore {
this.currentTurnLikelyMultiStep = false; this.currentTurnLikelyMultiStep = false;
} }
startNewConversation(): void {
this.agentState.resetConversation();
this.todoToolCyclesSinceUpdate = 0;
this.remindAboutTodosOnNextLoop = false;
this.currentTurnLikelyMultiStep = false;
this.currentUserMessageId = null;
this.currentAssistantMessageId = null;
}
restoreConversation(document: SavedConversationDocument): void {
this.agentState.replaceConversationState({
conversationId: document.conversationId,
messages: document.continuationState.messages,
fullMessages: document.fullHistory.messages,
todos: document.continuationState.todos,
});
this.todoToolCyclesSinceUpdate = 0;
this.remindAboutTodosOnNextLoop = false;
this.currentTurnLikelyMultiStep = false;
this.currentUserMessageId = null;
this.currentAssistantMessageId = null;
}
async shouldCompactBeforeNextTurn(userInput: string): Promise<boolean> { async shouldCompactBeforeNextTurn(userInput: string): Promise<boolean> {
if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) { if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) {
return false; return false;
+36
View File
@@ -109,4 +109,40 @@ describe('AgentState compaction helpers', () => {
expect(state.getTodos()).toEqual([]); expect(state.getTodos()).toEqual([]);
}); });
it('restores full conversation state including full history and todos', () => {
const state = new AgentState('conv_original');
state.replaceConversationState({
conversationId: 'conv_restored',
messages: [
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2, is_compacted_summary: true },
],
fullMessages: [
{ id: 'm1', role: 'user', content: 'prompt', timestamp: 1 },
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2, is_compacted_summary: true },
],
todos: [
{ id: 'todo-1', text: 'Resume work', status: 'in_progress', updatedAt: 3 },
],
});
expect(state.getConversationId()).toBe('conv_restored');
expect(state.getMessages()).toHaveLength(1);
expect(state.getFullMessages()).toHaveLength(2);
expect(state.getTodos()).toEqual([
{ id: 'todo-1', text: 'Resume work', status: 'in_progress', updatedAt: 3 },
]);
});
it('starts a fresh conversation with a new id when reset', () => {
const state = new AgentState('conv_test');
state.addMessage('user', 'first');
state.resetConversation('conv_new');
expect(state.getConversationId()).toBe('conv_new');
expect(state.getMessages()).toEqual([]);
expect(state.getFullMessages()).toEqual([]);
});
}); });
+19
View File
@@ -153,6 +153,25 @@ export class AgentState {
this.messages = [...messages]; this.messages = [...messages];
} }
replaceConversationState(state: {
conversationId: string;
messages: Message[];
fullMessages: Message[];
todos: TodoItem[];
}): void {
this.conversationId = state.conversationId;
this.messages = state.messages.map(message => ({ ...message }));
this.fullMessages = state.fullMessages.map(message => ({ ...message }));
this.setTodos(state.todos);
}
resetConversation(conversationId?: string): void {
this.conversationId = conversationId || this.generateConversationId();
this.messages = [];
this.fullMessages = [];
this.clearTodos();
}
/** /**
* Get the last N messages * Get the last N messages
*/ */
+119 -18
View File
@@ -176,6 +176,125 @@
gap: 12px; 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 { .chatbox-todo-card {
background: linear-gradient(180deg, #252525 0%, #202020 100%); background: linear-gradient(180deg, #252525 0%, #202020 100%);
border: 1px solid #3a3a3a; border: 1px solid #3a3a3a;
@@ -441,24 +560,6 @@
min-height: 32px; 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 { .message-tool-summary-content > :first-child {
margin-top: 0; margin-top: 0;
} }
+190 -2
View File
@@ -14,6 +14,8 @@ const {
streamProcessorCallbacks, streamProcessorCallbacks,
clearChatHistoryAndUIMock, clearChatHistoryAndUIMock,
projectStoreState, projectStoreState,
conversationStorageMock,
showConfirmMock,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
agentCoreMock: { agentCoreMock: {
setLLMProvider: vi.fn(), setLLMProvider: vi.fn(),
@@ -21,9 +23,12 @@ const {
abortCurrentRequest: vi.fn(), abortCurrentRequest: vi.fn(),
getAgentState: vi.fn(() => ({ getAgentState: vi.fn(() => ({
getMessages: vi.fn(() => []), getMessages: vi.fn(() => []),
getFullMessages: vi.fn(() => []),
getConversationId: vi.fn(() => 'conv_test'),
getTodos: vi.fn(() => []), getTodos: vi.fn(() => []),
subscribeTodoChanges: vi.fn(() => () => undefined), subscribeTodoChanges: vi.fn(() => () => undefined),
})), })),
restoreConversation: vi.fn(),
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })), compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
shouldCompactBeforeNextTurn: vi.fn(async () => false), shouldCompactBeforeNextTurn: vi.fn(async () => false),
}, },
@@ -31,11 +36,20 @@ const {
processStreamMock: vi.fn(async () => ''), processStreamMock: vi.fn(async () => ''),
clearChatHistoryAndUIMock: vi.fn(), clearChatHistoryAndUIMock: vi.fn(),
projectStoreState: { projectStoreState: {
projectName: 'Test Project',
toolFastForwardEnabled: false, toolFastForwardEnabled: false,
setStatus: vi.fn(), setStatus: vi.fn(),
setToolFastForwardEnabled: vi.fn(), setToolFastForwardEnabled: vi.fn(),
toggleToolFastForwardEnabled: 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: { streamProcessorCallbacks: {
onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined, onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined,
onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => 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', 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', () => ({ vi.mock('./common/KGDropdown', () => ({
default: () => null, default: () => null,
})); }));
@@ -209,16 +237,32 @@ describe('ChatBox', () => {
processUserMessageMock.mockReset(); processUserMessageMock.mockReset();
processStreamMock.mockClear(); processStreamMock.mockClear();
clearChatHistoryAndUIMock.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.compactConversation.mockClear();
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false); agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
agentCoreMock.restoreConversation.mockClear();
streamProcessorCallbacks.onMessageAdd = undefined; streamProcessorCallbacks.onMessageAdd = undefined;
streamProcessorCallbacks.onMessageUpdate = undefined; streamProcessorCallbacks.onMessageUpdate = undefined;
streamProcessorCallbacks.onMessageRemove = undefined; streamProcessorCallbacks.onMessageRemove = undefined;
streamProcessorCallbacks.onProcessingChange = undefined; streamProcessorCallbacks.onProcessingChange = undefined;
projectStoreState.projectName = 'Test Project';
projectStoreState.toolFastForwardEnabled = false; projectStoreState.toolFastForwardEnabled = false;
projectStoreState.setStatus.mockClear(); projectStoreState.setStatus.mockClear();
projectStoreState.setToolFastForwardEnabled.mockClear(); projectStoreState.setToolFastForwardEnabled.mockClear();
projectStoreState.toggleToolFastForwardEnabled.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', () => { 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'); 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; projectStoreState.toolFastForwardEnabled = true;
clearChatHistoryAndUIMock.mockImplementation(() => { clearChatHistoryAndUIMock.mockImplementation(() => {
projectStoreState.setToolFastForwardEnabled(false); projectStoreState.setToolFastForwardEnabled(false);
@@ -458,7 +502,151 @@ describe('ChatBox', () => {
</I18nContext.Provider>, </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'); 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
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
import './ChatBox.css'; 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 { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore'; import { AgentCore } from '../agent/core/AgentCore';
import { summarizeTodoCounts } from '../agent/core/todo'; 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 { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../util/localLLMConfig';
import KGDropdown from './common/KGDropdown'; import KGDropdown from './common/KGDropdown';
import { useI18n } from '../i18n/useI18n'; 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'; import type { ChatMessage } from '../types/projectTypes';
// Module-level guard to avoid duplicate welcome in React StrictMode dev remounts // Module-level guard to avoid duplicate welcome in React StrictMode dev remounts
let hasShownWelcomeOnceInRuntime = false; let hasShownWelcomeOnceInRuntime = false;
const TODO_TOOL_NAME = 'update_todo_list'; const TODO_TOOL_NAME = 'update_todo_list';
const HISTORY_TITLE_MAX_LENGTH = 48;
const HISTORY_PREVIEW_MAX_LENGTH = 96;
const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => { const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => {
if (message.toolName !== TODO_TOOL_NAME || !Array.isArray(message.todoSnapshot)) { 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; 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 * Create the LLM provider from current configuration
*/ */
@@ -78,6 +155,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const { t } = useI18n(); const { t } = useI18n();
const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled); const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled);
const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled); const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled);
const projectName = useProjectStore((state) => state.projectName);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -93,10 +171,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
// Export dropdown state and options // Export dropdown state and options
const [showExportDropdown, setShowExportDropdown] = useState(false); const [showExportDropdown, setShowExportDropdown] = useState(false);
const [showHistoryPanel, setShowHistoryPanel] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [conversationHistory, setConversationHistory] = useState<SavedConversationMeta[]>([]);
const exportOptions = [ const exportOptions = [
'Export conversation as JSON', 'Export conversation as JSON',
'Export conversation as Markdown' 'Export conversation as Markdown'
]; ];
const lastPersistedConversationKeyRef = useRef<string | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const handleExportOptionSelect = (option: string) => { const handleExportOptionSelect = (option: string) => {
if (option === 'Export conversation as JSON') { if (option === 'Export conversation as JSON') {
@@ -160,7 +243,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
// Message update callbacks for stream processor // Message update callbacks for stream processor
const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => { 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) => { const handleMessageAdd = useCallback((message: ChatMessage) => {
@@ -171,15 +258,23 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|| !Array.isArray(existingMessage.todoSnapshot) || !Array.isArray(existingMessage.todoSnapshot)
|| isCompletedTodoSnapshotMessage(existingMessage) || 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) => { 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) => { const handleProcessingChange = useCallback((processing: boolean) => {
@@ -195,15 +290,151 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
}); });
const clearChatUI = useCallback(async () => { const clearChatUI = useCallback(async () => {
messagesRef.current = [];
setMessages([]); setMessages([]);
setIsFirstMessage(true); setIsFirstMessage(true);
setShowHistoryPanel(false);
const welcomeMessage = await addWelcomeMessage(); const welcomeMessage = await addWelcomeMessage();
if (welcomeMessage) { if (welcomeMessage) {
messagesRef.current = [welcomeMessage];
setMessages([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 // Initialize AgentCore with configured provider and register clear UI callback
useEffect(() => { useEffect(() => {
const initializeProvider = async () => { const initializeProvider = async () => {
@@ -250,6 +481,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const welcomeMessage = await addWelcomeMessage(); const welcomeMessage = await addWelcomeMessage();
if (welcomeMessage) { if (welcomeMessage) {
messagesRef.current = [welcomeMessage];
setMessages([welcomeMessage]); setMessages([welcomeMessage]);
} }
})(); })();
@@ -262,6 +494,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
}; };
}, [clearChatUI]); }, [clearChatUI]);
useEffect(() => {
lastPersistedConversationKeyRef.current = null;
}, [projectName]);
const handleAbort = () => { const handleAbort = () => {
const controller = streamProcessor.abortController; const controller = streamProcessor.abortController;
if (controller) { if (controller) {
@@ -270,17 +506,16 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const agentCore = AgentCore.instance(); const agentCore = AgentCore.instance();
const userMessageContent = agentCore.abortCurrentRequest(); 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); setInputValue(userMessageContent || lastUserMessage);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
const handleClearCommand = () => {
const { setStatus } = useProjectStore.getState();
clearChatHistoryAndUI(setStatus);
};
const runCompactionWithStatus = useCallback(async ( const runCompactionWithStatus = useCallback(async (
trigger: 'manual' | 'auto', trigger: 'manual' | 'auto',
focus?: string, focus?: string,
@@ -313,6 +548,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet', content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet',
})); }));
handleMessageRemove(progressMessage.id); 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; return result.changed;
} catch (error) { } catch (error) {
console.error('Conversation compaction failed:', error); console.error('Conversation compaction failed:', error);
@@ -325,7 +568,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} finally { } finally {
setIsCompacting(false); setIsCompacting(false);
} }
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate]); }, [handleMessageAdd, handleMessageRemove, handleMessageUpdate, persistConversationDocument]);
const sendWithCompactionRecovery = useCallback(async ( const sendWithCompactionRecovery = useCallback(async (
llmInput: string, llmInput: string,
@@ -357,6 +600,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
setLastUserMessage(userMessage); setLastUserMessage(userMessage);
setInputValue(''); setInputValue('');
if (/^\/clear(\s|$)/i.test(userMessage)) {
await persistConversationDocument(messagesRef.current);
}
const filterResult = await processUserMessage(userMessage); const filterResult = await processUserMessage(userMessage);
if (filterResult.displayUserMessage) { if (filterResult.displayUserMessage) {
@@ -404,6 +651,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} }
await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage); await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage);
await persistConversationDocument(messagesRef.current);
} }
}; };
@@ -457,6 +705,22 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div className="chatbox-header"> <div className="chatbox-header">
<h3>{t('assistant.displayName')}</h3> <h3>{t('assistant.displayName')}</h3>
<div className="chatbox-actions"> <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 <button
type="button" type="button"
title={t('chatbox.fastForward.title')} title={t('chatbox.fastForward.title')}
@@ -491,8 +755,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<button <button
type="button" type="button"
title="New Chat" title="New Chat"
onClick={handleClearCommand} onClick={() => { void handleStartNewChat(); }}
className="chatbox-action-btn" className="chatbox-action-btn"
disabled={isProcessing || isCompacting}
> >
<FaPlus /> <FaPlus />
</button> </button>
@@ -538,31 +803,95 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
</div> </div>
)} )}
<div className="chatbox-messages"> {showHistoryPanel ? (
{messages.map((message) => ( <div className="chatbox-history-panel">
message.role === 'user' ? ( <div className="chatbox-history-header">
<UserMessage key={message.id} content={message.content} /> <h4>{t('chatbox.history.heading')}</h4>
) : ( <button
<AssistantMessage type="button"
key={message.id} className="chatbox-history-cancel"
content={message.content} onClick={() => setShowHistoryPanel(false)}
isStreaming={message.isStreaming} >
performanceInfo={message.performanceInfo} {t('chatbox.history.cancel')}
toolName={message.toolName} </button>
toolSuccess={message.toolSuccess} </div>
toolRawResult={message.toolRawResult} <div className="chatbox-history-list">
toolResultDisplayContent={message.toolResultDisplayContent} {isLoadingHistory && (
toolConfirmation={message.toolConfirmation} <div className="chatbox-history-empty">{t('chatbox.history.loading')}</div>
toolDenied={message.toolDenied} )}
onToolConfirmationDecision={message.onToolConfirmationDecision} {!isLoadingHistory && conversationHistory.length === 0 && (
todoSnapshot={message.todoSnapshot} <div className="chatbox-history-empty">{t('chatbox.history.empty')}</div>
isToolCallMessage={message.isToolCallMessage} )}
onAbort={message.isStreaming ? handleAbort : undefined} {!isLoadingHistory && conversationHistory.map((conversation) => (
/> <div
) key={conversation.conversationId}
))} className="chatbox-history-item"
<div ref={messagesEndRef} /> >
</div> <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 && ( {!isProcessing && !isCompacting && (
<div className="chatbox-input-area"> <div className="chatbox-input-area">
+2 -2
View File
@@ -279,14 +279,14 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
</button> </button>
<button <button
type="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')} onClick={() => onToolConfirmationDecision('always_allow')}
> >
{t('chatbox.tool.confirmation.alwaysAllow')} {t('chatbox.tool.confirmation.alwaysAllow')}
</button> </button>
<button <button
type="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')} onClick={() => onToolConfirmationDecision('deny')}
> >
{t('chatbox.tool.confirmation.deny')} {t('chatbox.tool.confirmation.deny')}
+2
View File
@@ -100,6 +100,8 @@ export const OPFS_CONSTANTS = {
PROJECT_FILE: 'project.json', PROJECT_FILE: 'project.json',
METADATA_FILE: 'meta.json', METADATA_FILE: 'meta.json',
MEDIA_DIR: 'media', MEDIA_DIR: 'media',
CONVERSATIONS_DIR: 'conversations',
CONVERSATION_FILE: 'conversation.json',
}; };
export const CONFIG_UPGRADER_CONSTANTS = { export const CONFIG_UPGRADER_CONSTANTS = {
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGConversationStorage } from './KGConversationStorage';
class MockFileSystemWritableFileStream {
public data = '';
async write(content: string) { this.data = content; }
async close() {}
}
class MockFileSystemFileHandle {
kind = 'file' as const;
constructor(public name: string, private _content: string = '') {}
async getFile() {
return { text: () => Promise.resolve(this._content) };
}
async createWritable() {
const stream = new MockFileSystemWritableFileStream();
const origClose = stream.close.bind(stream);
stream.close = async () => {
this._content = stream.data;
await origClose();
};
return stream;
}
}
class MockFileSystemDirectoryHandle {
kind = 'directory' as const;
private entries = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
constructor(public name: string) {}
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'directory') {
if (options?.create) {
entry = new MockFileSystemDirectoryHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemDirectoryHandle;
}
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'file') {
if (options?.create) {
entry = new MockFileSystemFileHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemFileHandle;
}
async removeEntry(name: string): Promise<void> {
this.entries.delete(name);
}
async *values(): AsyncIterableIterator<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
for (const entry of this.entries.values()) {
yield entry;
}
}
}
const mockRoot = new MockFileSystemDirectoryHandle('root');
vi.stubGlobal('navigator', {
...navigator,
storage: {
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
persist: vi.fn(() => Promise.resolve(true)),
},
});
describe('KGConversationStorage', () => {
let storage: KGConversationStorage;
beforeEach(async () => {
;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
const entries = (mockRoot as unknown as { entries: Map<string, unknown> }).entries;
entries.clear();
storage = KGConversationStorage.getInstance();
await storage.initialize();
});
it('saves, lists, and loads project-scoped conversations sorted by last turn time', async () => {
await storage.saveConversation('Song A', {
version: 1,
conversationId: 'conv_1',
continuationState: {
messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
},
displayTranscript: [{ id: 'display_1', role: 'user', content: 'first' }],
}, {
conversationId: 'conv_1',
title: 'First',
createdAt: 1,
updatedAt: 1,
lastTurnAt: 1,
messageCount: 1,
preview: 'first',
});
await storage.saveConversation('Song A', {
version: 1,
conversationId: 'conv_2',
continuationState: {
messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
},
displayTranscript: [{ id: 'display_2', role: 'user', content: 'second' }],
}, {
conversationId: 'conv_2',
title: 'Second',
createdAt: 2,
updatedAt: 2,
lastTurnAt: 2,
messageCount: 1,
preview: 'second',
});
const listed = await storage.listConversations('Song A');
expect(listed.map(item => item.conversationId)).toEqual(['conv_2', 'conv_1']);
const loaded = await storage.loadConversation('Song A', 'conv_1');
expect(loaded?.document.conversationId).toBe('conv_1');
expect(loaded?.document.displayTranscript).toEqual([
{ id: 'display_1', role: 'user', content: 'first' },
]);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
import type { SavedConversationDocument, SavedConversationMeta } from '../../types/conversationTypes';
export class KGConversationStorage {
private static _instance: KGConversationStorage;
private rootDirHandle: FileSystemDirectoryHandle | null = null;
private projectsDirHandle: FileSystemDirectoryHandle | null = null;
private _initialized = false;
private constructor() {}
public static getInstance(): KGConversationStorage {
if (!KGConversationStorage._instance) {
KGConversationStorage._instance = new KGConversationStorage();
}
return KGConversationStorage._instance;
}
public async initialize(): Promise<void> {
if (this._initialized) return;
if (!navigator.storage?.getDirectory) {
throw new Error(
'OPFS is unavailable. K.G.Studio requires a secure context (HTTPS or localhost). ' +
'Access via https:// or use localhost/127.0.0.1 instead of an IP address.',
);
}
this.rootDirHandle = await navigator.storage.getDirectory();
this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
OPFS_CONSTANTS.ROOT_DIR,
{ create: true },
);
this._initialized = true;
}
public async saveConversation(
projectName: string,
document: SavedConversationDocument,
meta: SavedConversationMeta,
): Promise<void> {
this.ensureInitialized();
const conversationDir = await this.getConversationDir(projectName, document.conversationId, true);
await this.writeFile(
conversationDir,
OPFS_CONSTANTS.CONVERSATION_FILE,
JSON.stringify(document, null, 2),
);
await this.writeFile(
conversationDir,
OPFS_CONSTANTS.METADATA_FILE,
JSON.stringify(meta, null, 2),
);
}
public async loadConversation(
projectName: string,
conversationId: string,
): Promise<{ document: SavedConversationDocument; meta: SavedConversationMeta } | null> {
this.ensureInitialized();
try {
const conversationDir = await this.getConversationDir(projectName, conversationId, false);
const [documentRaw, metaRaw] = await Promise.all([
this.readFile(conversationDir, OPFS_CONSTANTS.CONVERSATION_FILE),
this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE),
]);
return {
document: JSON.parse(documentRaw) as SavedConversationDocument,
meta: JSON.parse(metaRaw) as SavedConversationMeta,
};
} catch (error) {
console.error(`Error loading conversation "${conversationId}" for project "${projectName}":`, error);
return null;
}
}
public async listConversations(projectName: string): Promise<SavedConversationMeta[]> {
this.ensureInitialized();
let conversationsDir: FileSystemDirectoryHandle;
try {
conversationsDir = await this.getConversationsDir(projectName, false);
} catch {
return [];
}
const metas: SavedConversationMeta[] = [];
for await (const entry of conversationsDir.values()) {
if (entry.kind !== 'directory') {
continue;
}
try {
const conversationDir = await conversationsDir.getDirectoryHandle(entry.name);
const metaRaw = await this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE);
metas.push(JSON.parse(metaRaw) as SavedConversationMeta);
} catch {
// Skip malformed entries.
}
}
return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt);
}
public async deleteConversation(projectName: string, conversationId: string): Promise<void> {
this.ensureInitialized();
const conversationsDir = await this.getConversationsDir(projectName, false);
await conversationsDir.removeEntry(conversationId, { recursive: true });
}
private ensureInitialized(): void {
if (!this._initialized || !this.projectsDirHandle) {
throw new Error('KGConversationStorage not initialized. Call initialize() first.');
}
}
private async getConversationsDir(
projectName: string,
create: boolean,
): Promise<FileSystemDirectoryHandle> {
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create });
return projectDir.getDirectoryHandle(OPFS_CONSTANTS.CONVERSATIONS_DIR, { create });
}
private async getConversationDir(
projectName: string,
conversationId: string,
create: boolean,
): Promise<FileSystemDirectoryHandle> {
const conversationsDir = await this.getConversationsDir(projectName, create);
return conversationsDir.getDirectoryHandle(conversationId, { create });
}
private async writeFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
content: string,
): Promise<void> {
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
}
private async readFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
): Promise<string> {
const fileHandle = await dirHandle.getFileHandle(fileName);
const file = await fileHandle.getFile();
return file.text();
}
}
+77 -4
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage'; import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
import { KGConversationStorage } from './KGConversationStorage';
import { KGProject } from '../KGProject'; import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track'; import { GlobalTrackType } from '../global-track';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion'; import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
@@ -10,16 +11,23 @@ import { KGTrack } from '../track/KGTrack';
// --- OPFS mock infrastructure --- // --- OPFS mock infrastructure ---
class MockFileSystemWritableFileStream { class MockFileSystemWritableFileStream {
public data = ''; public data: string | ArrayBuffer = '';
async write(content: string) { this.data = content; } async write(content: string | ArrayBuffer) { this.data = content; }
async close() {} async close() {}
} }
class MockFileSystemFileHandle { class MockFileSystemFileHandle {
kind = 'file' as const; kind = 'file' as const;
constructor(public name: string, private _content: string = '') {} constructor(public name: string, private _content: string | ArrayBuffer = '') {}
async getFile() { async getFile() {
return { text: () => Promise.resolve(this._content) }; return {
text: () => Promise.resolve(typeof this._content === 'string' ? this._content : new TextDecoder().decode(this._content)),
arrayBuffer: () => Promise.resolve(
typeof this._content === 'string'
? new TextEncoder().encode(this._content).buffer
: this._content
),
};
} }
async createWritable() { async createWritable() {
const stream = new MockFileSystemWritableFileStream(); const stream = new MockFileSystemWritableFileStream();
@@ -93,6 +101,7 @@ vi.stubGlobal('navigator', {
describe('KGProjectStorage', () => { describe('KGProjectStorage', () => {
let storage: KGProjectStorage; let storage: KGProjectStorage;
let conversationStorage: KGConversationStorage;
beforeEach(async () => { beforeEach(async () => {
// Reset singleton and mock filesystem // Reset singleton and mock filesystem
@@ -103,6 +112,9 @@ describe('KGProjectStorage', () => {
storage = KGProjectStorage.getInstance(); storage = KGProjectStorage.getInstance();
await storage.initialize(); await storage.initialize();
;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
conversationStorage = KGConversationStorage.getInstance();
await conversationStorage.initialize();
}); });
function createTestProject(name = 'Test Project'): KGProject { function createTestProject(name = 'Test Project'): KGProject {
@@ -337,4 +349,65 @@ describe('KGProjectStorage', () => {
const loaded = await storage.load('New Name'); const loaded = await storage.load('New Name');
expect(loaded!.getName()).toBe('New Name'); expect(loaded!.getName()).toBe('New Name');
}); });
it('copies conversation history when saving under a new project name', async () => {
await storage.save('Source Song', createTestProject('Source Song'));
await conversationStorage.saveConversation('Source Song', {
version: 1,
conversationId: 'conv_1',
continuationState: {
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
},
displayTranscript: [{ id: 'display_1', role: 'user', content: 'hello' }],
}, {
conversationId: 'conv_1',
title: 'hello',
createdAt: 1,
updatedAt: 1,
lastTurnAt: 1,
messageCount: 1,
preview: 'hello',
});
await storage.saveAs('Source Song', 'Copied Song', createTestProject('Copied Song'));
const loadedConversation = await conversationStorage.loadConversation('Copied Song', 'conv_1');
expect(loadedConversation?.document.conversationId).toBe('conv_1');
});
it('includes conversation history in project bundle export and import', async () => {
await storage.save('Bundle Song', createTestProject('Bundle Song'));
await conversationStorage.saveConversation('Bundle Song', {
version: 1,
conversationId: 'conv_bundle',
continuationState: {
messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
},
displayTranscript: [{ id: 'display_1', role: 'user', content: 'bundle' }],
}, {
conversationId: 'conv_bundle',
title: 'bundle',
createdAt: 1,
updatedAt: 1,
lastTurnAt: 1,
messageCount: 1,
preview: 'bundle',
});
const bundle = await storage.exportAsZip('Bundle Song');
const importedName = await storage.importFromZip(bundle);
const loadedConversation = await conversationStorage.loadConversation(importedName, 'conv_bundle');
expect(loadedConversation?.document.displayTranscript).toEqual([
{ id: 'display_1', role: 'user', content: 'bundle' },
]);
});
}); });
+50 -25
View File
@@ -232,8 +232,8 @@ export class KGProjectStorage {
project.setName(targetName); project.setName(targetName);
await this.save(targetName, project, false); await this.save(targetName, project, false);
// Copy media files // Copy project-scoped auxiliary artifacts such as media and conversation history
await this.copyMediaFiles(sourceName, targetName); await this.copyProjectArtifacts(sourceName, targetName);
} }
/** /**
@@ -346,8 +346,8 @@ export class KGProjectStorage {
project.setName(newName); project.setName(newName);
await this.save(newName, project, false); await this.save(newName, project, false);
// Copy media files from old to new location // Copy project-scoped auxiliary artifacts such as media and conversation history
await this.copyMediaFiles(oldName, newName); await this.copyProjectArtifacts(oldName, newName);
// Delete old location // Delete old location
await this.delete(oldName); await this.delete(oldName);
@@ -366,7 +366,7 @@ export class KGProjectStorage {
// Migrate media files only if the old folder exists // Migrate media files only if the old folder exists
if (await this.exists(oldName)) { if (await this.exists(oldName)) {
await this.copyMediaFiles(oldName, newName); await this.copyProjectArtifacts(oldName, newName);
await this.delete(oldName); await this.delete(oldName);
} }
} }
@@ -381,7 +381,7 @@ export class KGProjectStorage {
await this.save(targetName, data, false); await this.save(targetName, data, false);
if (await this.exists(sourceName)) { if (await this.exists(sourceName)) {
await this.copyMediaFiles(sourceName, targetName); await this.copyProjectArtifacts(sourceName, targetName);
} }
} }
@@ -414,7 +414,7 @@ export class KGProjectStorage {
if (entry.kind === 'file') { if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle; const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile(); const file = await fileHandle.getFile();
zip.file(entryPath, file.arrayBuffer()); zip.file(entryPath, new Uint8Array(await this.readBlobLikeAsArrayBuffer(file)));
} else { } else {
const subDir = entry as FileSystemDirectoryHandle; const subDir = entry as FileSystemDirectoryHandle;
await this.addDirectoryToZip(zip, subDir, entryPath); await this.addDirectoryToZip(zip, subDir, entryPath);
@@ -551,40 +551,53 @@ export class KGProjectStorage {
// --- Media migration --- // --- Media migration ---
/** private async copyProjectArtifacts(fromName: string, toName: string): Promise<void> {
* Copy all files from projects/<fromName>/media/ to projects/<toName>/media/.
* If the source media directory doesn't exist, returns without error.
*/
private async copyMediaFiles(fromName: string, toName: string): Promise<void> {
try { try {
const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName); const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName);
let fromMedia: FileSystemDirectoryHandle;
try {
fromMedia = await fromDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
} catch {
// No media directory in source — nothing to copy
return;
}
const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName); const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName);
const toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true }); for await (const entry of fromDir.values()) {
if (entry.name === OPFS_CONSTANTS.PROJECT_FILE || entry.name === OPFS_CONSTANTS.METADATA_FILE) {
for await (const entry of fromMedia.values()) { continue;
}
if (entry.kind === 'file') { if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle; const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile(); const file = await fileHandle.getFile();
const newHandle = await toMedia.getFileHandle(entry.name, { create: true }); const newHandle = await toDir.getFileHandle(entry.name, { create: true });
const writable = await newHandle.createWritable(); const writable = await newHandle.createWritable();
await writable.write(await file.arrayBuffer()); await writable.write(await file.arrayBuffer());
await writable.close(); await writable.close();
} else {
await this.copyDirectoryContents(
entry as FileSystemDirectoryHandle,
await toDir.getDirectoryHandle(entry.name, { create: true }),
);
} }
} }
} catch (error) { } catch (error) {
console.error(`Error copying media files from "${fromName}" to "${toName}":`, error); console.error(`Error copying project artifacts from "${fromName}" to "${toName}":`, error);
throw error; throw error;
} }
} }
private async copyDirectoryContents(
fromDir: FileSystemDirectoryHandle,
toDir: FileSystemDirectoryHandle,
): Promise<void> {
for await (const entry of fromDir.values()) {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
const newHandle = await toDir.getFileHandle(entry.name, { create: true });
const writable = await newHandle.createWritable();
await writable.write(await this.readBlobLikeAsArrayBuffer(file));
await writable.close();
} else {
const newSubDir = await toDir.getDirectoryHandle(entry.name, { create: true });
await this.copyDirectoryContents(entry as FileSystemDirectoryHandle, newSubDir);
}
}
}
// --- File I/O helpers --- // --- File I/O helpers ---
private async writeFile( private async writeFile(
@@ -618,4 +631,16 @@ export class KGProjectStorage {
return { name, createdAt: 0, updatedAt: 0 }; return { name, createdAt: 0, updatedAt: 0 };
} }
} }
private async readBlobLikeAsArrayBuffer(
file: { arrayBuffer?: () => Promise<ArrayBuffer>; text?: () => Promise<string> },
): Promise<ArrayBuffer> {
if (typeof file.arrayBuffer === 'function') {
return file.arrayBuffer();
}
if (typeof file.text === 'function') {
return new TextEncoder().encode(await file.text()).buffer;
}
throw new Error('Unsupported file object: expected arrayBuffer() or text().');
}
} }
+7
View File
@@ -8,6 +8,13 @@ export const enUsMessages: TranslationMessages = {
'chatbox.todo.ariaLabel': 'Agent task checklist', 'chatbox.todo.ariaLabel': 'Agent task checklist',
'chatbox.todo.count': '{completed}/{total} completed', 'chatbox.todo.count': '{completed}/{total} completed',
'chatbox.todo.active': 'Working on: {task}', 'chatbox.todo.active': 'Working on: {task}',
'chatbox.history.title': 'Conversation history',
'chatbox.history.heading': 'Conversation History',
'chatbox.history.cancel': 'Cancel',
'chatbox.history.delete': 'Delete',
'chatbox.history.deleteConfirm': 'Delete conversation "{title}"? This cannot be undone.',
'chatbox.history.loading': 'Loading conversations...',
'chatbox.history.empty': 'No saved conversations yet.',
'chatbox.fastForward.title': 'Fast forward tool execution approvals', 'chatbox.fastForward.title': 'Fast forward tool execution approvals',
'chatbox.tool.confirmation.ariaLabel': 'Tool execution approval actions', 'chatbox.tool.confirmation.ariaLabel': 'Tool execution approval actions',
'chatbox.tool.confirmation.allow': 'Allow', 'chatbox.tool.confirmation.allow': 'Allow',
+7
View File
@@ -10,6 +10,13 @@ export const frFrMessages: TranslationMessages = {
'chatbox.todo.ariaLabel': 'Liste des tâches de l\'agent', 'chatbox.todo.ariaLabel': 'Liste des tâches de l\'agent',
'chatbox.todo.count': '{completed}/{total} terminée(s)', 'chatbox.todo.count': '{completed}/{total} terminée(s)',
'chatbox.todo.active': 'En cours : {task}', 'chatbox.todo.active': 'En cours : {task}',
'chatbox.history.title': 'Historique des conversations',
'chatbox.history.heading': 'Historique des conversations',
'chatbox.history.cancel': 'Annuler',
'chatbox.history.delete': 'Supprimer',
'chatbox.history.deleteConfirm': 'Supprimer la conversation "{title}" ? Cette action est irréversible.',
'chatbox.history.loading': 'Chargement des conversations...',
'chatbox.history.empty': 'Aucune conversation enregistrée pour l\'instant.',
'chatbox.fastForward.title': 'Approuver rapidement les exécutions d\'outils', 'chatbox.fastForward.title': 'Approuver rapidement les exécutions d\'outils',
'chatbox.tool.confirmation.ariaLabel': 'Actions d\'approbation d\'exécution d\'outil', 'chatbox.tool.confirmation.ariaLabel': 'Actions d\'approbation d\'exécution d\'outil',
'chatbox.tool.confirmation.allow': 'Autoriser', 'chatbox.tool.confirmation.allow': 'Autoriser',
+7
View File
@@ -10,6 +10,13 @@ export const zhCnMessages: TranslationMessages = {
'chatbox.todo.ariaLabel': '代理任务清单', 'chatbox.todo.ariaLabel': '代理任务清单',
'chatbox.todo.count': '已完成 {completed}/{total}', 'chatbox.todo.count': '已完成 {completed}/{total}',
'chatbox.todo.active': '当前进行中:{task}', 'chatbox.todo.active': '当前进行中:{task}',
'chatbox.history.title': '对话历史',
'chatbox.history.heading': '对话历史',
'chatbox.history.cancel': '取消',
'chatbox.history.delete': '删除',
'chatbox.history.deleteConfirm': '要删除对话“{title}”吗?此操作无法撤销。',
'chatbox.history.loading': '正在加载对话...',
'chatbox.history.empty': '还没有已保存的对话。',
'chatbox.fastForward.title': '快速放行工具执行审批', 'chatbox.fastForward.title': '快速放行工具执行审批',
'chatbox.tool.confirmation.ariaLabel': '工具执行审批操作', 'chatbox.tool.confirmation.ariaLabel': '工具执行审批操作',
'chatbox.tool.confirmation.allow': '允许', 'chatbox.tool.confirmation.allow': '允许',
+7
View File
@@ -10,6 +10,13 @@ export const zhHkMessages: TranslationMessages = {
'chatbox.todo.ariaLabel': '代理任務清單', 'chatbox.todo.ariaLabel': '代理任務清單',
'chatbox.todo.count': '已完成 {completed}/{total}', 'chatbox.todo.count': '已完成 {completed}/{total}',
'chatbox.todo.active': '當前進行中:{task}', 'chatbox.todo.active': '當前進行中:{task}',
'chatbox.history.title': '對話歷史',
'chatbox.history.heading': '對話歷史',
'chatbox.history.cancel': '取消',
'chatbox.history.delete': '刪除',
'chatbox.history.deleteConfirm': '要刪除對話「{title}」嗎?此操作無法撤銷。',
'chatbox.history.loading': '正在載入對話...',
'chatbox.history.empty': '還沒有已儲存的對話。',
'chatbox.fastForward.title': '快速放行工具執行審批', 'chatbox.fastForward.title': '快速放行工具執行審批',
'chatbox.tool.confirmation.ariaLabel': '工具執行審批操作', 'chatbox.tool.confirmation.ariaLabel': '工具執行審批操作',
'chatbox.tool.confirmation.allow': '允許', 'chatbox.tool.confirmation.allow': '允許',
+28
View File
@@ -0,0 +1,28 @@
import type { Message } from '../agent/core/AgentState';
import type { TodoItem } from '../agent/core/todo';
import type { ChatMessage } from './projectTypes';
export const SAVED_CONVERSATION_VERSION = 1;
export interface SavedConversationMeta {
conversationId: string;
title: string;
createdAt: number;
updatedAt: number;
lastTurnAt: number;
messageCount: number;
preview: string;
}
export interface SavedConversationDocument {
version: number;
conversationId: string;
continuationState: {
messages: Message[];
todos: TodoItem[];
};
fullHistory: {
messages: Message[];
};
displayTranscript: ChatMessage[];
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { useProjectStore } from '../stores/projectStore';
export const clearChatHistory = () => { export const clearChatHistory = () => {
// Clear agent state // Clear agent state
const agentCore = AgentCore.instance(); const agentCore = AgentCore.instance();
agentCore.clearConversation(); agentCore.startNewConversation();
useProjectStore.getState().setToolFastForwardEnabled(false); useProjectStore.getState().setToolFastForwardEnabled(false);
console.log('Chat history cleared programmatically'); console.log('Chat history cleared programmatically');