feat: preserve pages and compress context

This commit is contained in:
mesalogo
2026-08-15 20:44:47 +08:00
parent 1031ea618b
commit c2b9ce4ff0
23 changed files with 1972 additions and 507 deletions
+24 -6
View File
@@ -72,19 +72,37 @@ describe('context compression planning', () => {
it('uses an optional model context limit as an earlier trigger', () => { it('uses an optional model context limit as an earlier trigger', () => {
const history = [ const history = [
{ role: 'user' as const, content: 'a'.repeat(14_000) }, { role: 'user' as const, content: 'a'.repeat(16_000) },
{ role: 'assistant' as const, content: 'b'.repeat(14_000) }, { role: 'assistant' as const, content: 'b'.repeat(16_000) },
{ role: 'user' as const, content: 'c'.repeat(14_000) }, { role: 'user' as const, content: 'c'.repeat(16_000) },
{ role: 'assistant' as const, content: 'd'.repeat(14_000) } { role: 'assistant' as const, content: 'd'.repeat(16_000) }
] ]
const plan = planContextCompression({ const plan = planContextCompression({
history, history,
prompt: 'Continue', prompt: 'Continue',
settings: compressionSettings(), 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) expect(plan?.earlierMessages.length).toBeGreaterThan(0)
}) })
}) })
+20 -43
View File
@@ -1,4 +1,14 @@
import type { ContextCompressionSettings } from '../../shared/contracts' import type { ContextCompressionSettings } from '../../shared/contracts'
import {
estimateContextInputTokens,
estimateMessagesTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window'
export {
estimateMessagesTokens,
estimateTextTokens
} from '../../shared/context-window'
export type CompressibleConversationMessage = { export type CompressibleConversationMessage = {
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -12,34 +22,6 @@ export type ContextCompressionPlan = {
effectiveTriggerTokens: number 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( function groupConversationTurns(
messages: readonly CompressibleConversationMessage[] messages: readonly CompressibleConversationMessage[]
): CompressibleConversationMessage[][] { ): CompressibleConversationMessage[][] {
@@ -61,24 +43,19 @@ function groupConversationTurns(
export function planContextCompression(input: { export function planContextCompression(input: {
history: readonly CompressibleConversationMessage[] history: readonly CompressibleConversationMessage[]
prompt: string prompt: string
summaryTokens?: number
settings: ContextCompressionSettings settings: ContextCompressionSettings
contextWindowTokens?: number contextWindowTokens?: number
}): ContextCompressionPlan | undefined { }): ContextCompressionPlan | undefined {
const estimatedInputTokens = const estimatedInputTokens = estimateContextInputTokens({
estimateMessagesTokens(input.history) + history: input.history,
estimateTextTokens(input.prompt) + prompt: input.prompt,
estimatedRequestOverheadTokens summaryTokens: input.summaryTokens
const contextLimitedTrigger = })
input.contextWindowTokens === undefined const effectiveTriggerTokens = getEffectiveContextTriggerTokens({
? input.settings.triggerTokens triggerTokens: input.settings.triggerTokens,
: Math.max( contextWindowTokens: input.contextWindowTokens
8_000, })
input.contextWindowTokens - reservedOutputAndSafetyTokens
)
const effectiveTriggerTokens = Math.min(
input.settings.triggerTokens,
contextLimitedTrigger
)
if (estimatedInputTokens < effectiveTriggerTokens) { if (estimatedInputTokens < effectiveTriggerTokens) {
return undefined return undefined
} }
+22 -1
View File
@@ -379,9 +379,30 @@ describe('ModelAgentRuntime', () => {
expect(answerMessages).toContain('new-user-') expect(answerMessages).toContain('new-user-')
expect(answerMessages).not.toContain('old-user-') expect(answerMessages).not.toContain('old-user-')
expect(events).toContainEqual( 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({ expect.objectContaining({
type: 'status', type: 'status',
message: '较早的对话已压缩,正在生成回答' message: '正在准备直连模型上下文'
}) })
) )
expect(events).toContainEqual( expect(events).toContainEqual(
+105 -41
View File
@@ -42,8 +42,13 @@ import {
import { readBoundedResponseText } from './bounded-response' import { readBoundedResponseText } from './bounded-response'
import { import {
formatConversationForSummary, formatConversationForSummary,
planContextCompression planContextCompression,
estimateMessagesTokens
} from './context-compression' } from './context-compression'
import {
estimateContextInputTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window'
type ConversationMessage = { type ConversationMessage = {
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -1784,23 +1789,19 @@ export class ModelAgentRuntime implements AgentRuntime {
return { summary: summary.trim(), usageEvents } return { summary: summary.trim(), usageEvents }
} }
private async prepareCompressedRequest( private async *prepareCompressedRequest(
request: AgentExecutionRequest, request: AgentExecutionRequest,
signal: AbortSignal signal: AbortSignal
): Promise<{ ): AsyncGenerator<RuntimeEvent, {
request: AgentExecutionRequest request: AgentExecutionRequest
compressed: boolean compressed: boolean
usageEvents: RuntimeModelUsageEvent[] }, void> {
}> {
const compression = this.options.contextCompression const compression = this.options.contextCompression
if ( if (!compression) {
!compression?.settings.enabled || return { request, compressed: false }
!request.history?.length
) {
return { request, compressed: false, usageEvents: [] }
} }
const history = request.history const history = request.history ?? []
let state = this.conversationSummaries.get(request.conversationId) let state = this.conversationSummaries.get(request.conversationId)
if ( if (
state && state &&
@@ -1815,13 +1816,42 @@ export class ModelAgentRuntime implements AgentRuntime {
const remainingHistory = history.slice( const remainingHistory = history.slice(
state?.coveredMessageCount ?? 0 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({ const plan = planContextCompression({
history: remainingHistory, history: remainingHistory,
prompt: [ prompt: requestPrompt,
state?.summary ?? '', summaryTokens: currentSummaryTokens,
request.trustedInstructions ?? '',
request.prompt
].join('\n'),
settings: compression.settings, settings: compression.settings,
contextWindowTokens: compression.contextWindowTokens contextWindowTokens: compression.contextWindowTokens
}) })
@@ -1836,20 +1866,29 @@ export class ModelAgentRuntime implements AgentRuntime {
] ]
}, },
compressed: false, 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( const summarized = await this.summarizeEarlierHistory(
request, request,
plan.earlierMessages, plan.earlierMessages,
state?.summary, state?.summary,
signal signal
) )
const coveredMessageCount =
(state?.coveredMessageCount ?? 0) +
plan.earlierMessages.length
state = { state = {
coveredMessageCount, coveredMessageCount,
coveredHistoryDigest: this.historyDigest( coveredHistoryDigest: this.historyDigest(
@@ -1858,6 +1897,40 @@ export class ModelAgentRuntime implements AgentRuntime {
summary: summarized.summary summary: summarized.summary
} }
this.conversationSummaries.set(request.conversationId, state) 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 { return {
request: { request: {
...request, ...request,
@@ -1866,8 +1939,7 @@ export class ModelAgentRuntime implements AgentRuntime {
...plan.recentMessages ...plan.recentMessages
] ]
}, },
compressed: true, compressed: true
usageEvents: summarized.usageEvents
} }
} }
@@ -2791,29 +2863,21 @@ export class ModelAgentRuntime implements AgentRuntime {
throw new Error('当前模型连接未启用图像输入') throw new Error('当前模型连接未启用图像输入')
} }
if ( const preparation = this.prepareCompressedRequest(
this.options.contextCompression?.settings.enabled &&
request.history?.length
) {
yield {
requestId: request.requestId,
type: 'status',
message: '正在准备直连模型上下文'
}
}
const prepared = await this.prepareCompressedRequest(
request, request,
signal signal
) )
for (const usageEvent of prepared.usageEvents) { let prepared: {
yield usageEvent request: AgentExecutionRequest
compressed: boolean
} }
if (prepared.compressed) { while (true) {
yield { const result = await preparation.next()
requestId: request.requestId, if (result.done) {
type: 'status', prepared = result.value
message: '较早的对话已压缩,正在生成回答' break
} }
yield result.value
} }
const executionRequest = prepared.request const executionRequest = prepared.request
+15 -9
View File
@@ -226,10 +226,9 @@ describe.runIf(enabled)('runtime end-to-end', () => {
contextCompression: { contextCompression: {
settings: { settings: {
...defaultContextCompressionSettings, ...defaultContextCompressionSettings,
enabled: true, enabled: true
triggerTokens: 8_000, },
recentRawTokens: 4_000 contextWindowTokens: 32_000
}
} }
}) })
const events: RuntimeEvent[] = [] const events: RuntimeEvent[] = []
@@ -248,7 +247,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [ content: [
'The project codename is ORBIT-739.', 'The project codename is ORBIT-739.',
'Background notes:', 'Background notes:',
'alpha '.repeat(1_200) 'alpha '.repeat(5_000)
].join('\n') ].join('\n')
}, },
{ {
@@ -256,7 +255,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [ content: [
'I will remember the project codename.', 'I will remember the project codename.',
'Acknowledgement notes:', 'Acknowledgement notes:',
'gamma '.repeat(1_000) 'gamma '.repeat(4_000)
].join('\n') ].join('\n')
}, },
{ {
@@ -264,7 +263,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [ content: [
'The deploy region is AP-SOUTH-7.', 'The deploy region is AP-SOUTH-7.',
'Recent notes:', 'Recent notes:',
'beta '.repeat(900) 'beta '.repeat(3_000)
].join('\n') ].join('\n')
}, },
{ {
@@ -289,8 +288,15 @@ describe.runIf(enabled)('runtime end-to-end', () => {
.join('') .join('')
expect(events).toContainEqual( expect(events).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'status', type: 'context-compression',
message: '较早的对话已压缩,正在生成回答' state: 'started'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'context-compression',
state: 'completed',
estimatedAfterTokens: expect.any(Number)
}) })
) )
expect(events).toContainEqual( expect(events).toContainEqual(
+55
View File
@@ -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 () => { it('configures bundled runtimes from the default model profile', async () => {
const { store } = await createStore() const { store } = await createStore()
+7 -1
View File
@@ -14,6 +14,7 @@ import {
imageGenerationQualitySchema, imageGenerationQualitySchema,
isAgentRuntimeModelProtocol, isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile, isDeepSeekHarnessModelProfile,
minimumModelContextWindowTokens,
modelAuthenticationSchema, modelAuthenticationSchema,
modelProtocolSchema, modelProtocolSchema,
runtimeModelSourceSchema, runtimeModelSourceSchema,
@@ -555,7 +556,12 @@ function migrateVersion10(
function normalizeStoredSettings(settings: StoredSettings): StoredSettings { function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
const modelProfiles = settings.modelProfiles.map((profile) => ({ const modelProfiles = settings.modelProfiles.map((profile) => ({
...profile, ...profile,
baseUrl: normalizeModelBaseUrl(profile.baseUrl) baseUrl: normalizeModelBaseUrl(profile.baseUrl),
contextWindowTokens:
profile.contextWindowTokens === undefined ||
profile.contextWindowTokens >= minimumModelContextWindowTokens
? profile.contextWindowTokens
: undefined
})) }))
const fallbackProfileId = compatibleTextProfileId({ const fallbackProfileId = compatibleTextProfileId({
modelProfiles, modelProfiles,
+209 -15
View File
@@ -902,6 +902,32 @@ describe('App', () => {
} }
}) })
it('keeps a recently visited workspace page mounted', async () => {
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: '知识库' })
)
const heading = await screen.findByRole('heading', {
level: 1,
name: '知识库'
})
const route = heading.closest<HTMLElement>('[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 () => { it('preserves title, message, and project filtering with deferred search', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
@@ -1585,7 +1611,8 @@ describe('App', () => {
fireEvent.scroll(chat) fireEvent.scroll(chat)
fireEvent.click(screen.getByRole('button', { name: '知识库' })) fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(container.querySelector('.chat')).not.toBeInTheDocument() expect(chat).toBeInTheDocument()
expect(chat.closest('[data-route="chat"]')).toHaveAttribute('hidden')
act(() => { act(() => {
agentListener?.({ agentListener?.({
requestId: request.requestId, requestId: request.requestId,
@@ -1597,7 +1624,7 @@ describe('App', () => {
fireEvent.click(screen.getByRole('button', { name: '对话' })) fireEvent.click(screen.getByRole('button', { name: '对话' }))
expect(await screen.findByText('后台新增的回复内容')).toBeInTheDocument() expect(await screen.findByText('后台新增的回复内容')).toBeInTheDocument()
const restoredChat = container.querySelector<HTMLElement>('.chat') const restoredChat = container.querySelector<HTMLElement>('.chat')
expect(restoredChat).not.toBe(chat) expect(restoredChat).toBe(chat)
expect(restoredChat?.scrollTop).toBe(175) expect(restoredChat?.scrollTop).toBe(175)
expect( expect(
screen.getByRole('button', { name: '到底部' }) screen.getByRole('button', { name: '到底部' })
@@ -1609,6 +1636,16 @@ describe('App', () => {
'00000000-0000-4000-8000-000000000461' '00000000-0000-4000-8000-000000000461'
const secondConversationId = const secondConversationId =
'00000000-0000-4000-8000-000000000462' '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([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: firstConversationId, id: firstConversationId,
@@ -1619,6 +1656,7 @@ describe('App', () => {
id: `00000000-0000-4000-8100-${String(index).padStart(12, '0')}`, id: `00000000-0000-4000-8100-${String(index).padStart(12, '0')}`,
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
content: `第一段历史 ${String(index).padStart(3, '0')}`, content: `第一段历史 ${String(index).padStart(3, '0')}`,
reasoning: index === 159 ? '需要保留展开状态' : undefined,
createdAt: 1_775_000_000_000 + index, createdAt: 1_775_000_000_000 + index,
state: 'complete' as const state: 'complete' as const
})) }))
@@ -1647,31 +1685,65 @@ describe('App', () => {
name: '加载更早的消息(还剩 81 条)' name: '加载更早的消息(还剩 81 条)'
}) })
) )
expect(container.querySelectorAll('.message')).toHaveLength(160) const firstPane = container.querySelector<HTMLElement>(
const firstChat = container.querySelector<HTMLElement>('.chat') `[data-conversation-id="${firstConversationId}"]`
)
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
const firstChat = firstPane?.querySelector<HTMLElement>('.chat')
if (!firstChat) { if (!firstChat) {
throw new Error('Missing first chat scroll container') throw new Error('Missing first chat scroll container')
} }
const reasoningDetails = firstPane?.querySelector<HTMLDetailsElement>(
'.message-reasoning'
)
if (!reasoningDetails) {
throw new Error('Missing reasoning details')
}
reasoningDetails.open = true
Object.defineProperties(firstChat, { Object.defineProperties(firstChat, {
clientHeight: { configurable: true, value: 400 }, clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1_600 }, scrollHeight: { configurable: true, value: 1_600 },
scrollTop: { configurable: true, writable: true, value: 225 } scrollTop: { configurable: true, writable: true, value: 225 }
}) })
fireEvent.scroll(firstChat) fireEvent.scroll(firstChat)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第一段会话草稿' }
})
fireEvent.click(screen.getByLabelText('添加附件'))
expect(
await screen.findByText(draftAttachment.name)
).toBeInTheDocument()
fireEvent.click( fireEvent.click(
screen.getByText('第二段会话').closest('button')! screen.getByText('第二段会话').closest('button')!
) )
expect(await screen.findByText('第二段会话内容')).toBeInTheDocument() 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( fireEvent.click(
screen.getByText('第一段长会话').closest('button')! screen.getByText('第一段长会话').closest('button')!
) )
expect(await screen.findByText('第一段历史 001')).toBeInTheDocument() expect(await screen.findByText('第一段历史 001')).toBeInTheDocument()
expect(container.querySelectorAll('.message')).toHaveLength(160) const restoredFirstPane = container.querySelector<HTMLElement>(
expect(container.querySelector<HTMLElement>('.chat')?.scrollTop).toBe( `[data-conversation-id="${firstConversationId}"]`
225
) )
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 () => { it('requires an accessible confirmation before permanently deleting a conversation', async () => {
@@ -2227,6 +2299,124 @@ describe('App', () => {
expect(screen.getByText('正在分析真实推理内容')).toBeVisible() 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(<App />)
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 () => { it('keeps a tool failure in details and hides retry after continuing', async () => {
render(<App />) render(<App />)
@@ -2871,9 +3061,7 @@ describe('App', () => {
expect( expect(
screen.queryByRole('heading', { name: '设置中心' }) screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument() ).not.toBeInTheDocument()
expect( expect(screen.getByLabelText('发送')).toBeDisabled()
await screen.findByText(/ Agent Runtime/u)
).toBeInTheDocument()
expect(run).not.toHaveBeenCalled() expect(run).not.toHaveBeenCalled()
}) })
@@ -3794,9 +3982,7 @@ describe('App', () => {
expect(mode).toBeEnabled() expect(mode).toBeEnabled()
expect(mode.closest('.composer')).not.toBeNull() expect(mode.closest('.composer')).not.toBeNull()
expect( expect(
await screen.findByText( await screen.findByText('快捷唤起:', { exact: false })
new RegExp(`${label} Ask 模式.*只允许搜索当前启用的知识库`)
)
).toBeInTheDocument() ).toBeInTheDocument()
selectComposerOption('工作模式', 'Execute · 受控执行') selectComposerOption('工作模式', 'Execute · 受控执行')
expect(mode).toHaveAccessibleName( expect(mode).toHaveAccessibleName(
@@ -4076,7 +4262,11 @@ describe('App', () => {
expect(notification).toHaveTextContent( expect(notification).toHaveTextContent(
'当前对话已切换到 OpenCode · 默认模型' '当前对话已切换到 OpenCode · 默认模型'
) )
expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument() expect(
screen.getByRole('button', {
name: '工作模式:Ask · 只读问答'
})
).toBeInTheDocument()
fireEvent.click( fireEvent.click(
screen.getByRole('button', { screen.getByRole('button', {
@@ -4102,7 +4292,11 @@ describe('App', () => {
'当前对话已切换到 Continue · 默认模型' '当前对话已切换到 Continue · 默认模型'
) )
).not.toBeInTheDocument() ).not.toBeInTheDocument()
expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument() expect(
screen.getByRole('button', {
name: '工作模式:Ask · 只读问答'
})
).toBeInTheDocument()
} finally { } finally {
vi.useRealTimers() vi.useRealTimers()
} }
+979 -376
View File
File diff suppressed because it is too large Load Diff
+51 -1
View File
@@ -1,4 +1,4 @@
import { cleanup, render } from '@testing-library/react' import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
ChatTimeline, ChatTimeline,
@@ -85,4 +85,54 @@ describe('ChatTimeline', () => {
) )
expect(unchangedDetails).toHaveAttribute('open') 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(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
retryContent=""
totalMessageCount={messages.length}
/>
)
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')
})
}) })
+49
View File
@@ -51,6 +51,11 @@ export type Message = {
createdAt: number createdAt: number
state: 'streaming' | 'complete' | 'error' state: 'streaming' | 'complete' | 'error'
status?: string status?: string
contextCompression?: {
state: 'compressing' | 'completed' | 'failed'
estimatedBeforeTokens: number
estimatedAfterTokens?: number
}
tools?: ToolActivity[] tools?: ToolActivity[]
subagents?: SubagentActivity[] subagents?: SubagentActivity[]
approval?: { approval?: {
@@ -74,6 +79,14 @@ export type ImageViewerItem = {
title: string 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 = type MessageBlockRenderItem =
| { | {
kind: 'block' kind: 'block'
@@ -270,6 +283,41 @@ function ChatMessageRowView({
const { t } = useTranslation('app') const { t } = useTranslation('app')
return ( return (
<>
{message.role === 'assistant' && message.contextCompression && (
<div
aria-live="polite"
className={`context-compression-event context-compression-event--${message.contextCompression.state}`}
role="status"
>
<span className="context-compression-event__line" />
<span className="context-compression-event__label">
<span
aria-hidden="true"
className={
message.contextCompression.state === 'compressing'
? 'message__status-dot message__status-dot--active'
: 'message__status-dot'
}
/>
{message.contextCompression.state === 'compressing'
? t('chat.contextCompression.compressing')
: message.contextCompression.state === 'completed' &&
message.contextCompression.estimatedAfterTokens !==
undefined
? t('chat.contextCompression.completed', {
before: formatCompactTokens(
message.contextCompression.estimatedBeforeTokens
),
after: formatCompactTokens(
message.contextCompression.estimatedAfterTokens
)
})
: t('chat.contextCompression.failed')}
</span>
<span className="context-compression-event__line" />
</div>
)}
<article <article
className={`message message--${message.role}`} className={`message message--${message.role}`}
ref={(element) => onArticleRef(message.id, element)} ref={(element) => onArticleRef(message.id, element)}
@@ -755,6 +803,7 @@ function ChatMessageRowView({
)} )}
</div> </div>
</article> </article>
</>
) )
} }
+1
View File
@@ -604,6 +604,7 @@ describe('SettingsPanel runtime files', () => {
'上下文上限(可选)' '上下文上限(可选)'
) )
expect(contextWindow).toHaveValue(null) expect(contextWindow).toHaveValue(null)
expect(contextWindow).toHaveAttribute('min', '32')
fireEvent.change(contextWindow, { target: { value: '256' } }) fireEvent.change(contextWindow, { target: { value: '256' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' })) fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
+1 -1
View File
@@ -2418,7 +2418,7 @@ export function SettingsPanel({
aria-label={t('model.profile.contextWindow')} aria-label={t('model.profile.contextWindow')}
inputMode="numeric" inputMode="numeric"
max={10_000} max={10_000}
min={8} min={32}
onChange={(event) => { onChange={(event) => {
const value = event.target.valueAsNumber const value = event.target.valueAsNumber
updateModelProfile(profile.id, { updateModelProfile(profile.id, {
@@ -187,6 +187,12 @@ export const app = {
streaming: 'Reasoning', streaming: 'Reasoning',
complete: 'Reasoning process' complete: 'Reasoning process'
}, },
contextCompression: {
compressing: 'Compressing earlier conversation…',
completed:
'Earlier conversation compressed · ≈{{before}} → ≈{{after}}',
failed: 'Earlier conversation compression failed'
},
sources: 'Sources: {{sources}}', sources: 'Sources: {{sources}}',
citations: { citations: {
view: 'View {{count}} evidence references', view: 'View {{count}} evidence references',
@@ -314,6 +320,14 @@ export const app = {
send: 'Send', send: 'Send',
sendTitle: 'Send message', sendTitle: 'Send message',
shortcut: 'Quick access: ', 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: { experts: {
general: 'General assistant', general: 'General assistant',
generalDescription: 'Default single assistant', generalDescription: 'Default single assistant',
@@ -495,7 +495,7 @@ export const settings = {
'When enabled, GoodBuddy can send image context to this model connection.', 'When enabled, GoodBuddy can send image context to this model connection.',
contextWindow: 'Context window (optional)', contextWindow: 'Context window (optional)',
contextWindowDescription: contextWindowDescription:
'Enter K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.', 'Enter 32K10000K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.',
imageQuality: 'Image quality', imageQuality: 'Image quality',
imageQualityAriaLabel: 'Image quality for {{name}}', imageQualityAriaLabel: 'Image quality for {{name}}',
quality: { quality: {
@@ -182,6 +182,11 @@ export const app = {
streaming: '正在推理', streaming: '正在推理',
complete: '推理过程' complete: '推理过程'
}, },
contextCompression: {
compressing: '正在压缩较早对话…',
completed: '已压缩较早对话 · ≈{{before}} → ≈{{after}}',
failed: '较早对话压缩失败'
},
sources: '来源:{{sources}}', sources: '来源:{{sources}}',
citations: { citations: {
view: '查看 {{count}} 条证据引用', view: '查看 {{count}} 条证据引用',
@@ -306,6 +311,14 @@ export const app = {
send: '发送', send: '发送',
sendTitle: '发送消息', sendTitle: '发送消息',
shortcut: '快捷唤起:', shortcut: '快捷唤起:',
context: {
tokenCount: '上下文 ≈{{used}}',
windowUsage: '上下文 ≈{{used}} / {{total}} · {{percentage}}%',
thresholdUsage:
'距压缩阈值 ≈{{used}} / {{total}} · {{percentage}}%',
progressLabel: '当前上下文使用量',
compressionTrigger: '自动压缩线:≈{{tokens}}'
},
experts: { experts: {
general: '通用助手', general: '通用助手',
generalDescription: '默认单助手', generalDescription: '默认单助手',
@@ -452,7 +452,7 @@ export const settings = {
'启用后,GoodBuddy 可将图片上下文发送给此模型连接。', '启用后,GoodBuddy 可将图片上下文发送给此模型连接。',
contextWindow: '上下文上限(可选)', contextWindow: '上下文上限(可选)',
contextWindowDescription: contextWindowDescription:
'以 K tokens 填写。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。', '以 K tokens 填写,范围为 32K10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
imageQuality: '图片质量', imageQuality: '图片质量',
imageQualityAriaLabel: '图片质量 {{name}}', imageQualityAriaLabel: '图片质量 {{name}}',
quality: { quality: {
+96
View File
@@ -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<string>(),
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([])
})
})
+69
View File
@@ -0,0 +1,69 @@
export type KeepAliveCacheEntry<Key extends string> = {
key: Key
lastVisitedAt: number
}
export function touchKeepAliveEntry<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number
): KeepAliveCacheEntry<Key>[] {
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<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
{
currentKey,
expiresAfterMs,
maximumEntries,
now,
protectedKeys,
recentEntries
}: {
currentKey?: Key
expiresAfterMs: number
maximumEntries: number
now: number
protectedKeys?: ReadonlySet<Key>
recentEntries: number
}
): KeepAliveCacheEntry<Key>[] {
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))
}
+135 -9
View File
@@ -1963,6 +1963,17 @@ textarea:focus-visible {
grid-template-rows: 58px minmax(0, 1fr) auto; 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 { .topbar {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -2975,6 +2986,15 @@ button > svg {
background: var(--surface-raised); background: var(--surface-raised);
} }
.chat-history-pane {
height: 100%;
min-height: 0;
}
.chat-history-pane[hidden] {
display: none;
}
.chat { .chat {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -3608,6 +3628,55 @@ button > svg {
color: var(--danger); 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 { .message__status-dot {
flex: 0 0 auto; flex: 0 0 auto;
width: 6px; width: 6px;
@@ -4409,7 +4478,7 @@ button > svg {
} }
.composer-picker--mode > .model-button { .composer-picker--mode > .model-button {
width: 96px; width: 108px;
} }
.composer-picker--ask svg { .composer-picker--ask svg {
@@ -4595,29 +4664,31 @@ button > svg {
background: var(--danger-solid); background: var(--danger-solid);
} }
.composer-hint { .composer-meta {
display: flex; display: flex;
margin: var(--space-2) 0 0; margin: var(--space-2) 0 0;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-end;
color: var(--text-secondary);
font-size: var(--font-caption); font-size: var(--font-caption);
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-1) var(--space-3); gap: var(--space-2) var(--space-3);
line-height: 1.45; line-height: 1.45;
text-align: center;
} }
.composer-hint--error { .composer-meta__error {
margin-right: auto;
}
.composer-meta__error {
color: var(--danger); color: var(--danger);
} }
.composer-hint__shortcut { .composer-meta__shortcut {
color: var(--text-muted); color: var(--text-muted);
white-space: nowrap; white-space: nowrap;
} }
.composer-hint kbd { .composer-meta kbd {
padding: 1px var(--space-1); padding: 1px var(--space-1);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: 4px; border-radius: 4px;
@@ -4627,6 +4698,52 @@ button > svg {
font-size: 10px; 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) { @container (max-width: 700px) {
.composer__controls { .composer__controls {
flex-wrap: wrap; flex-wrap: wrap;
@@ -4649,6 +4766,15 @@ button > svg {
.model-button { .model-button {
width: 100%; width: 100%;
} }
.composer-context-meter {
width: 100%;
}
.composer-context-meter__track {
min-width: 64px;
flex: 1;
}
} }
.settings-backdrop { .settings-backdrop {
+8
View File
@@ -147,6 +147,14 @@ export const conversationMessageSchema = z
createdAt: z.number().int().nonnegative(), createdAt: z.number().int().nonnegative(),
state: z.enum(['streaming', 'complete', 'error']), state: z.enum(['streaming', 'complete', 'error']),
status: z.string().max(4_000).optional(), 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(), tools: z.array(conversationToolActivitySchema).max(100).optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(), sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z sourceReferences: z
+63
View File
@@ -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
)
}
+34 -2
View File
@@ -1,4 +1,8 @@
import { z } from 'zod' import { z } from 'zod'
import {
maximumModelContextWindowTokens,
minimumModelContextWindowTokens
} from './context-window'
import type { import type {
BrowserProfileCreateInput, BrowserProfileCreateInput,
BrowserProfileRenameInput, BrowserProfileRenameInput,
@@ -416,6 +420,11 @@ const modelApiKeyUpdateSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('clear') }).strict() z.object({ action: z.literal('clear') }).strict()
]) ])
export {
maximumModelContextWindowTokens,
minimumModelContextWindowTokens
} from './context-window'
const modelProfileInputSchema = z const modelProfileInputSchema = z
.object({ .object({
id: modelProfileIdSchema, id: modelProfileIdSchema,
@@ -433,8 +442,8 @@ const modelProfileInputSchema = z
contextWindowTokens: z contextWindowTokens: z
.number() .number()
.int() .int()
.min(8_000) .min(minimumModelContextWindowTokens)
.max(10_000_000) .max(maximumModelContextWindowTokens)
.optional(), .optional(),
imageGenerationQuality: imageGenerationQualitySchema, imageGenerationQuality: imageGenerationQualitySchema,
apiKey: modelApiKeyUpdateSchema apiKey: modelApiKeyUpdateSchema
@@ -907,6 +916,29 @@ export type AgentEvent =
type: 'reasoning' type: 'reasoning'
delta: string 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 requestId: string
type: 'tool' type: 'tool'