diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index a4286d1520..57342170e4 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -176,6 +176,7 @@ class ChatResponse(BaseModel): tasks: list | None = None summary: str | None = None + context_usage: dict | None = None class ChatTitleIdResponse(BaseModel): diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index e3aa0ec659..752c5f3468 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -37,7 +37,7 @@ from open_webui.tasks import has_active_tasks, stop_item_tasks from open_webui.utils.access_control import filter_allowed_access_grants, has_permission from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.context_compaction import compact_chat_branch +from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage from open_webui.utils.misc import get_message_list from open_webui.utils.models import get_all_models from pydantic import BaseModel @@ -1157,6 +1157,7 @@ async def compact_chat_by_id( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.') result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS) + result['context_usage'] = await get_chat_context_usage(chat, model_id) if result.get('compacted'): await publish_event( request, @@ -1203,7 +1204,9 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess chat = candidate if chat: - return ChatResponse(**chat.model_dump()) + data = ChatResponse(**chat.model_dump()).model_dump() + data['context_usage'] = await get_chat_context_usage(chat) + return data raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index 6e0eee379b..ad18ea175f 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -145,8 +145,6 @@ 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') @@ -158,11 +156,11 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d messages_map = history.get('messages') or {} messages, previous_summary = _apply_latest_summary_checkpoint(get_message_list(messages_map, current_id)) - if len(messages) <= 2: - return {'ok': True, 'compacted': False, 'reason': 'too_short'} - compacted_messages = messages[:-1] recent_messages = messages[-1:] + if not compacted_messages or not recent_messages: + return {'ok': True, 'compacted': False, 'reason': 'too_short'} + summary = await _generate_summary( request, user, @@ -215,6 +213,48 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d return min(configured_threshold or global_threshold, global_cap) +async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None: + history = (chat.chat or {}).get('history') or {} + current_id = history.get('currentId') + if not current_id: + return None + + messages_map = await Chats.get_messages_map_by_chat_id(chat.id) + messages = get_message_list(messages_map or history.get('messages') or {}, current_id) + if not messages: + return None + + config = await _load_config() + params = ((chat.chat or {}).get('params') or {}).copy() + if model_id: + params['model'] = model_id + threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], {'params': params}) + messages, previous_summary = _apply_latest_summary_checkpoint(messages) + + for idx in range(len(messages) - 1, -1, -1): + usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage') + input_tokens = (usage or {}).get('input_tokens') or (usage or {}).get('prompt_tokens') + if isinstance(usage, dict) and input_tokens: + tokens = int(input_tokens or 0) + int( + usage.get('output_tokens') or usage.get('completion_tokens') or 0 + ) + tokens += _estimate_messages_tokens(messages[idx + 1 :]) + return _build_context_usage(tokens, threshold) + + tokens = _estimate_tokens(previous_summary or '') + _estimate_messages_tokens(messages) + return _build_context_usage(tokens, threshold) + + +def _build_context_usage(tokens: int, threshold: int) -> dict: + return { + 'tokens': tokens, + 'estimated_tokens': tokens, + 'threshold': threshold, + 'percent': round((tokens / threshold) * 100) if threshold > 0 else 0, + 'source': 'estimated', + } + + def _apply_latest_summary_checkpoint(messages: list[dict]) -> tuple[list[dict], str | None]: summary = None summary_idx = None diff --git a/src/app.css b/src/app.css index 38d64ce88f..f07f59bfde 100644 --- a/src/app.css +++ b/src/app.css @@ -68,6 +68,18 @@ code { font-family: 'InstrumentSerif', sans-serif; } +.app-muted { + @apply text-gray-500 dark:text-gray-400; +} + +.app-icon-muted { + @apply text-gray-500 dark:text-gray-400; +} + +.app-interactive-active { + @apply bg-gray-50/40 dark:bg-gray-800/40; +} + .marked a { @apply underline; } diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 577b31cf49..b4bc2e002c 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -1191,6 +1191,39 @@ export const updateChatById = async (token: string, id: string, chat: object) => return res; }; +export const compactChatById = async (token: string, id: string, model?: string | null) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/compact`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ model }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteChatMessageById = async (token: string, id: string, messageId: string) => { let error = null; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 836f23762f..b3f3e90192 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -70,6 +70,7 @@ import { archiveChatById, + compactChatById, createNewChat, deleteChatById, getAllTags, @@ -150,6 +151,94 @@ } else { selectedModelIds = selectedModels; } + let serverContextUsage = null; + let contextUsage = null; + + 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 getContextThreshold = () => { + const chatThreshold = Number(params?.compact_token_threshold); + if (Number.isFinite(chatThreshold) && chatThreshold > 0) { + return chatThreshold; + } + + 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; + }; + + const getContextUsage = () => { + if (!history?.currentId) { + return null; + } + + const messages = createMessagesList(history, history.currentId); + const threshold = getContextThreshold(); + const systemTokens = estimateTokens($settings?.system ?? ''); + let estimatedTokens = systemTokens; + let hasUsageCheckpoint = false; + 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); + + 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, + percent: threshold > 0 ? Math.max(0, Math.round((estimatedTokens / threshold) * 100)) : 0, + source: 'estimated' + }; + }; + + $: contextUsage = getContextUsage() ?? serverContextUsage; let selectedToolIds = []; let selectedSkillIds = []; @@ -1635,6 +1724,7 @@ // Load tasks from chat-level DB field chatTasks = chat?.tasks ?? []; + serverContextUsage = chat?.context_usage ?? null; autoScroll = true; await tick(); @@ -2122,6 +2212,59 @@ await sendMessage(history, userMessageId); }; + const handleManualCompact = async () => { + if (!$chatId || !history?.currentId) { + toast.message($i18n.t('No chat to compact')); + return; + } + + const currentMessage = history.messages?.[history.currentId]; + if ( + generating || + taskIds?.length || + (currentMessage?.role === 'assistant' && !currentMessage.done) + ) { + toast.warning($i18n.t('Wait for the current response to finish before compacting.')); + return; + } + + const model = atSelectedModel?.id ?? selectedModels.find((modelId) => modelId); + const toastId = toast.loading($i18n.t('Compacting context...')); + + try { + const result = await compactChatById(localStorage.token, $chatId, model); + serverContextUsage = result?.context_usage ?? serverContextUsage; + + if (result?.compacted) { + toast.success($i18n.t('Context compacted'), { id: toastId }); + } else { + const skippedReason = + result?.reason === 'too_short' + ? $i18n.t('Chat is too short to compact') + : result?.reason === 'empty' + ? $i18n.t('No chat to compact') + : $i18n.t('Nothing to compact'); + toast.message(skippedReason, { id: toastId }); + } + + await loadChat(); + } catch (error) { + const message = error?.detail ?? error?.message ?? $i18n.t('Context compaction failed'); + toast.error(message, { id: toastId }); + } finally { + messageInput?.setText(''); + prompt = ''; + document.getElementById('chat-input')?.focus(); + } + }; + + const handleStatusCommand = () => { + messageInput?.showStatus(); + messageInput?.setText(''); + prompt = ''; + document.getElementById('chat-input')?.focus(); + }; + const submitHandler = async (userPrompt, { _raw = false } = {}) => { console.log('submitHandler', userPrompt, $chatId); @@ -2133,6 +2276,15 @@ selectedModels = _selectedModels; } + if (String(userPrompt).trim() === '/compact') { + await handleManualCompact(); + return; + } + if (String(userPrompt).trim() === '/status') { + handleStatusCommand(); + return; + } + if (pendingOAuthTools.length > 0) { toast.warning($i18n.t('Please connect all required integrations before sending a message')); return; @@ -3295,6 +3447,10 @@ bind:atSelectedModel bind:showCommands bind:dragged + chatId={$chatId} + {contextUsage} + compactHandler={handleManualCompact} + statusHandler={handleStatusCommand} toolServers={$toolServers} {generating} {stopResponse} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 1db0e202bd..0464e09586 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -114,6 +114,10 @@ export let createMessagePair: Function; export let stopResponse: Function; + export let compactHandler: Function = () => {}; + export let statusHandler: Function = () => {}; + export let chatId = ''; + export let contextUsage = null; export let autoScroll = false; export let generating = false; @@ -132,6 +136,7 @@ (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating; + $: canCompact = !!history?.currentId; export let prompt = ''; export let files = []; @@ -163,6 +168,8 @@ let inputVariableValues = {}; let showValvesModal = false; + let showStatusPanel = false; + let copiedStatusChatId = false; let selectedValvesType = 'tool'; // 'tool' or 'function' let selectedValvesItemId = null; let integrationsMenuCloseOnOutsideClick = true; @@ -364,6 +371,36 @@ } }; + export const showStatus = async () => { + showStatusPanel = true; + await tick(); + document.getElementById('chat-input')?.focus(); + }; + + const formatTokenCount = (value: number) => { + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000)}m`; + if (value >= 1_000) return `${trimNumber(value / 1_000)}k`; + return String(value ?? 0); + }; + + const trimNumber = (value: number) => + value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, ''); + + const copyStatusChatId = async () => { + if (!chatId) return; + await navigator.clipboard.writeText(chatId); + copiedStatusChatId = true; + setTimeout(() => { + copiedStatusChatId = false; + }, 1600); + }; + + $: contextPercent = Math.max(0, Math.round(contextUsage?.percent ?? 0)); + $: contextValue = contextUsage + ? `${contextPercent}% ${formatTokenCount(contextUsage.estimated_tokens || contextUsage.tokens)}/${formatTokenCount(contextUsage.threshold)}` + : $i18n.t('unknown'); + $: contextBarPercent = Math.min(contextPercent, 100); + const getCommand = () => { const chatInput = document.getElementById('chat-input'); let word = ''; @@ -1046,6 +1083,12 @@ char: '/', render: getSuggestionRenderer(CommandSuggestionList, { i18n, + canCompact: () => !!history?.currentId, + compactDisabled: () => isActive, + canStatus: () => !!history?.currentId, + contextUsage: () => contextUsage, + onCompact: compactHandler, + onStatus: statusHandler, onSelect: (e) => { const { type, data } = e; @@ -1359,6 +1402,86 @@ {/if} + {#if showStatusPanel} +
+ + {:else} + + none + + {/if} +
+ + + + + {/if} +
+ {$i18n.t('Commands')} +
+ + {#each commandItems as item, commandIdx} + {#if item.data.id === 'compact'} + + + + {:else if item.data.id === 'status'} + + + + {/if} + {/each} +{/if} + {#if filteredPrompts.length > 0}
{$i18n.t('Prompts')}
{#each filteredPrompts as promptItem, promptIdx} + {@const itemIdx = commandItems.length + promptIdx}