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', () => {
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)
})
})
+20 -43
View File
@@ -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
}
+22 -1
View File
@@ -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(
+105 -41
View File
@@ -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<RuntimeEvent, {
request: AgentExecutionRequest
compressed: boolean
usageEvents: RuntimeModelUsageEvent[]
}> {
}, 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 plan = planContextCompression({
history: remainingHistory,
prompt: [
state?.summary ?? '',
const requestPrompt = [
request.trustedInstructions ?? '',
request.prompt
].join('\n'),
].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: 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
+15 -9
View File
@@ -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(
+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 () => {
const { store } = await createStore()
+7 -1
View File
@@ -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,
+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 () => {
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<HTMLElement>('.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<HTMLElement>('.chat')
const firstPane = container.querySelector<HTMLElement>(
`[data-conversation-id="${firstConversationId}"]`
)
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
const firstChat = firstPane?.querySelector<HTMLElement>('.chat')
if (!firstChat) {
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, {
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<HTMLElement>('.chat')?.scrollTop).toBe(
225
const restoredFirstPane = container.querySelector<HTMLElement>(
`[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(<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 () => {
render(<App />)
@@ -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()
}
+930 -327
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 {
ChatTimeline,
@@ -85,4 +85,54 @@ describe('ChatTimeline', () => {
)
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
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 && (
<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
className={`message message--${message.role}`}
ref={(element) => onArticleRef(message.id, element)}
@@ -755,6 +803,7 @@ function ChatMessageRowView({
)}
</div>
</article>
</>
)
}
+1
View File
@@ -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: '保存设置' }))
+1 -1
View File
@@ -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, {
@@ -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',
@@ -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 32K10000K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.',
imageQuality: 'Image quality',
imageQualityAriaLabel: 'Image quality for {{name}}',
quality: {
@@ -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: '默认单助手',
@@ -452,7 +452,7 @@ export const settings = {
'启用后,GoodBuddy 可将图片上下文发送给此模型连接。',
contextWindow: '上下文上限(可选)',
contextWindowDescription:
'以 K tokens 填写。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
'以 K tokens 填写,范围为 32K10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
imageQuality: '图片质量',
imageQualityAriaLabel: '图片质量 {{name}}',
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;
}
.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 {
+8
View File
@@ -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
+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 {
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'