feat: added conversation history feature
This commit is contained in:
@@ -182,4 +182,33 @@ describe('AgentCore todo integration', () => {
|
||||
expect(provider.calls).toHaveLength(1);
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { buildTodoContext } from './todo';
|
||||
import type { SavedConversationDocument } from '../../types/conversationTypes';
|
||||
|
||||
export interface CompactConversationOptions {
|
||||
trigger: 'manual' | 'auto';
|
||||
@@ -261,6 +262,29 @@ export class AgentCore {
|
||||
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> {
|
||||
if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) {
|
||||
return false;
|
||||
|
||||
@@ -109,4 +109,40 @@ describe('AgentState compaction helpers', () => {
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,25 @@ export class AgentState {
|
||||
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
|
||||
*/
|
||||
|
||||
+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')}
|
||||
|
||||
@@ -100,6 +100,8 @@ export const OPFS_CONSTANTS = {
|
||||
PROJECT_FILE: 'project.json',
|
||||
METADATA_FILE: 'meta.json',
|
||||
MEDIA_DIR: 'media',
|
||||
CONVERSATIONS_DIR: 'conversations',
|
||||
CONVERSATION_FILE: 'conversation.json',
|
||||
};
|
||||
|
||||
export const CONFIG_UPGRADER_CONSTANTS = {
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
||||
import { KGConversationStorage } from './KGConversationStorage';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { GlobalTrackType } from '../global-track';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
@@ -10,16 +11,23 @@ import { KGTrack } from '../track/KGTrack';
|
||||
// --- OPFS mock infrastructure ---
|
||||
|
||||
class MockFileSystemWritableFileStream {
|
||||
public data = '';
|
||||
async write(content: string) { this.data = content; }
|
||||
public data: string | ArrayBuffer = '';
|
||||
async write(content: string | ArrayBuffer) { this.data = content; }
|
||||
async close() {}
|
||||
}
|
||||
|
||||
class MockFileSystemFileHandle {
|
||||
kind = 'file' as const;
|
||||
constructor(public name: string, private _content: string = '') {}
|
||||
constructor(public name: string, private _content: string | ArrayBuffer = '') {}
|
||||
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() {
|
||||
const stream = new MockFileSystemWritableFileStream();
|
||||
@@ -93,6 +101,7 @@ vi.stubGlobal('navigator', {
|
||||
|
||||
describe('KGProjectStorage', () => {
|
||||
let storage: KGProjectStorage;
|
||||
let conversationStorage: KGConversationStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset singleton and mock filesystem
|
||||
@@ -103,6 +112,9 @@ describe('KGProjectStorage', () => {
|
||||
|
||||
storage = KGProjectStorage.getInstance();
|
||||
await storage.initialize();
|
||||
;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
|
||||
conversationStorage = KGConversationStorage.getInstance();
|
||||
await conversationStorage.initialize();
|
||||
});
|
||||
|
||||
function createTestProject(name = 'Test Project'): KGProject {
|
||||
@@ -337,4 +349,65 @@ describe('KGProjectStorage', () => {
|
||||
const loaded = await storage.load('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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,8 +232,8 @@ export class KGProjectStorage {
|
||||
project.setName(targetName);
|
||||
await this.save(targetName, project, false);
|
||||
|
||||
// Copy media files
|
||||
await this.copyMediaFiles(sourceName, targetName);
|
||||
// Copy project-scoped auxiliary artifacts such as media and conversation history
|
||||
await this.copyProjectArtifacts(sourceName, targetName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,8 +346,8 @@ export class KGProjectStorage {
|
||||
project.setName(newName);
|
||||
await this.save(newName, project, false);
|
||||
|
||||
// Copy media files from old to new location
|
||||
await this.copyMediaFiles(oldName, newName);
|
||||
// Copy project-scoped auxiliary artifacts such as media and conversation history
|
||||
await this.copyProjectArtifacts(oldName, newName);
|
||||
|
||||
// Delete old location
|
||||
await this.delete(oldName);
|
||||
@@ -366,7 +366,7 @@ export class KGProjectStorage {
|
||||
|
||||
// Migrate media files only if the old folder exists
|
||||
if (await this.exists(oldName)) {
|
||||
await this.copyMediaFiles(oldName, newName);
|
||||
await this.copyProjectArtifacts(oldName, newName);
|
||||
await this.delete(oldName);
|
||||
}
|
||||
}
|
||||
@@ -381,7 +381,7 @@ export class KGProjectStorage {
|
||||
await this.save(targetName, data, false);
|
||||
|
||||
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') {
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
zip.file(entryPath, file.arrayBuffer());
|
||||
zip.file(entryPath, new Uint8Array(await this.readBlobLikeAsArrayBuffer(file)));
|
||||
} else {
|
||||
const subDir = entry as FileSystemDirectoryHandle;
|
||||
await this.addDirectoryToZip(zip, subDir, entryPath);
|
||||
@@ -551,40 +551,53 @@ export class KGProjectStorage {
|
||||
|
||||
// --- Media migration ---
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
private async copyProjectArtifacts(fromName: string, toName: string): Promise<void> {
|
||||
try {
|
||||
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 toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
|
||||
|
||||
for await (const entry of fromMedia.values()) {
|
||||
for await (const entry of fromDir.values()) {
|
||||
if (entry.name === OPFS_CONSTANTS.PROJECT_FILE || entry.name === OPFS_CONSTANTS.METADATA_FILE) {
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === 'file') {
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
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();
|
||||
await writable.write(await file.arrayBuffer());
|
||||
await writable.close();
|
||||
} else {
|
||||
await this.copyDirectoryContents(
|
||||
entry as FileSystemDirectoryHandle,
|
||||
await toDir.getDirectoryHandle(entry.name, { create: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 ---
|
||||
|
||||
private async writeFile(
|
||||
@@ -618,4 +631,16 @@ export class KGProjectStorage {
|
||||
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().');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ export const enUsMessages: TranslationMessages = {
|
||||
'chatbox.todo.ariaLabel': 'Agent task checklist',
|
||||
'chatbox.todo.count': '{completed}/{total} completed',
|
||||
'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.tool.confirmation.ariaLabel': 'Tool execution approval actions',
|
||||
'chatbox.tool.confirmation.allow': 'Allow',
|
||||
|
||||
@@ -10,6 +10,13 @@ export const frFrMessages: TranslationMessages = {
|
||||
'chatbox.todo.ariaLabel': 'Liste des tâches de l\'agent',
|
||||
'chatbox.todo.count': '{completed}/{total} terminée(s)',
|
||||
'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.tool.confirmation.ariaLabel': 'Actions d\'approbation d\'exécution d\'outil',
|
||||
'chatbox.tool.confirmation.allow': 'Autoriser',
|
||||
|
||||
@@ -10,6 +10,13 @@ export const zhCnMessages: TranslationMessages = {
|
||||
'chatbox.todo.ariaLabel': '代理任务清单',
|
||||
'chatbox.todo.count': '已完成 {completed}/{total}',
|
||||
'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.tool.confirmation.ariaLabel': '工具执行审批操作',
|
||||
'chatbox.tool.confirmation.allow': '允许',
|
||||
|
||||
@@ -10,6 +10,13 @@ export const zhHkMessages: TranslationMessages = {
|
||||
'chatbox.todo.ariaLabel': '代理任務清單',
|
||||
'chatbox.todo.count': '已完成 {completed}/{total}',
|
||||
'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.tool.confirmation.ariaLabel': '工具執行審批操作',
|
||||
'chatbox.tool.confirmation.allow': '允許',
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useProjectStore } from '../stores/projectStore';
|
||||
export const clearChatHistory = () => {
|
||||
// Clear agent state
|
||||
const agentCore = AgentCore.instance();
|
||||
agentCore.clearConversation();
|
||||
agentCore.startNewConversation();
|
||||
useProjectStore.getState().setToolFastForwardEnabled(false);
|
||||
|
||||
console.log('Chat history cleared programmatically');
|
||||
|
||||
Reference in New Issue
Block a user