This commit is contained in:
Timothy Jaeryang Baek 2026-10-25 15:42:14 +09:00
parent f465a4be11
commit c8786f426a
5 changed files with 90 additions and 64 deletions

View file

@ -3,14 +3,14 @@
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
import { fade } from 'svelte/transition';
const i18n: Writable<i18nType> = getContext('i18n');
import { goto, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { get, type Unsubscriber, type Writable } from 'svelte/store';
import { get, writable, type Unsubscriber, type Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { WEBUI_BASE_URL } from '$lib/constants';
import equal from 'fast-deep-equal';
@ -407,7 +407,13 @@
let generating = false;
let dragged = false;
let generationController = null;
let contextCompactionToastId = null;
const contextCompactions = writable<Record<string, 'in_progress' | { error: string }>>({});
setContext('contextCompactions', contextCompactions);
let compactionChatId = '';
$: if (compactionChatId !== $chatId) {
contextCompactions.set({});
compactionChatId = $chatId;
}
let chat: any = null;
let tags = [];
@ -1310,40 +1316,21 @@
}
};
const dismissContextCompactionToast = () => {
if (contextCompactionToastId !== null) {
toast.dismiss(contextCompactionToastId);
contextCompactionToastId = null;
}
const clearContextCompaction = (messageId: string) => {
const next = { ...$contextCompactions };
delete next[messageId];
contextCompactions.set(next);
};
const handleContextCompactionStatus = (status) => {
if (status?.action !== 'context_compaction') {
return;
}
if (status?.done) {
if (contextCompactionToastId !== null) {
if (status?.error) {
toast.error($i18n.t('Context compaction failed'), {
id: contextCompactionToastId,
duration: 3000
});
} else {
toast.success($i18n.t('Context compacted'), {
id: contextCompactionToastId,
duration: 1800
});
}
contextCompactionToastId = null;
}
return;
}
if (contextCompactionToastId === null) {
contextCompactionToastId = toast.loading($i18n.t('Compacting context'), {
duration: Infinity
});
const handleContextCompactionStatus = (status, messageId: string) => {
if (status?.action !== 'context_compaction') return;
if (status.done && !status.error) {
clearContextCompaction(messageId);
} else {
$contextCompactions = {
...$contextCompactions,
[messageId]: status.error ? { error: $i18n.t('Context compaction failed') } : 'in_progress'
};
}
};
@ -1607,7 +1594,8 @@
if (data?.checkpoint_message_id && history.messages[data.checkpoint_message_id]) {
history.messages[data.checkpoint_message_id].contextSummary = data.context_summary;
}
handleContextCompactionStatus(data);
handleContextCompactionStatus(data, event.message_id);
autoScrollToBottom();
} else if (type === 'chat:active') {
if (!data?.active) {
taskIds = null;
@ -1637,7 +1625,7 @@
}
message.bridgeCancelled = true;
bridgeCancellations.get(event.message_id)?.();
if (!event.shared) dismissContextCompactionToast();
clearContextCompaction(event.message_id);
if (data?.output) {
message.output = data.output;
}
@ -1646,6 +1634,7 @@
// Set all response messages to done
for (const messageId of history.messages[message.parentId].childrenIds) {
history.messages[messageId].done = true;
clearContextCompaction(messageId);
}
message.done = true;
await processNextInQueue($chatId);
@ -1682,7 +1671,7 @@
message.done = true;
} else handleOpenAIError(data.error, message);
if (!event.shared && !responseCompleted) {
dismissContextCompactionToast();
clearContextCompaction(message.id);
if (event.message_id === history.currentId) {
await processNextInQueue(event.chat_id);
}
@ -2042,7 +2031,7 @@
$socket?.off('disconnect', clearTyping);
$socket?.off('disconnect', clearPresence);
$socket?.off('disconnect', cancelBrowserInteractions);
dismissContextCompactionToast();
contextCompactions.set({});
audioQueueInstance?.destroy();
audioQueue.set(null);
} catch (e) {
@ -3554,9 +3543,10 @@
if (done) {
message.done = true;
if ($contextCompactions[message.id] === 'in_progress') clearContextCompaction(message.id);
if (shared) return;
if (message.error) {
dismissContextCompactionToast();
clearContextCompaction(message.id);
bridge?.update();
await processNextInQueue(chatId);
return;
@ -3692,15 +3682,17 @@
}
const model = atSelectedModel?.id ?? selectedModels.find((modelId) => modelId);
const toastId = toast.loading($i18n.t('Compacting context...'));
const compactChatId = $chatId;
const compactMessageId = history.currentId;
if ($contextCompactions[compactMessageId] === 'in_progress') return;
$contextCompactions = { ...$contextCompactions, [compactMessageId]: 'in_progress' };
try {
const result = await compactChatById(localStorage.token, $chatId, model);
const result = await compactChatById(localStorage.token, compactChatId, model);
if ($chatId !== compactChatId) return;
serverContextUsage = result?.context_usage ?? serverContextUsage;
if (result?.compacted) {
toast.success($i18n.t('Context compacted'), { id: toastId });
} else {
if (!result?.compacted) {
const skippedReason =
result?.reason === 'too_short'
? $i18n.t('Chat is too short to compact')
@ -3709,13 +3701,20 @@
: result?.reason === 'disabled'
? $i18n.t('Context compaction is disabled')
: $i18n.t('Nothing to compact');
toast.message(skippedReason, { id: toastId });
toast.message(skippedReason);
}
await loadChat();
clearContextCompaction(compactMessageId);
} catch (error) {
const message = error?.detail ?? error?.message ?? $i18n.t('Context compaction failed');
toast.error(message, { id: toastId });
if ($chatId === compactChatId) {
$contextCompactions = {
...$contextCompactions,
[compactMessageId]: {
error: String(error?.detail ?? error?.message ?? $i18n.t('Context compaction failed'))
}
};
}
} finally {
messageInput?.focus({ preventScroll: true });
}
@ -4616,6 +4615,7 @@
if (responseMessage?.parentId && history.messages[responseMessage.parentId]) {
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
history.messages[messageId].done = true;
clearContextCompaction(messageId);
}
}

View file

@ -1,21 +1,35 @@
<script lang="ts">
import { getContext } from 'svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
export let summary = '';
export let status: 'in_progress' | { error: string } | undefined = undefined;
export let compactPreview = false;
$: rowClass = `flex w-full min-w-0 items-center gap-1.5 py-0.5 ${
compactPreview ? 'text-xs' : 'text-[0.9375rem]'
} text-gray-400 dark:text-gray-500`;
</script>
{#if summary.trim()}
{#if status}
<div role="status" class={rowClass}>
{#if status === 'in_progress'}
<Spinner className="size-4 shrink-0" />
<span class="shimmer">{$i18n.t('Compacting context')}</span>
{:else}
<span>{status.error}</span>
{/if}
</div>
{:else if summary.trim()}
<!-- Only stop the response card's click handler; summary provides keyboard interaction. -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<details class="my-1" on:click|stopPropagation>
<details on:click|stopPropagation>
<summary
class="flex w-full cursor-pointer items-center gap-2 text-xs leading-5 text-gray-500 outline-offset-4 transition-colors before:flex-1 before:border-t before:border-dashed before:border-current before:opacity-25 after:flex-1 after:border-t after:border-dashed after:border-current after:opacity-25 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-gray-400 dark:text-gray-400 dark:hover:text-gray-200 [&::-webkit-details-marker]:hidden"
class="{rowClass} cursor-pointer outline-offset-4 transition-colors hover:text-gray-500 focus-visible:outline-2 focus-visible:outline-gray-400 dark:hover:text-gray-400 [&::-webkit-details-marker]:hidden"
>
<span class="min-w-0 px-2 text-center">
{$i18n.t('Context compacted')}
</span>
<span>{$i18n.t('Context compacted')}</span>
</summary>
<div
role="region"
@ -26,9 +40,7 @@
</div>
</details>
{:else}
<div
class="my-1 flex w-full items-center gap-2 text-xs leading-5 text-gray-500 before:flex-1 before:border-t before:border-dashed before:border-current before:opacity-25 after:flex-1 after:border-t after:border-dashed after:border-current after:opacity-25 dark:text-gray-400"
>
<span class="min-w-0 px-2 text-center">{$i18n.t('Context compacted')}</span>
<div class={rowClass}>
<span>{$i18n.t('Context compacted')}</span>
</div>
{/if}

View file

@ -13,6 +13,8 @@
import type { i18n as i18nType, t } from 'i18next';
const i18n = getContext<Writable<i18nType>>('i18n');
const contextCompactions =
getContext<Writable<Record<string, 'in_progress' | { error: string }>>>('contextCompactions');
const dispatch = createEventDispatcher();
@ -130,6 +132,7 @@
export let chatId = '';
export let history;
export let messageId;
$: compactionStatus = $contextCompactions?.[messageId];
export let selectedModels = [];
let messageSource = history.messages[messageId];
@ -714,8 +717,8 @@
</Name>
{/if}
{#if message.contextSummary}
<ContextCompactionLabel summary={message.contextSummary} />
{#if message.contextSummary && !compactionStatus}
<ContextCompactionLabel summary={message.contextSummary} {compactPreview} />
{/if}
<div>
@ -922,7 +925,11 @@
/>
{/if}
{#if !message.done && !message.error && (hasResponseContent || !hasVisibleStatus)}
{#if compactionStatus}
<ContextCompactionLabel status={compactionStatus} {compactPreview} />
{/if}
{#if !compactionStatus && !message.done && !message.error && (hasResponseContent || !hasVisibleStatus)}
<div class="text-[0.9375rem] leading-relaxed">
<span
class="inline-block w-[0.125rem] h-3.5 bg-gray-400 dark:bg-gray-500 ml-0.5 animate-cursor-pulse align-text-bottom"

View file

@ -172,7 +172,7 @@
<TerminalOutputFile item={displayItem.item} {chatId} />
{/if}
{:else if displayItem.type === 'context_compaction'}
<ContextCompactionLabel summary={displayItem.summary} />
<ContextCompactionLabel summary={displayItem.summary} {compactPreview} />
{:else}
{@const detailToken = displayItem.token}
{#if detailToken.attributes?.type === 'tool_calls'}

View file

@ -5,6 +5,7 @@
import ChevronRightIcon from '$lib/components/layout/Sidebar/icons/ChevronRight.svelte';
import { toast } from 'svelte-sonner';
import { tick, getContext, onMount } from 'svelte';
import type { Writable } from 'svelte/store';
import { models, settings } from '$lib/stores';
import { user as _user } from '$lib/stores';
@ -29,6 +30,8 @@
import SubagentResultRow from './SubagentResultRow.svelte';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
const contextCompactions =
getContext<Writable<Record<string, 'in_progress' | { error: string }>>>('contextCompactions');
export let user;
export let chatId;
@ -196,8 +199,12 @@
</div>
{/if}
{#if message.contextSummary}
<ContextCompactionLabel summary={message.contextSummary} />
{#if message.contextSummary || $contextCompactions?.[messageId]}
<ContextCompactionLabel
summary={message.contextSummary}
status={$contextCompactions?.[messageId]}
{compactPreview}
/>
{/if}
<div class="chat-{message.role} w-full min-w-full">