Skip to content

Commit 6cd348a

Browse files
committed
fix(compression): 修复上下文压缩边界
说明: - 以 compressed_until_message_id 解析压缩边界,并保留旧 marker fallback - 让手动和自动压缩统一写入边界并发送 typed 压缩事件 - 将前端压缩 loading 按会话隔离,并改用后端上下文用量 操作: - 新增 get_context_usage Tauri command,输入框 token 环优先使用服务端统计 Closes #103
1 parent a89ee8f commit 6cd348a

9 files changed

Lines changed: 781 additions & 110 deletions

File tree

src-tauri/src/commands/conversations.rs

Lines changed: 511 additions & 78 deletions
Large diffs are not rendered by default.

src-tauri/src/context_manager.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,11 @@ mod tests {
374374
assert_eq!(trimmed.len(), 1);
375375
assert_eq!(trimmed[0].role, "user");
376376
}
377+
378+
#[test]
379+
fn deepseek_v4_flash_budget_does_not_auto_compress_below_threshold() {
380+
let history = vec![text_message("user", &"token ".repeat(100_000))];
381+
382+
assert!(!should_auto_compress(&[], &history, Some(1_000_000)));
383+
}
377384
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ pub fn run() {
269269
commands::conversations::send_system_message,
270270
commands::conversations::compress_context,
271271
commands::conversations::get_compression_summary,
272+
commands::conversations::get_context_usage,
272273
commands::conversations::delete_compression,
273274
commands::conversations::regenerate_conversation_title,
274275
// conversation categories

src/components/chat/ChatView.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ import { formatChatTime } from './chatTime';
9191
import { invoke } from '@/lib/invoke';
9292
import { registerHighlight } from 'stream-markdown';
9393
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
94-
import type { Message, Attachment, ConversationStats } from '@/types';
94+
import type { Message, Attachment, ConversationStats, ConversationSummary } from '@/types';
9595

9696
// ── markstream-react custom thinking component ──────────────────────────
9797

@@ -1999,7 +1999,7 @@ export function ChatView() {
19991999
const loadingOlder = useConversationStore((s) => s.loadingOlder);
20002000
const hasOlderMessages = useConversationStore((s) => s.hasOlderMessages);
20012001
const streaming = useConversationStore((s) => s.streaming);
2002-
const compressing = useConversationStore((s) => s.compressing);
2002+
const compressingConversationId = useConversationStore((s) => s.compressingConversationId);
20032003
const streamingMessageId = useConversationStore((s) => s.streamingMessageId);
20042004
const streamActivityByMessageId = useConversationStore((s) => s.streamActivityByMessageId);
20052005
const multiModelParentId = useConversationStore((s) => s.multiModelParentId);
@@ -2022,6 +2022,7 @@ export function ChatView() {
20222022
const deleteCompression = useConversationStore((s) => s.deleteCompression);
20232023
const [summaryModalOpen, setSummaryModalOpen] = useState(false);
20242024
const [summaryModalText, setSummaryModalText] = useState('');
2025+
const [summaryModalSummary, setSummaryModalSummary] = useState<ConversationSummary | null>(null);
20252026
const [previewPayload, setPreviewPayload] = useState<CodeBlockPreviewPayload | null>(null);
20262027
const [previewModalOpen, setPreviewModalOpen] = useState(false);
20272028
const [mermaidPreviewSvg, setMermaidPreviewSvg] = useState<string | null>(null);
@@ -2072,7 +2073,16 @@ export function ChatView() {
20722073
}, []);
20732074

20742075
const activeConversation = conversations.find((c) => c.id === activeConversationId);
2076+
const compressing = compressingConversationId === activeConversationId;
20752077
const isTitleGenerating = activeConversationId != null && titleGeneratingConversationId === activeConversationId;
2078+
const summaryBoundaryLabel = useMemo(() => {
2079+
const boundaryId = summaryModalSummary?.compressed_until_message_id;
2080+
if (!boundaryId) return null;
2081+
const boundaryIndex = messages.findIndex((message) => message.id === boundaryId);
2082+
if (boundaryIndex < 0) return boundaryId.slice(0, 8);
2083+
const boundaryMessage = messages[boundaryIndex];
2084+
return `#${boundaryIndex + 1} - ${formatChatTime(boundaryMessage.created_at)}`;
2085+
}, [messages, summaryModalSummary?.compressed_until_message_id]);
20762086

20772087
const renderConvIconForChat = useCallback((size: number, modelId?: string | null) => {
20782088
if (!activeConversation) return <Avatar icon={<Bot size={16} />} style={{ background: token.colorPrimary }} size={size} />;
@@ -3759,6 +3769,7 @@ export function ChatView() {
37593769
if (!convId) return;
37603770
const summary = await getCompressionSummary(convId);
37613771
setSummaryModalText(summary?.summary_text ?? t('chat.noSummary'));
3772+
setSummaryModalSummary(summary ?? null);
37623773
setSummaryModalOpen(true);
37633774
}}
37643775
>
@@ -4113,11 +4124,27 @@ export function ChatView() {
41134124
<Modal
41144125
title={t('chat.compressionSummary')}
41154126
open={summaryModalOpen}
4116-
onCancel={() => setSummaryModalOpen(false)}
4127+
onCancel={() => {
4128+
setSummaryModalOpen(false);
4129+
setSummaryModalSummary(null);
4130+
}}
41174131
footer={null}
41184132
width={640}
41194133
>
41204134
<div style={{ maxHeight: 480, overflow: 'auto', padding: '8px 0' }}>
4135+
{summaryModalSummary && (
4136+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
4137+
{summaryModalSummary.model_used && (
4138+
<Tag>{t('chat.summaryModel', '模型')}: {summaryModalSummary.model_used}</Tag>
4139+
)}
4140+
{summaryModalSummary.token_count != null && (
4141+
<Tag>{t('chat.summaryTokens', '摘要 tokens')}: {summaryModalSummary.token_count.toLocaleString()}</Tag>
4142+
)}
4143+
{summaryBoundaryLabel && (
4144+
<Tag>{t('chat.summaryBoundary', '压缩到')}: {summaryBoundaryLabel}</Tag>
4145+
)}
4146+
</div>
4147+
)}
41214148
<NodeRenderer
41224149
content={summaryModalText}
41234150
isDark={isDarkMode}

src/components/chat/InputArea.tsx

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@ export function InputArea() {
115115

116116
const { message: messageApi, modal } = App.useApp();
117117
const streaming = useConversationStore((s) => s.streaming);
118-
const compressing = useConversationStore((s) => s.compressing);
118+
const compressingConversationId = useConversationStore((s) => s.compressingConversationId);
119119
const cancelCurrentStream = useConversationStore((s) => s.cancelCurrentStream);
120120
const activeConversationId = useConversationStore((s) => s.activeConversationId);
121+
const compressing = compressingConversationId === activeConversationId;
121122
const sendMessage = useConversationStore((s) => s.sendMessage);
122123
const sendAgentMessage = useConversationStore((s) => s.sendAgentMessage);
123124
const createConversation = useConversationStore((s) => s.createConversation);
@@ -694,23 +695,41 @@ export function InputArea() {
694695
);
695696

696697
// Context token usage calculation
697-
const getCompressionSummary = useConversationStore((s) => s.getCompressionSummary);
698-
const [summaryTokenCount, setSummaryTokenCount] = useState<number>(0);
698+
const getContextUsage = useConversationStore((s) => s.getContextUsage);
699+
const [serverContextUsage, setServerContextUsage] = useState<{
700+
usedTokens: number;
701+
maxTokens: number;
702+
percent: number;
703+
} | null>(null);
699704

700705
useEffect(() => {
701-
if (!activeConversationId || !activeConversation?.context_compression) {
702-
setSummaryTokenCount(0);
706+
if (!activeConversationId) {
707+
setServerContextUsage(null);
703708
return;
704709
}
705-
getCompressionSummary(activeConversationId).then((s) => {
706-
setSummaryTokenCount(s?.token_count ?? 0);
710+
let cancelled = false;
711+
getContextUsage(activeConversationId).then((usage) => {
712+
if (cancelled) return;
713+
if (!usage?.max_tokens) {
714+
setServerContextUsage(null);
715+
return;
716+
}
717+
setServerContextUsage({
718+
usedTokens: usage.used_tokens,
719+
maxTokens: usage.max_tokens,
720+
percent: Math.min(Math.round((usage.used_tokens / usage.max_tokens) * 100), 100),
721+
});
707722
});
708-
}, [activeConversationId, activeConversation?.context_compression, getCompressionSummary, messages]);
723+
return () => {
724+
cancelled = true;
725+
};
726+
}, [activeConversationId, getContextUsage, messages]);
709727

710-
// TODO: Token estimation only considers loaded messages. When hasOlderMessages is true
711-
// and no context-clear marker is found, the token estimate will be lower than actual.
712-
// A proper fix would require the backend to return total token counts.
728+
// Fallback only: local estimation sees loaded messages, so it can undercount
729+
// paginated conversations when the backend usage query is unavailable.
713730
const contextTokenUsage = useMemo(() => {
731+
if (serverContextUsage) return serverContextUsage;
732+
714733
const maxTokens = currentModel?.max_tokens;
715734
if (!maxTokens) return null;
716735

@@ -730,12 +749,9 @@ export function InputArea() {
730749
usedTokens += estimateTokens(activeConversation.system_prompt) + 4;
731750
}
732751

733-
// Add summary tokens
734-
usedTokens += summaryTokenCount;
735-
736752
const percent = Math.min(Math.round((usedTokens / maxTokens) * 100), 100);
737753
return { usedTokens, maxTokens, percent };
738-
}, [messages, currentModel?.max_tokens, activeConversation?.system_prompt, summaryTokenCount]);
754+
}, [messages, currentModel?.max_tokens, activeConversation?.system_prompt, serverContextUsage]);
739755

740756
const { hasRealtimeVoice, hasReasoning, hasVision } = React.useMemo(() => ({
741757
hasRealtimeVoice: activeConversation
@@ -1689,7 +1705,12 @@ export function InputArea() {
16891705
</span>
16901706
}
16911707
>
1692-
<svg width={size} height={size} style={{ display: 'block', cursor: 'pointer' }}>
1708+
<svg
1709+
aria-label={t('chat.contextTokenUsage', '上下文 tokens')}
1710+
width={size}
1711+
height={size}
1712+
style={{ display: 'block', cursor: 'pointer' }}
1713+
>
16931714
<circle cx={r + stroke} cy={r + stroke} r={r} fill="none" stroke={token.colorBorderSecondary} strokeWidth={stroke} />
16941715
<circle
16951716
cx={r + stroke} cy={r + stroke} r={r}

src/components/chat/__tests__/InputArea.test.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { App } from 'antd';
2-
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import { beforeEach, describe, expect, it, vi } from 'vitest';
55
import type { AppSettings } from '@/types';
@@ -19,11 +19,13 @@ const toggleMemoryNamespace = vi.fn();
1919
const setThinkingBudget = vi.fn();
2020
const setThinkingLevel = vi.fn();
2121
const insertContextClear = vi.fn();
22+
const getContextUsage = vi.fn();
2223
const setActivePage = vi.fn();
2324
const setSettingsSection = vi.fn();
2425

2526
const conversationState = {
2627
streaming: false,
28+
compressingConversationId: null as string | null,
2729
activeConversationId: 'conv-1',
2830
sendMessage,
2931
createConversation,
@@ -51,6 +53,7 @@ const conversationState = {
5153
setThinkingBudget,
5254
setThinkingLevel,
5355
insertContextClear,
56+
getContextUsage,
5457
};
5558

5659
const providerState = {
@@ -178,6 +181,8 @@ describe('InputArea', () => {
178181
conversationState.conversations[0].model_id = 'model-1';
179182
conversationState.thinkingBudget = null;
180183
conversationState.thinkingLevel = null;
184+
conversationState.compressingConversationId = null;
185+
getContextUsage.mockResolvedValue(null);
181186
settingsState.settings.document_attachment_reading_enabled = false;
182187
});
183188

@@ -296,6 +301,28 @@ describe('InputArea', () => {
296301
expect(screen.queryByText('XHigh')).not.toBeInTheDocument();
297302
});
298303

304+
it('shows backend context usage instead of a loaded-message estimate', async () => {
305+
getContextUsage.mockResolvedValueOnce({
306+
used_tokens: 720000,
307+
max_tokens: 1000000,
308+
threshold_tokens: 700000,
309+
has_summary: true,
310+
compressed_until_message_id: 'msg-1',
311+
messages_after_boundary: 3,
312+
});
313+
314+
render(
315+
<App>
316+
<InputArea />
317+
</App>,
318+
);
319+
320+
await waitFor(() => expect(getContextUsage).toHaveBeenCalledWith('conv-1'));
321+
await userEvent.hover(screen.getByLabelText('上下文 tokens'));
322+
323+
expect(await screen.findByText('720,000 / 1,000,000 tokens (72%)')).toBeInTheDocument();
324+
});
325+
299326
it('shows document attachment controls for non-vision models when document reading is enabled', () => {
300327
settingsState.settings.document_attachment_reading_enabled = true;
301328

src/stores/__tests__/conversationStore.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ function deferred<T>() {
7979
}
8080

8181
async function flushPromises() {
82-
for (let index = 0; index < 8; index += 1) {
82+
for (let index = 0; index < 16; index += 1) {
8383
await Promise.resolve();
8484
}
8585
}
@@ -1743,6 +1743,111 @@ describe('conversationStore pagination', () => {
17431743
expect(useConversationStore.getState().enabledMemoryNamespaceIds).toEqual([]);
17441744
});
17451745

1746+
it('tracks context compression loading by conversation id', async () => {
1747+
const summary = {
1748+
id: 'summary-1',
1749+
conversation_id: 'conv-1',
1750+
summary_text: 'compressed context',
1751+
compressed_until_message_id: 'msg-1',
1752+
token_count: 12,
1753+
model_used: 'summary-model',
1754+
created_at: 1,
1755+
updated_at: 1,
1756+
};
1757+
const compression = deferred<typeof summary>();
1758+
invokeMock.mockImplementation((cmd: string, args: Record<string, unknown>) => {
1759+
if (cmd === 'compress_context') {
1760+
expect(args.conversationId).toBe('conv-1');
1761+
return compression.promise;
1762+
}
1763+
if (cmd === 'list_messages_page') {
1764+
return Promise.resolve(makePage([makeMessage(1, 'conv-1')], false));
1765+
}
1766+
throw new Error(`unexpected command: ${cmd}`);
1767+
});
1768+
const { useConversationStore } = await import('../conversationStore');
1769+
1770+
useConversationStore.setState({
1771+
activeConversationId: 'conv-1',
1772+
conversations: [
1773+
makeConversation('conv-1'),
1774+
makeConversation('conv-2'),
1775+
] as never[],
1776+
messages: [makeMessage(1, 'conv-1')],
1777+
});
1778+
1779+
const pending = useConversationStore.getState().compressContext();
1780+
await flushPromises();
1781+
1782+
expect(useConversationStore.getState().compressingConversationId).toBe('conv-1');
1783+
useConversationStore.setState({ activeConversationId: 'conv-2' });
1784+
expect(useConversationStore.getState().compressingConversationId).toBe('conv-1');
1785+
1786+
compression.resolve(summary);
1787+
await pending;
1788+
1789+
expect(useConversationStore.getState().compressingConversationId).toBeNull();
1790+
});
1791+
1792+
it('applies compression events only to the matching active conversation', async () => {
1793+
const listeners = new Map<string, (event: { payload: unknown }) => void>();
1794+
listenMock.mockImplementation(async (eventName: string, handler: (event: { payload: unknown }) => void) => {
1795+
listeners.set(eventName, handler);
1796+
return () => {};
1797+
});
1798+
const { useConversationStore } = await import('../conversationStore');
1799+
const marker = {
1800+
...makeMessage(50, 'conv-1'),
1801+
role: 'system',
1802+
content: '<!-- context-compressed -->',
1803+
};
1804+
const otherMarker = {
1805+
...makeMessage(60, 'conv-2'),
1806+
role: 'system',
1807+
content: '<!-- context-compressed -->',
1808+
};
1809+
const summary = {
1810+
id: 'summary-1',
1811+
conversation_id: 'conv-1',
1812+
summary_text: 'compressed context',
1813+
compressed_until_message_id: 'msg-1',
1814+
token_count: 12,
1815+
model_used: 'summary-model',
1816+
created_at: 1,
1817+
updated_at: 1,
1818+
};
1819+
1820+
useConversationStore.setState({
1821+
activeConversationId: 'conv-1',
1822+
conversations: [
1823+
makeConversation('conv-1'),
1824+
makeConversation('conv-2'),
1825+
] as never[],
1826+
messages: [makeMessage(1, 'conv-1')],
1827+
});
1828+
1829+
await useConversationStore.getState().startStreamListening();
1830+
1831+
listeners.get('conversation:compressed')?.({
1832+
payload: {
1833+
conversation_id: 'conv-2',
1834+
marker_message: otherMarker,
1835+
summary: { ...summary, conversation_id: 'conv-2' },
1836+
},
1837+
});
1838+
expect(useConversationStore.getState().messages.map((message) => message.id)).toEqual(['msg-1']);
1839+
1840+
listeners.get('conversation:compressed')?.({
1841+
payload: {
1842+
conversation_id: 'conv-1',
1843+
marker_message: marker,
1844+
summary,
1845+
},
1846+
});
1847+
1848+
expect(useConversationStore.getState().messages.map((message) => message.id)).toEqual(['msg-1', 'msg-50']);
1849+
});
1850+
17461851
it('persists reasoning level changes separately from legacy thinking budget', async () => {
17471852
invokeMock.mockResolvedValueOnce(makeConversation('conv-1', { thinking_budget: 4096, thinking_level: 'high' }));
17481853
const { useConversationStore } = await import('../conversationStore');

0 commit comments

Comments
 (0)