diff --git a/src/main/agent/context-compression.test.ts b/src/main/agent/context-compression.test.ts index 73b5f04..edd1d79 100644 --- a/src/main/agent/context-compression.test.ts +++ b/src/main/agent/context-compression.test.ts @@ -72,19 +72,37 @@ describe('context compression planning', () => { it('uses an optional model context limit as an earlier trigger', () => { const history = [ - { role: 'user' as const, content: 'a'.repeat(14_000) }, - { role: 'assistant' as const, content: 'b'.repeat(14_000) }, - { role: 'user' as const, content: 'c'.repeat(14_000) }, - { role: 'assistant' as const, content: 'd'.repeat(14_000) } + { role: 'user' as const, content: 'a'.repeat(16_000) }, + { role: 'assistant' as const, content: 'b'.repeat(16_000) }, + { role: 'user' as const, content: 'c'.repeat(16_000) }, + { role: 'assistant' as const, content: 'd'.repeat(16_000) } ] const plan = planContextCompression({ history, prompt: 'Continue', settings: compressionSettings(), - contextWindowTokens: 30_000 + contextWindowTokens: 32_000 }) - expect(plan?.effectiveTriggerTokens).toBe(18_000) + expect(plan?.effectiveTriggerTokens).toBe(20_000) + expect(plan?.earlierMessages.length).toBeGreaterThan(0) + }) + + it('defensively clamps legacy undersized context limits', () => { + const history = [ + { role: 'user' as const, content: 'a'.repeat(40_000) }, + { role: 'assistant' as const, content: 'b'.repeat(40_000) }, + { role: 'user' as const, content: 'c'.repeat(40_000) }, + { role: 'assistant' as const, content: 'd'.repeat(40_000) } + ] + const plan = planContextCompression({ + history, + prompt: 'Continue', + settings: compressionSettings(), + contextWindowTokens: 10_000 + }) + + expect(plan?.effectiveTriggerTokens).toBe(20_000) expect(plan?.earlierMessages.length).toBeGreaterThan(0) }) }) diff --git a/src/main/agent/context-compression.ts b/src/main/agent/context-compression.ts index a490243..aba2b5a 100644 --- a/src/main/agent/context-compression.ts +++ b/src/main/agent/context-compression.ts @@ -1,4 +1,14 @@ import type { ContextCompressionSettings } from '../../shared/contracts' +import { + estimateContextInputTokens, + estimateMessagesTokens, + getEffectiveContextTriggerTokens +} from '../../shared/context-window' + +export { + estimateMessagesTokens, + estimateTextTokens +} from '../../shared/context-window' export type CompressibleConversationMessage = { role: 'user' | 'assistant' @@ -12,34 +22,6 @@ export type ContextCompressionPlan = { effectiveTriggerTokens: number } -const reservedOutputAndSafetyTokens = 12_000 -const estimatedRequestOverheadTokens = 4_000 - -export function estimateTextTokens(value: string): number { - let asciiCharacters = 0 - let nonAsciiCharacters = 0 - for (const character of value) { - if (character.codePointAt(0)! <= 0x7f) { - asciiCharacters += 1 - } else { - nonAsciiCharacters += 1 - } - } - return Math.max( - 1, - Math.ceil(asciiCharacters / 4 + nonAsciiCharacters) - ) -} - -export function estimateMessagesTokens( - messages: readonly CompressibleConversationMessage[] -): number { - return messages.reduce( - (total, message) => total + estimateTextTokens(message.content) + 4, - 0 - ) -} - function groupConversationTurns( messages: readonly CompressibleConversationMessage[] ): CompressibleConversationMessage[][] { @@ -61,24 +43,19 @@ function groupConversationTurns( export function planContextCompression(input: { history: readonly CompressibleConversationMessage[] prompt: string + summaryTokens?: number settings: ContextCompressionSettings contextWindowTokens?: number }): ContextCompressionPlan | undefined { - const estimatedInputTokens = - estimateMessagesTokens(input.history) + - estimateTextTokens(input.prompt) + - estimatedRequestOverheadTokens - const contextLimitedTrigger = - input.contextWindowTokens === undefined - ? input.settings.triggerTokens - : Math.max( - 8_000, - input.contextWindowTokens - reservedOutputAndSafetyTokens - ) - const effectiveTriggerTokens = Math.min( - input.settings.triggerTokens, - contextLimitedTrigger - ) + const estimatedInputTokens = estimateContextInputTokens({ + history: input.history, + prompt: input.prompt, + summaryTokens: input.summaryTokens + }) + const effectiveTriggerTokens = getEffectiveContextTriggerTokens({ + triggerTokens: input.settings.triggerTokens, + contextWindowTokens: input.contextWindowTokens + }) if (estimatedInputTokens < effectiveTriggerTokens) { return undefined } diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 61f6c14..1af73f0 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -379,9 +379,30 @@ describe('ModelAgentRuntime', () => { expect(answerMessages).toContain('new-user-') expect(answerMessages).not.toContain('old-user-') expect(events).toContainEqual( + expect.objectContaining({ + type: 'context-compression', + state: 'started', + estimatedBeforeTokens: expect.any(Number) + }) + ) + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context-compression', + state: 'completed', + estimatedAfterTokens: expect.any(Number) + }) + ) + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context-metrics', + coveredMessageCount: 4, + summaryTokens: expect.any(Number) + }) + ) + expect(events).not.toContainEqual( expect.objectContaining({ type: 'status', - message: '较早的对话已压缩,正在生成回答' + message: '正在准备直连模型上下文' }) ) expect(events).toContainEqual( diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 98ce147..a50fbfd 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -42,8 +42,13 @@ import { import { readBoundedResponseText } from './bounded-response' import { formatConversationForSummary, - planContextCompression + planContextCompression, + estimateMessagesTokens } from './context-compression' +import { + estimateContextInputTokens, + getEffectiveContextTriggerTokens +} from '../../shared/context-window' type ConversationMessage = { role: 'user' | 'assistant' @@ -1784,23 +1789,19 @@ export class ModelAgentRuntime implements AgentRuntime { return { summary: summary.trim(), usageEvents } } - private async prepareCompressedRequest( + private async *prepareCompressedRequest( request: AgentExecutionRequest, signal: AbortSignal - ): Promise<{ + ): AsyncGenerator { + }, void> { const compression = this.options.contextCompression - if ( - !compression?.settings.enabled || - !request.history?.length - ) { - return { request, compressed: false, usageEvents: [] } + if (!compression) { + return { request, compressed: false } } - const history = request.history + const history = request.history ?? [] let state = this.conversationSummaries.get(request.conversationId) if ( state && @@ -1815,13 +1816,42 @@ export class ModelAgentRuntime implements AgentRuntime { const remainingHistory = history.slice( state?.coveredMessageCount ?? 0 ) + const requestPrompt = [ + request.trustedInstructions ?? '', + request.prompt + ].join('\n') + const currentSummaryTokens = state + ? estimateMessagesTokens(this.summaryHistory(state.summary)) + : 0 + const effectiveTriggerTokens = + getEffectiveContextTriggerTokens({ + triggerTokens: compression.settings.triggerTokens, + contextWindowTokens: compression.contextWindowTokens + }) + const estimatedInputTokens = estimateContextInputTokens({ + history: remainingHistory, + prompt: requestPrompt, + summaryTokens: currentSummaryTokens + }) + yield { + requestId: request.requestId, + type: 'context-metrics', + estimatedInputTokens, + effectiveTriggerTokens, + contextWindowTokens: compression.contextWindowTokens, + compressionEnabled: compression.settings.enabled, + recentRawTokens: compression.settings.recentRawTokens, + coveredMessageCount: state?.coveredMessageCount ?? 0, + summaryTokens: currentSummaryTokens + } + if (!compression.settings.enabled || history.length === 0) { + return { request, compressed: false } + } + const plan = planContextCompression({ history: remainingHistory, - prompt: [ - state?.summary ?? '', - request.trustedInstructions ?? '', - request.prompt - ].join('\n'), + prompt: requestPrompt, + summaryTokens: currentSummaryTokens, settings: compression.settings, contextWindowTokens: compression.contextWindowTokens }) @@ -1836,20 +1866,29 @@ export class ModelAgentRuntime implements AgentRuntime { ] }, compressed: false, - usageEvents: [] } - : { request, compressed: false, usageEvents: [] } + : { request, compressed: false } } + const coveredMessageCount = + (state?.coveredMessageCount ?? 0) + + plan.earlierMessages.length + yield { + requestId: request.requestId, + type: 'context-compression', + state: 'started', + estimatedBeforeTokens: plan.estimatedInputTokens, + effectiveTriggerTokens: plan.effectiveTriggerTokens, + contextWindowTokens: compression.contextWindowTokens, + recentRawTokens: compression.settings.recentRawTokens, + coveredMessageCount + } const summarized = await this.summarizeEarlierHistory( request, plan.earlierMessages, state?.summary, signal ) - const coveredMessageCount = - (state?.coveredMessageCount ?? 0) + - plan.earlierMessages.length state = { coveredMessageCount, coveredHistoryDigest: this.historyDigest( @@ -1858,6 +1897,40 @@ export class ModelAgentRuntime implements AgentRuntime { summary: summarized.summary } this.conversationSummaries.set(request.conversationId, state) + for (const usageEvent of summarized.usageEvents) { + yield usageEvent + } + const summaryTokens = estimateMessagesTokens( + this.summaryHistory(state.summary) + ) + const estimatedAfterTokens = estimateContextInputTokens({ + history: plan.recentMessages, + prompt: requestPrompt, + summaryTokens + }) + yield { + requestId: request.requestId, + type: 'context-compression', + state: 'completed', + estimatedBeforeTokens: plan.estimatedInputTokens, + estimatedAfterTokens, + effectiveTriggerTokens: plan.effectiveTriggerTokens, + contextWindowTokens: compression.contextWindowTokens, + recentRawTokens: compression.settings.recentRawTokens, + coveredMessageCount, + summaryTokens + } + yield { + requestId: request.requestId, + type: 'context-metrics', + estimatedInputTokens: estimatedAfterTokens, + effectiveTriggerTokens: plan.effectiveTriggerTokens, + contextWindowTokens: compression.contextWindowTokens, + compressionEnabled: true, + recentRawTokens: compression.settings.recentRawTokens, + coveredMessageCount, + summaryTokens + } return { request: { ...request, @@ -1866,8 +1939,7 @@ export class ModelAgentRuntime implements AgentRuntime { ...plan.recentMessages ] }, - compressed: true, - usageEvents: summarized.usageEvents + compressed: true } } @@ -2791,29 +2863,21 @@ export class ModelAgentRuntime implements AgentRuntime { throw new Error('当前模型连接未启用图像输入') } - if ( - this.options.contextCompression?.settings.enabled && - request.history?.length - ) { - yield { - requestId: request.requestId, - type: 'status', - message: '正在准备直连模型上下文' - } - } - const prepared = await this.prepareCompressedRequest( + const preparation = this.prepareCompressedRequest( request, signal ) - for (const usageEvent of prepared.usageEvents) { - yield usageEvent + let prepared: { + request: AgentExecutionRequest + compressed: boolean } - if (prepared.compressed) { - yield { - requestId: request.requestId, - type: 'status', - message: '较早的对话已压缩,正在生成回答' + while (true) { + const result = await preparation.next() + if (result.done) { + prepared = result.value + break } + yield result.value } const executionRequest = prepared.request diff --git a/src/main/agent/runtime-e2e.manual.test.ts b/src/main/agent/runtime-e2e.manual.test.ts index 239d45e..7692104 100644 --- a/src/main/agent/runtime-e2e.manual.test.ts +++ b/src/main/agent/runtime-e2e.manual.test.ts @@ -226,10 +226,9 @@ describe.runIf(enabled)('runtime end-to-end', () => { contextCompression: { settings: { ...defaultContextCompressionSettings, - enabled: true, - triggerTokens: 8_000, - recentRawTokens: 4_000 - } + enabled: true + }, + contextWindowTokens: 32_000 } }) const events: RuntimeEvent[] = [] @@ -248,7 +247,7 @@ describe.runIf(enabled)('runtime end-to-end', () => { content: [ 'The project codename is ORBIT-739.', 'Background notes:', - 'alpha '.repeat(1_200) + 'alpha '.repeat(5_000) ].join('\n') }, { @@ -256,7 +255,7 @@ describe.runIf(enabled)('runtime end-to-end', () => { content: [ 'I will remember the project codename.', 'Acknowledgement notes:', - 'gamma '.repeat(1_000) + 'gamma '.repeat(4_000) ].join('\n') }, { @@ -264,7 +263,7 @@ describe.runIf(enabled)('runtime end-to-end', () => { content: [ 'The deploy region is AP-SOUTH-7.', 'Recent notes:', - 'beta '.repeat(900) + 'beta '.repeat(3_000) ].join('\n') }, { @@ -289,8 +288,15 @@ describe.runIf(enabled)('runtime end-to-end', () => { .join('') expect(events).toContainEqual( expect.objectContaining({ - type: 'status', - message: '较早的对话已压缩,正在生成回答' + type: 'context-compression', + state: 'started' + }) + ) + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context-compression', + state: 'completed', + estimatedAfterTokens: expect.any(Number) }) ) expect(events).toContainEqual( diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 6132fc9..07d92ed 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -156,6 +156,61 @@ describe('RuntimeSettingsStore', () => { }) }) + it('rejects undersized model context windows and repairs legacy values', async () => { + const { filePath, store } = await createStore() + const profileId = '00000000-0000-4000-8000-000000000062' + const profile = { + id: profileId, + name: 'Small context', + baseUrl: 'https://model.example/v1', + modelName: 'small-model', + protocol: 'openai-responses' as const, + authentication: 'api-key' as const, + supportsImageInput: false, + contextWindowTokens: 10_000, + imageGenerationQuality: 'auto' as const, + apiKey: { action: 'keep' as const } + } + + expect(() => + runtimeSettingsInputSchema.parse( + settings({ + modelProfiles: [profile], + defaultModelProfileId: profileId + }) + ) + ).toThrow() + + await store.update( + settings({ + modelProfiles: [ + { + ...profile, + contextWindowTokens: 32_000 + } + ], + defaultModelProfileId: profileId + }) + ) + const persisted = JSON.parse( + await readFile(filePath, 'utf8') + ) as { + modelProfiles: Array<{ contextWindowTokens?: number }> + } + persisted.modelProfiles[0]!.contextWindowTokens = 10_000 + await writeFile(filePath, JSON.stringify(persisted), 'utf8') + + const migrated = new RuntimeSettingsStore(filePath, cipher, {}) + await expect(migrated.getPublicSettings()).resolves.toMatchObject({ + modelProfiles: [ + expect.objectContaining({ + id: profileId, + contextWindowTokens: undefined + }) + ] + }) + }) + it('configures bundled runtimes from the default model profile', async () => { const { store } = await createStore() diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index cd86e11..4ad7394 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -14,6 +14,7 @@ import { imageGenerationQualitySchema, isAgentRuntimeModelProtocol, isDeepSeekHarnessModelProfile, + minimumModelContextWindowTokens, modelAuthenticationSchema, modelProtocolSchema, runtimeModelSourceSchema, @@ -555,7 +556,12 @@ function migrateVersion10( function normalizeStoredSettings(settings: StoredSettings): StoredSettings { const modelProfiles = settings.modelProfiles.map((profile) => ({ ...profile, - baseUrl: normalizeModelBaseUrl(profile.baseUrl) + baseUrl: normalizeModelBaseUrl(profile.baseUrl), + contextWindowTokens: + profile.contextWindowTokens === undefined || + profile.contextWindowTokens >= minimumModelContextWindowTokens + ? profile.contextWindowTokens + : undefined })) const fallbackProfileId = compatibleTextProfileId({ modelProfiles, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 4a1fd07..ba5dc02 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -902,6 +902,32 @@ describe('App', () => { } }) + it('keeps a recently visited workspace page mounted', async () => { + render() + fireEvent.click( + await screen.findByRole('button', { name: '知识库' }) + ) + const heading = await screen.findByRole('heading', { + level: 1, + name: '知识库' + }) + const route = heading.closest('[data-route="knowledge"]') + expect(route).not.toHaveAttribute('hidden') + + fireEvent.click(screen.getByRole('button', { name: '对话' })) + expect(heading).toBeInTheDocument() + expect(route).toHaveAttribute('hidden') + + fireEvent.click(screen.getByRole('button', { name: '知识库' })) + expect( + await screen.findByRole('heading', { + level: 1, + name: '知识库' + }) + ).toBe(heading) + expect(route).not.toHaveAttribute('hidden') + }) + it('preserves title, message, and project filtering with deferred search', async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { @@ -1585,7 +1611,8 @@ describe('App', () => { fireEvent.scroll(chat) fireEvent.click(screen.getByRole('button', { name: '知识库' })) - expect(container.querySelector('.chat')).not.toBeInTheDocument() + expect(chat).toBeInTheDocument() + expect(chat.closest('[data-route="chat"]')).toHaveAttribute('hidden') act(() => { agentListener?.({ requestId: request.requestId, @@ -1597,7 +1624,7 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: '对话' })) expect(await screen.findByText('后台新增的回复内容')).toBeInTheDocument() const restoredChat = container.querySelector('.chat') - expect(restoredChat).not.toBe(chat) + expect(restoredChat).toBe(chat) expect(restoredChat?.scrollTop).toBe(175) expect( screen.getByRole('button', { name: '到底部' }) @@ -1609,6 +1636,16 @@ describe('App', () => { '00000000-0000-4000-8000-000000000461' const secondConversationId = '00000000-0000-4000-8000-000000000462' + const draftAttachment = { + id: '00000000-0000-4000-8000-000000000463', + name: '第一段草稿附件.md', + size: 1_024, + preview: '会话级草稿附件', + kind: 'text' as const + } + vi.mocked(api.context.selectFiles).mockResolvedValueOnce([ + draftAttachment + ]) vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: firstConversationId, @@ -1619,6 +1656,7 @@ describe('App', () => { id: `00000000-0000-4000-8100-${String(index).padStart(12, '0')}`, role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), content: `第一段历史 ${String(index).padStart(3, '0')}`, + reasoning: index === 159 ? '需要保留展开状态' : undefined, createdAt: 1_775_000_000_000 + index, state: 'complete' as const })) @@ -1647,31 +1685,65 @@ describe('App', () => { name: '加载更早的消息(还剩 81 条)' }) ) - expect(container.querySelectorAll('.message')).toHaveLength(160) - const firstChat = container.querySelector('.chat') + const firstPane = container.querySelector( + `[data-conversation-id="${firstConversationId}"]` + ) + expect(firstPane?.querySelectorAll('.message')).toHaveLength(160) + const firstChat = firstPane?.querySelector('.chat') if (!firstChat) { throw new Error('Missing first chat scroll container') } + const reasoningDetails = firstPane?.querySelector( + '.message-reasoning' + ) + if (!reasoningDetails) { + throw new Error('Missing reasoning details') + } + reasoningDetails.open = true Object.defineProperties(firstChat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, value: 1_600 }, scrollTop: { configurable: true, writable: true, value: 225 } }) fireEvent.scroll(firstChat) + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '第一段会话草稿' } + }) + fireEvent.click(screen.getByLabelText('添加附件')) + expect( + await screen.findByText(draftAttachment.name) + ).toBeInTheDocument() fireEvent.click( screen.getByText('第二段会话').closest('button')! ) expect(await screen.findByText('第二段会话内容')).toBeInTheDocument() + expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('') + expect( + screen.queryByText(draftAttachment.name) + ).not.toBeInTheDocument() + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '第二段会话草稿' } + }) fireEvent.click( screen.getByText('第一段长会话').closest('button')! ) expect(await screen.findByText('第一段历史 001')).toBeInTheDocument() - expect(container.querySelectorAll('.message')).toHaveLength(160) - expect(container.querySelector('.chat')?.scrollTop).toBe( - 225 + const restoredFirstPane = container.querySelector( + `[data-conversation-id="${firstConversationId}"]` ) + expect(restoredFirstPane).toBe(firstPane) + expect(restoredFirstPane?.querySelectorAll('.message')).toHaveLength( + 160 + ) + expect(restoredFirstPane?.querySelector('.chat')).toBe(firstChat) + expect(firstChat.scrollTop).toBe(225) + expect(reasoningDetails).toHaveAttribute('open') + expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue( + '第一段会话草稿' + ) + expect(screen.getByText(draftAttachment.name)).toBeInTheDocument() }) it('requires an accessible confirmation before permanently deleting a conversation', async () => { @@ -2227,6 +2299,124 @@ describe('App', () => { expect(screen.getByText('正在分析真实推理内容')).toBeVisible() }) + it('shows live context usage and keeps explicit compression status', async () => { + const settings = await api.settings.getRuntime() + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + provider: 'model', + modelProfiles: settings.modelProfiles.map((profile) => ({ + ...profile, + contextWindowTokens: 32_000 + })), + contextCompression: { + enabled: true, + triggerTokens: 200_000, + recentRawTokens: 32_000, + modelSource: { kind: 'current' }, + summaryPrompt: 'Preserve important facts.' + } + }) + render() + + expect( + await screen.findByText(/上下文 ≈.+ \/ 32\.0K · \d+%/u) + ).toBeInTheDocument() + expect( + screen.getByRole('progressbar', { + name: '当前上下文使用量' + }) + ).toBeInTheDocument() + expect( + screen.queryByText('只读问答,不修改文件') + ).not.toBeInTheDocument() + expect( + await screen.findByText('快捷唤起:', { exact: false }) + ).toHaveTextContent('快捷唤起:Ctrl+Shift+Space') + + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '中'.repeat(1_000) } + }) + expect( + screen.getByText(/上下文 ≈5\.\dK \/ 32\.0K/u) + ).toBeInTheDocument() + fireEvent.click(screen.getByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + const request = run.mock.calls[0]?.[0] + if (!request) { + throw new Error('Missing request') + } + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'context-compression', + state: 'started', + estimatedBeforeTokens: 22_000, + effectiveTriggerTokens: 20_000, + contextWindowTokens: 32_000, + recentRawTokens: 32_000, + coveredMessageCount: 2 + }) + }) + expect( + screen.getByText('正在压缩较早对话…') + ).toBeInTheDocument() + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'status', + message: 'sonnet-5 正在思考' + }) + }) + expect( + screen.getByText('正在压缩较早对话…') + ).toBeInTheDocument() + expect(screen.getByText('sonnet-5 正在思考')).toBeInTheDocument() + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'context-compression', + state: 'completed', + estimatedBeforeTokens: 22_000, + estimatedAfterTokens: 9_000, + effectiveTriggerTokens: 20_000, + contextWindowTokens: 32_000, + recentRawTokens: 32_000, + coveredMessageCount: 2, + summaryTokens: 1_000 + }) + agentListener?.({ + requestId: request.requestId, + type: 'context-metrics', + estimatedInputTokens: 9_000, + effectiveTriggerTokens: 20_000, + contextWindowTokens: 32_000, + compressionEnabled: true, + recentRawTokens: 32_000, + coveredMessageCount: 2, + summaryTokens: 1_000 + }) + }) + + expect( + screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K') + ).toBeInTheDocument() + expect( + screen.getByText('上下文 ≈9.0K / 32.0K · 28%') + ).toBeInTheDocument() + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'done' + }) + }) + expect( + screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K') + ).toBeInTheDocument() + }) + it('keeps a tool failure in details and hides retry after continuing', async () => { render() @@ -2871,9 +3061,7 @@ describe('App', () => { expect( screen.queryByRole('heading', { name: '设置中心' }) ).not.toBeInTheDocument() - expect( - await screen.findByText(/请先配置可用的模型或 Agent Runtime/u) - ).toBeInTheDocument() + expect(screen.getByLabelText('发送')).toBeDisabled() expect(run).not.toHaveBeenCalled() }) @@ -3794,9 +3982,7 @@ describe('App', () => { expect(mode).toBeEnabled() expect(mode.closest('.composer')).not.toBeNull() expect( - await screen.findByText( - new RegExp(`${label} Ask 模式.*只允许搜索当前启用的知识库`) - ) + await screen.findByText('快捷唤起:', { exact: false }) ).toBeInTheDocument() selectComposerOption('工作模式', 'Execute · 受控执行') expect(mode).toHaveAccessibleName( @@ -4076,7 +4262,11 @@ describe('App', () => { expect(notification).toHaveTextContent( '当前对话已切换到 OpenCode · 默认模型' ) - expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: '工作模式:Ask · 只读问答' + }) + ).toBeInTheDocument() fireEvent.click( screen.getByRole('button', { @@ -4102,7 +4292,11 @@ describe('App', () => { '当前对话已切换到 Continue · 默认模型' ) ).not.toBeInTheDocument() - expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: '工作模式:Ask · 只读问答' + }) + ).toBeInTheDocument() } finally { vi.useRealTimers() } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 580b5f4..4749071 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -47,7 +47,8 @@ import { useReducer, useRef, useState, - type ReactNode + type ReactNode, + type SetStateAction } from 'react' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' @@ -65,7 +66,14 @@ import type { KnowledgeSnapshot, RuntimeSettings } from '../../shared/contracts' -import { maximumPastedImageBytes } from '../../shared/contracts' +import { + defaultContextCompressionSettings, + maximumPastedImageBytes +} from '../../shared/contracts' +import { + estimateContextInputTokens, + getEffectiveContextTriggerTokens +} from '../../shared/context-window' import { agentRuntimeSelectionKey, agentRuntimeSelectionSchema, @@ -165,7 +173,12 @@ import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts' import { ReleaseNotesDialog } from './ReleaseNotesDialog' import { scheduleIdleRoutePreload } from './idle-route-preload' import { createPreloadableComponent } from './preloadable-component' -import { formatTime } from './time-format' +import { formatTime, type TimeFormatLocale } from './time-format' +import { + pruneKeepAliveEntries, + touchKeepAliveEntry, + type KeepAliveCacheEntry +} from './keep-alive-cache' const knowledgeWorkspaceRoute = createPreloadableComponent( () => import('./KnowledgeWorkspace'), @@ -198,6 +211,12 @@ const SettingsPanel = settingsPanelRoute.Component const messageRenderBatchSize = 80 const conversationPersistenceIntervalMs = 500 const conversationSearchSnapshotDelayMs = 250 +const keepAliveExpirationMs = 60 * 60 * 1_000 +const keepAliveSweepIntervalMs = 5 * 60 * 1_000 +const maximumCachedConversations = 12 +const recentCachedConversations = 5 +const maximumCachedWorkspaceViews = 4 +const recentCachedWorkspaceViews = 3 type AppNotification = { id: string @@ -266,6 +285,28 @@ function RouteLoadingStatus({ ) } +function KeepAliveRoute({ + active, + children, + route +}: { + active: boolean + children: ReactNode + route: string +}): React.JSX.Element { + return ( + + ) +} + class RouteErrorBoundary extends Component< { children: ReactNode; fallback: ReactNode }, { failed: boolean } @@ -385,13 +426,6 @@ function AppNotificationViewport({ ) } -function isAgentRuntime( - runtime: AgentRuntimeStatus | undefined -): boolean { - return runtime?.id === 'opencode' || runtime?.id === 'continue' - || runtime?.id === 'deepseek-harness' -} - function supportsSubagentSmartRouting( workMode: string ): boolean { @@ -413,6 +447,14 @@ type ActiveRun = { conversationId: string messageId: string projectId?: string + runtimeSelectionKey: string +} + +type ConversationContextMetrics = Omit< + Extract, + 'requestId' | 'type' +> & { + runtimeSelectionKey: string } type WorkspaceView = @@ -594,6 +636,334 @@ function getConversationDisplayTitle( : conversation.title } +type ChatQuickAction = { + title: string + description: string + prompt: string +} + +type ChatScrollSnapshot = { + pinnedToBottom: boolean + scrollTop: number +} + +function ChatHistoryPane({ + active, + artifactById, + conversation, + locale, + onDownloadImage, + onOpenCitationContext, + onOpenCitationSource, + onOpenImage, + onRespondApproval, + onRespondQuestion, + onRetry, + onScrollSnapshotChange, + onSetInput, + onVisibleMessageCountChange, + quickActions, + scrollSnapshot, + visibleMessageCount +}: { + active: boolean + artifactById: ReadonlyMap + conversation: Conversation + locale: TimeFormatLocale + onDownloadImage: (item: ImageViewerItem) => void + onOpenCitationContext: ( + reference: KnowledgeSearchReference + ) => Promise + onOpenCitationSource: ( + reference: KnowledgeSearchReference + ) => Promise + onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void + onRespondApproval: ( + conversationId: string, + messageId: string, + approvalId: string, + decision: ApprovalDecision + ) => Promise + onRespondQuestion: ( + conversationId: string, + messageId: string, + questionId: string, + answers?: AgentQuestionAnswer[] + ) => Promise + onRetry: (content: string) => void + onScrollSnapshotChange: ( + conversationId: string, + snapshot: ChatScrollSnapshot + ) => void + onSetInput: (value: string) => void + onVisibleMessageCountChange: ( + conversationId: string, + count: number + ) => void + quickActions: ChatQuickAction[] + scrollSnapshot?: ChatScrollSnapshot + visibleMessageCount: number +}): React.JSX.Element { + const { t } = useTranslation('app') + const scrollRef = useRef(null) + const pinnedToBottomRef = useRef( + scrollSnapshot?.pinnedToBottom ?? true + ) + const latestScrollSnapshotRef = useRef(scrollSnapshot) + const restorePendingRef = useRef(true) + const prependScrollPositionRef = useRef<{ + scrollHeight: number + scrollTop: number + } | undefined>(undefined) + const finalRevealedMessageIdRef = useRef( + undefined + ) + const messageArticleRefs = useRef(new Map()) + const previousMessageCountRef = useRef(conversation.messages.length) + const [showScrollToBottom, setShowScrollToBottom] = useState( + scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false + ) + const visibleMessageStartIndex = Math.max( + 0, + conversation.messages.length - visibleMessageCount + ) + const visibleMessages = conversation.messages.slice( + visibleMessageStartIndex + ) + const hiddenMessageCount = visibleMessageStartIndex + + const saveScrollPosition = useCallback( + (scrollContainer: HTMLElement): boolean => { + const distanceFromBottom = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight + const pinnedToBottom = distanceFromBottom <= chatBottomProximity + latestScrollSnapshotRef.current = { + pinnedToBottom, + scrollTop: scrollContainer.scrollTop + } + return pinnedToBottom + }, + [] + ) + + const handleScrollRef = useCallback( + (element: HTMLElement | null): void => { + const previous = scrollRef.current + if (previous && previous !== element) { + saveScrollPosition(previous) + if (!element && latestScrollSnapshotRef.current) { + onScrollSnapshotChange( + conversation.id, + latestScrollSnapshotRef.current + ) + } + } + scrollRef.current = element + }, + [conversation.id, onScrollSnapshotChange, saveScrollPosition] + ) + + const updateScrollPosition = useCallback((): void => { + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + const atBottom = saveScrollPosition(scrollContainer) + pinnedToBottomRef.current = atBottom + setShowScrollToBottom(!atBottom) + }, [saveScrollPosition]) + + useLayoutEffect(() => { + const previousMessageCount = previousMessageCountRef.current + if ( + conversation.messages + .slice(previousMessageCount) + .some((message) => message.role === 'user') + ) { + pinnedToBottomRef.current = true + } + previousMessageCountRef.current = conversation.messages.length + }, [conversation.messages]) + + useLayoutEffect(() => { + if (!active) { + return + } + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + if (restorePendingRef.current) { + restorePendingRef.current = false + if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) { + pinnedToBottomRef.current = false + scrollContainer.scrollTop = scrollSnapshot.scrollTop + return + } + } + if (pinnedToBottomRef.current) { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: 'auto' + }) + } + }, [ + active, + conversation.messages, + scrollSnapshot, + visibleMessageCount + ]) + + useLayoutEffect(() => { + const previous = prependScrollPositionRef.current + if (!previous) { + return + } + prependScrollPositionRef.current = undefined + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + scrollContainer.scrollTop = + previous.scrollTop + + (scrollContainer.scrollHeight - previous.scrollHeight) + const finalRevealedMessageId = finalRevealedMessageIdRef.current + finalRevealedMessageIdRef.current = undefined + if (finalRevealedMessageId) { + messageArticleRefs.current + .get(finalRevealedMessageId) + ?.focus({ preventScroll: true }) + } + }, [visibleMessageCount]) + + const revealEarlierMessages = (): void => { + const scrollContainer = scrollRef.current + if (scrollContainer) { + prependScrollPositionRef.current = { + scrollHeight: scrollContainer.scrollHeight, + scrollTop: scrollContainer.scrollTop + } + } + if ( + visibleMessageCount + messageRenderBatchSize >= + conversation.messages.length + ) { + finalRevealedMessageIdRef.current = + conversation.messages[0]?.id + } + onVisibleMessageCountChange( + conversation.id, + visibleMessageCount + messageRenderBatchSize + ) + } + + const scrollToBottom = (): void => { + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + pinnedToBottomRef.current = true + setShowScrollToBottom(false) + const reduceMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: reduceMotion ? 'auto' : 'smooth' + }) + } + + return ( + + ) +} + function isConversationAttachment( value: unknown ): value is ConversationAttachment { @@ -697,6 +1067,16 @@ function isConversation(value: unknown): value is Conversation { (entry.state === 'streaming' || entry.state === 'complete' || entry.state === 'error') && + (entry.contextCompression === undefined || + (typeof entry.contextCompression === 'object' && + entry.contextCompression !== null && + ['compressing', 'completed', 'failed'].includes( + String( + ( + entry.contextCompression as Record + ).state + ) + ))) && (entry.artifactIds === undefined || (Array.isArray(entry.artifactIds) && entry.artifactIds.length <= 8 && @@ -741,6 +1121,7 @@ function toConversationMessage(message: Message): ConversationMessage { createdAt: message.createdAt, state: message.state, status: message.status, + contextCompression: message.contextCompression, tools: message.tools, sources: message.sources, sourceReferences: message.sourceReferences, @@ -926,6 +1307,14 @@ function formatAttachmentSize(size: number): string { return `${Math.max(1, Math.ceil(size / 1024))} KB` } +function formatCompactContextTokens(tokens: number): string { + if (tokens < 1_000) { + return tokens.toLocaleString() + } + const value = tokens / 1_000 + return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K` +} + const composerTextareaMinHeight = 72 const composerTextareaMaxHeight = 220 @@ -1281,7 +1670,9 @@ function App(): React.JSX.Element { t('conversation.interrupted') ) ) - const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '') + const [activeId, setActiveIdState] = useState( + () => conversations[0]?.id ?? '' + ) const activeConversationIdRef = useRef(activeId) const conversationsRef = useRef(conversations) const persistedLocalConversationsRef = useRef( @@ -1349,7 +1740,31 @@ function App(): React.JSX.Element { const heartbeatLoadRequestRef = useRef(0) const [workMode, setWorkMode] = useState('ask') - const [input, setInput] = useState('') + const [conversationDrafts, setConversationDrafts] = useState< + Record + >({}) + const input = conversationDrafts[activeId] ?? '' + const setInput = useCallback( + (update: SetStateAction): void => { + setConversationDrafts((current) => { + const currentValue = current[activeId] ?? '' + const nextValue = + typeof update === 'function' + ? update(currentValue) + : update + if (nextValue === currentValue) { + return current + } + if (!nextValue) { + const next = { ...current } + delete next[activeId] + return next + } + return { ...current, [activeId]: nextValue } + }) + }, + [activeId] + ) const [voiceListening, setVoiceListening] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false) const voiceRecordingRef = useRef(undefined) @@ -1363,6 +1778,8 @@ function App(): React.JSX.Element { const [runtime, setRuntime] = useState() const [runtimeStatusKey, setRuntimeStatusKey] = useState('') const [runtimeSettings, setRuntimeSettings] = useState() + const [contextMetricsByConversation, setContextMetricsByConversation] = + useState>({}) const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false) const [composerMenuOpen, setComposerMenuOpen] = useState< 'expert' | 'mode' | undefined @@ -1386,7 +1803,6 @@ function App(): React.JSX.Element { resolvedAppearanceTheme === 'dark' ? 'light' : 'dark' ) }, [resolvedAppearanceTheme]) - const agentRuntimeSelected = isAgentRuntime(runtime) const effectiveWorkMode = workMode === 'execute' && runtime?.supportsToolExecution === false @@ -1478,7 +1894,47 @@ function App(): React.JSX.Element { const [browserStates, setBrowserStates] = useState< Record >({}) - const [view, setView] = useState('chat') + const [view, setViewState] = useState('chat') + const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState< + KeepAliveCacheEntry[] + >(() => [{ key: 'chat', lastVisitedAt: Date.now() }]) + const [cachedConversationViews, setCachedConversationViews] = useState< + KeepAliveCacheEntry[] + >(() => + activeId + ? [{ key: activeId, lastVisitedAt: Date.now() }] + : [] + ) + const setView = useCallback( + (update: SetStateAction): void => { + const next = + typeof update === 'function' + ? update(viewRef.current) + : update + viewRef.current = next + setCachedWorkspaceViews((current) => + touchKeepAliveEntry(current, next, Date.now()) + ) + setViewState(next) + }, + [] + ) + const setActiveId = useCallback( + (update: SetStateAction): void => { + const next = + typeof update === 'function' + ? update(activeConversationIdRef.current) + : update + activeConversationIdRef.current = next + if (next) { + setCachedConversationViews((current) => + touchKeepAliveEntry(current, next, Date.now()) + ) + } + setActiveIdState(next) + }, + [] + ) const [settingsInitialCategory, setSettingsInitialCategory] = useState() const [settingsInitialChannel, setSettingsInitialChannel] = @@ -1504,22 +1960,39 @@ function App(): React.JSX.Element { }, [notify] ) - const [attachments, setAttachments] = useState([]) - const attachmentsRef = useRef([]) + const [attachmentsByConversation, setAttachmentsByConversation] = + useState>({}) + const attachments = + attachmentsByConversation[activeId] ?? [] + const attachmentsRef = useRef( + new Map() + ) const updateAttachments = useCallback( ( update: | ContextAttachment[] | ((current: ContextAttachment[]) => ContextAttachment[]) ): void => { + const current = attachmentsRef.current.get(activeId) ?? [] const next = typeof update === 'function' - ? update(attachmentsRef.current) + ? update(current) : update - attachmentsRef.current = next - setAttachments(next) + if (next.length > 0) { + attachmentsRef.current.set(activeId, next) + } else { + attachmentsRef.current.delete(activeId) + } + setAttachmentsByConversation((values) => { + if (next.length > 0) { + return { ...values, [activeId]: next } + } + const remaining = { ...values } + delete remaining[activeId] + return remaining + }) }, - [] + [activeId] ) const [contextError, setContextError] = useState() const [fileSelectionProgress, setFileSelectionProgress] = @@ -1576,42 +2049,13 @@ function App(): React.JSX.Element { const hydratingArtifactIds = useRef(new Set()) const knowledgeScopeInitialized = useRef(false) const inputRef = useRef(null) - const scrollRef = useRef(null) - const chatPinnedToBottomRef = useRef(true) - const chatScrollContextRef = useRef(activeId) - const chatScrollRestorePendingRef = useRef( - undefined - ) - const chatScrollSnapshotsRef = useRef( - new Map< - string, - { - pinnedToBottom: boolean - scrollTop: number - } - >() - ) - const prependScrollPositionRef = useRef<{ - conversationId: string - scrollHeight: number - scrollTop: number - } | undefined>(undefined) - const finalRevealedMessageIdRef = useRef(undefined) - const messageArticleRefs = useRef(new Map()) - const handleMessageArticleRef = useCallback( - (messageId: string, element: HTMLElement | null): void => { - if (element) { - messageArticleRefs.current.set(messageId, element) - } else { - messageArticleRefs.current.delete(messageId) - } - }, - [] - ) + const [chatScrollSnapshots, setChatScrollSnapshots] = useState< + Record + >({}) const retryMessage = useCallback((content: string): void => { setInput(content) inputRef.current?.focus() - }, []) + }, [setInput]) useEffect( () => scheduleIdleRoutePreload( @@ -1625,71 +2069,92 @@ function App(): React.JSX.Element { const [visibleMessageCounts, setVisibleMessageCounts] = useState< Record >({}) - const [showScrollToBottom, setShowScrollToBottom] = useState(false) const sidebarRef = useRef(null) const sidebarToggleRef = useRef(null) const conversationActionTriggerRefs = useRef( new Map() ) - const saveChatScrollPosition = useCallback( - (conversationId: string, scrollContainer: HTMLElement): boolean => { - const distanceFromBottom = - scrollContainer.scrollHeight - - scrollContainer.scrollTop - - scrollContainer.clientHeight - const pinnedToBottom = distanceFromBottom <= chatBottomProximity - chatScrollSnapshotsRef.current.set(conversationId, { - pinnedToBottom, - scrollTop: scrollContainer.scrollTop - }) - return pinnedToBottom + const handleChatScrollSnapshotChange = useCallback( + (conversationId: string, snapshot: ChatScrollSnapshot): void => { + setChatScrollSnapshots((current) => ({ + ...current, + [conversationId]: snapshot + })) }, [] ) - const handleChatScrollRef = useCallback( - (element: HTMLElement | null): void => { - const previous = scrollRef.current - if (previous && previous !== element) { - saveChatScrollPosition(activeId, previous) - } - scrollRef.current = element - if (element) { - chatScrollRestorePendingRef.current = activeId - } + const handleVisibleMessageCountChange = useCallback( + (conversationId: string, count: number): void => { + setVisibleMessageCounts((current) => ({ + ...current, + [conversationId]: count + })) }, - [activeId, saveChatScrollPosition] + [] ) - const updateChatScrollPosition = useCallback((): void => { - const scrollContainer = scrollRef.current - if (!scrollContainer || !activeId) { - return - } - const atBottom = saveChatScrollPosition(activeId, scrollContainer) - chatPinnedToBottomRef.current = atBottom - setShowScrollToBottom(!atBottom) - }, [activeId, saveChatScrollPosition]) - const scrollChatToBottom = useCallback((): void => { - const scrollContainer = scrollRef.current - if (!scrollContainer) { - return - } - chatPinnedToBottomRef.current = true - const reduceMotion = - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: reduceMotion ? 'auto' : 'smooth' - }) - }, []) const closeNarrowSidebar = useCallback((): void => { setSidebarOpen(false) requestAnimationFrame(() => sidebarToggleRef.current?.focus()) }, []) useEffect(() => { - activeConversationIdRef.current = activeId - }, [activeId]) + const sweep = (): void => { + const now = Date.now() + const conversationIds = new Set( + conversationsRef.current.map((conversation) => conversation.id) + ) + const runningConversationIds = new Set( + [...activeRuns.current.values()].map((run) => run.conversationId) + ) + preparingConversations.current.forEach((conversationId) => + runningConversationIds.add(conversationId) + ) + const protectedWorkspaceViews = new Set() + if (runningConversationIds.size > 0) { + protectedWorkspaceViews.add('chat') + protectedWorkspaceViews.add('activity') + } + if (knowledgeOperationCount > 0) { + protectedWorkspaceViews.add('knowledge') + protectedWorkspaceViews.add('activity') + } + if ( + assistantTasks.some( + (task) => + task.status === 'queued' || + task.status === 'running' || + task.status === 'waiting_approval' + ) + ) { + protectedWorkspaceViews.add('activity') + } + setCachedConversationViews((current) => + pruneKeepAliveEntries( + current.filter((entry) => conversationIds.has(entry.key)), + { + currentKey: activeId, + expiresAfterMs: keepAliveExpirationMs, + maximumEntries: maximumCachedConversations, + now, + protectedKeys: runningConversationIds, + recentEntries: recentCachedConversations + } + ) + ) + setCachedWorkspaceViews((current) => + pruneKeepAliveEntries(current, { + currentKey: view, + expiresAfterMs: keepAliveExpirationMs, + maximumEntries: maximumCachedWorkspaceViews, + now, + protectedKeys: protectedWorkspaceViews, + recentEntries: recentCachedWorkspaceViews + }) + ) + } + const interval = window.setInterval(sweep, keepAliveSweepIntervalMs) + return () => window.clearInterval(interval) + }, [activeId, assistantTasks, knowledgeOperationCount, view]) useEffect(() => { const collapseSidebarAtNarrowWidth = (): void => { @@ -1786,7 +2251,7 @@ function App(): React.JSX.Element { } }) .catch(() => undefined) - }, [i18n]) + }, [i18n, setView]) useEffect(() => { const releaseNotesApi = window.goodbuddy.releaseNotes @@ -1862,64 +2327,29 @@ function App(): React.JSX.Element { () => conversations.find((conversation) => conversation.id === activeId), [activeId, conversations] ) - const visibleMessageCount = - visibleMessageCounts[activeId] ?? messageRenderBatchSize - const visibleMessageStartIndex = Math.max( - 0, - (activeConversation?.messages.length ?? 0) - visibleMessageCount + const cachedWorkspaceViewKeys = useMemo( + () => new Set(cachedWorkspaceViews.map((entry) => entry.key)), + [cachedWorkspaceViews] ) - const visibleMessages = - activeConversation?.messages.slice(visibleMessageStartIndex) ?? [] - const hiddenMessageCount = visibleMessageStartIndex - - const revealEarlierMessages = useCallback((): void => { - const scrollContainer = scrollRef.current - if (scrollContainer) { - prependScrollPositionRef.current = { - conversationId: activeId, - scrollHeight: scrollContainer.scrollHeight, - scrollTop: scrollContainer.scrollTop - } - } - const currentCount = visibleMessageCount - if ( - activeConversation && - currentCount + messageRenderBatchSize >= - activeConversation.messages.length - ) { - finalRevealedMessageIdRef.current = - activeConversation.messages[0]?.id - } - setVisibleMessageCounts((current) => ({ - ...current, - [activeId]: currentCount + messageRenderBatchSize - })) - }, [activeConversation, activeId, visibleMessageCount]) - - useLayoutEffect(() => { - const previous = prependScrollPositionRef.current - if (!previous) { - return - } - prependScrollPositionRef.current = undefined - if (previous.conversationId !== activeId) { - return - } - const scrollContainer = scrollRef.current - if (!scrollContainer) { - return - } - scrollContainer.scrollTop = - previous.scrollTop + - (scrollContainer.scrollHeight - previous.scrollHeight) - const finalRevealedMessageId = finalRevealedMessageIdRef.current - finalRevealedMessageIdRef.current = undefined - if (finalRevealedMessageId) { - messageArticleRefs.current - .get(finalRevealedMessageId) - ?.focus({ preventScroll: true }) - } - }, [activeId, visibleMessageCount]) + const cachedConversations = useMemo(() => { + const conversationById = new Map( + conversations.map((conversation) => [ + conversation.id, + conversation + ]) + ) + const cachedIds = [ + activeId, + ...cachedConversationViews.map((entry) => entry.key) + ].filter( + (conversationId, index, values) => + conversationId && values.indexOf(conversationId) === index + ) + return cachedIds.flatMap((conversationId) => { + const conversation = conversationById.get(conversationId) + return conversation ? [conversation] : [] + }) + }, [activeId, cachedConversationViews, conversations]) const activeRuntimeSelection = useMemo( () => @@ -2105,7 +2535,8 @@ function App(): React.JSX.Element { }) }, [ activeRuntimeSelectionKey, - runtimeSettings + runtimeSettings, + setView ]) const startNewConversation = useCallback( @@ -2153,17 +2584,10 @@ function App(): React.JSX.Element { setConversations(nextConversations) setActiveId(conversation.id) setView('chat') - setInput('') - updateAttachments((current) => { - for (const attachment of current) { - void window.goodbuddy.context.remove(attachment.id) - } - return [] - }) requestAnimationFrame(() => inputRef.current?.focus()) return true }, - [notify, projects, runtimeSettings, updateAttachments] + [notify, projects, runtimeSettings, setActiveId, setView] ) const activeProject = useMemo( () => projects.find((project) => project.id === activeProjectId), @@ -2690,6 +3114,29 @@ function App(): React.JSX.Element { ) } }) + } else if (event.type === 'context-metrics') { + const { requestId: _requestId, type: _type, ...metrics } = event + void _requestId + void _type + setContextMetricsByConversation((current) => ({ + ...current, + [run.conversationId]: { + ...metrics, + runtimeSelectionKey: run.runtimeSelectionKey + } + })) + } else if (event.type === 'context-compression') { + updateMessage(run.conversationId, run.messageId, (message) => ({ + ...message, + contextCompression: { + state: + event.state === 'started' + ? 'compressing' + : 'completed', + estimatedBeforeTokens: event.estimatedBeforeTokens, + estimatedAfterTokens: event.estimatedAfterTokens + } + })) } else if (event.type === 'status') { updateMessage(run.conversationId, run.messageId, (message) => ({ ...message, @@ -2982,6 +3429,14 @@ function App(): React.JSX.Element { event.type === 'error' && !representedToolError ? event.message : undefined, + contextCompression: + event.type === 'error' && + message.contextCompression?.state === 'compressing' + ? { + ...message.contextCompression, + state: 'failed' as const + } + : message.contextCompression, approval: undefined, question: undefined, tools: toolTerminalState @@ -3358,7 +3813,7 @@ function App(): React.JSX.Element { return () => { active = false } - }, []) + }, [setActiveId]) useEffect(() => { if (!activeProjectId) { @@ -3831,7 +4286,7 @@ function App(): React.JSX.Element { removeAgentListener() removeOpenSettingsListener() } - }, [handleAgentEvent]) + }, [handleAgentEvent, setView]) useEffect( () => { @@ -3902,50 +4357,6 @@ function App(): React.JSX.Element { ) }, [startNewConversation]) - useLayoutEffect(() => { - if (view !== 'chat') { - return - } - const scrollContainer = scrollRef.current - if (!scrollContainer) { - return - } - const conversationChanged = - chatScrollContextRef.current !== activeId - if (conversationChanged) { - chatScrollContextRef.current = activeId - } - const shouldRestore = - conversationChanged || - chatScrollRestorePendingRef.current === activeId - if (shouldRestore) { - chatScrollRestorePendingRef.current = undefined - const snapshot = chatScrollSnapshotsRef.current.get(activeId) - chatPinnedToBottomRef.current = - snapshot?.pinnedToBottom ?? true - if (snapshot && !snapshot.pinnedToBottom) { - scrollContainer.scrollTop = snapshot.scrollTop - setShowScrollToBottom(true) - return - } - } - if (chatPinnedToBottomRef.current) { - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: 'auto' - }) - setShowScrollToBottom(false) - return - } - updateChatScrollPosition() - }, [ - activeConversation?.messages, - activeId, - updateChatScrollPosition, - visibleMessageCount, - view - ]) - const selectProject = (projectId: string): void => { const project = projects.find((candidate) => candidate.id === projectId) if (!project) { @@ -4214,6 +4625,32 @@ function App(): React.JSX.Element { delete next[conversationId] return next }) + const draftAttachments = + attachmentsRef.current.get(conversationId) ?? [] + attachmentsRef.current.delete(conversationId) + for (const attachment of draftAttachments) { + void window.goodbuddy.context.remove(attachment.id) + } + setAttachmentsByConversation((current) => { + const next = { ...current } + delete next[conversationId] + return next + }) + setConversationDrafts((current) => { + const next = { ...current } + delete next[conversationId] + return next + }) + setChatScrollSnapshots((current) => { + const next = { ...current } + delete next[conversationId] + return next + }) + setVisibleMessageCounts((current) => { + const next = { ...current } + delete next[conversationId] + return next + }) const remaining = conversations.filter( (conversation) => conversation.id !== conversationId ) @@ -4517,7 +4954,6 @@ function App(): React.JSX.Element { runtime.capability === 'image-generation' ? '' : selectedExpertId const workModeSnapshot = effectiveWorkMode preparingConversations.current.add(conversationId) - chatPinnedToBottomRef.current = true setInput('') updateAttachments([]) const userMessage: Message = { @@ -4567,7 +5003,10 @@ function App(): React.JSX.Element { activeRuns.current.set(requestId, { conversationId, messageId: assistantMessage.id, - projectId: projectIdSnapshot + projectId: projectIdSnapshot, + runtimeSelectionKey: agentRuntimeSelectionKey( + runtimeSelectionSnapshot + ) }) preparingConversations.current.delete(conversationId) const startedAt = new Date().toISOString() @@ -4749,11 +5188,13 @@ function App(): React.JSX.Element { const addContext = async ( action: () => Promise ): Promise => { + const conversationId = activeId setContextError(undefined) try { const result = await action() const selected = Array.isArray(result) ? result : [result] - const current = attachmentsRef.current + const current = + attachmentsRef.current.get(conversationId) ?? [] const unique = selected.filter( (item) => !current.some((existing) => existing.id === item.id) @@ -5105,9 +5546,13 @@ function App(): React.JSX.Element { await window.goodbuddy.agent.cancel(requestId) } activeRuns.current.clear() - for (const attachment of attachments) { - await window.goodbuddy.context.remove(attachment.id) + for (const attachments of attachmentsRef.current.values()) { + for (const attachment of attachments) { + await window.goodbuddy.context.remove(attachment.id) + } } + attachmentsRef.current.clear() + setAttachmentsByConversation({}) for (const library of knowledgeSnapshot.libraries) { await window.goodbuddy.knowledge.deleteLibrary(library.id) } @@ -5162,6 +5607,87 @@ function App(): React.JSX.Element { (message) => message.state === 'streaming' ) ?? false + const composerContextMetrics = useMemo(() => { + if ( + !activeConversation || + !runtimeSettings || + activeRuntimeSelection?.provider !== 'model' || + activeConversation.remote + ) { + return undefined + } + const profile = runtimeSettings.modelProfiles.find( + (candidate) => + candidate.id === activeRuntimeSelection.profileId + ) + if ( + !profile || + profile.protocol === 'openai-images-generations' + ) { + return undefined + } + const compression = + runtimeSettings.contextCompression ?? + defaultContextCompressionSettings + const latest = contextMetricsByConversation[activeConversation.id] + const applicableLatest = + latest?.runtimeSelectionKey === activeRuntimeSelectionKey + ? latest + : undefined + const history = activeConversation.messages + .filter( + (message) => + message.state === 'complete' && message.content.trim() + ) + .map((message) => ({ + role: message.role, + content: message.content + })) + const coveredMessageCount = Math.min( + applicableLatest?.coveredMessageCount ?? 0, + history.length + ) + const estimatedInputTokens = + isRunning && applicableLatest + ? applicableLatest.estimatedInputTokens + : estimateContextInputTokens({ + history: history.slice(coveredMessageCount), + prompt: input, + summaryTokens: applicableLatest?.summaryTokens ?? 0 + }) + const effectiveTriggerTokens = + getEffectiveContextTriggerTokens({ + triggerTokens: compression.triggerTokens, + contextWindowTokens: profile.contextWindowTokens + }) + const denominatorTokens = + profile.contextWindowTokens ?? + (compression.enabled ? effectiveTriggerTokens : undefined) + const percentage = + denominatorTokens === undefined + ? undefined + : Math.round( + (estimatedInputTokens / denominatorTokens) * 100 + ) + + return { + estimatedInputTokens, + effectiveTriggerTokens, + contextWindowTokens: profile.contextWindowTokens, + compressionEnabled: compression.enabled, + denominatorTokens, + percentage + } + }, [ + activeConversation, + activeRuntimeSelection, + activeRuntimeSelectionKey, + contextMetricsByConversation, + input, + isRunning, + runtimeSettings + ]) + return (
-

- - {contextError ?? - (!runtime?.available - ? t('composer.hints.configureRuntime') - : runtime.capability === 'image-generation' - ? t('composer.hints.imageGeneration') - : agentRuntimeSelected - ? effectiveWorkMode === 'ask' - ? t('composer.hints.agentAsk', { - runtime: runtime.label - }) - : t('composer.hints.agentExecute', { - runtime: runtime.label - }) - : effectiveWorkMode === 'ask' - ? t('composer.hints.ask') - : t('composer.hints.execute'))} - +

+ {composerContextMetrics && ( +
= 90 + ? ' composer-context-meter--warning' + : '' + }`} + title={ + composerContextMetrics.compressionEnabled + ? t('composer.context.compressionTrigger', { + tokens: formatCompactContextTokens( + composerContextMetrics.effectiveTriggerTokens + ) + }) + : undefined + } + > + + {composerContextMetrics.denominatorTokens === undefined + ? t('composer.context.tokenCount', { + used: formatCompactContextTokens( + composerContextMetrics.estimatedInputTokens + ) + }) + : composerContextMetrics.contextWindowTokens === + undefined + ? t('composer.context.thresholdUsage', { + used: formatCompactContextTokens( + composerContextMetrics.estimatedInputTokens + ), + total: formatCompactContextTokens( + composerContextMetrics.denominatorTokens + ), + percentage: + composerContextMetrics.percentage ?? 0 + }) + : t('composer.context.windowUsage', { + used: formatCompactContextTokens( + composerContextMetrics.estimatedInputTokens + ), + total: formatCompactContextTokens( + composerContextMetrics.denominatorTokens + ), + percentage: + composerContextMetrics.percentage ?? 0 + })} + + {composerContextMetrics.denominatorTokens !== undefined && ( +
+ + {composerContextMetrics.contextWindowTokens !== + undefined && + composerContextMetrics.compressionEnabled && ( +
+ )} +
+ )} + {contextError && ( + + {contextError} + + )} {appInfo?.shortcut && ( - + {t('composer.shortcut')} {appInfo.shortcut} )} -

+
)} - - ) : view === 'magic-notes' && magicNotesEnabled ? ( - + + + )} + {magicNotesEnabled && + (view === 'magic-notes' || + cachedWorkspaceViewKeys.has('magic-notes')) && ( + + - - ) : view === 'knowledge' ? ( - + + + )} + {(view === 'knowledge' || + cachedWorkspaceViewKeys.has('knowledge')) && ( + + - - ) : view === 'heartbeat' ? ( - + + + )} + {(view === 'heartbeat' || + cachedWorkspaceViewKeys.has('heartbeat')) && ( + + - - ) : view === 'settings' ? ( - + + )} + {(view === 'settings' || + cachedWorkspaceViewKeys.has('settings')) && ( + + - - ) : ( - - setActivityRecords([])} - onOpenConversation={openActivityConversation} - records={activityRecords} - tokenUsage={tokenUsage} - /> - + + + )} + {(view === 'activity' || + cachedWorkspaceViewKeys.has('activity')) && ( + + + setActivityRecords([])} + onOpenConversation={openActivityConversation} + records={activityRecords} + tokenUsage={tokenUsage} + /> + + )} { ) expect(unchangedDetails).toHaveAttribute('open') }) + + it('places compression progress between the user and assistant messages', () => { + const messages: Message[] = [ + { + id: 'user-message', + role: 'user', + content: 'Continue', + createdAt: 1_775_000_000_000, + state: 'complete' + }, + { + id: 'assistant-message', + role: 'assistant', + content: 'Answer', + contextCompression: { + state: 'completed', + estimatedBeforeTokens: 22_000, + estimatedAfterTokens: 9_000 + }, + createdAt: 1_775_000_001_000, + state: 'complete' + } + ] + const { container } = render( + + ) + + const children = Array.from( + container.querySelector('.message-list')?.children ?? [] + ) + expect(children.map((element) => element.className)).toEqual([ + 'message message--user', + 'context-compression-event context-compression-event--completed', + 'message message--assistant' + ]) + expect( + screen.getByRole('status') + ).toHaveTextContent('已压缩较早对话 · ≈22.0K → ≈9.0K') + }) }) diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 21df711..f1ab3bf 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -51,6 +51,11 @@ export type Message = { createdAt: number state: 'streaming' | 'complete' | 'error' status?: string + contextCompression?: { + state: 'compressing' | 'completed' | 'failed' + estimatedBeforeTokens: number + estimatedAfterTokens?: number + } tools?: ToolActivity[] subagents?: SubagentActivity[] approval?: { @@ -74,6 +79,14 @@ export type ImageViewerItem = { title: string } +function formatCompactTokens(tokens: number): string { + if (tokens < 1_000) { + return tokens.toLocaleString() + } + const value = tokens / 1_000 + return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K` +} + type MessageBlockRenderItem = | { kind: 'block' @@ -270,6 +283,41 @@ function ChatMessageRowView({ const { t } = useTranslation('app') return ( + <> + {message.role === 'assistant' && message.contextCompression && ( +
+ + + + +
+ )}
onArticleRef(message.id, element)} @@ -755,6 +803,7 @@ function ChatMessageRowView({ )}
+ ) } diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 70a85de..43c8bb6 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -604,6 +604,7 @@ describe('SettingsPanel runtime files', () => { '上下文上限(可选)' ) expect(contextWindow).toHaveValue(null) + expect(contextWindow).toHaveAttribute('min', '32') fireEvent.change(contextWindow, { target: { value: '256' } }) fireEvent.click(screen.getByRole('button', { name: '保存设置' })) diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index 08a8c16..b702e77 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -2418,7 +2418,7 @@ export function SettingsPanel({ aria-label={t('model.profile.contextWindow')} inputMode="numeric" max={10_000} - min={8} + min={32} onChange={(event) => { const value = event.target.valueAsNumber updateModelProfile(profile.id, { diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index f1687b4..1e808cc 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -187,6 +187,12 @@ export const app = { streaming: 'Reasoning', complete: 'Reasoning process' }, + contextCompression: { + compressing: 'Compressing earlier conversation…', + completed: + 'Earlier conversation compressed · ≈{{before}} → ≈{{after}}', + failed: 'Earlier conversation compression failed' + }, sources: 'Sources: {{sources}}', citations: { view: 'View {{count}} evidence references', @@ -314,6 +320,14 @@ export const app = { send: 'Send', sendTitle: 'Send message', shortcut: 'Quick access: ', + context: { + tokenCount: 'Context ≈{{used}}', + windowUsage: 'Context ≈{{used}} / {{total}} · {{percentage}}%', + thresholdUsage: + 'Compression threshold ≈{{used}} / {{total}} · {{percentage}}%', + progressLabel: 'Current context usage', + compressionTrigger: 'Automatic compression at ≈{{tokens}}' + }, experts: { general: 'General assistant', generalDescription: 'Default single assistant', diff --git a/src/renderer/src/i18n/locales/en-US/settings.ts b/src/renderer/src/i18n/locales/en-US/settings.ts index 354220f..32616f1 100644 --- a/src/renderer/src/i18n/locales/en-US/settings.ts +++ b/src/renderer/src/i18n/locales/en-US/settings.ts @@ -495,7 +495,7 @@ export const settings = { 'When enabled, GoodBuddy can send image context to this model connection.', contextWindow: 'Context window (optional)', contextWindowDescription: - 'Enter K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.', + 'Enter 32K–10000K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.', imageQuality: 'Image quality', imageQualityAriaLabel: 'Image quality for {{name}}', quality: { diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 5b72ed2..db39ae4 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -182,6 +182,11 @@ export const app = { streaming: '正在推理', complete: '推理过程' }, + contextCompression: { + compressing: '正在压缩较早对话…', + completed: '已压缩较早对话 · ≈{{before}} → ≈{{after}}', + failed: '较早对话压缩失败' + }, sources: '来源:{{sources}}', citations: { view: '查看 {{count}} 条证据引用', @@ -306,6 +311,14 @@ export const app = { send: '发送', sendTitle: '发送消息', shortcut: '快捷唤起:', + context: { + tokenCount: '上下文 ≈{{used}}', + windowUsage: '上下文 ≈{{used}} / {{total}} · {{percentage}}%', + thresholdUsage: + '距压缩阈值 ≈{{used}} / {{total}} · {{percentage}}%', + progressLabel: '当前上下文使用量', + compressionTrigger: '自动压缩线:≈{{tokens}}' + }, experts: { general: '通用助手', generalDescription: '默认单助手', diff --git a/src/renderer/src/i18n/locales/zh-CN/settings.ts b/src/renderer/src/i18n/locales/zh-CN/settings.ts index becd985..6a27338 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settings.ts @@ -452,7 +452,7 @@ export const settings = { '启用后,GoodBuddy 可将图片上下文发送给此模型连接。', contextWindow: '上下文上限(可选)', contextWindowDescription: - '以 K tokens 填写。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。', + '以 K tokens 填写,范围为 32K–10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。', imageQuality: '图片质量', imageQualityAriaLabel: '图片质量 {{name}}', quality: { diff --git a/src/renderer/src/keep-alive-cache.test.ts b/src/renderer/src/keep-alive-cache.test.ts new file mode 100644 index 0000000..f80400d --- /dev/null +++ b/src/renderer/src/keep-alive-cache.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + pruneKeepAliveEntries, + touchKeepAliveEntry +} from './keep-alive-cache' + +describe('keep-alive cache', () => { + it('updates a visited entry without duplicating it', () => { + expect( + touchKeepAliveEntry( + [ + { key: 'chat', lastVisitedAt: 10 }, + { key: 'knowledge', lastVisitedAt: 20 } + ], + 'chat', + 30 + ) + ).toEqual([ + { key: 'knowledge', lastVisitedAt: 20 }, + { key: 'chat', lastVisitedAt: 30 } + ]) + }) + + it('keeps recent and protected entries while expiring inactive ones', () => { + expect( + pruneKeepAliveEntries( + [ + { key: 'one', lastVisitedAt: 10 }, + { key: 'two', lastVisitedAt: 20 }, + { key: 'three', lastVisitedAt: 30 }, + { key: 'four', lastVisitedAt: 40 } + ], + { + currentKey: 'four', + expiresAfterMs: 50, + maximumEntries: 4, + now: 100, + protectedKeys: new Set(['one']), + recentEntries: 2 + } + ) + ).toEqual([ + { key: 'four', lastVisitedAt: 40 }, + { key: 'three', lastVisitedAt: 30 }, + { key: 'one', lastVisitedAt: 10 } + ]) + }) + + it('enforces the hard limit with least-recently-used eviction', () => { + expect( + pruneKeepAliveEntries( + Array.from({ length: 8 }, (_, index) => ({ + key: `conversation-${index}`, + lastVisitedAt: index + })), + { + currentKey: 'conversation-7', + expiresAfterMs: 1_000, + maximumEntries: 5, + now: 10, + protectedKeys: new Set(['conversation-0']), + recentEntries: 2 + } + ).map((entry) => entry.key) + ).toEqual([ + 'conversation-7', + 'conversation-6', + 'conversation-5', + 'conversation-4', + 'conversation-0' + ]) + }) + + it('expires an unprotected entry after one hour', () => { + const entries = [{ key: 'knowledge', lastVisitedAt: 1_000 }] + const options = { + expiresAfterMs: 60 * 60 * 1_000, + maximumEntries: 4, + protectedKeys: new Set(), + recentEntries: 0 + } + + expect( + pruneKeepAliveEntries(entries, { + ...options, + now: 1_000 + 60 * 60 * 1_000 - 1 + }) + ).toEqual(entries) + expect( + pruneKeepAliveEntries(entries, { + ...options, + now: 1_000 + 60 * 60 * 1_000 + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/keep-alive-cache.ts b/src/renderer/src/keep-alive-cache.ts new file mode 100644 index 0000000..31764b3 --- /dev/null +++ b/src/renderer/src/keep-alive-cache.ts @@ -0,0 +1,69 @@ +export type KeepAliveCacheEntry = { + key: Key + lastVisitedAt: number +} + +export function touchKeepAliveEntry( + entries: readonly KeepAliveCacheEntry[], + key: Key, + visitedAt: number +): KeepAliveCacheEntry[] { + const existing = entries.find((entry) => entry.key === key) + if (existing?.lastVisitedAt === visitedAt) { + return [...entries] + } + return [ + ...entries.filter((entry) => entry.key !== key), + { key, lastVisitedAt: visitedAt } + ] +} + +export function pruneKeepAliveEntries( + entries: readonly KeepAliveCacheEntry[], + { + currentKey, + expiresAfterMs, + maximumEntries, + now, + protectedKeys, + recentEntries + }: { + currentKey?: Key + expiresAfterMs: number + maximumEntries: number + now: number + protectedKeys?: ReadonlySet + recentEntries: number + } +): KeepAliveCacheEntry[] { + const newestFirst = [...entries].sort( + (left, right) => right.lastVisitedAt - left.lastVisitedAt + ) + const alwaysKeep = new Set( + newestFirst.slice(0, recentEntries).map((entry) => entry.key) + ) + if (currentKey) { + alwaysKeep.add(currentKey) + } + protectedKeys?.forEach((key) => alwaysKeep.add(key)) + + const retained = newestFirst.filter( + (entry) => + alwaysKeep.has(entry.key) || + now - entry.lastVisitedAt < expiresAfterMs + ) + if (retained.length <= maximumEntries) { + return retained + } + + const removableOldestFirst = retained + .filter((entry) => !alwaysKeep.has(entry.key)) + .sort((left, right) => left.lastVisitedAt - right.lastVisitedAt) + const removeCount = retained.length - maximumEntries + const removedKeys = new Set( + removableOldestFirst + .slice(0, removeCount) + .map((entry) => entry.key) + ) + return retained.filter((entry) => !removedKeys.has(entry.key)) +} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index e94aa3a..3b08812 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1963,6 +1963,17 @@ textarea:focus-visible { grid-template-rows: 58px minmax(0, 1fr) auto; } +.workspace-route-cache { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.workspace-route-cache[hidden] { + display: none; +} + .topbar { display: flex; min-width: 0; @@ -2975,6 +2986,15 @@ button > svg { background: var(--surface-raised); } +.chat-history-pane { + height: 100%; + min-height: 0; +} + +.chat-history-pane[hidden] { + display: none; +} + .chat { height: 100%; min-height: 0; @@ -3608,6 +3628,55 @@ button > svg { color: var(--danger); } +.context-compression-event { + display: flex; + width: 100%; + align-items: center; + color: var(--text-muted); + gap: var(--space-2); +} + +.context-compression-event__line { + height: 1px; + min-width: var(--space-6); + flex: 1; + background: var(--border-subtle); +} + +.context-compression-event__label { + display: inline-flex; + align-items: center; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + background: var(--surface-subtle); + color: var(--text-secondary); + font-size: var(--font-caption); + gap: var(--space-2); + white-space: nowrap; +} + +.context-compression-event--compressing + .context-compression-event__label { + border-color: var(--accent-selected); + background: var(--accent-subtle); + color: var(--accent); +} + +.context-compression-event--completed + .context-compression-event__label { + border-color: color-mix(in srgb, var(--success) 35%, var(--border-default)); + background: var(--success-subtle); + color: var(--success); +} + +.context-compression-event--failed + .context-compression-event__label { + border-color: var(--danger-border); + background: var(--danger-subtle); + color: var(--danger); +} + .message__status-dot { flex: 0 0 auto; width: 6px; @@ -4409,7 +4478,7 @@ button > svg { } .composer-picker--mode > .model-button { - width: 96px; + width: 108px; } .composer-picker--ask svg { @@ -4595,29 +4664,31 @@ button > svg { background: var(--danger-solid); } -.composer-hint { +.composer-meta { display: flex; margin: var(--space-2) 0 0; align-items: center; - justify-content: center; - color: var(--text-secondary); + justify-content: flex-end; font-size: var(--font-caption); flex-wrap: wrap; - gap: var(--space-1) var(--space-3); + gap: var(--space-2) var(--space-3); line-height: 1.45; - text-align: center; } -.composer-hint--error { +.composer-meta__error { + margin-right: auto; +} + +.composer-meta__error { color: var(--danger); } -.composer-hint__shortcut { +.composer-meta__shortcut { color: var(--text-muted); white-space: nowrap; } -.composer-hint kbd { +.composer-meta kbd { padding: 1px var(--space-1); border: 1px solid var(--border-default); border-radius: 4px; @@ -4627,6 +4698,52 @@ button > svg { font-size: 10px; } +.composer-context-meter { + display: flex; + min-width: 0; + align-items: center; + margin-right: auto; + color: var(--text-muted); + gap: var(--space-2); +} + +.composer-context-meter--warning { + color: var(--warning); +} + +.composer-context-meter__summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-context-meter__track { + position: relative; + width: 112px; + height: 4px; + flex: 0 0 112px; + border-radius: 999px; + overflow: visible; + background: var(--surface-muted); +} + +.composer-context-meter__fill { + position: absolute; + border-radius: inherit; + background: currentColor; + inset: 0 auto 0 0; + transition: width var(--motion-fast) ease-out; +} + +.composer-context-meter__trigger { + position: absolute; + top: -2px; + width: 1px; + height: 8px; + background: var(--text-secondary); + transform: translateX(-50%); +} + @container (max-width: 700px) { .composer__controls { flex-wrap: wrap; @@ -4649,6 +4766,15 @@ button > svg { .model-button { width: 100%; } + + .composer-context-meter { + width: 100%; + } + + .composer-context-meter__track { + min-width: 64px; + flex: 1; + } } .settings-backdrop { diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 131ed76..4fbe241 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -147,6 +147,14 @@ export const conversationMessageSchema = z createdAt: z.number().int().nonnegative(), state: z.enum(['streaming', 'complete', 'error']), status: z.string().max(4_000).optional(), + contextCompression: z + .object({ + state: z.enum(['compressing', 'completed', 'failed']), + estimatedBeforeTokens: z.number().int().nonnegative(), + estimatedAfterTokens: z.number().int().nonnegative().optional() + }) + .strict() + .optional(), tools: z.array(conversationToolActivitySchema).max(100).optional(), sources: z.array(z.string().max(8_192)).max(100).optional(), sourceReferences: z diff --git a/src/shared/context-window.ts b/src/shared/context-window.ts new file mode 100644 index 0000000..3f51262 --- /dev/null +++ b/src/shared/context-window.ts @@ -0,0 +1,63 @@ +export const minimumModelContextWindowTokens = 32_000 +export const maximumModelContextWindowTokens = 10_000_000 +export const contextOutputAndSafetyTokens = 12_000 +export const estimatedContextRequestOverheadTokens = 4_000 + +export type ContextWindowMessage = { + role: 'user' | 'assistant' + content: string +} + +export function estimateTextTokens(value: string): number { + let asciiCharacters = 0 + let nonAsciiCharacters = 0 + for (const character of value) { + if (character.codePointAt(0)! <= 0x7f) { + asciiCharacters += 1 + } else { + nonAsciiCharacters += 1 + } + } + return Math.max( + 1, + Math.ceil(asciiCharacters / 4 + nonAsciiCharacters) + ) +} + +export function estimateMessagesTokens( + messages: readonly ContextWindowMessage[] +): number { + return messages.reduce( + (total, message) => total + estimateTextTokens(message.content) + 4, + 0 + ) +} + +export function estimateContextInputTokens(input: { + history: readonly ContextWindowMessage[] + prompt: string + summaryTokens?: number +}): number { + return ( + estimateMessagesTokens(input.history) + + estimateTextTokens(input.prompt) + + (input.summaryTokens ?? 0) + + estimatedContextRequestOverheadTokens + ) +} + +export function getEffectiveContextTriggerTokens(input: { + triggerTokens: number + contextWindowTokens?: number +}): number { + if (input.contextWindowTokens === undefined) { + return input.triggerTokens + } + return Math.min( + input.triggerTokens, + Math.max( + input.contextWindowTokens, + minimumModelContextWindowTokens + ) - contextOutputAndSafetyTokens + ) +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index c8e72f5..922e44d 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + maximumModelContextWindowTokens, + minimumModelContextWindowTokens +} from './context-window' import type { BrowserProfileCreateInput, BrowserProfileRenameInput, @@ -416,6 +420,11 @@ const modelApiKeyUpdateSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('clear') }).strict() ]) +export { + maximumModelContextWindowTokens, + minimumModelContextWindowTokens +} from './context-window' + const modelProfileInputSchema = z .object({ id: modelProfileIdSchema, @@ -433,8 +442,8 @@ const modelProfileInputSchema = z contextWindowTokens: z .number() .int() - .min(8_000) - .max(10_000_000) + .min(minimumModelContextWindowTokens) + .max(maximumModelContextWindowTokens) .optional(), imageGenerationQuality: imageGenerationQualitySchema, apiKey: modelApiKeyUpdateSchema @@ -907,6 +916,29 @@ export type AgentEvent = type: 'reasoning' delta: string } + | { + requestId: string + type: 'context-metrics' + estimatedInputTokens: number + effectiveTriggerTokens: number + contextWindowTokens?: number + compressionEnabled: boolean + recentRawTokens: number + coveredMessageCount: number + summaryTokens: number + } + | { + requestId: string + type: 'context-compression' + state: 'started' | 'completed' + estimatedBeforeTokens: number + estimatedAfterTokens?: number + effectiveTriggerTokens: number + contextWindowTokens?: number + recentRawTokens: number + coveredMessageCount: number + summaryTokens?: number + } | { requestId: string type: 'tool'