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.
This commit is contained in:
Pascal committed 2026-10-21 09:39:23 +02:00
1 parent a8187b8b85
commit cddfa89091
12 files changed
+262 -8

No files matched your search

@@ -0,0 +1,76 @@
<script lang="ts">
import type { GroupedModelOptions, ModelItem } from './utils';
import { ModelsSelectorOption } from '$lib/components/app';
import { modelsStore } from '$lib/stores';
interface Props {
groups: GroupedModelOptions;
currentModel: string | null;
activeId: string | null;
sectionHeaderClass?: string;
orgHeaderClass?: string;
onSelect: (modelId: string) => void;
onInfoClick?: (modelName: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
}
let {
activeId,
currentModel,
groups,
onInfoClick,
onSelect,
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
renderOption,
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
</script>
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
isHighlighted={false}
{isSelected}
{onInfoClick}
onKeyDown={() => {}}
onMouseEnter={() => {}}
{onSelect}
{option}
/>
{/snippet}
{#if groups.loaded.length > 0}
<p class={sectionHeaderClass}>Loaded models</p>
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, false)}
{/each}
{/if}
{#if groups.favorites.length > 0}
<p class={sectionHeaderClass}>Favorite models</p>
{#each groups.favorites as item (`fav-${item.option.id}`)}
{@render render(item, true)}
{/each}
{/if}
{#if groups.available.length > 0}
<p class={sectionHeaderClass}>Available models</p>
{#each groups.available as group (group.orgName)}
{#if group.orgName}
<p class={orgHeaderClass}>{group.orgName}</p>
{/if}
{#each group.items as item (item.option.id)}
{@render render(item, true)}
{/each}
{/each}
{/if}
@@ -1,6 +1,9 @@
<script lang="ts">
import { FlaskConical, RotateCcw } from '@lucide/svelte';
import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
import {
SettingsChatModelSelector,
SettingsChatParameterSourceIndicator
} from '$lib/components/app/settings';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
@@ -166,6 +169,27 @@
</Label>
</div>
{/if}
{:else if field.type === SettingsFieldType.MODEL_SELECT}
<Label class="flex items-center gap-1.5 text-sm font-medium" for={field.key}>
{field.label}
{#if field.isExperimental}
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
{/if}
</Label>
<SettingsChatModelSelector
id={field.key}
onSelect={(model) => onConfigChange(field.key, model)}
placeholder={`Select ${field.label.toLowerCase()}`}
value={String(localConfig[field.key] ?? '')}
/>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === SettingsFieldType.SELECT}
{@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) =>
@@ -0,0 +1,74 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import { DropdownMenuSearchable, ModelsSelectorList } from '$lib/components/app';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { modelsStore } from '$lib/stores';
interface Props {
id: string;
onSelect: (model: string) => void;
placeholder: string;
value: string;
}
let { id, onSelect, placeholder, value }: Props = $props();
let open = $state(false);
let searchTerm = $state('');
let filteredOptions = $derived(filterModelOptions(modelsStore.models, searchTerm));
let groups = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (model) =>
modelsStore.isModelLoaded(model)
)
);
// The stored value survives a model leaving the router list, so the raw
// id is shown when no option matches it.
let triggerLabel = $derived(
modelsStore.models.find((m) => m.model === value)?.name || value || placeholder
);
function handleOpenChange(next: boolean) {
open = next;
searchTerm = '';
}
function handleSelect(modelId: string) {
const option = modelsStore.models.find((m) => m.id === modelId);
if (!option) return;
onSelect(option.model);
handleOpenChange(false);
}
</script>
<DropdownMenu.Root onOpenChange={handleOpenChange} {open}>
<DropdownMenu.Trigger {id}>
{#snippet child({ props })}
<Button {...props} class="w-full justify-between font-normal" variant="outline">
<span class="truncate">{triggerLabel}</span>
<ChevronDown class="h-4 w-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-100 max-w-[calc(100vw-2rem)] pt-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
bind:searchValue={searchTerm}
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0}
placeholder="Search models..."
>
<div class="max-h-72 overflow-y-auto">
<ModelsSelectorList activeId={null} currentModel={value} {groups} onSelect={handleSelect} />
</div>
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -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)
@@ -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
@@ -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',
@@ -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.',
+1
View File
@@ -21,6 +21,7 @@ export enum SyncableParameterType {
export enum SettingsFieldType {
CHECKBOX = 'checkbox',
INPUT = 'input',
MODEL_SELECT = 'model-select',
RADIO = 'radio',
SELECT = 'select',
TEXTAREA = 'textarea'
+16 -4
View File
@@ -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,
{
+17 -1
View File
@@ -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
+6 -1
View File
@@ -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.
+20 -1
View File
@@ -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();
});
});