feat: preserve pages and compress context
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
+979
-376
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '保存设置' }))
|
||||
|
||||
|
||||
@@ -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 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: {
|
||||
|
||||
@@ -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 填写,范围为 32K–10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
|
||||
imageQuality: '图片质量',
|
||||
imageQualityAriaLabel: '图片质量 {{name}}',
|
||||
quality: {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user