fix: allow editing context compression limits
This commit is contained in:
@@ -588,6 +588,87 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows editing context compression budgets through empty and single-digit states', async () => {
|
||||||
|
render(
|
||||||
|
<SettingsPanel
|
||||||
|
{...heartbeatSettingsProps}
|
||||||
|
initialCategory="context-control"
|
||||||
|
open
|
||||||
|
onClearLocalData={vi.fn(async () => {})}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole('switch', {
|
||||||
|
name: '自动压缩较早的对话'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const trigger = screen.getByLabelText('压缩触发阈值')
|
||||||
|
const recent = screen.getByLabelText('最近原文预算')
|
||||||
|
|
||||||
|
fireEvent.change(trigger, { target: { value: '' } })
|
||||||
|
expect(trigger).toHaveValue(null)
|
||||||
|
fireEvent.blur(trigger)
|
||||||
|
expect(trigger).toHaveValue(200)
|
||||||
|
fireEvent.change(trigger, { target: { value: '8' } })
|
||||||
|
expect(trigger).toHaveValue(8)
|
||||||
|
expect(recent).toHaveValue(32)
|
||||||
|
expect(recent).toHaveAttribute('max', '7')
|
||||||
|
|
||||||
|
fireEvent.change(recent, { target: { value: '4' } })
|
||||||
|
expect(recent).toHaveValue(4)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(updateRuntime).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contextCompression: expect.objectContaining({
|
||||||
|
triggerTokens: 8_000,
|
||||||
|
recentRawTokens: 4_000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes context compression limits only after editing finishes', async () => {
|
||||||
|
render(
|
||||||
|
<SettingsPanel
|
||||||
|
{...heartbeatSettingsProps}
|
||||||
|
initialCategory="context-control"
|
||||||
|
open
|
||||||
|
onClearLocalData={vi.fn(async () => {})}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole('switch', {
|
||||||
|
name: '自动压缩较早的对话'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const trigger = screen.getByLabelText('压缩触发阈值')
|
||||||
|
const recent = screen.getByLabelText('最近原文预算')
|
||||||
|
|
||||||
|
fireEvent.change(trigger, { target: { value: '2' } })
|
||||||
|
expect(trigger).toHaveValue(2)
|
||||||
|
expect(recent).toHaveAttribute('max', '7')
|
||||||
|
fireEvent.blur(trigger)
|
||||||
|
expect(trigger).toHaveValue(8)
|
||||||
|
expect(recent).toHaveValue(7)
|
||||||
|
|
||||||
|
fireEvent.change(trigger, { target: { value: '1200' } })
|
||||||
|
fireEvent.blur(trigger)
|
||||||
|
expect(trigger).toHaveValue(1000)
|
||||||
|
|
||||||
|
fireEvent.change(recent, { target: { value: '999' } })
|
||||||
|
fireEvent.blur(recent)
|
||||||
|
expect(recent).toHaveValue(256)
|
||||||
|
})
|
||||||
|
|
||||||
it('stores an optional context window on direct text models', async () => {
|
it('stores an optional context window on direct text models', async () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
|||||||
@@ -73,6 +73,86 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settingsTabs = settingsCategoryList.map(({ id }) => id)
|
const settingsTabs = settingsCategoryList.map(({ id }) => id)
|
||||||
|
const contextCompressionTokenScale = 1_000
|
||||||
|
const minimumContextCompressionTriggerThousands = 8
|
||||||
|
const maximumContextCompressionTriggerThousands = 1_000
|
||||||
|
const minimumContextCompressionRecentRawThousands = 4
|
||||||
|
const maximumContextCompressionRecentRawThousands = 256
|
||||||
|
|
||||||
|
type ContextCompressionTokenDrafts = {
|
||||||
|
trigger: string
|
||||||
|
recentRaw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextCompressionTokenDrafts(
|
||||||
|
settings: ContextCompressionSettings
|
||||||
|
): ContextCompressionTokenDrafts {
|
||||||
|
return {
|
||||||
|
trigger: String(
|
||||||
|
settings.triggerTokens / contextCompressionTokenScale
|
||||||
|
),
|
||||||
|
recentRaw: String(
|
||||||
|
settings.recentRawTokens / contextCompressionTokenScale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContextCompressionTokenDraft(
|
||||||
|
value: string
|
||||||
|
): number | undefined {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed)
|
||||||
|
? Math.round(parsed * contextCompressionTokenScale)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function maximumContextCompressionRecentRawThousandsFor(
|
||||||
|
triggerTokens: number
|
||||||
|
): number {
|
||||||
|
return Math.max(
|
||||||
|
minimumContextCompressionRecentRawThousands,
|
||||||
|
Math.min(
|
||||||
|
maximumContextCompressionRecentRawThousands,
|
||||||
|
triggerTokens / contextCompressionTokenScale - 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContextCompressionTokenDrafts(
|
||||||
|
settings: ContextCompressionSettings,
|
||||||
|
drafts: ContextCompressionTokenDrafts
|
||||||
|
): ContextCompressionSettings {
|
||||||
|
const triggerTokens = Math.min(
|
||||||
|
maximumContextCompressionTriggerThousands *
|
||||||
|
contextCompressionTokenScale,
|
||||||
|
Math.max(
|
||||||
|
minimumContextCompressionTriggerThousands *
|
||||||
|
contextCompressionTokenScale,
|
||||||
|
parseContextCompressionTokenDraft(drafts.trigger) ??
|
||||||
|
settings.triggerTokens
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const maximumRecentRawTokens =
|
||||||
|
maximumContextCompressionRecentRawThousandsFor(triggerTokens) *
|
||||||
|
contextCompressionTokenScale
|
||||||
|
const recentRawTokens = Math.min(
|
||||||
|
maximumRecentRawTokens,
|
||||||
|
Math.max(
|
||||||
|
minimumContextCompressionRecentRawThousands *
|
||||||
|
contextCompressionTokenScale,
|
||||||
|
parseContextCompressionTokenDraft(drafts.recentRaw) ??
|
||||||
|
settings.recentRawTokens
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
triggerTokens,
|
||||||
|
recentRawTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type SettingsPanelProps = {
|
type SettingsPanelProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -534,6 +614,14 @@ export function SettingsPanel({
|
|||||||
useState<ContextCompressionSettings>(
|
useState<ContextCompressionSettings>(
|
||||||
defaultContextCompressionSettings
|
defaultContextCompressionSettings
|
||||||
)
|
)
|
||||||
|
const [
|
||||||
|
contextCompressionTokenInput,
|
||||||
|
setContextCompressionTokenInput
|
||||||
|
] = useState<ContextCompressionTokenDrafts>(() =>
|
||||||
|
contextCompressionTokenDrafts(
|
||||||
|
defaultContextCompressionSettings
|
||||||
|
)
|
||||||
|
)
|
||||||
const modelProfileDisplayName = (
|
const modelProfileDisplayName = (
|
||||||
profile: Pick<ModelProfileDraft, 'id' | 'name'>
|
profile: Pick<ModelProfileDraft, 'id' | 'name'>
|
||||||
): string =>
|
): string =>
|
||||||
@@ -608,6 +696,12 @@ export function SettingsPanel({
|
|||||||
},
|
},
|
||||||
preserveSelectedProfile
|
preserveSelectedProfile
|
||||||
)
|
)
|
||||||
|
setContextCompressionTokenInput(
|
||||||
|
contextCompressionTokenDrafts(
|
||||||
|
value.contextCompression ??
|
||||||
|
defaultContextCompressionSettings
|
||||||
|
)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
@@ -735,6 +829,22 @@ export function SettingsPanel({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedContextCompression =
|
||||||
|
normalizeContextCompressionTokenDrafts(
|
||||||
|
contextCompression,
|
||||||
|
contextCompressionTokenInput
|
||||||
|
)
|
||||||
|
const commitContextCompressionTokenInput =
|
||||||
|
(): ContextCompressionSettings => {
|
||||||
|
setContextCompression(normalizedContextCompression)
|
||||||
|
setContextCompressionTokenInput(
|
||||||
|
contextCompressionTokenDrafts(
|
||||||
|
normalizedContextCompression
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return normalizedContextCompression
|
||||||
|
}
|
||||||
|
|
||||||
const close = (): void => {
|
const close = (): void => {
|
||||||
setModelProfiles((profiles) =>
|
setModelProfiles((profiles) =>
|
||||||
profiles.map((profile) => ({
|
profiles.map((profile) => ({
|
||||||
@@ -760,6 +870,8 @@ export function SettingsPanel({
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
try {
|
try {
|
||||||
|
const contextCompressionInput =
|
||||||
|
commitContextCompressionTokenInput()
|
||||||
const defaultProfile =
|
const defaultProfile =
|
||||||
modelProfiles.find(
|
modelProfiles.find(
|
||||||
(profile) => profile.id === defaultModelProfileId
|
(profile) => profile.id === defaultModelProfileId
|
||||||
@@ -857,7 +969,7 @@ export function SettingsPanel({
|
|||||||
continueModelSource,
|
continueModelSource,
|
||||||
deepseekHarnessModelSource:
|
deepseekHarnessModelSource:
|
||||||
normalizedDeepseekHarnessModelSource,
|
normalizedDeepseekHarnessModelSource,
|
||||||
contextCompression,
|
contextCompression: contextCompressionInput,
|
||||||
toolApproval,
|
toolApproval,
|
||||||
subagentSmartRoutingEnabled
|
subagentSmartRoutingEnabled
|
||||||
})
|
})
|
||||||
@@ -2824,34 +2936,25 @@ export function SettingsPanel({
|
|||||||
aria-label={t('contextControl.triggerTokens')}
|
aria-label={t('contextControl.triggerTokens')}
|
||||||
disabled={!contextCompression.enabled}
|
disabled={!contextCompression.enabled}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
max={1_000}
|
max={maximumContextCompressionTriggerThousands}
|
||||||
min={8}
|
min={minimumContextCompressionTriggerThousands}
|
||||||
onChange={(event) => {
|
onBlur={commitContextCompressionTokenInput}
|
||||||
const value = event.target.valueAsNumber
|
onChange={(event) =>
|
||||||
if (Number.isFinite(value)) {
|
setContextCompressionTokenInput((current) => ({
|
||||||
setContextCompression((current) => ({
|
|
||||||
...current,
|
...current,
|
||||||
triggerTokens: Math.round(value * 1_000),
|
trigger: event.target.value
|
||||||
recentRawTokens: Math.min(
|
|
||||||
current.recentRawTokens,
|
|
||||||
Math.max(
|
|
||||||
4_000,
|
|
||||||
Math.round(value * 1_000) - 1_000
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
|
step={1}
|
||||||
type="number"
|
type="number"
|
||||||
value={contextCompression.triggerTokens / 1_000}
|
value={contextCompressionTokenInput.trigger}
|
||||||
/>
|
/>
|
||||||
<small>
|
<small>
|
||||||
{t('contextControl.triggerTokensDescription', {
|
{t('contextControl.triggerTokensDescription', {
|
||||||
tokens:
|
tokens:
|
||||||
contextCompression.triggerTokens.toLocaleString(
|
normalizedContextCompression.triggerTokens
|
||||||
i18n.language
|
.toLocaleString(i18n.language)
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
@@ -2861,33 +2964,27 @@ export function SettingsPanel({
|
|||||||
aria-label={t('contextControl.recentRawTokens')}
|
aria-label={t('contextControl.recentRawTokens')}
|
||||||
disabled={!contextCompression.enabled}
|
disabled={!contextCompression.enabled}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
max={Math.min(
|
max={maximumContextCompressionRecentRawThousandsFor(
|
||||||
256,
|
normalizedContextCompression.triggerTokens
|
||||||
contextCompression.triggerTokens / 1_000 - 1
|
|
||||||
)}
|
)}
|
||||||
min={4}
|
min={minimumContextCompressionRecentRawThousands}
|
||||||
onChange={(event) => {
|
onBlur={commitContextCompressionTokenInput}
|
||||||
const value = event.target.valueAsNumber
|
onChange={(event) =>
|
||||||
if (Number.isFinite(value)) {
|
setContextCompressionTokenInput((current) => ({
|
||||||
setContextCompression((current) => ({
|
|
||||||
...current,
|
...current,
|
||||||
recentRawTokens: Math.min(
|
recentRaw: event.target.value
|
||||||
Math.round(value * 1_000),
|
|
||||||
current.triggerTokens - 1_000
|
|
||||||
)
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
|
step={1}
|
||||||
type="number"
|
type="number"
|
||||||
value={contextCompression.recentRawTokens / 1_000}
|
value={contextCompressionTokenInput.recentRaw}
|
||||||
/>
|
/>
|
||||||
<small>
|
<small>
|
||||||
{t('contextControl.recentRawTokensDescription', {
|
{t('contextControl.recentRawTokensDescription', {
|
||||||
tokens:
|
tokens:
|
||||||
contextCompression.recentRawTokens.toLocaleString(
|
normalizedContextCompression.recentRawTokens
|
||||||
i18n.language
|
.toLocaleString(i18n.language)
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user