This commit is contained in:
Timothy Jaeryang Baek
2026-07-16 02:35:44 -04:00
parent 8270aa59ab
commit 75894161e4
9 changed files with 188 additions and 60 deletions
+2
View File
@@ -1948,6 +1948,7 @@ async def get_app_config(request: Request):
'calendar.enable',
'automations.enable',
'notes.enable',
'chat.context_compaction.enable',
'web.search.enable',
'web.search.confirmation.enable',
'web.search.confirmation.content',
@@ -2017,6 +2018,7 @@ async def get_app_config(request: Request):
'enable_calendar': config.get('calendar.enable'),
'enable_automations': config.get('automations.enable'),
'enable_notes': config.get('notes.enable'),
'enable_context_compaction': config.get('chat.context_compaction.enable'),
'enable_web_search': config.get('web.search.enable'),
'enable_web_search_confirmation': config.get('web.search.confirmation.enable'),
'web_search_confirmation_content': config.get('web.search.confirmation.content'),
@@ -145,6 +145,8 @@ async def compact_messages_for_request(
async def compact_chat_branch(request, user, chat: Any, model_id: str, models: dict) -> dict:
config = await _load_config()
if not config['enable']:
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
history = (chat.chat or {}).get('history') or {}
current_id = history.get('currentId')
@@ -225,6 +227,9 @@ async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict
return None
config = await _load_config()
if not config['enable']:
return None
params = ((chat.chat or {}).get('params') or {}).copy()
if model_id:
params['model'] = model_id
@@ -13,6 +13,7 @@
import AdminSettingField from './AdminSettingField.svelte';
import AdminSettingRow from './AdminSettingRow.svelte';
import AdminSettingSection from './AdminSettingSection.svelte';
import { config as appConfig } from '$lib/stores';
const dispatch = createEventDispatcher();
@@ -51,6 +52,17 @@
updateTaskConfig(localStorage.token, taskConfig),
updateChatConfig(localStorage.token, chatConfig)
]);
appConfig.update((current) =>
current
? {
...current,
features: {
...current.features,
enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION
}
}
: current
);
};
let workspaceModels: any[] = [];
+18 -5
View File
@@ -220,6 +220,8 @@
return next;
}, 0);
$: contextCompactionEnabled = Boolean($config?.features?.enable_context_compaction);
const getContextThreshold = () => {
const chatThreshold = Number(params?.compact_token_threshold);
if (Number.isFinite(chatThreshold) && chatThreshold > 0) {
@@ -229,7 +231,7 @@
const modelId = atSelectedModel?.id ?? selectedModels.find((id) => id);
const model = $models.find((item) => item.id === modelId);
const threshold = Number(model?.info?.params?.compact_token_threshold);
return Number.isFinite(threshold) && threshold > 0 ? threshold : 80000;
return Number.isFinite(threshold) && threshold > 0 ? threshold : null;
};
const getContextUsage = () => {
@@ -238,7 +240,9 @@
}
const messages = createMessagesList(history, history.currentId);
const threshold = getContextThreshold();
const threshold = contextCompactionEnabled
? (getContextThreshold() ?? serverContextUsage?.threshold ?? null)
: null;
const systemTokens = estimateTokens($settings?.system ?? '');
let estimatedTokens = systemTokens;
let hasUsageCheckpoint = false;
@@ -276,12 +280,12 @@
tokens: estimatedTokens,
estimated_tokens: estimatedTokens,
threshold,
percent: threshold > 0 ? Math.max(0, Math.round((estimatedTokens / threshold) * 100)) : 0,
percent: threshold > 0 ? Math.max(0, Math.round((estimatedTokens / threshold) * 100)) : null,
source: 'estimated'
};
};
$: contextUsage = getContextUsage() ?? serverContextUsage;
$: contextUsage = getContextUsage() ?? (contextCompactionEnabled ? serverContextUsage : null);
$: embeddedHeaderTitle = embeddedTitle || $chatTitle || $i18n.t('Chat');
let selectedToolIds = [];
@@ -2400,6 +2404,11 @@
};
const handleManualCompact = async () => {
if (!contextCompactionEnabled) {
toast.message($i18n.t('Context compaction is disabled'));
return;
}
if (!$chatId || !history?.currentId) {
toast.message($i18n.t('No chat to compact'));
return;
@@ -2430,7 +2439,9 @@
? $i18n.t('Chat is too short to compact')
: result?.reason === 'empty'
? $i18n.t('No chat to compact')
: $i18n.t('Nothing to compact');
: result?.reason === 'disabled'
? $i18n.t('Context compaction is disabled')
: $i18n.t('Nothing to compact');
toast.message(skippedReason, { id: toastId });
}
@@ -3751,6 +3762,7 @@
dropzoneId={messageInputDropzoneId}
chatId={$chatId}
{contextUsage}
{contextCompactionEnabled}
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
@@ -3866,6 +3878,7 @@
dropzoneId={messageInputDropzoneId}
chatId={$chatId}
{contextUsage}
{contextCompactionEnabled}
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
+96 -13
View File
@@ -121,6 +121,7 @@
export let forkHandler: Function = () => {};
export let chatId = '';
export let contextUsage = null;
export let contextCompactionEnabled = false;
export let autoScroll = false;
export let generating = false;
@@ -389,6 +390,79 @@
const trimNumber = (value: number) =>
value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
const estimateTokens = (value) => {
if (value === null || value === undefined || value === '') {
return 0;
}
if (typeof value !== 'string') {
try {
value = JSON.stringify(value);
} catch {
value = String(value);
}
}
return Math.max(1, Math.floor(value.length / 4));
};
const estimateMessagesTokens = (messages) =>
messages.reduce((total, message) => {
let next = total + 4 + estimateTokens(message.content);
next += estimateTokens(message.output);
next += estimateTokens(message.tool_calls);
next += estimateTokens(message.files);
return next;
}, 0);
const getLocalContextUsage = () => {
if (!history?.currentId) {
return null;
}
const messages = createMessagesList(history, history.currentId);
if (!messages.length) {
return null;
}
let summary = '';
let startIdx = 0;
for (let idx = 0; idx < messages.length; idx += 1) {
const value = messages[idx]?.contextSummary ?? messages[idx]?.context_summary;
if (typeof value === 'string' && value.trim()) {
summary = value;
startIdx = idx;
}
}
const activeMessages = messages.slice(startIdx);
let estimatedTokens = estimateTokens($settings?.system ?? '');
let hasUsageCheckpoint = false;
for (let idx = activeMessages.length - 1; idx >= 0; idx -= 1) {
const usage = activeMessages[idx]?.usage ?? activeMessages[idx]?.info?.usage;
const inputTokens = usage?.input_tokens ?? usage?.prompt_tokens;
if (inputTokens) {
hasUsageCheckpoint = true;
estimatedTokens =
Number(inputTokens || 0) +
Number(usage.output_tokens ?? usage.completion_tokens ?? 0) +
estimateMessagesTokens(activeMessages.slice(idx + 1));
break;
}
}
if (!hasUsageCheckpoint) {
estimatedTokens += estimateTokens(summary) + estimateMessagesTokens(activeMessages);
}
return {
tokens: estimatedTokens,
estimated_tokens: estimatedTokens,
threshold: null,
percent: null,
source: 'estimated'
};
};
const copyStatusChatId = async () => {
if (!chatId) return;
await navigator.clipboard.writeText(chatId);
@@ -398,11 +472,18 @@
}, 1600);
};
$: contextPercent = Math.max(0, Math.round(contextUsage?.percent ?? 0));
$: contextValue = contextUsage
? `${contextPercent}% ${formatTokenCount(contextUsage.estimated_tokens || contextUsage.tokens)}/${formatTokenCount(contextUsage.threshold)}`
$: statusContextUsage = contextUsage ?? getLocalContextUsage();
$: contextHasThreshold = Number(statusContextUsage?.threshold) > 0;
$: contextPercent = contextHasThreshold
? Math.max(0, Math.round(statusContextUsage?.percent ?? 0))
: null;
$: contextTokens = formatTokenCount(statusContextUsage?.estimated_tokens || statusContextUsage?.tokens || 0);
$: contextValue = statusContextUsage
? contextHasThreshold
? `${contextPercent}% ${contextTokens}/${formatTokenCount(statusContextUsage.threshold)}`
: `${contextTokens} ${$i18n.t('tokens')}`
: $i18n.t('unknown');
$: contextBarPercent = Math.min(contextPercent, 100);
$: contextBarPercent = contextHasThreshold ? Math.min(contextPercent, 100) : 0;
const getCommand = () => {
const chatInput = document.getElementById('chat-input');
@@ -1086,12 +1167,12 @@
char: '/',
render: getSuggestionRenderer(CommandSuggestionList, {
i18n,
canCompact: () => !!history?.currentId,
canCompact: () => !!history?.currentId && contextCompactionEnabled,
compactDisabled: () => isActive,
canStatus: () => !!history?.currentId,
canFork: () => !!history?.currentId,
forkDisabled: () => isActive,
contextUsage: () => contextUsage,
contextUsage: () => statusContextUsage,
onCompact: compactHandler,
onStatus: statusHandler,
onFork: forkHandler,
@@ -1436,14 +1517,16 @@
{contextValue}
</span>
</div>
<div
class="mt-1.5 h-0.5 overflow-hidden rounded-full bg-gray-100 dark:bg-white/8"
>
{#if contextHasThreshold}
<div
class="h-full rounded-full bg-gray-300 dark:bg-white/20"
style={`width: ${contextBarPercent}%`}
></div>
</div>
class="mt-1.5 h-0.5 overflow-hidden rounded-full bg-gray-100 dark:bg-white/8"
>
<div
class="h-full rounded-full bg-gray-300 dark:bg-white/20"
style={`width: ${contextBarPercent}%`}
></div>
</div>
{/if}
</div>
{#if messageQueue.length}
@@ -30,7 +30,10 @@
$: forkAvailable = typeof canFork === 'function' ? canFork() : canFork;
$: isForkDisabled = typeof forkDisabled === 'function' ? forkDisabled() : forkDisabled;
$: resolvedContextUsage = typeof contextUsage === 'function' ? contextUsage() : contextUsage;
$: contextPercent = Math.max(0, Math.round(resolvedContextUsage?.percent ?? 0));
$: contextHasThreshold = Number(resolvedContextUsage?.threshold) > 0;
$: contextPercent = contextHasThreshold
? Math.max(0, Math.round(resolvedContextUsage?.percent ?? 0))
: null;
let suggestionElement: any = null;
let filteredItems: any[] = [];
@@ -85,6 +88,7 @@
canFork={forkAvailable}
forkDisabled={isForkDisabled}
{contextPercent}
{contextHasThreshold}
onSelect={(e) => {
const { type, data } = e;
@@ -15,6 +15,7 @@
export let canFork = false;
export let forkDisabled = false;
export let contextPercent = 0;
export let contextHasThreshold = false;
let selectedIdx = 0;
export let filteredItems = [];
@@ -23,7 +24,9 @@
let skills = [];
let searchDebounceTimer: ReturnType<typeof setTimeout>;
$: contextCirclePercent = Math.min(Math.max(0, Math.round(contextPercent)), 100);
$: contextCirclePercent = contextHasThreshold
? Math.min(Math.max(0, Math.round(contextPercent)), 100)
: 0;
$: contextCircleOffset = 50.27 * (1 - contextCirclePercent / 100);
$: commandItems = [
@@ -144,34 +147,38 @@
data-selected={commandIdx === selectedIdx}
>
<span class="app-icon-muted flex items-center justify-center w-4 shrink-0">
<svg class="size-3.5 -rotate-90" viewBox="0 0 20 20" aria-hidden="true">
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-20"
/>
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-dasharray="50.27"
style={`stroke-dashoffset: ${contextCircleOffset};`}
/>
</svg>
{#if contextHasThreshold}
<svg class="size-3.5 -rotate-90" viewBox="0 0 20 20" aria-hidden="true">
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-20"
/>
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-dasharray="50.27"
style={`stroke-dashoffset: ${contextCircleOffset};`}
/>
</svg>
{/if}
</span>
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
<span class="truncate">Compact</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">
{contextCirclePercent}% full
</span>
{#if contextHasThreshold}
<span class="app-muted text-[0.625rem] truncate shrink-0">
{contextCirclePercent}% full
</span>
{/if}
</span>
</button>
</Tooltip>
+16 -15
View File
@@ -665,6 +665,21 @@
title: 'Sub-agents',
keywords: ['sub-agents', 'subagents', 'delegation', 'background', 'agents']
},
{
id: 'admin:interface',
title: 'Interface',
keywords: ['interface', 'ui', 'appearance', 'banners', 'tasks', 'prompt suggestions', 'tags']
},
{
id: 'admin:audio',
title: 'Audio',
keywords: ['audio', 'voice', 'speech', 'tts', 'stt', 'whisper', 'deepgram', 'azure']
},
{
id: 'admin:images',
title: 'Images',
keywords: ['images', 'generation', 'dalle', 'stable diffusion', 'comfyui', 'automatic1111']
},
{
id: 'admin:evaluations',
title: 'Evaluations',
@@ -700,21 +715,7 @@
title: 'Pipelines',
keywords: ['pipelines', 'workflows', 'filters', 'valves', 'middleware']
},
{
id: 'admin:interface',
title: 'Interface',
keywords: ['interface', 'ui', 'appearance', 'banners', 'tasks', 'prompt suggestions', 'tags']
},
{
id: 'admin:audio',
title: 'Audio',
keywords: ['audio', 'voice', 'speech', 'tts', 'stt', 'whisper', 'deepgram', 'azure']
},
{
id: 'admin:images',
title: 'Images',
keywords: ['images', 'generation', 'dalle', 'stable diffusion', 'comfyui', 'automatic1111']
},
{
id: 'admin:db',
title: 'Database',
+1
View File
@@ -322,6 +322,7 @@ type Config = {
enable_admin_export: boolean;
enable_admin_chat_access: boolean;
enable_admin_analytics: boolean;
enable_context_compaction?: boolean;
enable_community_sharing: boolean;
enable_memories: boolean;
enable_plugins?: boolean;