From cddfa89091ced6e85459a56e7a0c91029d0aa65d Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 29 Aug 2026 18:11:19 +0200 Subject: [PATCH] ui: allow a dedicated model for conversation compaction An opt-in setting reveals a model selector whose options come from the live router model list. The chosen model writes the summary and is loaded on demand when it is not resident, while the default path keeps resolving to the conversation's own model. --- .../app/models/ModelsSelectorList.svelte | 76 +++++++++++++++++++ .../SettingsChat/SettingsChatFields.svelte | 26 ++++++- .../SettingsChatModelSelector.svelte | 74 ++++++++++++++++++ .../src/lib/components/app/settings/index.ts | 7 ++ .../src/lib/constants/compaction.constants.ts | 3 + .../lib/constants/settings-keys.constants.ts | 2 + .../src/lib/constants/settings.constants.ts | 15 ++++ tools/ui/src/lib/enums/settings.enums.ts | 1 + tools/ui/src/lib/stores/chat/index.svelte.ts | 20 ++++- tools/ui/src/lib/utils/compaction.ts | 18 ++++- tools/ui/src/lib/utils/index.ts | 7 +- tools/ui/tests/unit/compaction.test.ts | 21 ++++- 12 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte create mode 100644 tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatModelSelector.svelte diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte new file mode 100644 index 0000000000..3d413f5d2b --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte @@ -0,0 +1,76 @@ + + +{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)} + {@const { option } = item} + {@const isSelected = currentModel === option.model || activeId === option.id} + {@const isFav = modelsStore.favoriteModelIds.has(option.model)} + + {}} + onMouseEnter={() => {}} + {onSelect} + {option} + /> +{/snippet} + +{#if groups.loaded.length > 0} +

Loaded models

+ + {#each groups.loaded as item (`loaded-${item.option.id}`)} + {@render render(item, false)} + {/each} +{/if} + +{#if groups.favorites.length > 0} +

Favorite models

+ + {#each groups.favorites as item (`fav-${item.option.id}`)} + {@render render(item, true)} + {/each} +{/if} + +{#if groups.available.length > 0} +

Available models

+ + {#each groups.available as group (group.orgName)} + {#if group.orgName} +

{group.orgName}

+ {/if} + + {#each group.items as item (item.option.id)} + {@render render(item, true)} + {/each} + {/each} +{/if} diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte index 5dfba6bb37..a30c27529b 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte @@ -1,6 +1,9 @@ + + + + {#snippet child({ props })} + + {/snippet} + + + event.preventDefault()} + > + +
+ +
+
+
+
diff --git a/tools/ui/src/lib/components/app/settings/index.ts b/tools/ui/src/lib/components/app/settings/index.ts index 7473e6af8e..d40c1040d0 100644 --- a/tools/ui/src/lib/components/app/settings/index.ts +++ b/tools/ui/src/lib/components/app/settings/index.ts @@ -19,6 +19,13 @@ export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSide */ export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte'; +/** + * Model picker for settings fields. Shares the search, grouping and option + * rendering of the chat model selector, and writes the chosen model back + * through `onSelect`. + */ +export { default as SettingsChatModelSelector } from './SettingsChat/SettingsChatModelSelector.svelte'; + /** * Badge indicating parameter source for sampling settings. Shows one of: * - **Custom**: User has explicitly set this value (orange badge) diff --git a/tools/ui/src/lib/constants/compaction.constants.ts b/tools/ui/src/lib/constants/compaction.constants.ts index 60f47b2616..5e2e576e1b 100644 --- a/tools/ui/src/lib/constants/compaction.constants.ts +++ b/tools/ui/src/lib/constants/compaction.constants.ts @@ -7,6 +7,9 @@ export const COMPACTION = { CONTEXT_TEMPLATE: `This conversation was compacted. Summary of the earlier exchange:\n\n${SUMMARY_PLACEHOLDER}`, DEFAULT_PROMPT: 'Summarize this conversation so it can seamlessly continue in a fresh context. Capture: the overall goal, key facts and decisions, the current state of any ongoing work (including code, file names, and data discussed), and what remains to be done. Write the summary as dense prose addressed to the assistant that will resume the conversation. Return ONLY the summary, nothing else.', + // Setting value standing for "no dedicated model", so the conversation + // model writes the summary. + MODEL_UNSET: '', // Server stream sessions are matched by exact id or by the id::* prefix, // so a prefixed identity keeps the summary stream inside the visibility // kick and byte-offset resume machinery while the reload probe for the diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts index 04b101315c..a6c7b7ec54 100644 --- a/tools/ui/src/lib/constants/settings-keys.constants.ts +++ b/tools/ui/src/lib/constants/settings-keys.constants.ts @@ -11,7 +11,9 @@ export const SETTINGS_KEYS = { API_KEY: 'apiKey', AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty', BACKEND_SAMPLING: 'backend_sampling', + COMPACTION_MODEL: 'compactionModel', COMPACTION_PROMPT: 'compactionPrompt', + COMPACTION_USE_CUSTOM_MODEL: 'compactionUseCustomModel', CONVERSATION_TABS: 'conversationTabs', COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText', CUSTOM_CSS: 'customCss', diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts index 1389355512..e62d128541 100644 --- a/tools/ui/src/lib/constants/settings.constants.ts +++ b/tools/ui/src/lib/constants/settings.constants.ts @@ -177,6 +177,21 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [ standaloneField: false, type: SettingsFieldType.CHECKBOX }, + { + defaultValue: false, + help: 'Summarize with a model of your choice instead of the one the conversation runs on. Router mode only.', + key: SETTINGS_KEYS.COMPACTION_USE_CUSTOM_MODEL, + label: 'Compact with a different model', + type: SettingsFieldType.CHECKBOX + }, + { + defaultValue: COMPACTION.MODEL_UNSET, + dependsOn: SETTINGS_KEYS.COMPACTION_USE_CUSTOM_MODEL, + help: 'Model that writes the summary. It is loaded on demand when it is not resident.', + key: SETTINGS_KEYS.COMPACTION_MODEL, + label: 'Compaction model', + type: SettingsFieldType.MODEL_SELECT + }, { defaultValue: COMPACTION.DEFAULT_PROMPT, help: 'Optional override for the instruction sent to the model when compacting a conversation.', diff --git a/tools/ui/src/lib/enums/settings.enums.ts b/tools/ui/src/lib/enums/settings.enums.ts index 9911670b37..5e5551f819 100644 --- a/tools/ui/src/lib/enums/settings.enums.ts +++ b/tools/ui/src/lib/enums/settings.enums.ts @@ -21,6 +21,7 @@ export enum SyncableParameterType { export enum SettingsFieldType { CHECKBOX = 'checkbox', INPUT = 'input', + MODEL_SELECT = 'model-select', RADIO = 'radio', SELECT = 'select', TEXTAREA = 'textarea' diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts index cbc26429ef..0b83ca54fc 100644 --- a/tools/ui/src/lib/stores/chat/index.svelte.ts +++ b/tools/ui/src/lib/stores/chat/index.svelte.ts @@ -47,6 +47,7 @@ import type { } from '$lib/types'; import { canCompactMessages, + customCompactionModel, filterByLeafNodeId, findMessageById, formatCwdMessage, @@ -320,11 +321,16 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost { }; const abortController = this.getOrCreateAbortController(convId); const compactionStreamId = COMPACTION.STREAM_ID_PREFIX + convId; + const config = settingsStore.config; // Model resolution mirrors the request funnel: the selected model // first, then the model the branch was generated with, so router // mode targets the child that owns this conversation's KV cache. + // A dedicated compaction model wins over both. + const compactionModel = serverStore.isRouterMode + ? customCompactionModel(Boolean(config.compactionUseCustomModel), config.compactionModel) + : undefined; const effectiveModel = serverStore.isRouterMode - ? modelsStore.selectedModelName || getConversationModel(source) + ? compactionModel || modelsStore.selectedModelName || getConversationModel(source) : undefined; let summary = ''; @@ -333,14 +339,20 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost { const apiMessages: ApiChatMessageData[] = await Promise.all( source.map((m) => ChatService.convertDbMessageToApiChatMessageData(m)) ); - const configValue = settingsStore.config; const compactionPrompt = - typeof configValue.compactionPrompt === 'string' && configValue.compactionPrompt.trim() - ? configValue.compactionPrompt + typeof config.compactionPrompt === 'string' && config.compactionPrompt.trim() + ? config.compactionPrompt : COMPACTION.DEFAULT_PROMPT; apiMessages.push({ content: compactionPrompt, role: MessageRole.USER }); + // A dedicated model that is not resident is loaded on demand. The + // status manager surfaces the failure toast and throws, so the + // rollback below removes the node instead of leaving it empty. + if (compactionModel && !modelsStore.isModelLoaded(compactionModel)) { + await modelsStore.status.load(compactionModel); + } + await ChatService.sendMessage( apiMessages, { diff --git a/tools/ui/src/lib/utils/compaction.ts b/tools/ui/src/lib/utils/compaction.ts index 7b0efb9726..c7db22ecf4 100644 --- a/tools/ui/src/lib/utils/compaction.ts +++ b/tools/ui/src/lib/utils/compaction.ts @@ -1,6 +1,6 @@ import { COMPACTION } from '$lib/constants'; import { MessageRole, MessageType } from '$lib/enums'; -import type { DatabaseMessage } from '$lib/types'; +import type { DatabaseMessage, SettingsConfigValue } from '$lib/types'; /** * Applies the compaction boundary to a branch message list before it is @@ -42,6 +42,22 @@ export function sliceAtLastCompaction(messages: DatabaseMessage[]): DatabaseMess return [...systemPrefix, framedNode, ...messages.slice(boundary + 1)]; } +/** + * The model dedicated to summarization, or undefined when the conversation + * model writes the summary. The opt-in flag gates the setting, and an unset + * or blank value keeps the conversation model. + */ +export function customCompactionModel( + useCustomModel: boolean, + model: SettingsConfigValue +): string | undefined { + if (!useCustomModel || typeof model !== 'string') return undefined; + + const trimmed = model.trim(); + + return trimmed === COMPACTION.MODEL_UNSET ? undefined : trimmed; +} + /** * A branch is compactable when at least one assistant turn with content * exists after the last compaction boundary: anything less has nothing diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 0a342dcfeb..a2587cd975 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -352,7 +352,12 @@ export { getToolUi } from './tool-ui'; // Chat command picker export { getChatCommands } from './chat-commands'; -export { canCompactMessages, lastContextBearingMessage, sliceAtLastCompaction } from './compaction'; +export { + canCompactMessages, + customCompactionModel, + lastContextBearingMessage, + sliceAtLastCompaction +} from './compaction'; // Sandbox tool definition // SANDBOX_TOOL_DEFINITION is deprecated; kept for backward compatibility. diff --git a/tools/ui/tests/unit/compaction.test.ts b/tools/ui/tests/unit/compaction.test.ts index 78158495d6..dde318e843 100644 --- a/tools/ui/tests/unit/compaction.test.ts +++ b/tools/ui/tests/unit/compaction.test.ts @@ -1,7 +1,7 @@ import { COMPACTION } from '$lib/constants'; import { MessageRole, MessageType } from '$lib/enums'; import type { DatabaseMessage } from '$lib/types'; -import { canCompactMessages, sliceAtLastCompaction } from '$lib/utils'; +import { canCompactMessages, customCompactionModel, sliceAtLastCompaction } from '$lib/utils'; import { describe, expect, it } from 'vitest'; const CONV_ID = 'conv'; @@ -144,3 +144,22 @@ describe('canCompactMessages', () => { expect(canCompactMessages(messages)).toBe(true); }); }); + +describe('customCompactionModel', () => { + it('keeps the conversation model when the opt-in is off', () => { + expect(customCompactionModel(false, 'summarizer')).toBeUndefined(); + }); + + it('returns the selected model when the opt-in is on', () => { + expect(customCompactionModel(true, 'summarizer')).toBe('summarizer'); + }); + + it('keeps the conversation model when no model is selected', () => { + expect(customCompactionModel(true, COMPACTION.MODEL_UNSET)).toBeUndefined(); + }); + + it('keeps the conversation model for a blank or non string value', () => { + expect(customCompactionModel(true, ' ')).toBeUndefined(); + expect(customCompactionModel(true, undefined)).toBeUndefined(); + }); +});