feat: add manual/auto conversation compaction and preserve full export history

This commit is contained in:
Xiaohan-Tian
2026-06-01 19:55:59 -07:00
parent 936fb461e3
commit c1d9f4c9fb
23 changed files with 1059 additions and 27 deletions
+23 -1
View File
@@ -313,6 +313,28 @@
color: #909090;
}
.message-divider-banner {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 2px 0;
}
.message-divider-banner-line {
flex: 1 1 auto;
height: 1px;
background: linear-gradient(90deg, rgba(102, 102, 102, 0.2) 0%, rgba(112, 112, 112, 0.55) 50%, rgba(102, 102, 102, 0.2) 100%);
}
.message-divider-banner-label {
flex: 0 0 auto;
color: #8c8c8c;
font-size: 11px;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* Abort link styling */
.abort-link {
background: none !important;
@@ -326,7 +348,7 @@
}
.abort-link:hover {
color: #9b88ff !important;
color: #5a9fd4 !important;
text-decoration: none !important;
}
+65 -11
View File
@@ -1,11 +1,28 @@
import React from 'react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import ChatBox from './ChatBox';
import { I18nContext } from '../i18n/I18nProvider';
import type { ResolvedLocaleCode } from '../i18n/types';
import { translate } from '../i18n/translate';
const {
agentCoreMock,
processUserMessageMock,
processStreamMock,
} = vi.hoisted(() => ({
agentCoreMock: {
setLLMProvider: vi.fn(),
getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })),
abortCurrentRequest: vi.fn(),
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
shouldCompactBeforeNextTurn: vi.fn(async () => false),
},
processUserMessageMock: vi.fn(),
processStreamMock: vi.fn(async () => ''),
}));
vi.mock('./chat', () => ({
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
@@ -13,12 +30,7 @@ vi.mock('./chat', () => ({
vi.mock('../agent/core/AgentCore', () => ({
AgentCore: {
instance: () => ({
setLLMProvider: vi.fn(),
getLLMProvider: vi.fn(),
abortCurrentRequest: vi.fn(),
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
}),
instance: () => agentCoreMock,
},
}));
@@ -66,18 +78,29 @@ vi.mock('../util/chatUtil', () => ({
}));
vi.mock('../util/messageFilter/UserMessageFilter', () => ({
processUserMessage: vi.fn(),
processUserMessage: processUserMessageMock,
}));
vi.mock('../hooks/useStreamProcessor', () => ({
useStreamProcessor: () => ({
abortController: null,
processStream: vi.fn(),
processStream: processStreamMock,
}),
}));
vi.mock('../utils/chatMessageUtils', () => ({
createMessage: vi.fn(),
createMessage: vi.fn((role: 'user' | 'assistant', content: string) => ({
id: `${role}-${content}`,
role,
content,
})),
createStreamingMessage: vi.fn(() => ({
id: 'streaming-message',
role: 'assistant',
content: '<span class="processing-wave">Thinking...</span> click here to abort.',
isStreaming: true,
tokenCount: 0,
})),
addWelcomeMessage: vi.fn().mockResolvedValue(null),
}));
@@ -134,6 +157,13 @@ describe('ChatBox', () => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
processUserMessageMock.mockReset();
processStreamMock.mockClear();
agentCoreMock.compactConversation.mockClear();
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
});
it('renders the English assistant title under en_us', () => {
renderWithLocale('en_us');
@@ -151,4 +181,28 @@ describe('ChatBox', () => {
expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy();
});
it('shows compacting status and completion for /compact', async () => {
processUserMessageMock.mockResolvedValue({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: null,
metadata: {
command: 'compact',
focus: 'keep the latest work',
},
});
renderWithLocale('en_us');
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
fireEvent.change(input, { target: { value: '/compact keep the latest work' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
await waitFor(() => {
expect(agentCoreMock.compactConversation).toHaveBeenCalled();
expect(screen.getByText('Conversation Compacted')).toBeTruthy();
});
});
});
+93 -8
View File
@@ -11,7 +11,7 @@ import { SystemPrompts } from '../agent/core/SystemPrompts';
import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil';
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
import { useStreamProcessor } from '../hooks/useStreamProcessor';
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
import { createMessage, createStreamingMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
import { formatLocalDateTime } from '../util/timeUtil';
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager';
@@ -70,6 +70,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isCompacting, setIsCompacting] = useState(false);
const [lastUserMessage, setLastUserMessage] = useState<string>('');
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
const [activeProvider, setActiveProvider] = useState<string>('openai');
@@ -87,7 +88,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const handleExportOptionSelect = (option: string) => {
if (option === 'Export conversation as JSON') {
try {
const agentMessages = AgentCore.instance().getAgentState().getMessages();
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
const exportMessages = agentMessages.map((m) => ({
id: m.id,
role: m.role,
@@ -105,7 +106,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} else if (option === 'Export conversation as Markdown') {
(async () => {
try {
const agentMessages = AgentCore.instance().getAgentState().getMessages();
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
const res = await fetch(templateUrl);
const template = await res.text();
@@ -204,6 +205,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
changedKeys.includes('general.llm_provider') ||
changedKeys.includes('general.local_browser.context_length') ||
changedKeys.some(k => k.startsWith('general.openai.')) ||
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
) {
applyProviderFromConfig();
@@ -255,8 +257,78 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
clearChatHistoryAndUI(setStatus);
};
const runCompactionWithStatus = useCallback(async (
trigger: 'manual' | 'auto',
focus?: string,
): Promise<boolean> => {
const agentCore = AgentCore.instance();
const statusMessage = createMessage('assistant', 'Compacting Conversation');
const progressMessage = createStreamingMessage();
let progressTokenCount = 0;
handleMessageAdd(statusMessage);
handleMessageAdd(progressMessage);
setIsCompacting(true);
try {
const result = await agentCore.compactConversation({
trigger,
focus,
onProgress: () => {
progressTokenCount += 1;
handleMessageUpdate(progressMessage.id, (msg) => ({
...msg,
content: `<span class="processing-wave">Processing...</span>${progressTokenCount > 0 ? ` ${progressTokenCount} tokens received.` : ''} click here to abort.`,
tokenCount: progressTokenCount,
}));
},
});
handleMessageUpdate(statusMessage.id, (msg) => ({
...msg,
content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet',
}));
handleMessageRemove(progressMessage.id);
return result.changed;
} catch (error) {
console.error('Conversation compaction failed:', error);
handleMessageUpdate(statusMessage.id, (msg) => ({
...msg,
content: `Compaction failed: ${error instanceof Error ? error.message : 'Unable to compact the conversation.'}`,
}));
handleMessageRemove(progressMessage.id);
return false;
} finally {
setIsCompacting(false);
}
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate]);
const sendWithCompactionRecovery = useCallback(async (
llmInput: string,
originalUserMessage: string,
): Promise<void> => {
try {
await streamProcessor.processStream(llmInput, 'USER');
} catch (error) {
const provider = AgentCore.instance().getLLMProvider();
if (provider?.isContextTooLongError?.(error)) {
const compacted = await runCompactionWithStatus('auto');
if (compacted) {
try {
await streamProcessor.processStream(llmInput, 'USER');
} catch (retryError) {
console.error('Retry after compaction failed:', retryError, originalUserMessage);
}
return;
}
}
console.error('Failed to process user message:', error, originalUserMessage);
}
}, [runCompactionWithStatus, streamProcessor]);
const handleSend = async () => {
if (inputValue.trim() && !isProcessing) {
if (inputValue.trim() && !isProcessing && !isCompacting) {
const userMessage = inputValue.trim();
setLastUserMessage(userMessage);
setInputValue('');
@@ -273,6 +345,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
handleMessageAdd(pseudoMessage);
}
if (filterResult.metadata?.command === 'compact') {
await runCompactionWithStatus(
'manual',
typeof filterResult.metadata.focus === 'string' ? filterResult.metadata.focus : undefined,
);
return;
}
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
return;
}
@@ -294,7 +374,12 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
}
// Process through stream processor — AgentCore handles the full agentic loop internally
await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
const agentCore = AgentCore.instance();
if (await agentCore.shouldCompactBeforeNextTurn(filterResult.finalMessageForLLM)) {
await runCompactionWithStatus('auto');
}
await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage);
}
};
@@ -333,13 +418,13 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
// Auto-focus input when processing completes
useEffect(() => {
if (!isProcessing && textareaRef.current) {
if (!isProcessing && !isCompacting && textareaRef.current) {
setTimeout(() => {
textareaRef.current?.focus();
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0);
}
}, [isProcessing]);
}, [isCompacting, isProcessing]);
const localRuntimeMessage = localModelState.runtimeSupport.reason;
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
@@ -448,7 +533,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div ref={messagesEndRef} />
</div>
{!isProcessing && (
{!isProcessing && !isCompacting && (
<div className="chatbox-input-area">
<textarea
ref={textareaRef}
@@ -93,4 +93,21 @@ describe('AssistantMessage', () => {
expect(codeElement).toBeInTheDocument();
expect(codeElement).toHaveTextContent('const value = 1;');
});
it('renders compacting and compacted messages as divider banners', () => {
const { rerender, container } = render(
<AssistantMessage content="Compacting Conversation" />
);
expect(container.querySelector('.message-divider-banner')).toBeInTheDocument();
expect(screen.getByLabelText('Compacting Conversation')).toBeInTheDocument();
rerender(<AssistantMessage content="Conversation Compacted" />);
expect(screen.getByLabelText('Conversation Compacted')).toBeInTheDocument();
rerender(<AssistantMessage content="Nothing to Compact Yet" />);
expect(screen.getByLabelText('Nothing to Compact Yet')).toBeInTheDocument();
});
});
+16
View File
@@ -44,6 +44,9 @@ const formatTps = (value?: number): string | null => {
const THINKING_LABEL = 'Thinking...';
const PROCESSING_LABEL = 'Processing...';
const COMPACTION_IN_PROGRESS_LABEL = 'Compacting Conversation';
const COMPACTION_DONE_LABEL = 'Conversation Compacted';
const COMPACTION_EMPTY_LABEL = 'Nothing to Compact Yet';
const formatThinkingDuration = (elapsedSeconds: number): string => {
if (elapsedSeconds < 60) {
@@ -62,6 +65,9 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0);
const processingWaveLabels = [THINKING_LABEL, PROCESSING_LABEL];
const isThinking = isStreaming && content.includes(`<span class="processing-wave">${THINKING_LABEL}</span>`);
const isCompactionBanner = content === COMPACTION_IN_PROGRESS_LABEL
|| content === COMPACTION_DONE_LABEL
|| content === COMPACTION_EMPTY_LABEL;
useEffect(() => {
if (!isThinking) {
@@ -83,6 +89,16 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
}, [isThinking]);
const renderContent = () => {
if (isCompactionBanner) {
return (
<div className="message-divider-banner" aria-label={content}>
<span className="message-divider-banner-line" aria-hidden="true" />
<span className="message-divider-banner-label">{content}</span>
<span className="message-divider-banner-line" aria-hidden="true" />
</div>
);
}
// Handle special abort link for streaming messages
if (isStreaming && onAbort && content.includes('click here to abort')) {
const processingWaveMarkup = processingWaveLabels
@@ -16,6 +16,7 @@ const configState = new Map<string, unknown>([
['general.language', 'auto'],
['general.llm_provider', 'local_browser'],
['general.persist_api_keys_non_localhost', false],
['general.auto_compact_threshold_percent', 90],
['general.openai.api_key', ''],
['general.openai.model', 'gpt-5.4-mini'],
['general.openai.flex', false],
@@ -107,6 +108,7 @@ describe('GeneralSettings', () => {
beforeEach(() => {
configState.set('general.language', 'auto');
configState.set('general.local_browser.context_length', 65536);
configState.set('general.auto_compact_threshold_percent', 90);
configManagerMock.get.mockClear();
configManagerMock.set.mockClear();
localModelState.isCached = false;
@@ -173,6 +175,19 @@ describe('GeneralSettings', () => {
});
});
it('renders and persists the auto-compact threshold', async () => {
renderSettings();
const select = await screen.findByLabelText('Auto-Compact Threshold');
expect((select as HTMLSelectElement).value).toBe('90');
fireEvent.change(select, { target: { value: '80' } });
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('general.auto_compact_threshold_percent', 80);
});
});
it('renders and persists local runtime download URLs', async () => {
renderSettings();
@@ -41,6 +41,7 @@ const GeneralSettings: React.FC = () => {
const [claudeOpenRouterModel, setClaudeOpenRouterModel] = useState<string>('');
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
const [persistApiKeysNonLocalhost, setPersistApiKeysNonLocalhost] = useState<boolean>(false);
const [autoCompactThresholdPercent, setAutoCompactThresholdPercent] = useState<80 | 90 | 95>(90);
const [compatibleKey, setCompatibleKey] = useState<string>('');
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
const [compatibleModel, setCompatibleModel] = useState<string>('');
@@ -107,6 +108,9 @@ const GeneralSettings: React.FC = () => {
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
setPersistApiKeysNonLocalhost((configManager.get('general.persist_api_keys_non_localhost') as boolean) ?? false);
setAutoCompactThresholdPercent(
((configManager.get('general.auto_compact_threshold_percent') as 80 | 90 | 95 | undefined) ?? 90),
);
setGeminiKey((configManager.get('general.gemini.api_key') as string) || '');
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
@@ -206,6 +210,18 @@ const GeneralSettings: React.FC = () => {
}
};
const handleAutoCompactThresholdChange = async (value: string) => {
const parsed = Number(value);
const normalized: 80 | 90 | 95 = parsed === 80 || parsed === 95 ? parsed : 90;
setAutoCompactThresholdPercent(normalized);
try {
await configManager.set('general.auto_compact_threshold_percent', normalized);
console.log('Auto-compact threshold changed to:', normalized);
} catch (error) {
console.error('Failed to save auto-compact threshold:', error);
}
};
const handleGeminiKeyChange = (value: string) => {
setGeminiKey(value);
debouncedSave('general.gemini.api_key', value);
@@ -407,6 +423,25 @@ const GeneralSettings: React.FC = () => {
{t('settings.general.persistKeys.help')}
</div>
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="general-auto-compact-threshold">
Auto-Compact Threshold
</label>
<select
id="general-auto-compact-threshold"
className="settings-select"
value={autoCompactThresholdPercent}
onChange={(e) => void handleAutoCompactThresholdChange(e.target.value)}
>
<option value="95">Conservative (95%)</option>
<option value="90">Standard (90%)</option>
<option value="80">Early (80%)</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Compact the conversation before the next request when estimated context usage reaches this level.
</div>
</div>
</div>
<div className="settings-group">