This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-16 02:35:44 -04:00
1 parent 8270aa59ab
commit 75894161e4
9 files changed
+188 -60

No files matched your search

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