feat: added conversation history feature
This commit is contained in:
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 { 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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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().');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '允许',
|
||||||
|
|||||||
@@ -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': '允許',
|
||||||
|
|||||||
@@ -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 = () => {
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user