mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-27 23:17:30 -05:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e285cfe2d8 | ||
|
|
6f6689fe0b | ||
|
|
883f7387bd | ||
|
|
2894120ee1 | ||
|
|
857354c064 | ||
|
|
3b1e02fd69 | ||
|
|
accaf63d41 | ||
|
|
584326fcec | ||
|
|
afe06daa52 | ||
|
|
815edb76ad | ||
|
|
6e24c83b2f | ||
|
|
e138b82720 | ||
|
|
f9fc748df4 | ||
|
|
cddfa89091 | ||
|
|
a8187b8b85 | ||
|
|
ade61a6d7a | ||
|
|
0543f7ba85 |
+1
-2
@@ -406,8 +406,7 @@ Hooks are the thin view-layer between components and stores: they own UI concern
|
||||
| `use-chat-message-edit-context` | Message edit context (content + extras) |
|
||||
| `use-chat-screen-drag-and-drop` | Drag-and-drop state machine |
|
||||
| `use-chat-screen-file-upload` | File upload queue + capability validation |
|
||||
| `use-chat-screen-scroll` | Scroll container binding + navigation guard |
|
||||
| `use-auto-scroll` | Auto-scroll controller for streaming |
|
||||
| `use-stick-to-bottom` | Follows new content while the reader sits at the bottom of a scroller |
|
||||
| `use-marquee-selection` | Shift+click / marquee range selection |
|
||||
| `use-keyboard-shortcuts` | Global keyboard shortcuts |
|
||||
| `use-settings-navigation` | Settings section navigation |
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
} from '$lib/types';
|
||||
import {
|
||||
buildMentionInsertion,
|
||||
canCompactMessages,
|
||||
containsCodeSpan,
|
||||
containsFileMentionLink,
|
||||
findCommandToken,
|
||||
@@ -144,6 +145,7 @@
|
||||
);
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
canCompact: () => canCompactMessages(conversationsStore.activeMessages),
|
||||
focusInput: refocusInput,
|
||||
getCaretOffset: () => inputRef?.getCaretOffset(),
|
||||
getCwd: () => cwd,
|
||||
@@ -163,6 +165,7 @@
|
||||
.getServers()
|
||||
.some((s) => s.enabled && prefs.isServerToolsEnabled(s.id) && s.url.trim());
|
||||
},
|
||||
onCompact: () => chatStore.compactConversation(),
|
||||
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
|
||||
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
|
||||
setValue: (v) => {
|
||||
|
||||
+47
-1
@@ -8,9 +8,12 @@
|
||||
gaugePopup,
|
||||
gaugePopupClose
|
||||
} from './gauge-popup.svelte';
|
||||
import { FoldVertical, Loader2 } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import { agenticStore, chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||
import { canCompactMessages } from '$lib/utils';
|
||||
import { formatParameters } from '$lib/utils/formatters';
|
||||
|
||||
const gauge = useContextGauge();
|
||||
@@ -53,6 +56,25 @@
|
||||
gauge.contextTotal > 0 &&
|
||||
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
|
||||
);
|
||||
|
||||
const showCompactButton = $derived(canCompactMessages(conversationsStore.activeMessages));
|
||||
|
||||
const activeConvId = $derived(conversationsStore.activeConversation?.id ?? null);
|
||||
|
||||
// A summary in flight keeps the card open for the numbers, so the action
|
||||
// reports the run instead of inviting a second one.
|
||||
const isCompacting = $derived(activeConvId !== null && chatStore.isCompacting(activeConvId));
|
||||
|
||||
const isCompactDisabled = $derived.by(() => {
|
||||
if (!activeConvId) return true;
|
||||
|
||||
return chatStore.isChatLoading(activeConvId) || agenticStore.isRunning(activeConvId);
|
||||
});
|
||||
|
||||
function handleCompact() {
|
||||
gaugePopupClose();
|
||||
chatStore.compactConversation();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet usage()}
|
||||
@@ -62,6 +84,26 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet compactAction()}
|
||||
{#if showCompactButton}
|
||||
<Button
|
||||
class="my-1 mt-3 w-full gap-1.5"
|
||||
disabled={isCompactDisabled}
|
||||
onclick={handleCompact}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{#if isCompacting}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
Compacting...
|
||||
{:else}
|
||||
<FoldVertical class="h-3.5 w-3.5" />
|
||||
Compact conversation
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet body()}
|
||||
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
|
||||
<ContextGaugeLoadModel
|
||||
@@ -123,6 +165,8 @@
|
||||
{@render usage()}
|
||||
|
||||
{@render body()}
|
||||
|
||||
{@render compactAction()}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
@@ -145,6 +189,8 @@
|
||||
</div>
|
||||
|
||||
{@render body()}
|
||||
|
||||
{@render compactAction()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
ChatMessageAssistant,
|
||||
ChatMessageCompaction,
|
||||
ChatMessageMcpPrompt,
|
||||
ChatMessageSynthetic,
|
||||
ChatMessageSystem,
|
||||
@@ -14,7 +15,7 @@
|
||||
SYSTEM_MESSAGE_PLACEHOLDER
|
||||
} from '$lib/constants';
|
||||
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
|
||||
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
|
||||
import { AgenticSectionType, AttachmentType, MessageRole, MessageType } from '$lib/enums';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||
import type {
|
||||
@@ -60,6 +61,7 @@
|
||||
// Synthetic cwd-change messages render with the folder-row UI instead
|
||||
// of a user bubble. The persisted flag is the single source of truth.
|
||||
let isSynthetic = $derived(Boolean(message.isSynthetic));
|
||||
let isCompaction = $derived(message.type === MessageType.COMPACTION);
|
||||
|
||||
let rawEditContent = $derived.by(() => {
|
||||
if (message.role !== MessageRole.ASSISTANT) return undefined;
|
||||
@@ -313,6 +315,10 @@
|
||||
chatActions.regenerateWithBranching(message, modelOverride);
|
||||
}
|
||||
|
||||
function handleRegenerateCompaction(modelOverride?: string) {
|
||||
chatActions.regenerateCompaction(message, modelOverride);
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
chatActions.continueAssistantMessage(message);
|
||||
}
|
||||
@@ -409,6 +415,8 @@
|
||||
<ChatMessageSystem bind:textareaElement class={className} {message} />
|
||||
{:else if mcpPromptExtra}
|
||||
<ChatMessageMcpPrompt class={className} mcpPrompt={mcpPromptExtra} {message} />
|
||||
{:else if isCompaction}
|
||||
<ChatMessageCompaction class={className} {message} onRegenerate={handleRegenerateCompaction} />
|
||||
{:else if isSynthetic}
|
||||
<ChatMessageSynthetic class={className} {message} />
|
||||
{:else if message.role === MessageRole.USER}
|
||||
|
||||
+5
-1
@@ -52,6 +52,9 @@
|
||||
let isStreaming = $derived(chatStore.isStreaming());
|
||||
let hasNoContent = $derived(!message?.content?.trim());
|
||||
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
|
||||
// A compaction summary reports its own progress on the compaction row, so
|
||||
// this turn stays quiet while it runs instead of duplicating the indicator.
|
||||
let isCompacting = $derived(chatStore.isCompacting(message.convId));
|
||||
|
||||
// during a router auto-load the message has no model yet: target the model frozen in the
|
||||
// persisted stream state (survives a reload), then fall back to the dropdown selection
|
||||
@@ -66,6 +69,7 @@
|
||||
let showProcessingInfoTop = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
!isCompacting &&
|
||||
hasNoContent &&
|
||||
!isAgentic &&
|
||||
isLastAssistantMessage
|
||||
@@ -74,6 +78,7 @@
|
||||
let showProcessingInfoBottom = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
!isCompacting &&
|
||||
(!hasNoContent || isAgentic) &&
|
||||
isLastAssistantMessage
|
||||
);
|
||||
@@ -188,7 +193,6 @@
|
||||
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
|
||||
{onRegenerate}
|
||||
rawOutputEnabled={showRawOutput}
|
||||
role={MessageRole.ASSISTANT}
|
||||
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+9
@@ -31,6 +31,15 @@
|
||||
promptMs={liveLlm.prompt_ms}
|
||||
promptTokens={liveLlm.prompt_n}
|
||||
/>
|
||||
{:else if showMessageStats && message.timings?.compaction}
|
||||
{@const summary = message.timings.compaction}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
predictedMs={summary.predicted_ms}
|
||||
predictedTokens={summary.predicted_n}
|
||||
promptMs={summary.prompt_ms}
|
||||
promptTokens={summary.prompt_n}
|
||||
/>
|
||||
{:else if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
{@const agentic = message.timings.agentic}
|
||||
<ChatMessageStatistics
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { FoldVertical, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ChatMessageActionIcons,
|
||||
ChatMessageAssistantModel,
|
||||
ChatMessageAssistantStatistics,
|
||||
ChatMessageEditForm,
|
||||
CollapsibleContentBlock,
|
||||
MarkdownContent
|
||||
} from '$lib/components/app';
|
||||
import { COLLAPSIBLE_TRANSITION_MS } from '$lib/constants';
|
||||
import { getChatMessageEditContext } from '$lib/contexts';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { chatStore, conversationsStore, serverStore, settingsStore } from '$lib/stores';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { compactionTokensBefore, formatNumber } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
}
|
||||
|
||||
let { class: className = '', message, onRegenerate }: Props = $props();
|
||||
|
||||
const COMPACTION_HEADER = 'Conversation compacted';
|
||||
const COMPACTION_RUNNING_LABEL = 'Compacting conversation';
|
||||
const COMPACTION_GENERATING_LABEL = 'Generating summary';
|
||||
|
||||
// The shared row context, so the summary is editable and branchable through
|
||||
// the same actions as a chat turn.
|
||||
const editCtx = getChatMessageEditContext();
|
||||
|
||||
const currentConfig = $derived(settingsStore.config);
|
||||
const isRouter = $derived(serverStore.isRouterMode);
|
||||
// The model the server reported for the summary, so a compaction says who
|
||||
// wrote it and the selector can re-summarize with another child.
|
||||
const displayedModel = $derived(message.model ?? null);
|
||||
|
||||
const hasContent = $derived(message.content.trim().length > 0);
|
||||
|
||||
// Only the node a running summary writes to reports progress: a finished
|
||||
// compaction further up the branch keeps its headline while another one runs.
|
||||
const isSummarizing = $derived(chatStore.getCompactingNodeId(message.convId) === message.id);
|
||||
|
||||
const processingState = useProcessingState();
|
||||
|
||||
// The summary request feeds the shared processing state, so the body shows
|
||||
// the same prompt progress line a chat turn does while it waits.
|
||||
$effect(() => {
|
||||
if (isSummarizing) processingState.startMonitoring();
|
||||
});
|
||||
|
||||
// The header carries the run: the prompt progress until the summary starts
|
||||
// streaming, the phase after that. There is nothing to collapse before the
|
||||
// first summary token arrives.
|
||||
const runningLabel = $derived.by(() => {
|
||||
// The prompt is through: whatever streams in now is the summary itself.
|
||||
if (hasContent) return `${COMPACTION_RUNNING_LABEL} — ${COMPACTION_GENERATING_LABEL}`;
|
||||
|
||||
const progress = processingState.getPromptProgressText();
|
||||
|
||||
return progress
|
||||
? `${COMPACTION_RUNNING_LABEL} — ${progress}`
|
||||
: `${COMPACTION_RUNNING_LABEL}...`;
|
||||
});
|
||||
|
||||
// Shrinking the branch is the feature's own headline, so the finished
|
||||
// toggle reports the context size it compacted.
|
||||
const tokensBefore = $derived(compactionTokensBefore(conversationsStore.activeMessages, message));
|
||||
|
||||
const completedLabel = $derived(
|
||||
tokensBefore === null
|
||||
? COMPACTION_HEADER
|
||||
: `${COMPACTION_HEADER} from ${formatNumber(tokensBefore)}`
|
||||
);
|
||||
|
||||
const title = $derived(isSummarizing || !hasContent ? runningLabel : completedLabel);
|
||||
|
||||
let open = $state(false);
|
||||
// A toggle by hand outranks the in-progress expansion, the way the agentic
|
||||
// sections treat their own overrides.
|
||||
let userChoseOpen = $state<boolean | null>(null);
|
||||
|
||||
// The progress lives in the body (as it does for an assistant turn), so the
|
||||
// row opens while the summary runs and collapses once it is done.
|
||||
$effect(() => {
|
||||
if (userChoseOpen !== null) return;
|
||||
|
||||
open = isSummarizing;
|
||||
});
|
||||
|
||||
let rootEl = $state<HTMLDivElement>();
|
||||
// The scroll waiting for the body to reach its height, so closing the block
|
||||
// before that leaves no scroll behind it.
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* A toggle hands the reader the block's own boundary, just below the tabs: one
|
||||
* scroll, and only once the body has its height - before that the page is too
|
||||
* short to scroll the divider up there and the call would clamp.
|
||||
*/
|
||||
function handleToggle() {
|
||||
userChoseOpen = open;
|
||||
cancelSettledScroll();
|
||||
|
||||
if (!rootEl) return;
|
||||
|
||||
settleTimer = setTimeout(() => {
|
||||
settleTimer = null;
|
||||
rootEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, COLLAPSIBLE_TRANSITION_MS);
|
||||
}
|
||||
|
||||
function cancelSettledScroll() {
|
||||
if (settleTimer === null) return;
|
||||
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
|
||||
$effect(() => cancelSettledScroll);
|
||||
</script>
|
||||
|
||||
<div bind:this={rootEl} class="scroll-mt-[calc(var(--chat-tabs-height,0px)+1rem)] {className}">
|
||||
{#if editCtx.isEditing}
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
<CollapsibleContentBlock
|
||||
bind:open
|
||||
class="my-2"
|
||||
collapsible={hasContent}
|
||||
icon={isSummarizing ? Loader2 : FoldVertical}
|
||||
iconClass={isSummarizing ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'}
|
||||
onToggle={handleToggle}
|
||||
shimmerTitle={isSummarizing || !hasContent}
|
||||
{title}
|
||||
variant="divider"
|
||||
>
|
||||
<MarkdownContent class="text-muted-foreground" content={message.content} />
|
||||
|
||||
<!-- The summary's own metadata lives with the summary, so the divider
|
||||
stays a one-line headline. -->
|
||||
{#if displayedModel}
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
<ChatMessageAssistantModel
|
||||
{displayedModel}
|
||||
isLoading={chatStore.isLoading}
|
||||
{isRouter}
|
||||
{onRegenerate}
|
||||
/>
|
||||
|
||||
<ChatMessageAssistantStatistics
|
||||
isLoading={chatStore.isLoading}
|
||||
{message}
|
||||
{processingState}
|
||||
showMessageStats={currentConfig.showMessageStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasContent && !isSummarizing}
|
||||
<!-- Copy, edit, fork, regenerate and delete all apply to the summary:
|
||||
regenerate re-runs it in place. Continue stays a chat-turn flow. -->
|
||||
<ChatMessageActionIcons actionsPosition="left" justify="start" {onRegenerate} />
|
||||
{/if}
|
||||
</CollapsibleContentBlock>
|
||||
{/if}
|
||||
</div>
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
ChatMessageMcpPromptContent
|
||||
} from '$lib/components/app';
|
||||
import { getChatMessageEditContext } from '$lib/contexts';
|
||||
import { McpPromptVariant, MessageRole } from '$lib/enums';
|
||||
import { McpPromptVariant } from '$lib/enums';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
{#if message.timestamp}
|
||||
<div class="max-w-[80%]">
|
||||
<ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
|
||||
<ChatMessageActionIcons actionsPosition="right" justify="end" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import { INPUT_CLASSES } from '$lib/constants';
|
||||
import { getChatMessageEditContext } from '$lib/contexts';
|
||||
import { KeyboardKey, MessageRole } from '$lib/enums';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import { autoResizeTextarea, isIMEComposing } from '$lib/utils';
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
|
||||
{#if message.timestamp}
|
||||
<div class="max-w-[80%]">
|
||||
<ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
|
||||
<ChatMessageActionIcons actionsPosition="right" justify="end" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
+7
-3
@@ -6,7 +6,7 @@
|
||||
ChatMessageUserBubble
|
||||
} from '$lib/components/app/chat';
|
||||
import { getChatMessageEditContext } from '$lib/contexts';
|
||||
import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
|
||||
import { ChatMessageStatisticsMode } from '$lib/enums';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { chatStore, settingsStore } from '$lib/stores';
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
const processingState = useProcessingState();
|
||||
|
||||
const currentConfig = $derived(settingsStore.config);
|
||||
const isActivelyProcessing = $derived(isLastUserMessage && chatStore.isLoading);
|
||||
// A compaction summary reports its own progress on the compaction row, so
|
||||
// the summarized turn does not show live reading stats while it runs.
|
||||
const isActivelyProcessing = $derived(
|
||||
isLastUserMessage && chatStore.isLoading && !chatStore.isCompacting(message.convId)
|
||||
);
|
||||
|
||||
// For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
|
||||
let storedReadingStats = $derived.by(() => {
|
||||
@@ -107,7 +111,7 @@
|
||||
|
||||
{#if message.timestamp}
|
||||
<div class="max-w-[80%]">
|
||||
<ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
|
||||
<ChatMessageActionIcons actionsPosition="right" justify="end" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
+2
-5
@@ -10,11 +10,9 @@
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { getChatMessageActionsContext, getChatMessageEditContext } from '$lib/contexts';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { conversationsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
role: MessageRole.USER | MessageRole.ASSISTANT;
|
||||
justify: 'start' | 'end';
|
||||
actionsPosition: 'left' | 'right';
|
||||
onRegenerate?: () => void;
|
||||
@@ -31,7 +29,6 @@
|
||||
onRawOutputToggle,
|
||||
onRegenerate,
|
||||
rawOutputEnabled = false,
|
||||
role,
|
||||
showRawOutputSwitch = false
|
||||
}: Props = $props();
|
||||
|
||||
@@ -81,11 +78,11 @@
|
||||
|
||||
<ActionIcon icon={Edit} onclick={editCtx.startEdit} tooltip="Edit" />
|
||||
|
||||
{#if role === MessageRole.ASSISTANT && onRegenerate}
|
||||
{#if onRegenerate}
|
||||
<ActionIcon icon={RefreshCw} onclick={() => onRegenerate()} tooltip="Regenerate" />
|
||||
{/if}
|
||||
|
||||
{#if role === MessageRole.ASSISTANT && onContinue}
|
||||
{#if onContinue}
|
||||
<ActionIcon icon={ArrowRight} onclick={onContinue} tooltip="Continue" />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
|
||||
interface Props {
|
||||
messages?: DatabaseMessage[];
|
||||
onUserAction?: () => void;
|
||||
onMessagesReady?: (messageCount: number) => void;
|
||||
}
|
||||
|
||||
let { messages = [], onMessagesReady, onUserAction }: Props = $props();
|
||||
let { messages = [], onMessagesReady }: Props = $props();
|
||||
|
||||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||
|
||||
@@ -25,7 +24,6 @@
|
||||
|
||||
const chatActions: ChatMessageActions = {
|
||||
continueAssistantMessage: async (message: DatabaseMessage) => {
|
||||
onUserAction?.();
|
||||
await chatStore.continueAssistantMessage(message.id);
|
||||
refreshAllMessages();
|
||||
},
|
||||
@@ -51,7 +49,6 @@
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
// in-place edit: the store already updated activeMessages and no
|
||||
// branch is created, so sibling info stays valid without a refetch
|
||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||
@@ -62,7 +59,6 @@
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
|
||||
refreshAllMessages();
|
||||
},
|
||||
@@ -72,7 +68,6 @@
|
||||
newContent: string,
|
||||
shouldBranch: boolean
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
|
||||
|
||||
// only a branch changes sibling info; an in-place edit already
|
||||
@@ -91,8 +86,12 @@
|
||||
await conversationsStore.navigateToSibling(siblingId);
|
||||
},
|
||||
|
||||
regenerateCompaction: async (message: DatabaseMessage, modelOverride?: string) => {
|
||||
await chatStore.regenerateCompaction(message.id, modelOverride);
|
||||
refreshAllMessages();
|
||||
},
|
||||
|
||||
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
|
||||
onUserAction?.();
|
||||
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ChatMessage from './ChatMessage/ChatMessage.svelte';
|
||||
import { MessageType } from '$lib/enums';
|
||||
import { chatStore } from '$lib/stores';
|
||||
import type { ChatMessageActions } from '$lib/types';
|
||||
|
||||
@@ -65,7 +66,8 @@
|
||||
|
||||
<div
|
||||
bind:this={wrapperEl}
|
||||
class:chat-message--synthetic={Boolean(message.isSynthetic)}
|
||||
class:chat-message--synthetic={Boolean(message.isSynthetic) &&
|
||||
message.type !== MessageType.COMPACTION}
|
||||
class="chat-message"
|
||||
>
|
||||
{#if mounted}
|
||||
|
||||
@@ -11,13 +11,12 @@
|
||||
ChatScreenStreamResumeStatus,
|
||||
ServerLoadingSplash
|
||||
} from '$lib/components/app';
|
||||
import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
|
||||
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
|
||||
import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
|
||||
import { useChatScreenScroll } from '$lib/hooks/use-chat-screen-scroll.svelte';
|
||||
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
||||
import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
|
||||
import {
|
||||
chatStore,
|
||||
conversationsStore,
|
||||
@@ -26,16 +25,10 @@
|
||||
settingsStore
|
||||
} from '$lib/stores';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
let { showCenteredEmpty = false } = $props();
|
||||
|
||||
let disableAutoScroll = $derived(
|
||||
Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
|
||||
);
|
||||
let isMobileUserScrolledUp = $state(false);
|
||||
let mobileScrollDownHint = $state(false);
|
||||
let mobileScrollDownHintLockedUntil = $state(0);
|
||||
let emptyFileNames = $state<string[]>([]);
|
||||
let initialMessage = $state('');
|
||||
let showDeleteDialog = $state(false);
|
||||
@@ -57,8 +50,9 @@
|
||||
return '0.5rem';
|
||||
});
|
||||
|
||||
const autoScroll = createAutoScrollController();
|
||||
const scroll = useChatScreenScroll(autoScroll);
|
||||
// The page is the scroller: it follows new content while the reader sits at
|
||||
// the bottom, and otherwise leaves their position alone.
|
||||
const stick = createStickToBottom();
|
||||
const activeModel = useChatScreenActiveModel();
|
||||
const fileUpload = useChatScreenFileUpload({
|
||||
activeModelId: () => activeModel.activeModelId,
|
||||
@@ -79,19 +73,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleMobileScroll() {
|
||||
if (!deviceStore.isMobile) return;
|
||||
|
||||
const container = scroll.chatScrollContainer;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.clientHeight - container.scrollTop;
|
||||
|
||||
isMobileUserScrolledUp = distanceFromBottom > 300;
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
const conversation = conversationsStore.activeConversation;
|
||||
|
||||
@@ -123,7 +104,8 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
handleSendLikeScroll();
|
||||
// Sending re-arms following: the reply streams in under the message.
|
||||
stick.scrollToBottom();
|
||||
|
||||
await chatStore.sendMessage(message, result?.extras);
|
||||
|
||||
@@ -132,12 +114,9 @@
|
||||
|
||||
let lastScrolledConversationId: string | null = null;
|
||||
|
||||
// Lands at the bottom of a conversation the first time its messages
|
||||
// render, whether the route comes from another conversation or from a
|
||||
// non-conversation route. The page keeps growing after the first pin
|
||||
// without DOM mutations (content-visibility size realizations, syntax
|
||||
// highlight passes), so the instant pin repeats every frame until the
|
||||
// height settles, bailing out on user scroll or conversation change.
|
||||
// Lands at the bottom of a conversation the first time its messages render.
|
||||
// The resize observer keeps it there while the list settles (lazy rows,
|
||||
// syntax highlighting, images) without polling for a stable height.
|
||||
async function handleMessagesReady(messageCount: number) {
|
||||
if (messageCount === 0) return;
|
||||
|
||||
@@ -147,80 +126,7 @@
|
||||
|
||||
lastScrolledConversationId = id;
|
||||
await tick();
|
||||
autoScroll.scrollToBottom();
|
||||
|
||||
const container = scroll.chatScrollContainer;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const started = performance.now();
|
||||
|
||||
let stableFrames = 0;
|
||||
let lastHeight = container.scrollHeight;
|
||||
|
||||
const settle = () => {
|
||||
if (autoScroll.userScrolledUp) return;
|
||||
|
||||
if (conversationsStore.activeConversation?.id !== id) return;
|
||||
|
||||
autoScroll.scrollToBottom();
|
||||
const height = container.scrollHeight;
|
||||
|
||||
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
|
||||
lastHeight = height;
|
||||
|
||||
if (stableFrames >= LANDING_STABLE_FRAMES) return;
|
||||
|
||||
if (performance.now() - started > LANDING_SETTLE_MAX_MS) return;
|
||||
|
||||
requestAnimationFrame(settle);
|
||||
};
|
||||
|
||||
requestAnimationFrame(settle);
|
||||
}
|
||||
|
||||
function handleSendLikeScroll() {
|
||||
if (!deviceStore.isMobile) {
|
||||
autoScroll.enable();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const container = scroll.chatScrollContainer;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const lastUserBubble = container.querySelector(
|
||||
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (deviceStore.isMobile) {
|
||||
// Keep the last user message bubble just above the input on mobile
|
||||
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
|
||||
const baseHeight = container.scrollHeight - innerHeight;
|
||||
|
||||
container.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight
|
||||
});
|
||||
} else if (lastUserBubble) {
|
||||
// On desktop, place the last user message near the top of the viewport
|
||||
const topPadding = 24;
|
||||
const bubbleRect = lastUserBubble.getBoundingClientRect();
|
||||
|
||||
container.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding)
|
||||
});
|
||||
} else {
|
||||
autoScroll.scrollToBottom();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
if (deviceStore.isMobile) {
|
||||
autoScroll.setDisabled(disableAutoScroll);
|
||||
mobileScrollDownHint = true;
|
||||
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
||||
}
|
||||
stick.scrollToBottom();
|
||||
}
|
||||
|
||||
function handleErrorDialogOpenChange(open: boolean) {
|
||||
@@ -238,15 +144,13 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const shouldDisableAutoScroll =
|
||||
settingsStore.config.disableAutoScroll ||
|
||||
(deviceStore.isMobile && isCurrentConversationLoading);
|
||||
stick.setEnabled(!settingsStore.config.disableAutoScroll);
|
||||
});
|
||||
|
||||
autoScroll.setDisabled(shouldDisableAutoScroll);
|
||||
$effect(() => {
|
||||
stick.attach(document.documentElement, document.body);
|
||||
|
||||
if (!shouldDisableAutoScroll) {
|
||||
autoScroll.enable();
|
||||
}
|
||||
return () => stick.detach();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
@@ -256,39 +160,14 @@
|
||||
initialMessage = pendingDraft.message;
|
||||
fileUpload.uploadedFiles = pendingDraft.files;
|
||||
}
|
||||
|
||||
autoScroll.startObserving();
|
||||
|
||||
if (!disableAutoScroll) {
|
||||
autoScroll.enable();
|
||||
}
|
||||
|
||||
if (deviceStore.isMobile && isCurrentConversationLoading) {
|
||||
mobileScrollDownHint = true;
|
||||
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
||||
}
|
||||
|
||||
handleMobileScroll();
|
||||
});
|
||||
|
||||
onDestroy(() => autoScroll.destroy());
|
||||
</script>
|
||||
|
||||
{#if dragAndDrop.isDragOver}
|
||||
<ChatScreenDragOverlay />
|
||||
{/if}
|
||||
|
||||
<svelte:window
|
||||
onkeydown={handleKeydown}
|
||||
onscroll={(e) => {
|
||||
scroll.handleScroll(e);
|
||||
handleMobileScroll();
|
||||
|
||||
if (e.isTrusted && Date.now() > mobileScrollDownHintLockedUntil) {
|
||||
mobileScrollDownHint = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<svelte:window onkeydown={handleKeydown} onscroll={() => stick.handleScroll()} />
|
||||
|
||||
{#if isServerLoading}
|
||||
<ServerLoadingSplash />
|
||||
@@ -306,9 +185,6 @@
|
||||
<ChatMessages
|
||||
messages={conversationsStore.activeMessages}
|
||||
onMessagesReady={handleMessagesReady}
|
||||
onUserAction={() => {
|
||||
handleSendLikeScroll();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -338,16 +214,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
|
||||
{#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
|
||||
<ChatScreenActionScrollDown
|
||||
onclick={() => {
|
||||
mobileScrollDownHint = false;
|
||||
scroll.chatScrollContainer?.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: scroll.chatScrollContainer.scrollHeight
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{#if !stick.atBottom && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
|
||||
<ChatScreenActionScrollDown onclick={() => stick.scrollToBottom('smooth')} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -412,10 +412,7 @@ export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormP
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatMessages
|
||||
* messages={activeMessages()}
|
||||
* onUserAction={resetAutoScroll}
|
||||
* />
|
||||
* <ChatMessages messages={activeMessages()} onMessagesReady={handleReady} />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
|
||||
@@ -549,6 +546,13 @@ export { default as ChatMessageCwdChange } from './ChatMessages/ChatMessage/Chat
|
||||
*/
|
||||
export { default as ChatMessageSynthetic } from './ChatMessages/ChatMessage/ChatMessageSynthetic.svelte';
|
||||
|
||||
/**
|
||||
* Collapsible summary card for compaction nodes. Shows a shimmering pending
|
||||
* header while the summary streams in, then the stored summary as markdown;
|
||||
* later requests slice the branch at this node.
|
||||
*/
|
||||
export { default as ChatMessageCompaction } from './ChatMessages/ChatMessage/ChatMessageCompaction.svelte';
|
||||
|
||||
/**
|
||||
* Formatted content display for MCP prompt messages. Renders the full prompt
|
||||
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
|
||||
@@ -616,7 +620,7 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMes
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Composes ChatMessages, ChatScreenForm, and dialogs
|
||||
* - Manages auto-scroll via `createAutoScrollController()` hook
|
||||
* - Follows new content via `createStickToBottom()` while the reader is at the bottom
|
||||
* - Handles file upload pipeline (validation → processing → state update)
|
||||
* - Integrates with serverStore for loading/error/warning states
|
||||
* - Tracks active model for modality validation (vision, audio)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import CollapsibleRegion from '$lib/components/app/misc/CollapsibleRegion.svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
@@ -16,6 +17,16 @@
|
||||
titleSnippet?: Snippet;
|
||||
subtitle?: string;
|
||||
shimmerTitle?: boolean;
|
||||
/**
|
||||
* `divider` centers the toggle between two rules, for blocks that mark a
|
||||
* section boundary in the conversation instead of decorating a message.
|
||||
*/
|
||||
variant?: 'default' | 'divider';
|
||||
/**
|
||||
* Render the header only, without a chevron or a body, for a block that
|
||||
* has nothing to show yet and turns into a collapsible once it has.
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
onToggle?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
@@ -23,6 +34,7 @@
|
||||
let {
|
||||
children,
|
||||
class: className = '',
|
||||
collapsible = true,
|
||||
icon: IconComponent,
|
||||
iconClass = ICON_CLASS_DEFAULT,
|
||||
iconUrl = null,
|
||||
@@ -31,7 +43,8 @@
|
||||
shimmerTitle = false,
|
||||
subtitle,
|
||||
title = '',
|
||||
titleSnippet
|
||||
titleSnippet,
|
||||
variant = 'default'
|
||||
}: Props = $props();
|
||||
|
||||
function hideBrokenIcon(event: Event) {
|
||||
@@ -39,6 +52,60 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet toggleIcon(layoutClass: string)}
|
||||
{#if iconUrl}
|
||||
<img
|
||||
alt=""
|
||||
class={cn('shrink-0 rounded-sm', layoutClass, iconClass)}
|
||||
onerror={hideBrokenIcon}
|
||||
src={iconUrl}
|
||||
/>
|
||||
{:else if IconComponent}
|
||||
<IconComponent class={cn('shrink-0 text-muted-foreground/60', layoutClass, iconClass)} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet dividerRow()}
|
||||
<span aria-hidden="true" class="h-px min-w-4 flex-1 bg-current opacity-25"></span>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{@render toggleIcon('')}
|
||||
|
||||
{@render toggleTitle('text-xs whitespace-nowrap')}
|
||||
|
||||
{#if collapsible}
|
||||
<ChevronDown
|
||||
class={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground/60 transition-transform duration-150 ease-out',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span aria-hidden="true" class="h-px min-w-4 flex-1 bg-current opacity-25"></span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet toggleTitle(layoutClass: string)}
|
||||
<span
|
||||
class={cn(
|
||||
'min-w-0 font-medium',
|
||||
layoutClass,
|
||||
shimmerTitle ? 'shimmer-text' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{#if titleSnippet}
|
||||
{@render titleSnippet()}
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if subtitle}
|
||||
<span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Collapsible.Root
|
||||
class={cn('group/collapsible', 'my-0!', className)}
|
||||
onOpenChange={(value) => {
|
||||
@@ -47,62 +114,55 @@
|
||||
}}
|
||||
{open}
|
||||
>
|
||||
<Collapsible.Trigger
|
||||
class={cn(
|
||||
'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
|
||||
'py-1.5 pr-1'
|
||||
)}
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-2 text-muted-foreground">
|
||||
{#if iconUrl}
|
||||
<img
|
||||
alt=""
|
||||
class={cn('shrink-0 rounded-sm mt-0.75', iconClass)}
|
||||
onerror={hideBrokenIcon}
|
||||
src={iconUrl}
|
||||
/>
|
||||
{:else if IconComponent}
|
||||
<IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.75', iconClass)} />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'min-w-0 overflow-x-auto text-sm font-medium',
|
||||
shimmerTitle ? 'shimmer-text' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{#if titleSnippet}
|
||||
{@render titleSnippet()}
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if subtitle}
|
||||
<span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
|
||||
{/if}
|
||||
{#if variant === 'divider' && !collapsible}
|
||||
<div class="flex w-full items-center gap-2 py-1.5 text-muted-foreground">
|
||||
{@render dividerRow()}
|
||||
</div>
|
||||
{:else if variant === 'divider'}
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full cursor-pointer items-center gap-2 py-1.5 text-muted-foreground"
|
||||
>
|
||||
{@render dividerRow()}
|
||||
|
||||
<ChevronDown
|
||||
<span class="sr-only">Toggle content</span>
|
||||
</Collapsible.Trigger>
|
||||
{:else}
|
||||
<Collapsible.Trigger
|
||||
class={cn(
|
||||
'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.75',
|
||||
open && 'rotate-180'
|
||||
'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
|
||||
'py-1.5 pr-1'
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-2 text-muted-foreground">
|
||||
{@render toggleIcon('mt-0.75')}
|
||||
|
||||
<span class="sr-only">Toggle content</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<!-- Collapsible.Content renders its children unconditionally and only sets
|
||||
`hidden`, so a closed block would keep re-rendering its whole body on
|
||||
every streamed token. Gate on `open` so collapsed content costs nothing. -->
|
||||
{#if open}
|
||||
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
|
||||
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
{@render toggleTitle('overflow-x-auto text-sm')}
|
||||
</div>
|
||||
{/if}
|
||||
</Collapsible.Content>
|
||||
|
||||
<ChevronDown
|
||||
class={cn(
|
||||
'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.75',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span class="sr-only">Toggle content</span>
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
|
||||
{#if collapsible}
|
||||
<!-- Content is force-mounted so the trigger keeps a real `aria-controls` target;
|
||||
the region owns opening and closing. It animates the height instead of
|
||||
snapping it, and unmounts the body once the collapse is over - a closed
|
||||
block still costs nothing, which matters while tokens stream past it. -->
|
||||
<Collapsible.Content forceMount>
|
||||
<CollapsibleRegion {open}>
|
||||
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
|
||||
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleRegion>
|
||||
</Collapsible.Content>
|
||||
{/if}
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
UI_DATA_ATTRS
|
||||
} from '$lib/constants';
|
||||
import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import {
|
||||
@@ -123,8 +123,8 @@
|
||||
|
||||
let streamingCodeScrollContainer = $state<HTMLDivElement>();
|
||||
|
||||
// Auto-scroll controller for streaming code block content
|
||||
const streamingAutoScroll = createAutoScrollController();
|
||||
// Follows the code that streams into the fixed-height block below.
|
||||
const streamingScroll = createStickToBottom();
|
||||
|
||||
let pendingMarkdown: string | null = null;
|
||||
let isProcessing = false;
|
||||
@@ -800,18 +800,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-scroll for streaming code block
|
||||
// Follow the streaming code block while the reader stays at its bottom; the
|
||||
// resize observer does the pinning, so no polling for a stable height.
|
||||
$effect(() => {
|
||||
streamingAutoScroll.setContainer(streamingCodeScrollContainer);
|
||||
});
|
||||
streamingScroll.attach(streamingCodeScrollContainer);
|
||||
|
||||
$effect(() => {
|
||||
streamingAutoScroll.updateInterval(incompleteCodeBlock !== null);
|
||||
return () => streamingScroll.detach();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupEventListeners();
|
||||
streamingAutoScroll.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -905,7 +903,7 @@
|
||||
<div
|
||||
bind:this={streamingCodeScrollContainer}
|
||||
class="streaming-code-scroll-container"
|
||||
onscroll={() => streamingAutoScroll.handleScroll()}
|
||||
onscroll={() => streamingScroll.handleScroll()}
|
||||
>
|
||||
<pre class="streaming-code-pre"><code
|
||||
class="hljs language-{incompleteCodeBlock.language || 'text'}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { COLLAPSIBLE_TRANSITION_MS } from '$lib/constants';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -8,9 +9,8 @@
|
||||
|
||||
let { children, open }: Props = $props();
|
||||
|
||||
// rows stay mounted through the collapse transition so it can play
|
||||
const EXPAND_TRANSITION_MS = 200;
|
||||
// the initial state only: the effect below owns it afterwards
|
||||
// rows stay mounted through the collapse transition so it can play; this is
|
||||
// only the initial state, the effect below owns it afterwards
|
||||
// svelte-ignore state_referenced_locally
|
||||
let contentMounted = $state(open);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => (contentMounted = false), EXPAND_TRANSITION_MS);
|
||||
const timer = setTimeout(() => (contentMounted = false), COLLAPSIBLE_TRANSITION_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
import { DropdownMenuSearchable, ModelsSelectorList } from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/utils';
|
||||
|
||||
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));
|
||||
// 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)
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
export const AUTO_SCROLL_INTERVAL = 100;
|
||||
// Conversation landing: the page keeps growing after the first bottom pin
|
||||
// without DOM mutations (content-visibility size realizations, syntax
|
||||
// highlight passes), so the pin repeats every frame until the height holds
|
||||
// for this many consecutive frames, bounded by the time cap below.
|
||||
export const LANDING_STABLE_FRAMES = 10;
|
||||
export const LANDING_SETTLE_MAX_MS = 1000;
|
||||
// Chat main view: tight threshold because scroll-here events come from
|
||||
// discrete assistant-message appends.
|
||||
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
|
||||
// Nested scroll areas that follow their own content while it streams. The page
|
||||
// itself is driven by `createStickToBottom()`, which needs no tuning knobs.
|
||||
// Reasoning block: stickier because reasoning fires many small
|
||||
// incremental DOM writes that easily drift a few pixels off bottom.
|
||||
export const REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/* Conversation compaction constants */
|
||||
import { CONVERSATION_ID_SEPARATOR } from './stream.constants';
|
||||
|
||||
const SUMMARY_PLACEHOLDER = '{{SUMMARY}}';
|
||||
|
||||
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
|
||||
// bare conversation id can never reattach it as a chat turn.
|
||||
STREAM_ID_PREFIX: `compact${CONVERSATION_ID_SEPARATOR}`,
|
||||
SUMMARY_PLACEHOLDER
|
||||
} as const;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
File as FileIcon,
|
||||
FileText as FileTextIcon,
|
||||
FolderOpen as CwdCommandIcon,
|
||||
FoldVertical as CompactCommandIcon,
|
||||
Image as ImageIcon,
|
||||
Lightbulb as ReasoningIcon,
|
||||
Loader2 as LoadingIcon,
|
||||
@@ -104,6 +105,7 @@ export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabiliti
|
||||
|
||||
/** Icons of the slash-command picker, one per chat-form command. */
|
||||
export const CHAT_FORM_COMMAND_ICONS: Record<ChatFormCommandAction, Component> = {
|
||||
[ChatFormCommandAction.COMPACT]: CompactCommandIcon,
|
||||
[ChatFormCommandAction.CWD]: CwdCommandIcon,
|
||||
[ChatFormCommandAction.MODEL]: MODEL_ICON,
|
||||
[ChatFormCommandAction.PROMPT]: PromptCommandIcon
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from './tool-ui.constants';
|
||||
export * from './cache.constants';
|
||||
export * from './chat-form.constants';
|
||||
export * from './cli-flags.constants';
|
||||
export * from './compaction.constants';
|
||||
export * from './code-block.constants';
|
||||
export * from './context-keys.constants';
|
||||
export * from './control-actions.constants';
|
||||
|
||||
@@ -11,6 +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',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_AGENTIC_CONFIG } from './agentic.constants';
|
||||
import { CLI_FLAGS } from './cli-flags.constants';
|
||||
import { COMPACTION } from './compaction.constants';
|
||||
import { DEFAULT_MCP_CONFIG } from './mcp.constants';
|
||||
import { SETTINGS_KEYS } from './settings-keys.constants';
|
||||
import { TITLE_GENERATION } from './title-generation.constants';
|
||||
@@ -176,6 +177,28 @@ 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.',
|
||||
key: SETTINGS_KEYS.COMPACTION_PROMPT,
|
||||
label: 'Compaction prompt',
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
|
||||
@@ -257,7 +280,7 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
|
||||
help: 'Stop the view from following new content while messages stream, so you control the viewport position yourself.',
|
||||
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
|
||||
label: 'Disable automatic scroll',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
|
||||
@@ -10,6 +10,9 @@ export const COPY_FEEDBACK_RESET_MS = 1500;
|
||||
/** How long a models dialog view fades out before the next one fades in. */
|
||||
export const MODELS_DIALOG_VIEW_FADE_MS = 150;
|
||||
|
||||
/** How long a collapsible block's body takes to expand or collapse. */
|
||||
export const COLLAPSIBLE_TRANSITION_MS = 200;
|
||||
|
||||
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export enum MessageRole {
|
||||
* Message types for different content kinds.
|
||||
*/
|
||||
export enum MessageType {
|
||||
COMPACTION = 'compaction',
|
||||
ROOT = 'root',
|
||||
SYSTEM = 'system',
|
||||
TEXT = 'text',
|
||||
@@ -80,6 +81,7 @@ export enum PdfViewMode {
|
||||
}
|
||||
|
||||
export enum ChatFormCommandAction {
|
||||
COMPACT = 'compact',
|
||||
CWD = 'cwd',
|
||||
MODEL = 'model',
|
||||
PROMPT = 'prompt'
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum SyncableParameterType {
|
||||
export enum SettingsFieldType {
|
||||
CHECKBOX = 'checkbox',
|
||||
INPUT = 'input',
|
||||
MODEL_SELECT = 'model_select',
|
||||
RADIO = 'radio',
|
||||
SELECT = 'select',
|
||||
TEXTAREA = 'textarea'
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
|
||||
|
||||
export interface AutoScrollOptions {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an auto-scroll controller for a scrollable container.
|
||||
*
|
||||
* Features:
|
||||
* - Auto-scrolls to bottom during streaming/loading
|
||||
* - Stops auto-scroll when user manually scrolls up
|
||||
* - Resumes auto-scroll when user scrolls back to bottom
|
||||
*/
|
||||
export class AutoScrollController {
|
||||
private _autoScrollEnabled = $state(true);
|
||||
private _container: HTMLElement | undefined;
|
||||
private _disabled: boolean;
|
||||
private _lastScrollTop = $state(0);
|
||||
private _mutationObserver: MutationObserver | null = null;
|
||||
private _observerEnabled = false;
|
||||
private _rafPending = false;
|
||||
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
|
||||
private _userScrolledUp = $state(false);
|
||||
get autoScrollEnabled(): boolean {
|
||||
return this._autoScrollEnabled;
|
||||
}
|
||||
|
||||
get userScrolledUp(): boolean {
|
||||
return this._userScrolledUp;
|
||||
}
|
||||
|
||||
constructor(options: AutoScrollOptions = {}) {
|
||||
this._disabled = options.disabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources. Call this in onDestroy or when the component unmounts.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stopInterval();
|
||||
this._doStopObserving();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables auto-scroll (e.g., when user sends a message).
|
||||
*/
|
||||
enable(): void {
|
||||
if (this._disabled) return;
|
||||
|
||||
this._userScrolledUp = false;
|
||||
this._autoScrollEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles scroll events to detect user scroll direction and toggle auto-scroll.
|
||||
*/
|
||||
handleScroll(): void {
|
||||
if (this._disabled || !this._container) return;
|
||||
|
||||
const { clientHeight, scrollHeight, scrollTop } = this._container;
|
||||
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
|
||||
const isScrollingUp = scrollTop < this._lastScrollTop;
|
||||
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
|
||||
|
||||
if (isScrollingUp && !isAtBottom) {
|
||||
this._userScrolledUp = true;
|
||||
this._autoScrollEnabled = false;
|
||||
} else if (isAtBottom && this._userScrolledUp) {
|
||||
this._userScrolledUp = false;
|
||||
this._autoScrollEnabled = true;
|
||||
}
|
||||
|
||||
this._lastScrollTop = scrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets scroll state when switching conversations.
|
||||
*/
|
||||
resetScrollState(): void {
|
||||
this._userScrolledUp = false;
|
||||
this._autoScrollEnabled = !this._disabled;
|
||||
|
||||
if (this._container) {
|
||||
this._lastScrollTop = this._container.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the container to the bottom instantly.
|
||||
*/
|
||||
scrollToBottom(): void {
|
||||
if (this._disabled || !this._container) return;
|
||||
|
||||
this._container.scrollTop = this._container.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the controller to a scrollable container element.
|
||||
*/
|
||||
setContainer(container: HTMLElement | undefined): void {
|
||||
this._doStopObserving();
|
||||
this._container = container;
|
||||
|
||||
if (this._observerEnabled && container && !this._disabled) {
|
||||
this._doStartObserving();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the disabled state.
|
||||
*/
|
||||
setDisabled(disabled: boolean): void {
|
||||
if (this._disabled === disabled) return;
|
||||
|
||||
this._disabled = disabled;
|
||||
|
||||
if (disabled) {
|
||||
this._autoScrollEnabled = false;
|
||||
this.stopInterval();
|
||||
this._doStopObserving();
|
||||
} else if (this._observerEnabled && this._container && !this._mutationObserver) {
|
||||
this._doStartObserving();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auto-scroll interval for continuous scrolling during streaming.
|
||||
*/
|
||||
startInterval(): void {
|
||||
if (this._disabled || this._scrollInterval) return;
|
||||
|
||||
this._scrollInterval = setInterval(() => {
|
||||
this.scrollToBottom();
|
||||
}, AUTO_SCROLL_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a MutationObserver on the container that auto-scrolls to bottom
|
||||
* on content changes. More responsive than interval-based polling.
|
||||
*/
|
||||
startObserving(): void {
|
||||
this._observerEnabled = true;
|
||||
|
||||
if (this._container && !this._disabled && !this._mutationObserver) {
|
||||
this._doStartObserving();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the auto-scroll interval.
|
||||
*/
|
||||
stopInterval(): void {
|
||||
if (this._scrollInterval) {
|
||||
clearInterval(this._scrollInterval);
|
||||
this._scrollInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the MutationObserver.
|
||||
*/
|
||||
stopObserving(): void {
|
||||
this._observerEnabled = false;
|
||||
this._doStopObserving();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the auto-scroll interval based on streaming state.
|
||||
* Call this in a $effect to automatically manage the interval.
|
||||
*/
|
||||
updateInterval(isStreaming: boolean): void {
|
||||
if (this._disabled) {
|
||||
this.stopInterval();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStreaming && this._autoScrollEnabled) {
|
||||
if (!this._scrollInterval) {
|
||||
this.startInterval();
|
||||
}
|
||||
} else {
|
||||
this.stopInterval();
|
||||
}
|
||||
}
|
||||
|
||||
private _doStartObserving(): void {
|
||||
if (!this._container || this._mutationObserver) return;
|
||||
|
||||
this._mutationObserver = new MutationObserver(() => {
|
||||
if (!this._autoScrollEnabled || this._rafPending) return;
|
||||
|
||||
this._rafPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._rafPending = false;
|
||||
|
||||
if (this._autoScrollEnabled && this._container) {
|
||||
this._container.scrollTop = this._container.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this._mutationObserver.observe(this._container, {
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
private _doStopObserving(): void {
|
||||
if (this._mutationObserver) {
|
||||
this._mutationObserver.disconnect();
|
||||
this._mutationObserver = null;
|
||||
}
|
||||
|
||||
this._rafPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AutoScrollController instance.
|
||||
*/
|
||||
export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController {
|
||||
return new AutoScrollController(options);
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export interface UseChatFormPickersOptions {
|
||||
hasPrompts: () => boolean;
|
||||
/** Gates `/cwd`. */
|
||||
hasCwdTools: () => boolean;
|
||||
/** Gates `/compact`. */
|
||||
canCompact: () => boolean;
|
||||
/** Runs `/compact`. */
|
||||
onCompact: () => void;
|
||||
getCwd: () => string | null;
|
||||
/** Mention search fallback scope. */
|
||||
getServerHome: () => string | null;
|
||||
@@ -59,6 +63,7 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
|
||||
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
|
||||
const availableCommands = $derived(
|
||||
getChatCommands({
|
||||
canCompact: opts.canCompact,
|
||||
hasCwdTools: opts.hasCwdTools,
|
||||
hasPrompts: opts.hasPrompts,
|
||||
showModelSelector: opts.getShowModelSelector()
|
||||
@@ -74,6 +79,12 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
|
||||
commandQuery = '';
|
||||
|
||||
switch (command.action) {
|
||||
case ChatFormCommandAction.COMPACT:
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
opts.setValue('');
|
||||
opts.onCompact();
|
||||
|
||||
break;
|
||||
case ChatFormCommandAction.PROMPT:
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
opts.setValue('');
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Scroll container binding and navigation guard for the ChatScreen.
|
||||
*
|
||||
* Binds the `AutoScrollController` to `document.documentElement`, exposes
|
||||
* the container for programmatic scrolling, and flags an `isNavigating`
|
||||
* window during route changes so the controller can reset without its
|
||||
* scroll handler seeing spurious events from layout shifts.
|
||||
*/
|
||||
|
||||
import type { AutoScrollController } from './use-auto-scroll.svelte';
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
|
||||
export function useChatScreenScroll(autoScroll: AutoScrollController) {
|
||||
let chatScrollContainer: HTMLElement | undefined = $state();
|
||||
let isNavigating = $state(false);
|
||||
|
||||
function handleScroll(event: UIEvent) {
|
||||
// Ignore scroll events caused by navigation layout changes or by our own
|
||||
// programmatic scrolls so they don't accidentally disable auto-scroll.
|
||||
if (isNavigating || !event.isTrusted) return;
|
||||
|
||||
autoScroll.handleScroll();
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
isNavigating = true;
|
||||
autoScroll.resetScrollState();
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
setTimeout(() => {
|
||||
isNavigating = false;
|
||||
autoScroll.resetScrollState();
|
||||
}, 10);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
chatScrollContainer = document.documentElement;
|
||||
autoScroll.setContainer(chatScrollContainer);
|
||||
});
|
||||
|
||||
return {
|
||||
get chatScrollContainer() {
|
||||
return chatScrollContainer;
|
||||
},
|
||||
handleScroll
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Keeps a scroll container pinned to its bottom while content grows at the end.
|
||||
*
|
||||
* The rules are deliberately small, because the browser already does the hard
|
||||
* part: content that grows below the viewport does not move the reader (scroll
|
||||
* anchoring), so a block they expand or a late layout pass leaves the viewport
|
||||
* exactly where it was. This only adds the one behaviour CSS cannot express -
|
||||
* following new content while the reader sits at the bottom - and it stops the
|
||||
* moment they scroll away from it.
|
||||
*/
|
||||
|
||||
/** Distance from the bottom that still counts as being at the bottom. */
|
||||
const AT_BOTTOM_PX = 24;
|
||||
/**
|
||||
* Growth this soon after a press is the reader's own doing (expanding a block,
|
||||
* opening a preview), so it must not drag the viewport down with it.
|
||||
*/
|
||||
const INTERACTION_GRACE_MS = 400;
|
||||
|
||||
export class StickToBottom {
|
||||
/** Measured position, which drives the "scroll to bottom" affordance. */
|
||||
atBottom = $state(true);
|
||||
/** The reader is at the bottom, so new content should follow. */
|
||||
stick = $state(true);
|
||||
|
||||
private container: HTMLElement | undefined;
|
||||
private enabled = true;
|
||||
private markPressed = (): void => {
|
||||
this.pressedAt = performance.now();
|
||||
};
|
||||
private observer: ResizeObserver | null = null;
|
||||
|
||||
private pressedAt = 0;
|
||||
|
||||
/**
|
||||
* Binds the scroller and the element whose growth should be followed.
|
||||
* `content` defaults to the scroller's first child, which is what a panel
|
||||
* with a single growing body needs.
|
||||
*/
|
||||
attach(container: HTMLElement | undefined, content?: HTMLElement | undefined): void {
|
||||
this.detach();
|
||||
this.container = container;
|
||||
|
||||
const observed = content ?? container?.firstElementChild;
|
||||
|
||||
if (!container || !observed) return;
|
||||
|
||||
this.observer = new ResizeObserver(() => this.follow());
|
||||
this.observer.observe(observed);
|
||||
container.addEventListener('pointerdown', this.markPressed, {
|
||||
capture: true,
|
||||
passive: true
|
||||
});
|
||||
|
||||
this.measure();
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
this.container?.removeEventListener('pointerdown', this.markPressed, { capture: true });
|
||||
this.container = undefined;
|
||||
}
|
||||
|
||||
/** Reader scrolls are the only thing that re-arms or stops following. */
|
||||
handleScroll(): void {
|
||||
this.measure();
|
||||
}
|
||||
|
||||
scrollToBottom(behavior: ScrollBehavior = 'auto'): void {
|
||||
const el = this.container;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
el.scrollTo({ behavior, top: el.scrollHeight });
|
||||
this.stick = true;
|
||||
this.atBottom = true;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
private distanceFromBottom(el: HTMLElement): number {
|
||||
return el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
}
|
||||
|
||||
private follow(): void {
|
||||
const el = this.container;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
const pressedRecently = performance.now() - this.pressedAt < INTERACTION_GRACE_MS;
|
||||
|
||||
if (this.enabled && this.stick && !pressedRecently) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
this.atBottom = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
|
||||
}
|
||||
|
||||
private measure(): void {
|
||||
const el = this.container;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
this.stick = this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
|
||||
}
|
||||
}
|
||||
|
||||
export function createStickToBottom(): StickToBottom {
|
||||
return new StickToBottom();
|
||||
}
|
||||
@@ -842,7 +842,7 @@ export class ChatService {
|
||||
model?: string | null,
|
||||
excludeReasoning?: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
): Promise<ChatMessageTimings | null> {
|
||||
const normalizedMessages: ApiChatMessageData[] =
|
||||
await ChatService.normalizeMessagesForApi(messages);
|
||||
const requestBody: Record<string, unknown> = {
|
||||
@@ -869,17 +869,38 @@ export class ChatService {
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(API_CHAT.COMPLETIONS, {
|
||||
const response = await fetch(API_CHAT.COMPLETIONS, {
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: getJsonHeaders(),
|
||||
method: 'POST',
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// llama.cpp attaches a timings object with the exact fresh and
|
||||
// cached prompt token split; usage.prompt_tokens is the
|
||||
// OpenAI-compatible total, kept as fallback.
|
||||
if (typeof data?.timings?.prompt_n === 'number') {
|
||||
return {
|
||||
cache_n: data.timings.cache_n ?? 0,
|
||||
predicted_n: 0,
|
||||
prompt_n: data.timings.prompt_n
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof data?.usage?.prompt_tokens === 'number') {
|
||||
return { cache_n: 0, predicted_n: 0, prompt_n: data.usage.prompt_tokens };
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn('[ChatService] Pre-encode request failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// probe the resume route status without consuming the stream: the SSE route has no HEAD,
|
||||
|
||||
@@ -16,14 +16,13 @@
|
||||
* Composed under chatStore.activity; not exported from the stores barrel.
|
||||
*/
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export class ChatActivityStore {
|
||||
/** Convs this browser is piping a stream for (send, attach, resume-wait). */
|
||||
private local = new SvelteSet<string>();
|
||||
/** Convs the backend reports as having a running session (snapshot sync). */
|
||||
private remote = new SvelteSet<string>();
|
||||
|
||||
/** Convs with any activity, the union the sidebar spinners render. */
|
||||
loadingConvs = $derived.by(() => {
|
||||
const out = new SvelteSet<string>(this.local);
|
||||
@@ -33,6 +32,13 @@ export class ChatActivityStore {
|
||||
return Array.from(out);
|
||||
});
|
||||
|
||||
/**
|
||||
* Convs whose in-flight run is a compaction summary, mapped to the node
|
||||
* being summarized. That row reports the progress itself, so the chat
|
||||
* turns it summarizes must not repeat the same indicator.
|
||||
*/
|
||||
private compacting = new SvelteMap<string, string>();
|
||||
|
||||
/**
|
||||
* Apply a backend snapshot of running sessions (mount / visibilitychange).
|
||||
* Diffed so unchanged entries do not re-trigger reactivity.
|
||||
@@ -47,6 +53,20 @@ export class ChatActivityStore {
|
||||
for (const id of next) this.remote.add(id);
|
||||
}
|
||||
|
||||
/** The conv's compaction summary ended, however it ended. */
|
||||
endCompacting(convId: string): void {
|
||||
this.compacting.delete(convId);
|
||||
}
|
||||
|
||||
/** Node id of the summary running for the conv, or null when none runs. */
|
||||
getCompactingNodeId(convId: string): string | null {
|
||||
return this.compacting.get(convId) ?? null;
|
||||
}
|
||||
|
||||
isCompacting(convId: string): boolean {
|
||||
return this.compacting.has(convId);
|
||||
}
|
||||
|
||||
isLocal(convId: string): boolean {
|
||||
return this.local.has(convId);
|
||||
}
|
||||
@@ -65,6 +85,11 @@ export class ChatActivityStore {
|
||||
this.remote.delete(convId);
|
||||
}
|
||||
|
||||
/** A compaction summary started for the conv. */
|
||||
markCompacting(convId: string, nodeId: string): void {
|
||||
this.compacting.set(convId, nodeId);
|
||||
}
|
||||
|
||||
/** A local pipe (send, attach or resume-wait) started for the conv. */
|
||||
markLocal(convId: string): void {
|
||||
this.local.add(convId);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* and live processing state while streaming (chatStore).
|
||||
*/
|
||||
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
// direct imports between stores, not via the barrel, to avoid circular deps
|
||||
import { agenticStore } from '$lib/stores/agentic/index.svelte';
|
||||
import { chatStore } from '$lib/stores/chat/index.svelte';
|
||||
@@ -38,7 +38,7 @@ interface AssistantTimingsSummary {
|
||||
|
||||
/**
|
||||
* One forward pass over the messages computing everything the deriveds
|
||||
* below need: the last assistant timings (per-turn gauges), the last
|
||||
* below need: the last context bearing timings (per-turn gauges), the last
|
||||
* agentic llm totals (cumulative gauge) and the cumulative sums. During
|
||||
* streaming activeMessages churns every chunk, and each of these used to be
|
||||
* its own O(n) scan re-run per chunk.
|
||||
@@ -52,6 +52,15 @@ function summarizeAssistantTimings(messages: DatabaseMessage[]): AssistantTiming
|
||||
let outputMs = 0;
|
||||
|
||||
for (const m of messages) {
|
||||
// A stamped compaction node carries the prompt timings of the reduced
|
||||
// context, so it supersedes earlier assistant turns as the current
|
||||
// context size while staying out of the cumulative sums.
|
||||
if (m.type === MessageType.COMPACTION && m.timings) {
|
||||
lastTimings = m.timings;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
|
||||
|
||||
lastTimings = m.timings;
|
||||
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
findDescendantMessages,
|
||||
findLeafNode,
|
||||
findMessageById,
|
||||
isAbortError
|
||||
isAbortError,
|
||||
sliceAtLastCompaction
|
||||
} from '$lib/utils';
|
||||
|
||||
/**
|
||||
@@ -117,7 +118,9 @@ export class ChatMessageFlows {
|
||||
// internal converter preserves tool_calls and extras when present.
|
||||
// Reconstructing a bare {role, content} here would drop those fields
|
||||
// and break continue_final_message for messages with tool calls.
|
||||
const contextWithContinue = conversationsStore.activeMessages.slice(0, idx + 1);
|
||||
const contextWithContinue = sliceAtLastCompaction(
|
||||
conversationsStore.activeMessages.slice(0, idx + 1)
|
||||
);
|
||||
|
||||
let appendedContent = '';
|
||||
let appendedReasoning = '';
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
* Uses ChatService for the API layer and conversationsStore for persistence.
|
||||
*/
|
||||
|
||||
import { CWD_CLEARED_TEXT, SYSTEM_MESSAGE_PLACEHOLDER, TITLE_GENERATION } from '$lib/constants';
|
||||
import {
|
||||
COMPACTION,
|
||||
CWD_CLEARED_TEXT,
|
||||
SYSTEM_MESSAGE_PLACEHOLDER,
|
||||
TITLE_GENERATION
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
ErrorDialogType,
|
||||
MessageRole,
|
||||
@@ -33,6 +38,7 @@ import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import type {
|
||||
ApiChatMessageData,
|
||||
ChatMessageCompactionTimings,
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageTimings,
|
||||
ChatStreamCallbacks,
|
||||
@@ -41,11 +47,15 @@ import type {
|
||||
ErrorDialogState
|
||||
} from '$lib/types';
|
||||
import {
|
||||
canCompactMessages,
|
||||
customCompactionModel,
|
||||
filterByLeafNodeId,
|
||||
findMessageById,
|
||||
formatCwdMessage,
|
||||
getConversationModel,
|
||||
isAbortError,
|
||||
normalizeModelName
|
||||
normalizeModelName,
|
||||
sliceAtLastCompaction
|
||||
} from '$lib/utils';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
@@ -269,6 +279,57 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
this.pendingMessages.delete(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compacts the active branch: the model summarizes every turn since the
|
||||
* last compaction boundary and the summary is stored in a new compaction
|
||||
* node appended to the branch. Later requests slice the branch at that
|
||||
* node, so the full history stays visible and navigable while the model
|
||||
* context restarts from the summary.
|
||||
*/
|
||||
async compactConversation(): Promise<void> {
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
|
||||
if (!activeConv) return;
|
||||
|
||||
const convId = activeConv.id;
|
||||
|
||||
if (this.isChatLoadingInternal(convId) || agenticStore.isRunning(convId)) return;
|
||||
|
||||
// Snapshot of the reactive branch: the compaction node created below
|
||||
// must not leak into the summarization request.
|
||||
const source = sliceAtLastCompaction([...conversationsStore.activeMessages]);
|
||||
|
||||
if (!canCompactMessages(source)) return;
|
||||
|
||||
this.showErrorDialog(null);
|
||||
this.setChatLoading(convId, true);
|
||||
|
||||
const node = await this.addMessage(
|
||||
MessageRole.USER,
|
||||
'',
|
||||
MessageType.COMPACTION,
|
||||
'-1',
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
// An interrupted compaction leaves no summary behind, so the node goes
|
||||
// along with the request instead of lingering empty in the branch.
|
||||
await this.summarizeIntoNode(convId, node, source, async () => {
|
||||
const idx = conversationsStore.findMessageIndex(node.id);
|
||||
|
||||
if (idx >= 0) conversationsStore.removeMessageAtIndex(idx);
|
||||
|
||||
await DatabaseService.deleteMessage(node.id);
|
||||
|
||||
if (node.parent) await conversationsStore.updateCurrentNode(node.parent);
|
||||
});
|
||||
|
||||
const pending = this.consumePendingMessage(convId);
|
||||
|
||||
if (pending) await this.sendMessage(pending.content, pending.extras);
|
||||
}
|
||||
|
||||
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
|
||||
if (!this.pendingDraftMessage && this.pendingDraftFiles.length === 0) return null;
|
||||
|
||||
@@ -455,6 +516,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
return this.getChatStreamingState(convId);
|
||||
}
|
||||
|
||||
/** Node id of the summary running for the conv, or null when none runs. */
|
||||
getCompactingNodeId(convId: string): string | null {
|
||||
return this.activity.getCompactingNodeId(convId);
|
||||
}
|
||||
|
||||
async getDeletionInfo(messageId: string): Promise<{
|
||||
totalCount: number;
|
||||
userMessages: number;
|
||||
@@ -507,6 +573,14 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
return this.activity.isLocal(convId) || this.chatStreamingStates.has(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the conv's in-flight run is a compaction summary, which reports
|
||||
* its own progress on the compaction row.
|
||||
*/
|
||||
isCompacting(convId: string): boolean {
|
||||
return this.activity.isCompacting(convId);
|
||||
}
|
||||
|
||||
isEditing(): boolean {
|
||||
return this.isEditModeActive;
|
||||
}
|
||||
@@ -544,6 +618,53 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
await this.addMessage(MessageRole.USER, content, MessageType.TEXT, '-1', undefined, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs the summary of an existing compaction node in place. The node
|
||||
* keeps its position in the branch, so the turns below it stay untouched
|
||||
* while the summarized context is rewritten; an explicit model re-summarizes
|
||||
* with that model instead of the configured compaction one.
|
||||
*/
|
||||
async regenerateCompaction(messageId: string, modelOverride?: string): Promise<void> {
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
|
||||
if (!activeConv) return;
|
||||
|
||||
const convId = activeConv.id;
|
||||
|
||||
if (this.isChatLoadingInternal(convId) || agenticStore.isRunning(convId)) return;
|
||||
|
||||
const allMessages = await conversationsStore.getConversationMessages(convId);
|
||||
const node = findMessageById(allMessages, messageId);
|
||||
|
||||
if (!node || node.type !== MessageType.COMPACTION) return;
|
||||
|
||||
// The summary covers the branch above the node: walking the path up to
|
||||
// its parent keeps the old summary from being folded into the new one.
|
||||
const branchPath = node.parent ? [...filterByLeafNodeId(allMessages, node.parent, false)] : [];
|
||||
const source = sliceAtLastCompaction(branchPath);
|
||||
|
||||
if (!canCompactMessages(source)) return;
|
||||
|
||||
this.showErrorDialog(null);
|
||||
this.setChatLoading(convId, true);
|
||||
|
||||
const previous = { content: node.content, model: node.model };
|
||||
|
||||
await this.summarizeIntoNode(
|
||||
convId,
|
||||
node,
|
||||
source,
|
||||
async () => {
|
||||
const idx = conversationsStore.findMessageIndex(node.id);
|
||||
|
||||
if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, previous);
|
||||
|
||||
await DatabaseService.updateMessage(node.id, previous);
|
||||
},
|
||||
modelOverride
|
||||
);
|
||||
}
|
||||
|
||||
async regenerateMessage(messageId: string): Promise<void> {
|
||||
return this.flows.regenerateMessage(messageId);
|
||||
}
|
||||
@@ -605,6 +726,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
this.pendingDraftMessage = message;
|
||||
this.pendingDraftFiles = [...files];
|
||||
}
|
||||
|
||||
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
|
||||
if (!content.trim() && (!extras || extras.length === 0)) return;
|
||||
|
||||
@@ -732,7 +854,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setChatLoading(convId: string, loading: boolean): void {
|
||||
if (loading) {
|
||||
this.activity.markLocal(convId);
|
||||
@@ -820,6 +941,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
await modelsStore.props.fetchModelProps(effectiveModel);
|
||||
}
|
||||
|
||||
// Every request path funnels through here, so the compaction boundary
|
||||
// applied once covers the plain send, the agentic flow, and the
|
||||
// pre-encode that reuses this list from the closure.
|
||||
allMessages = sliceAtLastCompaction(allMessages);
|
||||
|
||||
// Mutable state for the current message being streamed
|
||||
let currentMessageId = assistantMessage.id;
|
||||
let streamedContent = '';
|
||||
@@ -1257,6 +1383,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
async updateMessage(messageId: string, newContent: string): Promise<void> {
|
||||
return this.flows.updateMessage(messageId, newContent);
|
||||
}
|
||||
|
||||
private abortRequest(convId?: string): void {
|
||||
if (convId) {
|
||||
const c = this.abortControllers.get(convId);
|
||||
@@ -1270,7 +1397,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
this.abortControllers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private async generateTitleWithLLM(
|
||||
userContent: string,
|
||||
assistantContent: string,
|
||||
@@ -1399,6 +1525,216 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a fresh summary of `source` into the compaction node and re-stamps
|
||||
* the reduced context afterwards. `rollback` restores the row when nothing
|
||||
* usable came back, so an empty or aborted summary never replaces what the
|
||||
* node already held.
|
||||
*/
|
||||
private async summarizeIntoNode(
|
||||
convId: string,
|
||||
node: DatabaseMessage,
|
||||
source: DatabaseMessage[],
|
||||
rollback: () => Promise<void>,
|
||||
modelOverride?: string
|
||||
): Promise<void> {
|
||||
const abortController = this.getOrCreateAbortController(convId);
|
||||
const compactionStreamId = COMPACTION.STREAM_ID_PREFIX + convId;
|
||||
const config = settingsStore.config;
|
||||
// A dedicated compaction model wins over the funnel's own resolution:
|
||||
// the model the user picked, then the configured summarizer, then the
|
||||
// selected model, then the model the branch was generated with, so
|
||||
// router mode targets the child that owns this conversation's KV cache.
|
||||
const compactionModel = serverStore.isRouterMode
|
||||
? customCompactionModel(Boolean(config.compactionUseCustomModel), config.compactionModel)
|
||||
: undefined;
|
||||
const effectiveModel = serverStore.isRouterMode
|
||||
? modelOverride ||
|
||||
compactionModel ||
|
||||
modelsStore.selectedModelName ||
|
||||
getConversationModel(source)
|
||||
: undefined;
|
||||
|
||||
let summary = '';
|
||||
let summaryModel = node.model;
|
||||
let summaryTimings: ChatMessageCompactionTimings | undefined;
|
||||
|
||||
const writeNode = (updates: Partial<DatabaseMessage>) => {
|
||||
if (conversationsStore.activeConversation?.id !== convId) return;
|
||||
|
||||
const idx = conversationsStore.findMessageIndex(node.id);
|
||||
|
||||
if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, updates);
|
||||
};
|
||||
|
||||
// The node is the row that reports this run's progress, so the turns it
|
||||
// summarizes stay quiet for as long as it runs.
|
||||
this.activity.markCompacting(convId, node.id);
|
||||
// Clearing the stored summary first hands the row over to the pending
|
||||
// state, where it reports the prompt progress instead of the stale text.
|
||||
writeNode({ content: '' });
|
||||
|
||||
try {
|
||||
const apiMessages: ApiChatMessageData[] = await Promise.all(
|
||||
source.map((m) => ChatService.convertDbMessageToApiChatMessageData(m))
|
||||
);
|
||||
const 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 restores the node instead of leaving it empty.
|
||||
if (compactionModel && !modelsStore.isModelLoaded(compactionModel)) {
|
||||
await modelsStore.status.load(compactionModel);
|
||||
}
|
||||
|
||||
await ChatService.sendMessage(
|
||||
apiMessages,
|
||||
{
|
||||
custom: { chat_template_kwargs: { enable_thinking: false } },
|
||||
model: effectiveModel,
|
||||
onChunk: (chunk: string) => {
|
||||
summary += chunk;
|
||||
writeNode({ content: summary });
|
||||
},
|
||||
onModel: (modelName: string) => {
|
||||
summaryModel = modelName;
|
||||
writeNode({ model: modelName });
|
||||
},
|
||||
// The summary streams through the same pipe as a chat turn, which
|
||||
// enables prompt progress on the wire; feeding it to the shared
|
||||
// processing state lets the pending row show the percentage. The
|
||||
// closing event carries the timings the row reports once done.
|
||||
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
|
||||
if (timings) summaryTimings = timings;
|
||||
|
||||
this.processing.applyStreamTimings(timings, promptProgress, convId);
|
||||
},
|
||||
stream: true,
|
||||
timings_per_token: true
|
||||
},
|
||||
// Registered under the prefixed identity: the visibility kick
|
||||
// and byte-offset resume protect the summary through tab
|
||||
// switches, while the reload probe for the bare conversation
|
||||
// id never reattaches it as a chat turn.
|
||||
compactionStreamId,
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
if (!summary.trim()) {
|
||||
await rollback();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const stored: Partial<DatabaseMessage> = { content: summary };
|
||||
|
||||
if (summaryModel) stored.model = summaryModel;
|
||||
|
||||
writeNode(stored);
|
||||
await DatabaseService.updateMessage(node.id, stored);
|
||||
|
||||
// Re-encoding the reduced prefix is part of the compaction
|
||||
// itself: it rebuilds the server KV cache on the summary and
|
||||
// stamps the true context size on the node for the gauge,
|
||||
// independently of the per-turn pre-fill setting.
|
||||
void this.triggerCompactionPreEncode(convId, node.id, effectiveModel, summaryTimings);
|
||||
} catch (error) {
|
||||
await rollback();
|
||||
|
||||
if (isAbortError(error)) {
|
||||
// The server keeps generating a detached summary otherwise.
|
||||
void ChatService.cancelServerStream(compactionStreamId, effectiveModel);
|
||||
} else {
|
||||
console.error('Failed to compact conversation:', error);
|
||||
this.showErrorDialog({
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
type: ErrorDialogType.SERVER
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.abortControllers.delete(convId);
|
||||
// Nothing infers for this conversation anymore: the summarization
|
||||
// progress reported above must not outlive the request, or a
|
||||
// skipped pre-encode would leave the gauge on a live state that
|
||||
// describes the pre-compaction prompt.
|
||||
this.processing.setState(convId, null);
|
||||
this.setChatLoading(convId, false);
|
||||
// Released last: ending it after the loading flag keeps the chat
|
||||
// turns from flashing their own indicator for a tick.
|
||||
this.activity.endCompacting(convId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-encodes the compacted branch and stamps the returned prompt
|
||||
* timings on the compaction node, so the context gauge reflects the
|
||||
* reduced context as soon as the compaction finishes instead of after
|
||||
* the next turn.
|
||||
*/
|
||||
private async triggerCompactionPreEncode(
|
||||
convId: string,
|
||||
nodeId: string,
|
||||
model?: string | null,
|
||||
summaryTimings?: ChatMessageCompactionTimings
|
||||
): Promise<void> {
|
||||
this.cancelPreEncode();
|
||||
this.preEncodeAbortController = new AbortController();
|
||||
|
||||
const signal = this.preEncodeAbortController.signal;
|
||||
|
||||
try {
|
||||
const allIdle = await ChatService.areAllSlotsIdle(model, signal);
|
||||
|
||||
if (!allIdle || signal.aborted) {
|
||||
if (!signal.aborted) {
|
||||
console.warn('[ChatStore] Compaction pre-encode skipped: slots busy');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const allMessages = await conversationsStore.getConversationMessages(convId);
|
||||
const branchPath = [...filterByLeafNodeId(allMessages, nodeId, false)];
|
||||
const messages = sliceAtLastCompaction(branchPath);
|
||||
const timings = await ChatService.preEncode(messages, model, undefined, signal);
|
||||
|
||||
if (!timings || signal.aborted) {
|
||||
if (!signal.aborted) {
|
||||
console.warn('[ChatStore] Compaction pre-encode returned no timings');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The row reports both what the summary cost (summaryTimings) and the
|
||||
// size of the context it left behind (timings), so the second is
|
||||
// nested under the first instead of overwriting it.
|
||||
const stamped = summaryTimings ? { ...timings, compaction: summaryTimings } : timings;
|
||||
|
||||
await DatabaseService.updateMessage(nodeId, { timings: stamped });
|
||||
|
||||
if (conversationsStore.activeConversation?.id === convId) {
|
||||
const idx = conversationsStore.findMessageIndex(nodeId);
|
||||
|
||||
if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, { timings: stamped });
|
||||
}
|
||||
|
||||
// Nothing is inferring anymore: clearing the live processing
|
||||
// state hands the gauge over to the stamped node, which any
|
||||
// later restore also picks as the context truth.
|
||||
this.processing.setState(convId, null);
|
||||
} catch (err) {
|
||||
if (!isAbortError(err)) {
|
||||
console.warn('[ChatStore] Compaction pre-encode failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerPreEncode(
|
||||
allMessages: DatabaseMessage[],
|
||||
assistantMessage: DatabaseMessage,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* Composed under chatStore.processing; not exported from the stores barrel.
|
||||
*/
|
||||
|
||||
import { MessageRole } from '$lib/enums';
|
||||
// direct imports between stores, not via the barrel, to avoid circular deps
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
@@ -19,6 +18,7 @@ import type {
|
||||
ChatMessageTimings,
|
||||
DatabaseMessage
|
||||
} from '$lib/types';
|
||||
import { lastContextBearingMessage } from '$lib/utils/compaction';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface ProcessingTimingData {
|
||||
@@ -79,27 +79,27 @@ export class ChatProcessingStore {
|
||||
}
|
||||
|
||||
restoreFromMessages(messages: DatabaseMessage[], conversationId: string): void {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
// The same source of truth as the context gauge: a stamped compaction
|
||||
// node supersedes earlier assistant turns and restores to an idle
|
||||
// state (predicted_n is 0), so the reduced context is never
|
||||
// overridden by pre-compaction numbers.
|
||||
const message = lastContextBearingMessage(messages);
|
||||
|
||||
if (message.role === MessageRole.ASSISTANT && message.timings) {
|
||||
this.setState(
|
||||
conversationId,
|
||||
this.parseTimingData({
|
||||
cache_n: message.timings.cache_n || 0,
|
||||
predicted_n: message.timings.predicted_n || 0,
|
||||
predicted_per_second:
|
||||
message.timings.predicted_n && message.timings.predicted_ms
|
||||
? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
|
||||
: 0,
|
||||
prompt_ms: message.timings.prompt_ms,
|
||||
prompt_n: message.timings.prompt_n || 0
|
||||
})
|
||||
);
|
||||
if (!message?.timings) return;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.setState(
|
||||
conversationId,
|
||||
this.parseTimingData({
|
||||
cache_n: message.timings.cache_n || 0,
|
||||
predicted_n: message.timings.predicted_n || 0,
|
||||
predicted_per_second:
|
||||
message.timings.predicted_n && message.timings.predicted_ms
|
||||
? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
|
||||
: 0,
|
||||
prompt_ms: message.timings.prompt_ms,
|
||||
prompt_n: message.timings.prompt_n || 0
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setActiveConversation(conversationId: string | null): void {
|
||||
|
||||
Vendored
+19
@@ -85,6 +85,21 @@ export interface ChatMessageTimings {
|
||||
prompt_ms?: number;
|
||||
prompt_n?: number;
|
||||
agentic?: ChatMessageAgenticTimings;
|
||||
/**
|
||||
* Compaction nodes only: the summarization request that wrote the summary,
|
||||
* kept apart from the prompt timings the post-compaction pre-encode stamps
|
||||
* for the context gauge (those describe the reduced context, not the work
|
||||
* the summary cost).
|
||||
*/
|
||||
compaction?: ChatMessageCompactionTimings;
|
||||
}
|
||||
|
||||
export interface ChatMessageCompactionTimings {
|
||||
cache_n?: number;
|
||||
predicted_ms?: number;
|
||||
predicted_n?: number;
|
||||
prompt_ms?: number;
|
||||
prompt_n?: number;
|
||||
}
|
||||
|
||||
export interface ChatMessageAgenticTimings {
|
||||
@@ -214,6 +229,8 @@ export interface FileMentionEntry {
|
||||
* MCP server exposes prompts): visible but greyed out and not selectable.
|
||||
*/
|
||||
export interface ChatCommandsOptions {
|
||||
/** Gates `/compact`. */
|
||||
canCompact: () => boolean;
|
||||
/** Gates `/model`. */
|
||||
showModelSelector: boolean;
|
||||
/** Gates `/prompt`. */
|
||||
@@ -268,6 +285,8 @@ export interface ChatMessageActions {
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => void;
|
||||
regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
|
||||
/** Re-runs the summary of an existing compaction node and replaces it. */
|
||||
regenerateCompaction: (message: DatabaseMessage, modelOverride?: string) => void;
|
||||
continueAssistantMessage: (message: DatabaseMessage) => void;
|
||||
forkConversation: (
|
||||
message: DatabaseMessage,
|
||||
|
||||
@@ -68,6 +68,7 @@ export type {
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageTimings,
|
||||
ChatMessageAgenticTimings,
|
||||
ChatMessageCompactionTimings,
|
||||
ChatMessageAgenticTurnStats,
|
||||
ChatMessageToolCallTiming,
|
||||
ChatStreamCallbacks,
|
||||
|
||||
@@ -30,6 +30,13 @@ export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[]
|
||||
description: 'Select model',
|
||||
disabled: !options.showModelSelector,
|
||||
name: 'model'
|
||||
},
|
||||
{
|
||||
action: ChatFormCommandAction.COMPACT,
|
||||
description: 'Summarize the conversation to free up context',
|
||||
disabled: !options.canCompact(),
|
||||
keywords: ['summarize', 'context'],
|
||||
name: 'compact'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { COMPACTION } from '$lib/constants';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage, SettingsConfigValue } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Applies the compaction boundary to a branch message list before it is
|
||||
* sent to the model. Messages before the last compaction node are dropped,
|
||||
* except system messages, which carry conversation-wide instructions. The
|
||||
* compaction node itself is emitted as a copy whose content frames the
|
||||
* stored summary, so the display row stays clean while the model receives
|
||||
* the framing.
|
||||
*
|
||||
* A list without a compaction node is returned as-is.
|
||||
*/
|
||||
export function sliceAtLastCompaction(messages: DatabaseMessage[]): DatabaseMessage[] {
|
||||
let boundary = -1;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
// An empty compaction node is an interrupted compaction and carries
|
||||
// no summary, so it never acts as a boundary.
|
||||
if (messages[i].type === MessageType.COMPACTION && messages[i].content.trim().length > 0) {
|
||||
boundary = i;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (boundary === -1) return messages;
|
||||
|
||||
const systemPrefix = messages
|
||||
.slice(0, boundary)
|
||||
.filter((m) => m.role === MessageRole.SYSTEM || m.type === MessageType.ROOT);
|
||||
const compactionNode = messages[boundary];
|
||||
const framedNode: DatabaseMessage = {
|
||||
...compactionNode,
|
||||
content: COMPACTION.CONTEXT_TEMPLATE.replace(
|
||||
COMPACTION.SUMMARY_PLACEHOLDER,
|
||||
compactionNode.content
|
||||
)
|
||||
};
|
||||
|
||||
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
|
||||
* worth summarizing.
|
||||
*/
|
||||
export function canCompactMessages(messages: DatabaseMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
|
||||
if (m.type === MessageType.COMPACTION && m.content.trim().length > 0) return false;
|
||||
|
||||
if (m.role === MessageRole.ASSISTANT && m.content.trim().length > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context size the compaction shrank: the timings of the last stamped
|
||||
* message ahead of the node, i.e. what the branch carried when the summary
|
||||
* was requested. Null while nothing ahead of the node is stamped.
|
||||
*/
|
||||
export function compactionTokensBefore(
|
||||
messages: DatabaseMessage[],
|
||||
node: DatabaseMessage
|
||||
): number | null {
|
||||
const nodeIndex = messages.findIndex((m) => m.id === node.id);
|
||||
const timings =
|
||||
nodeIndex > 0 ? lastContextBearingMessage(messages.slice(0, nodeIndex))?.timings : undefined;
|
||||
|
||||
if (!timings) return null;
|
||||
|
||||
const tokens = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0) + (timings.predicted_n ?? 0);
|
||||
|
||||
return tokens > 0 ? tokens : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message carrying the current context size of the branch: the
|
||||
* latest assistant turn or compaction node holding timings. A compaction
|
||||
* node is stamped with the prompt timings of the reduced context by the
|
||||
* post-compaction pre-encode, so it supersedes earlier assistant turns.
|
||||
*/
|
||||
export function lastContextBearingMessage(
|
||||
messages: DatabaseMessage[]
|
||||
): DatabaseMessage | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
|
||||
if (m.role === MessageRole.ASSISTANT && m.timings) return m;
|
||||
|
||||
if (m.type === MessageType.COMPACTION && m.timings) return m;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -352,6 +352,13 @@ export { getToolUi } from './tool-ui';
|
||||
// Chat command picker
|
||||
|
||||
export { getChatCommands } from './chat-commands';
|
||||
export {
|
||||
canCompactMessages,
|
||||
compactionTokensBefore,
|
||||
customCompactionModel,
|
||||
lastContextBearingMessage,
|
||||
sliceAtLastCompaction
|
||||
} from './compaction';
|
||||
|
||||
// Sandbox tool definition
|
||||
// SANDBOX_TOOL_DEFINITION is deprecated; kept for backward compatibility.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={showTabs ? 'md:[--chat-tabs-offset:1.25rem]' : ''}>
|
||||
<div class={showTabs ? 'md:[--chat-tabs-height:3.3125rem] md:[--chat-tabs-offset:1.25rem]' : ''}>
|
||||
{#if showTabs}
|
||||
<ChatTabs />
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// sets `hidden`, so before this was gated a collapsed tool result kept its whole
|
||||
// body in the DOM and re-rendered it on every streamed token (measured at
|
||||
// ~41ms/section/token for a 200KB result). These tests pin the fix: closed means
|
||||
// not rendered, and opening still mounts the body.
|
||||
// not rendered, and opening still mounts the body. The collapse is animated, so
|
||||
// "not rendered" happens when the transition ends rather than when it starts.
|
||||
|
||||
import CollapsibleLazyBodyHarness from './components/CollapsibleLazyBodyHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
@@ -12,6 +13,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const MARKER = 'collapsible-body-marker';
|
||||
// The shared region's collapse transition, plus a frame of slack.
|
||||
const COLLAPSE_TRANSITION_MS = 250;
|
||||
|
||||
describe('collapsible wrappers render their body lazily', () => {
|
||||
for (const variant of ['content', 'terminal'] as const) {
|
||||
@@ -27,9 +30,10 @@ describe('collapsible wrappers render their body lazily', () => {
|
||||
|
||||
expect(document.body.textContent).toContain(MARKER);
|
||||
|
||||
// And it unmounts again on close, so a collapsed block stops costing
|
||||
// anything during streaming.
|
||||
// And it unmounts again once the collapse is over, so a collapsed block
|
||||
// stops costing anything during streaming.
|
||||
await screen.rerender({ open: false, variant });
|
||||
await new Promise((resolve) => setTimeout(resolve, COLLAPSE_TRANSITION_MS));
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(MARKER);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const calls: string[] = [];
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
canCompact: () => true,
|
||||
focusInput: () => {},
|
||||
getCaretOffset: () => caretOffset,
|
||||
getCwd: () => null,
|
||||
@@ -18,6 +19,9 @@
|
||||
getValue: () => value,
|
||||
hasCwdTools: () => true,
|
||||
hasPrompts: () => true,
|
||||
onCompact: () => {
|
||||
calls.push('onCompact');
|
||||
},
|
||||
openModelSelector: () => {
|
||||
calls.push('openModelSelector');
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
editWithReplacement: () => {},
|
||||
forkConversation: () => {},
|
||||
navigateToSibling: () => {},
|
||||
regenerateCompaction: () => {},
|
||||
regenerateWithBranching: () => {}
|
||||
};
|
||||
|
||||
@@ -99,6 +100,77 @@
|
||||
type: 'message'
|
||||
});
|
||||
|
||||
const compactionMessage: DatabaseMessage = {
|
||||
children: [],
|
||||
content:
|
||||
'The user asked about the ultimate answer and its origin. Key facts: the answer is **42**, from Douglas Adams\' "The Hitchhiker\'s Guide to the Galaxy", computed by Deep Thought over 7.5 million years.\n\nNothing is left to do unless the user wants the question itself discussed.',
|
||||
convId: 'conv-1',
|
||||
id: '8',
|
||||
// The model the server reports for the summary request.
|
||||
model: 'ggml-org/Qwen3.8-Flash-Next',
|
||||
parent: '1',
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
timestamp: Date.now() - 1000 * 60 * 30,
|
||||
// The pre-encode stamps the reduced prompt on the node; `compaction`
|
||||
// keeps the summary request's own timings for the stats row.
|
||||
timings: {
|
||||
cache_n: 0,
|
||||
compaction: {
|
||||
cache_n: 0,
|
||||
predicted_ms: 12000,
|
||||
predicted_n: 300,
|
||||
prompt_ms: 18200,
|
||||
prompt_n: 42200
|
||||
},
|
||||
predicted_n: 0,
|
||||
prompt_n: 3204
|
||||
},
|
||||
type: 'compaction'
|
||||
};
|
||||
|
||||
// The turn the compaction summarized: its timings are the context size
|
||||
// the node's toggle reports as the "before" number.
|
||||
const preCompactionAssistant: DatabaseMessage = {
|
||||
children: [],
|
||||
content: 'Deep Thought took 7.5 million years to answer.',
|
||||
convId: 'conv-1',
|
||||
id: '7',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
timestamp: Date.now() - 1000 * 60 * 31,
|
||||
timings: { cache_n: 0, predicted_n: 118, prompt_n: 42200 },
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
const compactionPendingMessage: DatabaseMessage = {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: '9',
|
||||
parent: '1',
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
timestamp: Date.now() - 1000 * 60 * 29,
|
||||
type: 'compaction'
|
||||
};
|
||||
|
||||
// The same node once the prompt is through and the summary is streaming.
|
||||
const compactionGeneratingMessage: DatabaseMessage = {
|
||||
children: [],
|
||||
content:
|
||||
'The user asked about the ultimate answer and its origin. Key facts: the answer is **42**',
|
||||
convId: 'conv-1',
|
||||
id: '10',
|
||||
model: 'ggml-org/Qwen3.8-Flash-Next',
|
||||
parent: '1',
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
timestamp: Date.now() - 1000 * 60 * 29,
|
||||
type: 'compaction'
|
||||
};
|
||||
|
||||
let streamingMessage = $state({
|
||||
children: [],
|
||||
content: '',
|
||||
@@ -176,6 +248,95 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: compactionMessage
|
||||
}}
|
||||
name="Compaction"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showMessageStats', true);
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
// The toggle reads the before size from the branch, so the story needs
|
||||
// the summarized turn in the active message list.
|
||||
conversationsStore.activeMessages = [preCompactionAssistant, compactionMessage];
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: compactionPendingMessage
|
||||
}}
|
||||
name="CompactionPrefill"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
const { chatActivityStore } = await import('$lib/stores/chat/activity.svelte');
|
||||
const { chatStore } = await import('$lib/stores/chat/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
|
||||
// The summary request reports its prompt progress through the shared
|
||||
// processing state; seed one so the story shows the prefill detail.
|
||||
chatActivityStore.markCompacting('conv-1', compactionPendingMessage.id);
|
||||
chatStore.processing.setActiveConversation('conv-1');
|
||||
chatStore.processing.setState('conv-1', {
|
||||
contextTotal: 32768,
|
||||
contextUsed: 10000,
|
||||
hasNextToken: false,
|
||||
outputTokensMax: -1,
|
||||
outputTokensUsed: 0,
|
||||
progressPercent: 42,
|
||||
promptProgress: { cache: 0, processed: 4200, time_ms: 6000, total: 10000 },
|
||||
promptTokens: 0,
|
||||
speculative: false,
|
||||
status: 'preparing',
|
||||
temperature: 0.8,
|
||||
tokensDecoded: 0,
|
||||
tokensPerSecond: 0,
|
||||
tokensRemaining: -1,
|
||||
topP: 0.95
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: compactionGeneratingMessage
|
||||
}}
|
||||
name="CompactionGenerating"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
const { chatActivityStore } = await import('$lib/stores/chat/activity.svelte');
|
||||
const { chatStore } = await import('$lib/stores/chat/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
|
||||
// Prompt processing is over, so only the generated tokens are reported.
|
||||
chatActivityStore.markCompacting('conv-1', compactionGeneratingMessage.id);
|
||||
chatStore.processing.setActiveConversation('conv-1');
|
||||
chatStore.processing.setState('conv-1', {
|
||||
contextTotal: 32768,
|
||||
contextUsed: 45200,
|
||||
hasNextToken: true,
|
||||
outputTokensMax: -1,
|
||||
outputTokensUsed: 128,
|
||||
promptTokens: 42200,
|
||||
speculative: false,
|
||||
status: 'generating',
|
||||
temperature: 0.8,
|
||||
tokensDecoded: 128,
|
||||
tokensPerSecond: 25,
|
||||
tokensRemaining: -1,
|
||||
topP: 0.95
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
message: streamingMessage
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { COMPACTION } from '$lib/constants';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import {
|
||||
canCompactMessages,
|
||||
compactionTokensBefore,
|
||||
customCompactionModel,
|
||||
sliceAtLastCompaction
|
||||
} from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const CONV_ID = 'conv';
|
||||
|
||||
function makeMessage(
|
||||
id: string,
|
||||
role: MessageRole,
|
||||
content: string,
|
||||
type: MessageType = MessageType.TEXT
|
||||
): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content,
|
||||
convId: CONV_ID,
|
||||
id,
|
||||
parent: null,
|
||||
role,
|
||||
timestamp: 0,
|
||||
type
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
function makeCompactionNode(id: string, summary: string): DatabaseMessage {
|
||||
return makeMessage(id, MessageRole.USER, summary, MessageType.COMPACTION);
|
||||
}
|
||||
|
||||
describe('sliceAtLastCompaction', () => {
|
||||
it('returns the list unchanged when no compaction node is present', () => {
|
||||
const messages = [
|
||||
makeMessage('u1', MessageRole.USER, 'hi'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'hello')
|
||||
];
|
||||
|
||||
expect(sliceAtLastCompaction(messages)).toBe(messages);
|
||||
});
|
||||
|
||||
it('drops messages before the boundary and keeps the system prefix', () => {
|
||||
const system = makeMessage('s1', MessageRole.SYSTEM, 'be brief', MessageType.SYSTEM);
|
||||
const messages = [
|
||||
system,
|
||||
makeMessage('u1', MessageRole.USER, 'old question'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'old answer'),
|
||||
makeCompactionNode('c1', 'summary'),
|
||||
makeMessage('u2', MessageRole.USER, 'new question')
|
||||
];
|
||||
const sliced = sliceAtLastCompaction(messages);
|
||||
|
||||
expect(sliced.map((m) => m.id)).toEqual(['s1', 'c1', 'u2']);
|
||||
expect(sliced[0]).toBe(system);
|
||||
});
|
||||
|
||||
it('frames the compaction node content without mutating the original', () => {
|
||||
const node = makeCompactionNode('c1', 'summary text');
|
||||
const messages = [makeMessage('u1', MessageRole.USER, 'question'), node];
|
||||
const sliced = sliceAtLastCompaction(messages);
|
||||
const framed = COMPACTION.CONTEXT_TEMPLATE.replace(
|
||||
COMPACTION.SUMMARY_PLACEHOLDER,
|
||||
'summary text'
|
||||
);
|
||||
|
||||
expect(sliced[0].content).toBe(framed);
|
||||
expect(sliced[0].role).toBe(MessageRole.USER);
|
||||
expect(node.content).toBe('summary text');
|
||||
});
|
||||
|
||||
it('slices at the last boundary when the branch was compacted twice', () => {
|
||||
const messages = [
|
||||
makeCompactionNode('c1', 'first summary'),
|
||||
makeMessage('u1', MessageRole.USER, 'question'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'answer'),
|
||||
makeCompactionNode('c2', 'second summary'),
|
||||
makeMessage('u2', MessageRole.USER, 'follow-up')
|
||||
];
|
||||
const sliced = sliceAtLastCompaction(messages);
|
||||
|
||||
expect(sliced.map((m) => m.id)).toEqual(['c2', 'u2']);
|
||||
});
|
||||
|
||||
it('ignores an empty node left by an interrupted compaction', () => {
|
||||
const messages = [
|
||||
makeMessage('u1', MessageRole.USER, 'question'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'answer'),
|
||||
makeCompactionNode('c1', '')
|
||||
];
|
||||
|
||||
expect(sliceAtLastCompaction(messages)).toBe(messages);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canCompactMessages', () => {
|
||||
it('rejects a branch with no assistant turn', () => {
|
||||
expect(canCompactMessages([makeMessage('u1', MessageRole.USER, 'hi')])).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a branch with a non-empty assistant turn', () => {
|
||||
const messages = [
|
||||
makeMessage('u1', MessageRole.USER, 'hi'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'hello')
|
||||
];
|
||||
|
||||
expect(canCompactMessages(messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a branch whose only assistant turn is empty', () => {
|
||||
const messages = [
|
||||
makeMessage('u1', MessageRole.USER, 'hi'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, ' ')
|
||||
];
|
||||
|
||||
expect(canCompactMessages(messages)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a branch with nothing after the last compaction node', () => {
|
||||
const messages = [
|
||||
makeMessage('u1', MessageRole.USER, 'hi'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'hello'),
|
||||
makeCompactionNode('c1', 'summary')
|
||||
];
|
||||
|
||||
expect(canCompactMessages(messages)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a compacted branch once a new assistant turn exists', () => {
|
||||
const messages = [
|
||||
makeCompactionNode('c1', 'summary'),
|
||||
makeMessage('u1', MessageRole.USER, 'next'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'reply')
|
||||
];
|
||||
|
||||
expect(canCompactMessages(messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a branch whose trailing compaction node is empty', () => {
|
||||
const messages = [
|
||||
makeMessage('u1', MessageRole.USER, 'hi'),
|
||||
makeMessage('a1', MessageRole.ASSISTANT, 'hello'),
|
||||
makeCompactionNode('c1', '')
|
||||
];
|
||||
|
||||
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 value', () => {
|
||||
expect(customCompactionModel(true, ' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the conversation model for a non string value', () => {
|
||||
expect(customCompactionModel(true, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactionTokensBefore', () => {
|
||||
function withTimings(
|
||||
message: DatabaseMessage,
|
||||
timings: { cache_n?: number; predicted_n?: number; prompt_n?: number }
|
||||
): DatabaseMessage {
|
||||
return { ...message, timings } as DatabaseMessage;
|
||||
}
|
||||
|
||||
it('reports the context the branch carried before the compaction', () => {
|
||||
const previous = withTimings(makeMessage('a1', MessageRole.ASSISTANT, 'hello'), {
|
||||
cache_n: 200,
|
||||
predicted_n: 118,
|
||||
prompt_n: 42000
|
||||
});
|
||||
const node = makeCompactionNode('c1', 'summary');
|
||||
|
||||
expect(compactionTokensBefore([previous, node], node)).toBe(42318);
|
||||
});
|
||||
|
||||
it('ignores the node own stamped timings', () => {
|
||||
const previous = withTimings(makeMessage('a1', MessageRole.ASSISTANT, 'hello'), {
|
||||
prompt_n: 42000
|
||||
});
|
||||
const node = withTimings(makeCompactionNode('c1', 'summary'), { prompt_n: 3204 });
|
||||
|
||||
expect(compactionTokensBefore([previous, node], node)).toBe(42000);
|
||||
});
|
||||
|
||||
it('reports nothing while no message ahead of the node is stamped', () => {
|
||||
const previous = makeMessage('a1', MessageRole.ASSISTANT, 'hello');
|
||||
const node = withTimings(makeCompactionNode('c1', 'summary'), { prompt_n: 3204 });
|
||||
|
||||
expect(compactionTokensBefore([previous, node], node)).toBeNull();
|
||||
});
|
||||
|
||||
it('reports nothing when the node is the first message', () => {
|
||||
expect(
|
||||
compactionTokensBefore(
|
||||
[makeCompactionNode('c1', 'summary')],
|
||||
makeCompactionNode('c1', 'summary')
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the previous compaction node when no turn follows it', () => {
|
||||
const earlier = withTimings(makeCompactionNode('c0', 'older'), { prompt_n: 2800 });
|
||||
const node = makeCompactionNode('c1', 'summary');
|
||||
|
||||
expect(compactionTokensBefore([earlier, node], node)).toBe(2800);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user