feat: add durable context compression
Long direct-model conversations mixed provider usage with local estimates, and Agent tool rounds could remain above the configured compression target. Compression state and status markers also did not reliably survive restarts or bounded history rollover. Direct-model calls now prefer provider-reported usage, compact complete conversation turns and Agent tool rounds within reserved payload budgets, and persist reusable summaries with scope-specific markers. The chat meter separates latest-call usage from estimated compressed conversation size, while failed or cancelled calls retain the last successful measurement. Release note: 直连模型现可在长对话和多轮工具执行中自动压缩旧上下文,并分别显示本次调用用量与压缩后对话估算;摘要会自动保存并跨重启复用,无需手动操作。
This commit is contained in:
+335
-23
@@ -2331,7 +2331,7 @@ describe('App', () => {
|
||||
expect(screen.getByText('正在分析真实推理内容')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows live context usage and keeps explicit compression status', async () => {
|
||||
it('updates context usage after model responses and keeps compression status', async () => {
|
||||
const settings = await api.settings.getRuntime()
|
||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||
...settings,
|
||||
@@ -2350,14 +2350,12 @@ describe('App', () => {
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
await screen.findByLabelText('向 GoodBuddy 提问')
|
||||
expect(
|
||||
await screen.findByText(/上下文 ≈.+ \/ 32\.0K · \d+%/u)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
screen.queryByRole('progressbar', {
|
||||
name: '当前上下文使用量'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('只读问答,不修改文件')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -2369,8 +2367,10 @@ describe('App', () => {
|
||||
target: { value: '中'.repeat(1_000) }
|
||||
})
|
||||
expect(
|
||||
screen.getByText(/上下文 ≈5\.\dK \/ 32\.0K/u)
|
||||
).toBeInTheDocument()
|
||||
screen.queryByRole('progressbar', {
|
||||
name: '当前上下文使用量'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
@@ -2378,10 +2378,26 @@ describe('App', () => {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'context-metrics',
|
||||
contextTokens: 22_000,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
contextWindowTokens: 32_000,
|
||||
compressionEnabled: true,
|
||||
source: 'provider'
|
||||
})
|
||||
})
|
||||
expect(
|
||||
screen.getByText('本次调用 22.0K / 32.0K · 69%')
|
||||
).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'context-compression',
|
||||
scope: 'conversation',
|
||||
state: 'started',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
@@ -2410,6 +2426,7 @@ describe('App', () => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'context-compression',
|
||||
scope: 'conversation',
|
||||
state: 'completed',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000,
|
||||
@@ -2419,25 +2436,105 @@ describe('App', () => {
|
||||
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')
|
||||
screen.getByText(
|
||||
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('上下文 ≈9.0K / 32.0K · 28%')
|
||||
screen.getByText(
|
||||
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'context-compression',
|
||||
scope: 'agent-run',
|
||||
state: 'started',
|
||||
estimatedBeforeTokens: 24_000,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
contextWindowTokens: 32_000,
|
||||
recentRawTokens: 4_000,
|
||||
compressionCount: 1
|
||||
})
|
||||
})
|
||||
expect(
|
||||
screen.getByText('正在整理 Agent 执行上下文…')
|
||||
).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'context-compression',
|
||||
scope: 'agent-run',
|
||||
state: 'completed',
|
||||
estimatedBeforeTokens: 24_000,
|
||||
estimatedAfterTokens: 11_000,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
contextWindowTokens: 32_000,
|
||||
recentRawTokens: 4_000,
|
||||
compressionCount: 2
|
||||
})
|
||||
})
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'context-compression',
|
||||
scope: 'conversation',
|
||||
state: 'completed',
|
||||
estimatedBeforeTokens: 24_000,
|
||||
estimatedAfterTokens: 8_500,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
contextWindowTokens: 32_000,
|
||||
recentRawTokens: 4_000,
|
||||
coveredMessageCount: 4
|
||||
})
|
||||
})
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'已压缩较早对话(估算) · ≈24.0K → ≈8.5K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'压缩后对话估算 ≈8.5K / 32.0K · 27%'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll(
|
||||
'.context-compression-event__label'
|
||||
)
|
||||
).map((element) => element.textContent)
|
||||
).toEqual([
|
||||
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K',
|
||||
'已压缩较早对话(估算) · ≈24.0K → ≈8.5K'
|
||||
])
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
@@ -2445,10 +2542,183 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
expect(
|
||||
screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K')
|
||||
screen.getByText(
|
||||
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not present the compression threshold as a context-window percentage', async () => {
|
||||
const settings = await api.settings.getRuntime()
|
||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||
...settings,
|
||||
provider: 'model',
|
||||
modelProfiles: settings.modelProfiles.map((profile) => ({
|
||||
...profile,
|
||||
contextWindowTokens: undefined
|
||||
})),
|
||||
contextCompression: {
|
||||
enabled: true,
|
||||
triggerTokens: 20_000,
|
||||
recentRawTokens: 4_000,
|
||||
modelSource: { kind: 'current' },
|
||||
summaryPrompt: 'Preserve important facts.'
|
||||
}
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '检查上下文显示' }
|
||||
})
|
||||
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-metrics',
|
||||
contextTokens: 22_000,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
compressionEnabled: true,
|
||||
source: 'provider'
|
||||
})
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText('本次调用 22.0K · 压缩线 20.0K')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('progressbar', {
|
||||
name: '当前上下文使用量'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('110%')).not.toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'context-compression',
|
||||
scope: 'conversation',
|
||||
state: 'completed',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_400,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
recentRawTokens: 4_000,
|
||||
coveredMessageCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'压缩后对话估算 ≈9.4K · 压缩线 20.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('本次调用 22.0K · 压缩线 20.0K')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('restores persisted context usage and compression state after restart', async () => {
|
||||
const settings = await api.settings.getRuntime()
|
||||
const profile = settings.modelProfiles[0]!
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000451'
|
||||
const compressionState = {
|
||||
coveredHistoryDigest: 'a'.repeat(64),
|
||||
coveredMessageCount: 2,
|
||||
coveredFromMessageId:
|
||||
'00000000-0000-4000-8000-000000000452',
|
||||
coveredThroughMessageId:
|
||||
'00000000-0000-4000-8000-000000000453',
|
||||
summary: 'Persisted conversation summary'
|
||||
}
|
||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||
...settings,
|
||||
provider: 'model',
|
||||
defaultModelProfileId: profile.id,
|
||||
modelProfiles: settings.modelProfiles.map((candidate) => ({
|
||||
...candidate,
|
||||
contextWindowTokens: 32_000
|
||||
}))
|
||||
})
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: conversationId,
|
||||
projectId,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: profile.id
|
||||
},
|
||||
contextMetrics: {
|
||||
runtimeSelectionKey: `model:${profile.id}`,
|
||||
contextTokens: 9_000,
|
||||
effectiveTriggerTokens: 20_000,
|
||||
contextWindowTokens: 32_000,
|
||||
compressionEnabled: true,
|
||||
source: 'estimated',
|
||||
basis: 'conversation'
|
||||
},
|
||||
contextCompressionState: compressionState,
|
||||
title: '已压缩会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000452',
|
||||
role: 'user',
|
||||
content: '此前问题',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000453',
|
||||
role: 'assistant',
|
||||
content: '此前回答',
|
||||
createdAt: 1_775_000_000_001,
|
||||
state: 'complete',
|
||||
contextCompression: {
|
||||
state: 'completed',
|
||||
scope: 'conversation',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '继续工作' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(run.mock.calls[0]?.[0].contextCompressionState).toEqual(
|
||||
compressionState
|
||||
)
|
||||
expect(run.mock.calls[0]?.[0]).toMatchObject({
|
||||
historyMessageIds: [
|
||||
'00000000-0000-4000-8000-000000000452',
|
||||
'00000000-0000-4000-8000-000000000453'
|
||||
],
|
||||
currentUserMessageId: expect.any(String),
|
||||
currentAssistantMessageId: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a tool failure in details and hides retry after continuing', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -2747,6 +3017,48 @@ describe('App', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('locks agent context controls while a response is running', async () => {
|
||||
render(<App />)
|
||||
|
||||
const expertButton = composerMenuTrigger('专家角色')
|
||||
const modeButton = composerMenuTrigger('工作模式')
|
||||
const runtimeButton = await screen.findByRole('button', {
|
||||
name: /sonnet-5/u
|
||||
})
|
||||
expect(expertButton).toBeEnabled()
|
||||
expect(modeButton).toBeEnabled()
|
||||
expect(runtimeButton).toBeEnabled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '检查运行上下文锁定' }
|
||||
})
|
||||
openComposerMenu('专家角色')
|
||||
fireEvent.click(await screen.findByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '专家角色' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(expertButton).toBeDisabled()
|
||||
expect(modeButton).toBeDisabled()
|
||||
expect(runtimeButton).toBeDisabled()
|
||||
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
act(() => {
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect(expertButton).toBeEnabled())
|
||||
expect(modeButton).toBeEnabled()
|
||||
expect(runtimeButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps sent documents and images in conversation history', async () => {
|
||||
const documentAttachment = {
|
||||
id: '00000000-0000-4000-8000-000000000301',
|
||||
@@ -4134,7 +4446,7 @@ describe('App', () => {
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(mode).toBeEnabled()
|
||||
expect(mode).toBeDisabled()
|
||||
})
|
||||
|
||||
it('terminalizes tools and activity when a request is cancelled', async () => {
|
||||
|
||||
+200
-128
@@ -61,19 +61,13 @@ import type {
|
||||
BrowserLiveState,
|
||||
ContextAttachment,
|
||||
ContextFileSelectionProgress,
|
||||
KnowledgeRetrievalMode,
|
||||
KnowledgeSearchReference,
|
||||
KnowledgeSnapshot,
|
||||
RuntimeSettings
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
defaultContextCompressionSettings,
|
||||
maximumPastedImageBytes
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
estimateContextInputTokens,
|
||||
getEffectiveContextTriggerTokens
|
||||
} from '../../shared/context-window'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
agentRuntimeSelectionSchema,
|
||||
@@ -98,6 +92,7 @@ import type {
|
||||
ConversationMessage,
|
||||
ConversationSnapshot,
|
||||
ConversationAttachment,
|
||||
ConversationContextCompressionMarker,
|
||||
ConversationMessageBlock,
|
||||
LocalConversationHeader,
|
||||
LocalConversationSaveBatch,
|
||||
@@ -108,6 +103,7 @@ import type {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
conversationAttachmentSchema,
|
||||
conversationContextCompressionMarkerSchema,
|
||||
conversationMessageBlocksSchema,
|
||||
interactiveWorkModes,
|
||||
normalizeInteractiveWorkMode,
|
||||
@@ -174,6 +170,7 @@ import { ReleaseNotesDialog } from './ReleaseNotesDialog'
|
||||
import { scheduleIdleRoutePreload } from './idle-route-preload'
|
||||
import { createPreloadableComponent } from './preloadable-component'
|
||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||
import { formatCompactTokens } from './token-format'
|
||||
import {
|
||||
pruneKeepAliveEntries,
|
||||
touchKeepAliveEntry,
|
||||
@@ -432,14 +429,7 @@ function supportsSubagentSmartRouting(
|
||||
return workMode === 'ask'
|
||||
}
|
||||
|
||||
type Conversation = {
|
||||
id: string
|
||||
projectId?: string
|
||||
runtimeSelection?: AgentRuntimeSelection
|
||||
knowledgeRetrievalMode?: KnowledgeRetrievalMode
|
||||
remote?: ConversationSnapshot['remote']
|
||||
title: string
|
||||
updatedAt: number
|
||||
type Conversation = Omit<ConversationSnapshot, 'messages'> & {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
@@ -450,13 +440,6 @@ type ActiveRun = {
|
||||
runtimeSelectionKey: string
|
||||
}
|
||||
|
||||
type ConversationContextMetrics = Omit<
|
||||
Extract<AgentEvent, { type: 'context-metrics' }>,
|
||||
'requestId' | 'type'
|
||||
> & {
|
||||
runtimeSelectionKey: string
|
||||
}
|
||||
|
||||
type WorkspaceView =
|
||||
| 'chat'
|
||||
| 'magic-notes'
|
||||
@@ -1068,14 +1051,17 @@ function isConversation(value: unknown): value is Conversation {
|
||||
entry.state === 'complete' ||
|
||||
entry.state === 'error') &&
|
||||
(entry.contextCompression === undefined ||
|
||||
(typeof entry.contextCompression === 'object' &&
|
||||
entry.contextCompression !== null &&
|
||||
['compressing', 'completed', 'failed'].includes(
|
||||
String(
|
||||
(
|
||||
entry.contextCompression as Record<string, unknown>
|
||||
).state
|
||||
)
|
||||
conversationContextCompressionMarkerSchema.safeParse(
|
||||
entry.contextCompression
|
||||
).success) &&
|
||||
(entry.contextCompressions === undefined ||
|
||||
(Array.isArray(entry.contextCompressions) &&
|
||||
entry.contextCompressions.length <= 2 &&
|
||||
entry.contextCompressions.every(
|
||||
(compression) =>
|
||||
conversationContextCompressionMarkerSchema.safeParse(
|
||||
compression
|
||||
).success
|
||||
))) &&
|
||||
(entry.artifactIds === undefined ||
|
||||
(Array.isArray(entry.artifactIds) &&
|
||||
@@ -1103,6 +1089,8 @@ function toConversationSnapshots(
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt,
|
||||
messages: conversation.messages
|
||||
@@ -1122,6 +1110,7 @@ function toConversationMessage(message: Message): ConversationMessage {
|
||||
state: message.state,
|
||||
status: message.status,
|
||||
contextCompression: message.contextCompression,
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
@@ -1139,6 +1128,8 @@ function toLocalConversationHeader(
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt
|
||||
}
|
||||
@@ -1307,14 +1298,6 @@ function formatAttachmentSize(size: number): string {
|
||||
return `${Math.max(1, Math.ceil(size / 1024))} KB`
|
||||
}
|
||||
|
||||
function formatCompactContextTokens(tokens: number): string {
|
||||
if (tokens < 1_000) {
|
||||
return tokens.toLocaleString()
|
||||
}
|
||||
const value = tokens / 1_000
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K`
|
||||
}
|
||||
|
||||
const composerTextareaMinHeight = 72
|
||||
const composerTextareaMaxHeight = 220
|
||||
|
||||
@@ -1778,8 +1761,6 @@ function App(): React.JSX.Element {
|
||||
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
||||
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
||||
const [contextMetricsByConversation, setContextMetricsByConversation] =
|
||||
useState<Record<string, ConversationContextMetrics>>({})
|
||||
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
|
||||
const [composerMenuOpen, setComposerMenuOpen] = useState<
|
||||
'expert' | 'mode' | undefined
|
||||
@@ -3138,25 +3119,103 @@ function App(): React.JSX.Element {
|
||||
const { requestId: _requestId, type: _type, ...metrics } = event
|
||||
void _requestId
|
||||
void _type
|
||||
setContextMetricsByConversation((current) => ({
|
||||
...current,
|
||||
[run.conversationId]: {
|
||||
...metrics,
|
||||
runtimeSelectionKey: run.runtimeSelectionKey
|
||||
}
|
||||
}))
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === run.conversationId
|
||||
? {
|
||||
...conversation,
|
||||
contextMetrics: {
|
||||
...metrics,
|
||||
basis: 'model-call',
|
||||
runtimeSelectionKey: run.runtimeSelectionKey
|
||||
}
|
||||
}
|
||||
: conversation
|
||||
)
|
||||
)
|
||||
} else if (event.type === 'context-compression') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
contextCompression: {
|
||||
const estimatedAfterTokens = event.estimatedAfterTokens
|
||||
const conversationScoped = event.scope !== 'agent-run'
|
||||
const scope = event.scope ?? 'conversation'
|
||||
const marker: ConversationContextCompressionMarker = {
|
||||
state:
|
||||
event.state === 'started'
|
||||
? 'compressing'
|
||||
: 'completed',
|
||||
: event.state,
|
||||
scope,
|
||||
estimatedBeforeTokens: event.estimatedBeforeTokens,
|
||||
estimatedAfterTokens: event.estimatedAfterTokens
|
||||
estimatedAfterTokens: event.estimatedAfterTokens,
|
||||
compressionCount: event.compressionCount
|
||||
}
|
||||
}))
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const current =
|
||||
message.contextCompressions ??
|
||||
(message.contextCompression
|
||||
? [message.contextCompression]
|
||||
: [])
|
||||
const existingIndex = current.findIndex(
|
||||
(compression) =>
|
||||
(compression.scope ?? 'conversation') === scope
|
||||
)
|
||||
const contextCompressions =
|
||||
existingIndex >= 0
|
||||
? [
|
||||
...current.filter(
|
||||
(_compression, index) =>
|
||||
index !== existingIndex
|
||||
),
|
||||
marker
|
||||
]
|
||||
: [...current, marker]
|
||||
return {
|
||||
...message,
|
||||
contextCompression: undefined,
|
||||
contextCompressions
|
||||
}
|
||||
})
|
||||
if (
|
||||
conversationScoped &&
|
||||
event.state === 'completed' &&
|
||||
estimatedAfterTokens !== undefined
|
||||
) {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === run.conversationId
|
||||
? {
|
||||
...conversation,
|
||||
contextMetrics: {
|
||||
runtimeSelectionKey: run.runtimeSelectionKey,
|
||||
contextTokens: estimatedAfterTokens,
|
||||
effectiveTriggerTokens:
|
||||
event.effectiveTriggerTokens,
|
||||
contextWindowTokens:
|
||||
event.contextWindowTokens,
|
||||
compressionEnabled: true,
|
||||
source: 'estimated',
|
||||
basis: 'conversation'
|
||||
},
|
||||
contextCompressionState:
|
||||
event.conversationState ??
|
||||
conversation.contextCompressionState
|
||||
}
|
||||
: conversation
|
||||
)
|
||||
)
|
||||
} else if (
|
||||
conversationScoped &&
|
||||
event.conversationState
|
||||
) {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === run.conversationId
|
||||
? {
|
||||
...conversation,
|
||||
contextCompressionState: event.conversationState
|
||||
}
|
||||
: conversation
|
||||
)
|
||||
)
|
||||
}
|
||||
} else if (event.type === 'status') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
@@ -3457,6 +3516,17 @@ function App(): React.JSX.Element {
|
||||
state: 'failed' as const
|
||||
}
|
||||
: message.contextCompression,
|
||||
contextCompressions:
|
||||
event.type === 'error'
|
||||
? message.contextCompressions?.map((compression) =>
|
||||
compression.state === 'compressing'
|
||||
? {
|
||||
...compression,
|
||||
state: 'failed' as const
|
||||
}
|
||||
: compression
|
||||
)
|
||||
: message.contextCompressions,
|
||||
approval: undefined,
|
||||
question: undefined,
|
||||
tools: toolTerminalState
|
||||
@@ -4965,6 +5035,12 @@ function App(): React.JSX.Element {
|
||||
const conversationId = activeConversation.id
|
||||
const attachmentSnapshot = attachments.slice(0, 8)
|
||||
const historySnapshot = activeConversation.messages
|
||||
const retainedHistorySnapshot = historySnapshot
|
||||
.filter(
|
||||
(message) =>
|
||||
message.state === 'complete' && message.content.trim()
|
||||
)
|
||||
.slice(-500)
|
||||
const projectIdSnapshot = activeProjectId || undefined
|
||||
const knowledgeRetrievalModeSnapshot =
|
||||
activeConversation.knowledgeRetrievalMode ?? 'auto'
|
||||
@@ -4976,6 +5052,8 @@ function App(): React.JSX.Element {
|
||||
const selectedExpertSnapshot =
|
||||
runtime.capability === 'image-generation' ? '' : selectedExpertId
|
||||
const workModeSnapshot = effectiveWorkMode
|
||||
setComposerMenuOpen(undefined)
|
||||
setRuntimeMenuOpen(false)
|
||||
preparingConversations.current.add(conversationId)
|
||||
setConversationActivity(conversationId, true)
|
||||
setInput('')
|
||||
@@ -5097,16 +5175,17 @@ function App(): React.JSX.Element {
|
||||
contextIds: attachmentSnapshot.map(
|
||||
(attachment) => attachment.id
|
||||
),
|
||||
history: historySnapshot
|
||||
.filter(
|
||||
(message) =>
|
||||
message.state === 'complete' && message.content.trim()
|
||||
)
|
||||
.slice(-500)
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content
|
||||
}))
|
||||
contextCompressionState:
|
||||
activeConversation.contextCompressionState,
|
||||
history: retainedHistorySnapshot.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content
|
||||
})),
|
||||
historyMessageIds: retainedHistorySnapshot.map(
|
||||
(message) => message.id
|
||||
),
|
||||
currentUserMessageId: userMessage.id,
|
||||
currentAssistantMessageId: assistantMessage.id
|
||||
})
|
||||
for (const attachment of attachmentSnapshot) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
@@ -5651,55 +5730,38 @@ function App(): React.JSX.Element {
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const compression =
|
||||
runtimeSettings.contextCompression ??
|
||||
defaultContextCompressionSettings
|
||||
const latest = contextMetricsByConversation[activeConversation.id]
|
||||
const latest = activeConversation.contextMetrics
|
||||
const applicableLatest =
|
||||
latest?.runtimeSelectionKey === activeRuntimeSelectionKey
|
||||
? latest
|
||||
: undefined
|
||||
const history = activeConversation.messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.state === 'complete' && message.content.trim()
|
||||
)
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content
|
||||
}))
|
||||
const coveredMessageCount = Math.min(
|
||||
applicableLatest?.coveredMessageCount ?? 0,
|
||||
history.length
|
||||
)
|
||||
const estimatedInputTokens =
|
||||
isRunning && applicableLatest
|
||||
? applicableLatest.estimatedInputTokens
|
||||
: estimateContextInputTokens({
|
||||
history: history.slice(coveredMessageCount),
|
||||
prompt: input,
|
||||
summaryTokens: applicableLatest?.summaryTokens ?? 0
|
||||
})
|
||||
if (!applicableLatest) {
|
||||
return undefined
|
||||
}
|
||||
const contextTokens = applicableLatest.contextTokens
|
||||
const effectiveTriggerTokens =
|
||||
getEffectiveContextTriggerTokens({
|
||||
triggerTokens: compression.triggerTokens,
|
||||
contextWindowTokens: profile.contextWindowTokens
|
||||
})
|
||||
applicableLatest.effectiveTriggerTokens
|
||||
const denominatorTokens =
|
||||
profile.contextWindowTokens ??
|
||||
(compression.enabled ? effectiveTriggerTokens : undefined)
|
||||
applicableLatest.contextWindowTokens
|
||||
const percentage =
|
||||
denominatorTokens === undefined
|
||||
? undefined
|
||||
: Math.round(
|
||||
(estimatedInputTokens / denominatorTokens) * 100
|
||||
(contextTokens / denominatorTokens) * 100
|
||||
)
|
||||
|
||||
return {
|
||||
estimatedInputTokens,
|
||||
contextTokens,
|
||||
effectiveTriggerTokens,
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
compressionEnabled: compression.enabled,
|
||||
contextWindowTokens: applicableLatest.contextWindowTokens,
|
||||
compressionEnabled: applicableLatest.compressionEnabled,
|
||||
source: applicableLatest.source,
|
||||
basis:
|
||||
applicableLatest.basis ??
|
||||
(applicableLatest.source === 'estimated' &&
|
||||
activeConversation.contextCompressionState
|
||||
? 'conversation'
|
||||
: 'model-call'),
|
||||
denominatorTokens,
|
||||
percentage
|
||||
}
|
||||
@@ -5707,9 +5769,6 @@ function App(): React.JSX.Element {
|
||||
activeConversation,
|
||||
activeRuntimeSelection,
|
||||
activeRuntimeSelectionKey,
|
||||
contextMetricsByConversation,
|
||||
input,
|
||||
isRunning,
|
||||
runtimeSettings
|
||||
])
|
||||
|
||||
@@ -6658,6 +6717,7 @@ function App(): React.JSX.Element {
|
||||
ariaLabel={t('composer.expertLabel')}
|
||||
className="composer-picker--expert"
|
||||
disabled={
|
||||
isRunning ||
|
||||
runtime?.capability === 'image-generation'
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={15} />}
|
||||
@@ -6670,6 +6730,7 @@ function App(): React.JSX.Element {
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t('composer.modeLabel')}
|
||||
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
|
||||
disabled={isRunning}
|
||||
icon={
|
||||
effectiveWorkMode === 'execute' ? (
|
||||
<ShieldCheck aria-hidden="true" size={15} />
|
||||
@@ -6979,7 +7040,7 @@ function App(): React.JSX.Element {
|
||||
title={
|
||||
composerContextMetrics.compressionEnabled
|
||||
? t('composer.context.compressionTrigger', {
|
||||
tokens: formatCompactContextTokens(
|
||||
tokens: formatCompactTokens(
|
||||
composerContextMetrics.effectiveTriggerTokens
|
||||
)
|
||||
})
|
||||
@@ -6988,33 +7049,44 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<span className="composer-context-meter__summary">
|
||||
{composerContextMetrics.denominatorTokens === undefined
|
||||
? t('composer.context.tokenCount', {
|
||||
used: formatCompactContextTokens(
|
||||
composerContextMetrics.estimatedInputTokens
|
||||
? t(
|
||||
composerContextMetrics.basis === 'conversation'
|
||||
? composerContextMetrics.compressionEnabled
|
||||
? 'composer.context.conversationThresholdUsage'
|
||||
: 'composer.context.conversationTokenCount'
|
||||
: composerContextMetrics.compressionEnabled
|
||||
? composerContextMetrics.source === 'provider'
|
||||
? 'composer.context.confirmedThresholdUsage'
|
||||
: 'composer.context.thresholdUsage'
|
||||
: composerContextMetrics.source === 'provider'
|
||||
? 'composer.context.confirmedTokenCount'
|
||||
: 'composer.context.tokenCount',
|
||||
{
|
||||
used: formatCompactTokens(
|
||||
composerContextMetrics.contextTokens
|
||||
),
|
||||
total: formatCompactTokens(
|
||||
composerContextMetrics.effectiveTriggerTokens
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
: composerContextMetrics.contextWindowTokens ===
|
||||
undefined
|
||||
? t('composer.context.thresholdUsage', {
|
||||
used: formatCompactContextTokens(
|
||||
composerContextMetrics.estimatedInputTokens
|
||||
),
|
||||
total: formatCompactContextTokens(
|
||||
composerContextMetrics.denominatorTokens
|
||||
),
|
||||
percentage:
|
||||
composerContextMetrics.percentage ?? 0
|
||||
})
|
||||
: t('composer.context.windowUsage', {
|
||||
used: formatCompactContextTokens(
|
||||
composerContextMetrics.estimatedInputTokens
|
||||
),
|
||||
total: formatCompactContextTokens(
|
||||
composerContextMetrics.denominatorTokens
|
||||
),
|
||||
percentage:
|
||||
composerContextMetrics.percentage ?? 0
|
||||
})}
|
||||
: t(
|
||||
composerContextMetrics.basis === 'conversation'
|
||||
? 'composer.context.conversationWindowUsage'
|
||||
: composerContextMetrics.source === 'provider'
|
||||
? 'composer.context.confirmedWindowUsage'
|
||||
: 'composer.context.windowUsage',
|
||||
{
|
||||
used: formatCompactTokens(
|
||||
composerContextMetrics.contextTokens
|
||||
),
|
||||
total: formatCompactTokens(
|
||||
composerContextMetrics.denominatorTokens
|
||||
),
|
||||
percentage:
|
||||
composerContextMetrics.percentage ?? 0
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{composerContextMetrics.denominatorTokens !== undefined && (
|
||||
<div
|
||||
@@ -7024,7 +7096,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={Math.min(
|
||||
composerContextMetrics.estimatedInputTokens,
|
||||
composerContextMetrics.contextTokens,
|
||||
composerContextMetrics.denominatorTokens
|
||||
)}
|
||||
className="composer-context-meter__track"
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('ChatTimeline', () => {
|
||||
expect(unchangedDetails).toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('places compression progress between the user and assistant messages', () => {
|
||||
it('keeps Agent and conversation compression markers below the assistant message', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'user-message',
|
||||
@@ -99,11 +99,21 @@ describe('ChatTimeline', () => {
|
||||
id: 'assistant-message',
|
||||
role: 'assistant',
|
||||
content: 'Answer',
|
||||
contextCompression: {
|
||||
state: 'completed',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
},
|
||||
contextCompressions: [
|
||||
{
|
||||
state: 'completed',
|
||||
scope: 'agent-run',
|
||||
estimatedBeforeTokens: 24_000,
|
||||
estimatedAfterTokens: 11_000,
|
||||
compressionCount: 2
|
||||
},
|
||||
{
|
||||
state: 'completed',
|
||||
scope: 'conversation',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
}
|
||||
],
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'complete'
|
||||
}
|
||||
@@ -128,11 +138,19 @@ describe('ChatTimeline', () => {
|
||||
)
|
||||
expect(children.map((element) => element.className)).toEqual([
|
||||
'message message--user',
|
||||
'message message--assistant',
|
||||
'context-compression-event context-compression-event--completed',
|
||||
'message message--assistant'
|
||||
'context-compression-event context-compression-event--completed'
|
||||
])
|
||||
expect(
|
||||
screen.getByRole('status')
|
||||
).toHaveTextContent('已压缩较早对话 · ≈22.0K → ≈9.0K')
|
||||
screen.getByText(
|
||||
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,12 +18,15 @@ import type {
|
||||
import type {
|
||||
AssistantArtifact,
|
||||
ConversationAttachment,
|
||||
ConversationContextCompressionMarker,
|
||||
ConversationMessage,
|
||||
ConversationMessageBlock,
|
||||
ConversationToolActivity
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { AgentQuestionCard } from './AgentQuestionCard'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||
import { formatCompactTokens } from './token-format'
|
||||
|
||||
export type ToolActivity = ConversationToolActivity
|
||||
|
||||
@@ -51,11 +54,8 @@ export type Message = {
|
||||
createdAt: number
|
||||
state: 'streaming' | 'complete' | 'error'
|
||||
status?: string
|
||||
contextCompression?: {
|
||||
state: 'compressing' | 'completed' | 'failed'
|
||||
estimatedBeforeTokens: number
|
||||
estimatedAfterTokens?: number
|
||||
}
|
||||
contextCompression?: ConversationMessage['contextCompression']
|
||||
contextCompressions?: ConversationMessage['contextCompressions']
|
||||
tools?: ToolActivity[]
|
||||
subagents?: SubagentActivity[]
|
||||
approval?: {
|
||||
@@ -79,14 +79,6 @@ 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'
|
||||
@@ -281,43 +273,42 @@ function ChatMessageRowView({
|
||||
retryContent
|
||||
}: ChatMessageRowProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const compressionMarkers =
|
||||
message.contextCompressions ??
|
||||
(message.contextCompression ? [message.contextCompression] : [])
|
||||
const compressionLabel = (
|
||||
compression: ConversationContextCompressionMarker
|
||||
): string =>
|
||||
compression.state === 'compressing'
|
||||
? compression.scope === 'agent-run'
|
||||
? t('chat.contextCompression.agentCompressing')
|
||||
: t('chat.contextCompression.compressing')
|
||||
: compression.state === 'completed' &&
|
||||
compression.estimatedAfterTokens !== undefined
|
||||
? compression.scope === 'agent-run'
|
||||
? t('chat.contextCompression.agentCompleted', {
|
||||
before: formatCompactTokens(
|
||||
compression.estimatedBeforeTokens
|
||||
),
|
||||
after: formatCompactTokens(
|
||||
compression.estimatedAfterTokens
|
||||
),
|
||||
count: compression.compressionCount ?? 1
|
||||
})
|
||||
: t('chat.contextCompression.completed', {
|
||||
before: formatCompactTokens(
|
||||
compression.estimatedBeforeTokens
|
||||
),
|
||||
after: formatCompactTokens(
|
||||
compression.estimatedAfterTokens
|
||||
)
|
||||
})
|
||||
: compression.scope === 'agent-run'
|
||||
? t('chat.contextCompression.agentFailed')
|
||||
: t('chat.contextCompression.failed')
|
||||
|
||||
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)}
|
||||
@@ -803,6 +794,29 @@ function ChatMessageRowView({
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
{message.role === 'assistant' &&
|
||||
compressionMarkers.map((compression, index) => (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={`context-compression-event context-compression-event--${compression.state}`}
|
||||
key={`${compression.scope ?? 'conversation'}:${index}`}
|
||||
role="status"
|
||||
>
|
||||
<span className="context-compression-event__line" />
|
||||
<span className="context-compression-event__label">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
compression.state === 'compressing'
|
||||
? 'message__status-dot message__status-dot--active'
|
||||
: 'message__status-dot'
|
||||
}
|
||||
/>
|
||||
{compressionLabel(compression)}
|
||||
</span>
|
||||
<span className="context-compression-event__line" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,8 +191,12 @@ export const app = {
|
||||
contextCompression: {
|
||||
compressing: 'Compressing earlier conversation…',
|
||||
completed:
|
||||
'Earlier conversation compressed · ≈{{before}} → ≈{{after}}',
|
||||
failed: 'Earlier conversation compression failed'
|
||||
'Earlier conversation compressed (estimated) · ≈{{before}} → ≈{{after}}',
|
||||
failed: 'Earlier conversation compression failed',
|
||||
agentCompressing: 'Compacting Agent execution context…',
|
||||
agentCompleted:
|
||||
'Agent context compacted {{count}} time(s) (estimated) · ≈{{before}} → ≈{{after}}',
|
||||
agentFailed: 'Agent execution context compression failed'
|
||||
},
|
||||
sources: 'Sources: {{sources}}',
|
||||
citations: {
|
||||
@@ -322,10 +326,22 @@ export const app = {
|
||||
sendTitle: 'Send message',
|
||||
shortcut: 'Quick access: ',
|
||||
context: {
|
||||
tokenCount: 'Context ≈{{used}}',
|
||||
windowUsage: 'Context ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
confirmedTokenCount: 'Latest call {{used}}',
|
||||
tokenCount: 'Estimated latest call ≈{{used}}',
|
||||
confirmedWindowUsage:
|
||||
'Latest call {{used}} / {{total}} · {{percentage}}%',
|
||||
windowUsage:
|
||||
'Estimated latest call ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
confirmedThresholdUsage:
|
||||
'Latest call {{used}} · Compression at {{total}}',
|
||||
thresholdUsage:
|
||||
'Compression threshold ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
'Estimated latest call ≈{{used}} · Compression at {{total}}',
|
||||
conversationTokenCount:
|
||||
'Estimated compressed conversation ≈{{used}}',
|
||||
conversationWindowUsage:
|
||||
'Estimated compressed conversation ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
conversationThresholdUsage:
|
||||
'Estimated compressed conversation ≈{{used}} · Compression at {{total}}',
|
||||
progressLabel: 'Current context usage',
|
||||
compressionTrigger: 'Automatic compression at ≈{{tokens}}'
|
||||
},
|
||||
|
||||
@@ -185,8 +185,13 @@ export const app = {
|
||||
},
|
||||
contextCompression: {
|
||||
compressing: '正在压缩较早对话…',
|
||||
completed: '已压缩较早对话 · ≈{{before}} → ≈{{after}}',
|
||||
failed: '较早对话压缩失败'
|
||||
completed:
|
||||
'已压缩较早对话(估算) · ≈{{before}} → ≈{{after}}',
|
||||
failed: '较早对话压缩失败',
|
||||
agentCompressing: '正在整理 Agent 执行上下文…',
|
||||
agentCompleted:
|
||||
'Agent 执行期间已压缩上下文 {{count}} 次(估算) · ≈{{before}} → ≈{{after}}',
|
||||
agentFailed: 'Agent 执行上下文压缩失败'
|
||||
},
|
||||
sources: '来源:{{sources}}',
|
||||
citations: {
|
||||
@@ -313,10 +318,21 @@ export const app = {
|
||||
sendTitle: '发送消息',
|
||||
shortcut: '快捷唤起:',
|
||||
context: {
|
||||
tokenCount: '上下文 ≈{{used}}',
|
||||
windowUsage: '上下文 ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
confirmedTokenCount: '本次调用 {{used}}',
|
||||
tokenCount: '本次调用估算 ≈{{used}}',
|
||||
confirmedWindowUsage:
|
||||
'本次调用 {{used}} / {{total}} · {{percentage}}%',
|
||||
windowUsage:
|
||||
'本次调用估算 ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
confirmedThresholdUsage:
|
||||
'本次调用 {{used}} · 压缩线 {{total}}',
|
||||
thresholdUsage:
|
||||
'距压缩阈值 ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
'本次调用估算 ≈{{used}} · 压缩线 {{total}}',
|
||||
conversationTokenCount: '压缩后对话估算 ≈{{used}}',
|
||||
conversationWindowUsage:
|
||||
'压缩后对话估算 ≈{{used}} / {{total}} · {{percentage}}%',
|
||||
conversationThresholdUsage:
|
||||
'压缩后对话估算 ≈{{used}} · 压缩线 {{total}}',
|
||||
progressLabel: '当前上下文使用量',
|
||||
compressionTrigger: '自动压缩线:≈{{tokens}}'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export 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`
|
||||
}
|
||||
Reference in New Issue
Block a user