feat: add direct model context compression

This commit is contained in:
mesalogo
2026-08-15 18:06:04 +08:00
parent 5f1ae7727a
commit afa7719ff2
15 changed files with 1572 additions and 167 deletions
+1 -1
View File
@@ -4597,7 +4597,7 @@ function App(): React.JSX.Element {
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-30)
.slice(-500)
.map((message) => ({
role: message.role,
content: message.content
+83
View File
@@ -538,6 +538,89 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('configures direct model context compression with explicit token budgets', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="context-control"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
await screen.findByRole('heading', {
level: 2,
name: '上下文控制'
})
).toBeInTheDocument()
expect(
screen.getByText('直连模型的历史压缩与原文保留')
).toBeInTheDocument()
const enabled = screen.getByRole('switch', {
name: '自动压缩较早的对话'
})
const trigger = screen.getByLabelText('压缩触发阈值')
const recent = screen.getByLabelText('最近原文预算')
expect(enabled).not.toBeChecked()
expect(trigger).toHaveValue(200)
expect(trigger).toBeDisabled()
expect(recent).toHaveValue(32)
fireEvent.click(enabled)
fireEvent.change(trigger, { target: { value: '240' } })
fireEvent.change(recent, { target: { value: '40' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
contextCompression: expect.objectContaining({
enabled: true,
triggerTokens: 240_000,
recentRawTokens: 40_000,
modelSource: { kind: 'current' }
})
})
)
)
})
it('stores an optional context window on direct text models', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="model"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const contextWindow = await screen.findByLabelText(
'上下文上限(可选)'
)
expect(contextWindow).toHaveValue(null)
fireEvent.change(contextWindow, { target: { value: '256' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
id: modelProfileId,
contextWindowTokens: 256_000
})
])
})
)
)
})
it('applies and persists an English interface language immediately', async () => {
render(
<UiLocaleProvider initialPreference="zh-CN">
+270 -13
View File
@@ -21,6 +21,7 @@ import type {
} from '../../shared/assistant-contracts'
import type {
AgentRuntimeDetection,
ContextCompressionSettings,
RuntimeConfigActionInput,
RuntimeFileSelectionKind,
RuntimeSettings,
@@ -28,6 +29,7 @@ import type {
RuntimeModelSource
} from '../../shared/contracts'
import {
defaultContextCompressionSettings,
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings,
isAgentRuntimeModelProtocol,
@@ -189,6 +191,7 @@ function hydrateRuntimeSettings(
workspacePath: (value: string) => void
toolApproval: (value: RuntimeSettingsInput['toolApproval']) => void
subagentSmartRoutingEnabled: (value: boolean) => void
contextCompression: (value: ContextCompressionSettings) => void
},
preserveSelectedProfile = false
): void {
@@ -249,6 +252,9 @@ function hydrateRuntimeSettings(
setters.subagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
setters.contextCompression(
value.contextCompression ?? defaultContextCompressionSettings
)
}
type RuntimeConfigCardProps = {
@@ -524,6 +530,10 @@ export function SettingsPanel({
subagentSmartRoutingEnabled,
setSubagentSmartRoutingEnabled
] = useState(false)
const [contextCompression, setContextCompression] =
useState<ContextCompressionSettings>(
defaultContextCompressionSettings
)
const modelProfileDisplayName = (
profile: Pick<ModelProfileDraft, 'id' | 'name'>
): string =>
@@ -593,7 +603,8 @@ export function SettingsPanel({
clearKnowledgeRerankApiKey: setClearKnowledgeRerankApiKey,
workspacePath: setWorkspacePath,
toolApproval: setToolApproval,
subagentSmartRoutingEnabled: setSubagentSmartRoutingEnabled
subagentSmartRoutingEnabled: setSubagentSmartRoutingEnabled,
contextCompression: setContextCompression
},
preserveSelectedProfile
)
@@ -602,6 +613,7 @@ export function SettingsPanel({
)
const configurationTab =
activeTab === 'model' ||
activeTab === 'context-control' ||
activeTab === 'runtime' ||
activeTab === 'security' ||
activeTab === 'roles'
@@ -778,6 +790,7 @@ export function SettingsPanel({
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
@@ -844,6 +857,7 @@ export function SettingsPanel({
continueModelSource,
deepseekHarnessModelSource:
normalizedDeepseekHarnessModelSource,
contextCompression,
toolApproval,
subagentSmartRoutingEnabled
})
@@ -1125,6 +1139,15 @@ export function SettingsPanel({
: { kind: 'platform' }
)
}
if (
contextCompression.modelSource.kind === 'profile' &&
contextCompression.modelSource.profileId === id
) {
setContextCompression((current) => ({
...current,
modelSource: { kind: 'current' }
}))
}
}
const selectDefaultModelProfile = (
@@ -2313,6 +2336,18 @@ export function SettingsPanel({
: { kind: 'platform' }
)
}
if (
!isAgentRuntimeModelProtocol(protocol) &&
contextCompression.modelSource.kind ===
'profile' &&
contextCompression.modelSource.profileId ===
profile.id
) {
setContextCompression((current) => ({
...current,
modelSource: { kind: 'current' }
}))
}
}
}
value={profile.protocol}
@@ -2358,24 +2393,53 @@ export function SettingsPanel({
</select>
</label>
{isAgentRuntimeModelProtocol(profile.protocol) && (
<div className="field">
<label className="toggle-row">
<>
<div className="field">
<label className="toggle-row">
<input
checked={profile.supportsImageInput}
onChange={(event) =>
updateModelProfile(profile.id, {
supportsImageInput: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('model.profile.supportsImageInput')}</span>
</label>
<small>
{t('model.profile.supportsImageInputDescription')}
</small>
</div>
<label className="field">
<span>{t('model.profile.contextWindow')}</span>
<input
checked={profile.supportsImageInput}
onChange={(event) =>
aria-label={t('model.profile.contextWindow')}
inputMode="numeric"
max={10_000}
min={8}
onChange={(event) => {
const value = event.target.valueAsNumber
updateModelProfile(profile.id, {
supportsImageInput: event.target.checked
contextWindowTokens: Number.isFinite(value)
? Math.round(value * 1_000)
: undefined
})
}}
placeholder="200"
type="number"
value={
profile.contextWindowTokens === undefined
? ''
: profile.contextWindowTokens / 1_000
}
role="switch"
type="checkbox"
/>
<span>{t('model.profile.supportsImageInput')}</span>
<small>
{t('model.profile.contextWindowDescription')}
</small>
</label>
<small>
{t('model.profile.supportsImageInputDescription')}
</small>
</div>
</>
)}
{profile.protocol ===
'openai-images-generations' && (
@@ -2731,6 +2795,199 @@ export function SettingsPanel({
</>
)}
{activeTab === 'context-control' && (
<>
<div className="settings-section">
<div className="field">
<label className="toggle-row">
<input
checked={contextCompression.enabled}
onChange={(event) =>
setContextCompression((current) => ({
...current,
enabled: event.target.checked
}))
}
role="switch"
type="checkbox"
/>
<span>{t('contextControl.enabled')}</span>
</label>
<small>{t('contextControl.enabledDescription')}</small>
<small>{t('contextControl.usageNotice')}</small>
</div>
</div>
<div className="settings-section">
<label className="field">
<span>{t('contextControl.triggerTokens')}</span>
<input
aria-label={t('contextControl.triggerTokens')}
disabled={!contextCompression.enabled}
inputMode="numeric"
max={1_000}
min={8}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value)) {
setContextCompression((current) => ({
...current,
triggerTokens: Math.round(value * 1_000),
recentRawTokens: Math.min(
current.recentRawTokens,
Math.max(
4_000,
Math.round(value * 1_000) - 1_000
)
)
}))
}
}}
required
type="number"
value={contextCompression.triggerTokens / 1_000}
/>
<small>
{t('contextControl.triggerTokensDescription', {
tokens:
contextCompression.triggerTokens.toLocaleString(
i18n.language
)
})}
</small>
</label>
<label className="field">
<span>{t('contextControl.recentRawTokens')}</span>
<input
aria-label={t('contextControl.recentRawTokens')}
disabled={!contextCompression.enabled}
inputMode="numeric"
max={Math.min(
256,
contextCompression.triggerTokens / 1_000 - 1
)}
min={4}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value)) {
setContextCompression((current) => ({
...current,
recentRawTokens: Math.min(
Math.round(value * 1_000),
current.triggerTokens - 1_000
)
}))
}
}}
required
type="number"
value={contextCompression.recentRawTokens / 1_000}
/>
<small>
{t('contextControl.recentRawTokensDescription', {
tokens:
contextCompression.recentRawTokens.toLocaleString(
i18n.language
)
})}
</small>
</label>
<label className="field">
<span>{t('contextControl.summaryModel')}</span>
<select
aria-label={t('contextControl.summaryModel')}
disabled={!contextCompression.enabled}
onChange={(event) =>
setContextCompression((current) => ({
...current,
modelSource:
event.target.value === 'current'
? { kind: 'current' }
: {
kind: 'profile',
profileId: event.target.value
}
}))
}
value={
contextCompression.modelSource.kind === 'current'
? 'current'
: contextCompression.modelSource.profileId
}
>
<option value="current">
{t('contextControl.currentModel')}
</option>
{modelProfiles
.filter((profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
)
.map((profile) => (
<option key={profile.id} value={profile.id}>
{modelProfileDisplayName(profile)} ·{' '}
{profile.modelName}
</option>
))}
</select>
<small>{t('contextControl.summaryModelDescription')}</small>
</label>
<p className="settings-panel__description">
{t('contextControl.fixedTarget')}
</p>
</div>
<div className="runtime-note">
<p>{t('contextControl.modelLimits')}</p>
<button
className="secondary-button"
onClick={() => {
setModelType('llm')
setActiveTab('model')
}}
type="button"
>
{t('contextControl.manageModelLimits')}
</button>
</div>
<details className="settings-section">
<summary>{t('contextControl.advanced')}</summary>
<label className="field">
<span>{t('contextControl.summaryPrompt')}</span>
<textarea
disabled={!contextCompression.enabled}
onChange={(event) =>
setContextCompression((current) => ({
...current,
summaryPrompt: event.target.value
}))
}
rows={7}
value={contextCompression.summaryPrompt}
/>
<small>
{t('contextControl.summaryPromptDescription')}
</small>
</label>
<button
className="secondary-button"
disabled={
!contextCompression.enabled ||
contextCompression.summaryPrompt ===
defaultContextCompressionSettings.summaryPrompt
}
onClick={() =>
setContextCompression((current) => ({
...current,
summaryPrompt:
defaultContextCompressionSettings.summaryPrompt
}))
}
type="button"
>
{t('contextControl.restoreDefaultPrompt')}
</button>
</details>
</>
)}
{activeTab === 'document-parsing' && (
<DocumentParsingSettingsSection onNotify={onNotify} />
)}
@@ -27,6 +27,13 @@ export const settings = {
'LLMs, embedding and rerank models, and credentials',
description: 'LLMs, embedding and rerank models, and credentials'
},
contextControl: {
label: 'Context control',
navigationDescription:
'Direct model history compression and recent raw context',
description:
'Manage compression thresholds, recent raw context, and the summary model for direct models'
},
documentParsing: {
label: 'Document parsing',
navigationDescription: 'Attachments, knowledge, and local OCR',
@@ -486,6 +493,9 @@ export const settings = {
supportsImageInput: 'Supports image input',
supportsImageInputDescription:
'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.',
imageQuality: 'Image quality',
imageQualityAriaLabel: 'Image quality for {{name}}',
quality: {
@@ -539,6 +549,32 @@ export const settings = {
'Only retrieval queries and candidate knowledge chunks are sent to this endpoint. The API Key is encrypted in secure system storage. If reranking fails, the original retrieval order is preserved.'
}
},
contextControl: {
enabled: 'Automatically compress earlier conversation',
enabledDescription:
'Applies only to direct text models. GoodBuddy generates a summary at the threshold without deleting the original chat history.',
usageNotice: 'Generating a summary uses additional model tokens.',
triggerTokens: 'Compression threshold',
triggerTokensDescription:
'Prepare direct model context at approximately {{tokens}} tokens.',
recentRawTokens: 'Recent raw context budget',
recentRawTokensDescription:
'After compression, preserve complete recent turns within approximately {{tokens}} tokens.',
summaryModel: 'Summary model',
currentModel: 'Direct model used by the current conversation (recommended)',
summaryModelDescription:
'Image generation connections cannot summarize. If a selected connection is unavailable, compression stops and keeps the original input.',
fixedTarget:
'Earlier conversation is compressed to an approximately 8K-token summary. The current request is always preserved in full.',
modelLimits:
'Optional context windows are configured per direct model under Model connections. When set, GoodBuddy compresses before reaching that model limit.',
manageModelLimits: 'Manage model context windows',
advanced: 'Advanced settings',
summaryPrompt: 'Summary prompt',
summaryPromptDescription:
'This prompt is sent as a trusted summary instruction. Conversation content is always treated as untrusted historical data.',
restoreDefaultPrompt: 'Restore default prompt'
},
security: {
toolPolicy: {
label: 'Direct model tool security policy',
@@ -22,6 +22,12 @@ export const settings = {
navigationDescription: 'LLM、向量、重排模型与凭据',
description: 'LLM、向量、重排模型与凭据'
},
contextControl: {
label: '上下文控制',
navigationDescription: '直连模型的历史压缩与原文保留',
description:
'管理直连模型的上下文压缩阈值、最近原文预算和摘要模型'
},
documentParsing: {
label: '文档解析',
navigationDescription: '附件、知识库与本地 OCR',
@@ -444,6 +450,9 @@ export const settings = {
supportsImageInput: '支持图像输入',
supportsImageInputDescription:
'启用后,GoodBuddy 可将图片上下文发送给此模型连接。',
contextWindow: '上下文上限(可选)',
contextWindowDescription:
'以 K tokens 填写。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
imageQuality: '图片质量',
imageQualityAriaLabel: '图片质量 {{name}}',
quality: {
@@ -489,6 +498,32 @@ export const settings = {
'仅向所填接口发送检索查询和候选知识片段。API Key 由系统安全存储加密;重排服务失败时保留原始检索排序。'
}
},
contextControl: {
enabled: '自动压缩较早的对话',
enabledDescription:
'仅对直连文本模型生效。达到阈值后生成摘要,原始聊天记录不会被删除。',
usageNotice: '生成摘要会产生额外的模型用量。',
triggerTokens: '压缩触发阈值',
triggerTokensDescription:
'达到约 {{tokens}} tokens 时开始整理直连模型的对话上下文。',
recentRawTokens: '最近原文预算',
recentRawTokensDescription:
'压缩后尽量保留最近 {{tokens}} tokens 的完整问答原文。',
summaryModel: '摘要模型',
currentModel: '当前对话使用的直连模型(推荐)',
summaryModelDescription:
'图像生成连接不能用于摘要。指定连接不可用时,本次压缩会停止并保留原始输入。',
fixedTarget:
'较早的对话将压缩为约 8K tokens 的摘要;当前问题始终完整保留。',
modelLimits:
'各直连模型的可选上下文上限在“模型连接”中配置。已填写时,GoodBuddy 会在模型上限前提前触发压缩。',
manageModelLimits: '管理模型上下文上限',
advanced: '高级设置',
summaryPrompt: '摘要提示词',
summaryPromptDescription:
'提示词作为受信任的摘要指令发送;对话内容始终按不可信历史数据处理。',
restoreDefaultPrompt: '恢复默认提示词'
},
security: {
toolPolicy: {
label: '直连模型工具安全策略',
+4
View File
@@ -13,6 +13,10 @@ export const settingsCategoryList = [
id: 'model',
translationKey: 'model'
},
{
id: 'context-control',
translationKey: 'contextControl'
},
{
id: 'document-parsing',
translationKey: 'documentParsing'