import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { MCPResourceAttachment } from '$lib/types';
import { getResourceDisplayName, getResourceIcon } from '$lib/utils';
ChatAttachmentsPreviewNavButtons,
ChatAttachmentsPreviewThumbnailStrip
} from '$lib/components/app';
- import { modelsStore } from '$lib/stores/models.svelte';
+ import { modelsStore } from '$lib/stores';
import {
createBase64DataUrl,
formatFileSize,
SpecialFileType
} from '$lib/enums';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
- import { chatStore } from '$lib/stores/chat.svelte';
import {
- activeConversation,
- activeMessages,
+ chatStore,
conversationsStore,
- pendingCwd
- } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
- import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
- import { config } from '$lib/stores/settings.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ mcpResourceStore,
+ mcpStore,
+ modelsStore,
+ serverStore,
+ settingsStore,
+ toolsStore
+ } from '$lib/stores';
import type {
FileMentionEntry,
GetPromptResult,
// float above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
- let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
+ let cwd = $derived(conversationsStore.activeConversation?.cwd ?? conversationsStore.pendingCwd);
const pickers = useChatFormPickers({
focusInput: refocusInput,
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
- let currentConfig = $derived(config());
+ let currentConfig = $derived(settingsStore.config);
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
- let isRouter = $derived(isRouterMode());
+ let isRouter = $derived(serverStore.isRouterMode);
let conversationModel = $derived(
- chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
- const options = modelOptions();
+ const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
- const selectedId = selectedModelId();
+ const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
return null;
});
- let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
+ let hasModelSelected = $derived(
+ !isRouter || !!conversationModel || !!modelsStore.selectedModelId
+ );
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
/>
{/if}
- {#if mcpHasResourceAttachments()}
+ {#if mcpResourceStore.hasAttachments}
<ChatFormMcpResourcesList
class="mb-3"
onResourceClick={(uri) => {
import { Switch } from '$lib/components/ui/switch';
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { conversationsStore, mcpStore } from '$lib/stores';
import type { MCPServerSettingsEntry } from '$lib/types';
interface Props {
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { conversationsStore, mcpStore } from '$lib/stores';
import type { Snippet } from 'svelte';
interface Props {
import * as Tooltip from '$lib/components/ui/tooltip';
import { CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { mcpStore, toolsStore } from '$lib/stores';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { isMobile } from '$lib/stores';
interface Props {
disabled?: boolean;
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
- import { chatStore } from '$lib/stores/chat.svelte';
- import { activeMessages } from '$lib/stores/conversations.svelte';
- import {
- modelOptions,
- modelsStore,
- selectedModelId,
- selectedModelName
- } from '$lib/stores/models.svelte';
- import { isRouterMode, serverError } from '$lib/stores/server.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { chatStore, conversationsStore, isMobile, modelsStore, serverStore } from '$lib/stores';
interface Props {
disabled?: boolean;
useGlobalSelection = false
}: Props = $props();
- let isRouter = $derived(isRouterMode());
- let isOffline = $derived(!!serverError());
+ let isRouter = $derived(serverStore.isRouterMode);
+ let isOffline = $derived(!!serverStore.error);
let conversationModel = $derived(
- chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
let lastSyncedConversationModel: string | null = null;
let selectorModel = $derived.by(() => {
- const storeModel = selectedModelName();
+ const storeModel = modelsStore.selectedModelName;
if (storeModel && storeModel !== conversationModel) {
return storeModel;
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
- if (modelOptions().some((m) => m.model === conversationModel)) {
+ if (modelsStore.models.some((m) => m.model === conversationModel)) {
modelsStore.selectedModelName = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else {
isRouter &&
!modelsStore.selectedModelId &&
modelsStore.loadedModelIds.length > 0 &&
- activeMessages().length > 0 &&
+ conversationsStore.activeMessages.length > 0 &&
!conversationModel
) {
lastSyncedConversationModel = null;
- const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
+ const first = modelsStore.models.find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
}
});
let activeModelId = $derived.by(() => {
- const options = modelOptions();
+ const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
- const selectedId = selectedModelId();
+ const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
});
$effect(() => {
- hasModelSelected = !isRouter || !!conversationModel || !!selectedModelId();
+ hasModelSelected = !isRouter || !!conversationModel || !!modelsStore.selectedModelId;
});
$effect(() => {
if (!isRouter) {
isSelectedModelInCache = true;
} else if (conversationModel) {
- isSelectedModelInCache = modelOptions().some((option) => option.model === conversationModel);
+ isSelectedModelInCache = modelsStore.models.some(
+ (option) => option.model === conversationModel
+ );
} else {
- const currentModelId = selectedModelId();
+ const currentModelId = modelsStore.selectedModelId;
if (!currentModelId) {
isSelectedModelInCache = false;
} else {
- isSelectedModelInCache = modelOptions().some((option) => option.id === currentModelId);
+ isSelectedModelInCache = modelsStore.models.some((option) => option.id === currentModelId);
}
}
});
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { ChatService } from '$lib/services';
- import {
- activeProcessingState,
- isChatStreaming,
- isLoading as chatIsLoading
- } from '$lib/stores/chat.svelte';
- import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { config } from '$lib/stores/settings.svelte';
+ import { chatStore, conversationsStore, mcpStore, settingsStore } from '$lib/stores';
import { getFileTypeCategory } from '$lib/utils';
interface Props {
uploadedFiles = []
}: Props = $props();
- let currentConfig = $derived(config());
+ let currentConfig = $derived(settingsStore.config);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
let hasProcessedTokens = $derived.by(() => {
if (!page.params.id) return false;
- const messages = activeMessages() as DatabaseMessage[];
+ const messages = conversationsStore.activeMessages as DatabaseMessage[];
let totalHistoricalTokens = 0;
if (totalHistoricalTokens > 0) return true;
- if (!chatIsLoading() && !isChatStreaming()) return false;
+ if (!chatStore.isLoading && !chatStore.isStreaming()) return false;
- const processingState = activeProcessingState();
+ const processingState = chatStore.activeProcessingState;
if (!processingState) return false;
<script lang="ts">
import { CODE_BLOCK } from '$lib/constants';
import { ColorMode } from '$lib/enums';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { isMobile } from '$lib/stores';
import type { ContentEditableToken } from '$lib/types';
import type { SourceHistoryEntry } from '$lib/utils';
import {
<script lang="ts">
import ContextGaugeDial from './ContextGaugeDial.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
- import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import {
+ chatStore,
+ conversationsStore,
gaugeTriggerClick,
gaugeTriggerEnter,
gaugeTriggerKeydown,
gaugeTriggerLeave,
gaugeTriggerPointerDown
- } from '$lib/stores/context-gauge-popup.svelte';
- import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
+ } from '$lib/stores';
import { untrack } from 'svelte';
const gauge = useContextGauge();
$effect(() => {
- const conv = activeConversation();
+ const conv = conversationsStore.activeConversation;
untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
});
$effect(() => {
- const conv = activeConversation();
- const messages = activeMessages() as DatabaseMessage[];
+ const conv = conversationsStore.activeConversation;
+ const messages = conversationsStore.activeMessages as DatabaseMessage[];
if (!conv) return;
- if (isLoading() || isChatStreaming()) return;
+ if (chatStore.isLoading || chatStore.isStreaming()) return;
if (messages.length === 0) {
untrack(() => chatStore.clearProcessingState(conv.id));
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
- import {
- gaugeCardEnter,
- gaugeCardLeave,
- gaugePopup,
- gaugePopupClose
- } from '$lib/stores/context-gauge-popup.svelte';
+ import { gaugeCardEnter, gaugeCardLeave, gaugePopup, gaugePopupClose } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
</span>
<span>
- {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
+ {formatParameters(gauge.contextAvailable ?? 0)} remaining
</span>
</div>
{:else}
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import {
- mcpHasResourceAttachments,
- mcpResourceAttachments
- } from '$lib/stores/mcp-resources.svelte';
+ import { mcpResourceStore, mcpStore } from '$lib/stores';
interface Props {
class?: string;
let { class: className, onResourceClick }: Props = $props();
- const attachments = $derived(mcpResourceAttachments());
- const hasAttachments = $derived(mcpHasResourceAttachments());
+ const attachments = $derived(mcpResourceStore.attachments);
+ const hasAttachments = $derived(mcpResourceStore.hasAttachments);
function handleRemove(attachmentId: string) {
mcpStore.removeResourceAttachment(attachmentId);
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
- import { config } from '$lib/stores/settings.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { isMobile, settingsStore, toolsStore } from '$lib/stores';
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
// Coerce the depth setting to a positive integer; an invalid value
// would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => {
- const n = Number(config().mentionSearchMaxDepth);
+ const n = Number(settingsStore.config.mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH;
});
<script lang="ts">
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { MCPServerSettingsEntry } from '$lib/types';
import type { Snippet } from 'svelte';
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { KeyboardKey } from '$lib/enums';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { conversationsStore, mcpStore } from '$lib/stores';
import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { debounce, uuid } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
<script lang="ts">
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { isMobile } from '$lib/stores';
import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { toolsStore } from '$lib/stores';
import type { GlobEntry } from '$lib/types';
import {
abbreviateHome,
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
- import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { chatStore, conversationsStore, isMobile } from '$lib/stores';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { deriveAgenticSections } from '$lib/utils';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
});
$effect(() => {
- const pendingId = pendingEditMessageId();
+ const pendingId = chatStore.pendingEditMessageId;
if (pendingId && pendingId === message.id && !isEditing) {
handleEdit();
import { getMessageEditContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
- import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
- import { modelsStore } from '$lib/stores/models.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
- import { config } from '$lib/stores/settings.svelte';
+ import { chatStore, modelsStore, serverStore, settingsStore } from '$lib/stores';
import { modelLoadProgressText } from '$lib/utils';
import { hasAgenticContent } from '$lib/utils';
const isAgentic = $derived(hasAgenticContent(message, toolMessages));
const processingState = useProcessingState();
- let currentConfig = $derived(config());
- let isRouter = $derived(isRouterMode());
+ let currentConfig = $derived(settingsStore.config);
+ let isRouter = $derived(serverStore.isRouterMode);
let showRawOutput = $state(false);
let displayedModel = $derived(message.model ?? null);
- let isCurrentlyLoading = $derived(isLoading());
- let isStreaming = $derived(isChatStreaming());
+ let isCurrentlyLoading = $derived(chatStore.isLoading);
+ let isStreaming = $derived(chatStore.isStreaming());
let hasNoContent = $derived(!message?.content?.trim());
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
<ChatMessageAgenticContent
{message}
{toolMessages}
- isStreaming={isChatStreaming()}
+ isStreaming={chatStore.isStreaming()}
{isLastAssistantMessage}
/>
{/if}
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
<ChatMessageAssistantModel
{displayedModel}
- isLoading={isLoading()}
+ isLoading={chatStore.isLoading}
{isRouter}
{onRegenerate}
/>
<ChatMessageAssistantStatistics
{message}
- isLoading={isLoading()}
+ isLoading={chatStore.isLoading}
{processingState}
showMessageStats={currentConfig.showMessageStats}
/>
<script lang="ts">
import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
import { ServerModelStatus } from '$lib/enums';
- import { modelsStore } from '$lib/stores/models.svelte';
+ import { modelsStore } from '$lib/stores';
import { copyToClipboard } from '$lib/utils';
interface Props {
let { modelLoadingText, position, processingState }: Props = $props();
- const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
+ const marginClass = $derived(position === 'top' ? 'mt-6' : 'mt-4');
</script>
<div class="{marginClass} w-full max-w-3xl" in:fade>
import { ChatMessageStatistics } from '$lib/components/app';
import { ChatMessageStatisticsMode } from '$lib/enums';
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
+ import { agenticStore } from '$lib/stores';
interface Props {
message: DatabaseMessage;
}
let { isLoading, message, processingState, showMessageStats }: Props = $props();
+
+ // A running agentic flow stamps per-turn timings on its root message at each
+ // turn boundary and the cumulative agentic totals only on exit; while it runs,
+ // show the session's live totals on the root message instead.
+ const liveLlm = $derived(agenticStore.getLiveLlmTotals(message.convId));
+ const isLiveFlowRoot = $derived(
+ liveLlm !== null && agenticStore.getFlowRootMessageId(message.convId) === message.id
+ );
</script>
-{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
+{#if showMessageStats && isLiveFlowRoot && liveLlm}
+ <ChatMessageStatistics
+ mode={ChatMessageStatisticsMode.GENERATION}
+ isLive
+ promptTokens={liveLlm.prompt_n}
+ promptMs={liveLlm.prompt_ms}
+ predictedTokens={liveLlm.predicted_n}
+ predictedMs={liveLlm.predicted_ms}
+ />
+{:else if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
import { Card } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { McpPromptVariant } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import { INPUT_CLASSES } from '$lib/constants';
import { getMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums';
- import { config } from '$lib/stores/settings.svelte';
+ import { settingsStore } from '$lib/stores';
import { autoResizeTextarea, isIMEComposing } from '$lib/utils';
interface Props {
let contentHeight = $state(0);
const MAX_HEIGHT = 200; // pixels
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome, computeLineDiff, prefixFor } from '$lib/utils';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
import { AttachmentType } from '$lib/enums';
- import { config } from '$lib/stores/settings.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { settingsStore, toolsStore } from '$lib/stores';
import type { AgenticSection, ToolResultLine } from '$lib/types';
import type { DatabaseMessageExtra } from '$lib/types';
import {
);
const useFullHeightCodeBlocks = $derived(
- Boolean(config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
+ Boolean(settingsStore.config[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
);
const autoScroll = $derived(isLive && !useFullHeightCodeBlocks);
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome } from '$lib/utils';
<script lang="ts">
import { Info, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome } from '$lib/utils';
import { parseGrepSearchMeta } from './parsers/grep-search';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome } from '$lib/utils';
import * as HoverCard from '$lib/components/ui/hover-card';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { AgenticSection, SearchResult } from '$lib/types';
import {
extractSearchQuery,
import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome } from '$lib/utils';
import { CollapsibleContentBlock } from '$lib/components/app';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { AgenticSection, BuiltinToolUiEntry } from '$lib/types';
import { getBuiltinToolUi } from '$lib/utils';
import type { Component, Snippet } from 'svelte';
import { getMessageEditContext } from '$lib/contexts';
import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
- import { isLoading } from '$lib/stores/chat.svelte';
- import { config } from '$lib/stores/settings.svelte';
+ import { chatStore, settingsStore } from '$lib/stores';
interface Props {
class?: string;
const editCtx = getMessageEditContext();
const processingState = useProcessingState();
- const currentConfig = $derived(config());
- const isActivelyProcessing = $derived(isLastUserMessage && isLoading());
+ const currentConfig = $derived(settingsStore.config);
+ const isActivelyProcessing = $derived(isLastUserMessage && chatStore.isLoading);
// For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
let storedReadingStats = $derived.by(() => {
<script lang="ts">
import { ChatAttachmentsList, MarkdownContent, MentionText } from '$lib/components/app';
import { Card } from '$lib/components/ui/card';
- import { config } from '$lib/stores/settings.svelte';
+ import { settingsStore } from '$lib/stores';
import type { DatabaseMessageExtra } from '$lib/types/database';
interface Props {
let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state();
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
$effect(() => {
if (!messageElement || !content.trim()) return;
import { cn } from '$lib/components/ui/utils';
import { TOOL_SERVER_LABELS } from '$lib/constants';
import { ToolPermissionDecision, ToolSource } from '$lib/enums';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { toolsStore } from '$lib/stores';
interface Props {
toolName: string;
import Label from '$lib/components/ui/label/label.svelte';
import { Switch } from '$lib/components/ui/switch';
import { MessageRole } from '$lib/enums';
- import { activeConversation } from '$lib/stores/conversations.svelte';
+ import { conversationsStore } from '$lib/stores';
interface Props {
role: MessageRole.USER | MessageRole.ASSISTANT;
}
function handleOpenForkDialog() {
- const conv = activeConversation();
+ const conv = conversationsStore.activeConversation;
forkName = `Fork of ${conv?.name ?? 'Conversation'}`;
forkIncludeAttachments = true;
MarkdownContent
} from '$lib/components/app';
import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
- import {
- agenticExecutingToolCallId,
- agenticLastError,
- agenticPendingContinueRequest,
- agenticPendingPermissionRequest,
- agenticResolveContinue,
- agenticResolvePermission
- } from '$lib/stores/agentic.svelte';
- import { config } from '$lib/stores/settings.svelte';
+ import { agenticStore, settingsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import type {
ChatMessageAgenticTimings,
let expandedStates: Record<number, boolean> = $state({});
- const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
- const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
- const showMessageStats = $derived(Boolean(config().showMessageStats));
- const showAgenticTurnStats = $derived(showMessageStats && Boolean(config().showAgenticTurnStats));
+ const showThoughtInProgress = $derived(Boolean(settingsStore.config.showThoughtInProgress));
+ const alwaysShowToolCallContent = $derived(
+ Boolean(settingsStore.config.alwaysShowToolCallContent)
+ );
+ const showMessageStats = $derived(Boolean(settingsStore.config.showMessageStats));
+ const showAgenticTurnStats = $derived(
+ showMessageStats && Boolean(settingsStore.config.showAgenticTurnStats)
+ );
const hasReasoningError = $derived(
- isLastAssistantMessage ? !!agenticLastError(message.convId) : false
+ isLastAssistantMessage ? !!agenticStore.lastError(message.convId) : false
);
let permissionDismissed = $state(false);
const pendingPermission = $derived(
- isStreaming && isLastAssistantMessage ? agenticPendingPermissionRequest(message.convId) : null
+ isStreaming && isLastAssistantMessage
+ ? agenticStore.pendingPermissionRequest(message.convId)
+ : null
);
let prevPendingRef: typeof pendingPermission = null;
function handlePermission(decision: ToolPermissionDecision) {
permissionDismissed = true;
- agenticResolvePermission(message.convId, decision);
+ agenticStore.resolvePermission(message.convId, decision);
}
let continueDismissed = $state(false);
const pendingContinue = $derived(
- isStreaming && isLastAssistantMessage ? agenticPendingContinueRequest(message.convId) : false
+ isStreaming && isLastAssistantMessage
+ ? agenticStore.pendingContinueRequest(message.convId)
+ : false
);
let prevContinueRef = false;
function handleContinue(shouldContinue: boolean) {
continueDismissed = true;
- agenticResolveContinue(message.convId, shouldContinue);
+ agenticStore.resolveContinue(message.convId, shouldContinue);
}
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
const currentlyExecutingToolCallId = $derived(
- isStreaming ? agenticExecutingToolCallId(message.convId) : null
+ isStreaming ? agenticStore.executingToolCallId(message.convId) : null
);
type TurnGroup = {
import { Switch } from '$lib/components/ui/switch';
import { getMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums';
- import { chatStore } from '$lib/stores/chat.svelte';
+ import { chatStore } from '$lib/stores';
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
const editCtx = getMessageEditContext();
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
- import { config } from '$lib/stores/settings.svelte';
+ import { settingsStore } from '$lib/stores';
import type { DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/types';
section
}: Props = $props();
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
const REASONING_HEADER = 'Reasoning';
const REASONING_HEADER_PENDING = 'Reasoning...';
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
- import {
- agenticClearSteeringMessage,
- agenticInjectSteeringMessage,
- agenticPendingSteeringMessageContent,
- agenticPendingSteeringMessageExtras
- } from '$lib/stores/agentic.svelte';
- import { chatStore } from '$lib/stores/chat.svelte';
- import {
- chatClearPendingMessage,
- chatInjectPendingMessage,
- chatPendingMessageContent,
- chatPendingMessageExtras
- } from '$lib/stores/chat.svelte';
- import { activeConversation, conversationsStore } from '$lib/stores/conversations.svelte';
- import { config } from '$lib/stores/settings.svelte';
+ import { agenticStore, chatStore, conversationsStore, settingsStore } from '$lib/stores';
import {
buildSiblingInfoMap,
copyToClipboard,
let allConversationMessages = $state<DatabaseMessage[]>([]);
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
setChatActionsContext({
continueAssistantMessage: async (message: DatabaseMessage) => {
});
function refreshAllMessages() {
- const conversation = activeConversation();
+ const conversation = conversationsStore.activeConversation;
if (conversation) {
conversationsStore.getConversationMessages(conversation.id).then((messages) => {
// Refresh messages whenever the active conversation changes
$effect(() => {
- if (activeConversation()) {
+ if (conversationsStore.activeConversation) {
refreshAllMessages();
}
});
/>
{/each}
- {#if activeConversation() && agenticPendingSteeringMessageContent(activeConversation()!.id)}
- {@const convId = activeConversation()!.id}
- {@const pendingContent = agenticPendingSteeringMessageContent(convId)}
+ {#if conversationsStore.activeConversation && agenticStore.pendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
+ {@const convId = conversationsStore.activeConversation!.id}
+ {@const pendingContent = agenticStore.pendingSteeringMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
- extras={agenticPendingSteeringMessageExtras(convId)}
+ extras={agenticStore.pendingSteeringMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
- onEdit={(newContent, extras) => agenticInjectSteeringMessage(convId, newContent, extras)}
- onDelete={() => agenticClearSteeringMessage(convId)}
+ onEdit={(newContent, extras) =>
+ agenticStore.injectSteeringMessage(convId, newContent, extras)}
+ onDelete={() => agenticStore.clearSteeringMessage(convId)}
/>
{/if}
- {:else if activeConversation() && chatPendingMessageContent(activeConversation()!.id)}
- {@const convId = activeConversation()!.id}
- {@const pendingContent = chatPendingMessageContent(convId)}
+ {:else if conversationsStore.activeConversation && chatStore.pendingMessageContent(conversationsStore.activeConversation!.id)}
+ {@const convId = conversationsStore.activeConversation!.id}
+ {@const pendingContent = chatStore.pendingMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
- extras={chatPendingMessageExtras(convId)}
+ extras={chatStore.pendingMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
- onEdit={(newContent, extras) => chatInjectPendingMessage(convId, newContent, extras)}
- onDelete={() => chatClearPendingMessage(convId)}
+ onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
+ onDelete={() => chatStore.clearPendingMessage(convId)}
/>
{/if}
{/if}
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import {
chatStore,
- errorDialog,
- isChatStreaming,
- isEditing,
- isLoading
- } from '$lib/stores/chat.svelte';
- import {
- activeConversation,
- activeMessages,
- conversationsStore
- } from '$lib/stores/conversations.svelte';
- import { device } from '$lib/stores/device.svelte';
- import { serverError, serverLoading } from '$lib/stores/server.svelte';
- import { config } from '$lib/stores/settings.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ conversationsStore,
+ device,
+ isMobile,
+ serverStore,
+ settingsStore
+ } from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { onDestroy, onMount, tick } from 'svelte';
let { showCenteredEmpty = false } = $props();
- let disableAutoScroll = $derived(Boolean(config().disableAutoScroll) || isMobile.current);
+ let disableAutoScroll = $derived(
+ Boolean(settingsStore.config.disableAutoScroll) || isMobile.current
+ );
let isMobileUserScrolledUp = $state(false);
let mobileScrollDownHint = $state(false);
let mobileScrollDownHintLockedUntil = $state(0);
let showDeleteDialog = $state(false);
let showEmptyFileDialog = $state(false);
let isEmpty = $derived(
- showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
+ showCenteredEmpty &&
+ !conversationsStore.activeConversation &&
+ conversationsStore.activeMessages.length === 0 &&
+ !chatStore.isLoading
);
- let activeErrorDialog = $derived(errorDialog());
- let isServerLoading = $derived(serverLoading());
- let hasPropsError = $derived(!!serverError());
- let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming());
+ let activeErrorDialog = $derived(chatStore.errorDialogState);
+ let isServerLoading = $derived(serverStore.loading);
+ let hasPropsError = $derived(!!serverStore.error);
+ let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
let chatFormBottomPosition = $derived.by(() => {
if (!isMobile.current) return '1rem';
});
const { handleKeydown } = useKeyboardShortcuts({
deleteActiveConversation: () => {
- if (activeConversation()) {
+ if (conversationsStore.activeConversation) {
showDeleteDialog = true;
}
}
}
async function handleDeleteConfirm() {
- const conversation = activeConversation();
+ const conversation = conversationsStore.activeConversation;
if (conversation) {
await conversationsStore.deleteConversation(conversation.id);
async function handleMessagesReady(messageCount: number) {
if (messageCount === 0) return;
- const id = activeConversation()?.id ?? null;
+ const id = conversationsStore.activeConversation?.id ?? null;
if (!id || id === lastScrolledConversationId) return;
const settle = () => {
if (autoScroll.userScrolledUp) return;
- if (activeConversation()?.id !== id) return;
+ if (conversationsStore.activeConversation?.id !== id) return;
autoScroll.scrollToBottom();
const height = container.scrollHeight;
$effect(() => {
const shouldDisableAutoScroll =
- config().disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
+ settingsStore.config.disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
autoScroll.setDisabled(shouldDisableAutoScroll);
>
{#if !isEmpty}
<ChatMessages
- messages={activeMessages()}
+ messages={conversationsStore.activeMessages}
onMessagesReady={handleMessagesReady}
onUserAction={() => {
handleSendLikeScroll();
<ChatScreenForm
class="pointer-events-auto conversation-chat-form"
- disabled={hasPropsError || isEditing()}
+ disabled={hasPropsError || chatStore.isEditing()}
{initialMessage}
isLoading={isCurrentConversationLoading}
onFileRemove={fileUpload.handleFileRemove}
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { isMobile } from '$lib/stores';
import { onMount } from 'svelte';
interface Props {
<script lang="ts">
- import { serverStore } from '$lib/stores/server.svelte';
+ import { serverStore } from '$lib/stores';
interface Props {
isEmpty: boolean;
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { serverError, serverLoading, serverStatus, serverStore } from '$lib/stores/server.svelte';
+ import { serverStore } from '$lib/stores';
- let hasError = $derived(!!serverError());
- let isLoadingModel = $derived(serverStatus() === 503);
+ let hasError = $derived(!!serverStore.error);
+ let isLoadingModel = $derived(serverStore.status === 503);
</script>
{#if hasError}
{#if !isLoadingModel}
<button
onclick={() => serverStore.fetch()}
- disabled={serverLoading()}
+ disabled={serverStore.loading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
>
- <RefreshCw class="h-3 w-3 {serverLoading() ? 'animate-spin' : ''}" />
- {serverLoading() ? 'Retrying...' : 'Retry'}
+ <RefreshCw class="h-3 w-3 {serverStore.loading ? 'animate-spin' : ''}" />
+ {serverStore.loading ? 'Retrying...' : 'Retry'}
</button>
{/if}
</Alert.Title>
{#if !isLoadingModel}
- <Alert.Description>{serverError()}</Alert.Description>
+ <Alert.Description>{serverStore.error}</Alert.Description>
{/if}
</Alert.Root>
</div>
<script lang="ts">
import { Loader2 } from '@lucide/svelte';
import { StreamConnectionState } from '$lib/enums';
- import { chatStore } from '$lib/stores/chat.svelte';
+ import { chatStore } from '$lib/stores';
let state = $derived(chatStore.streamConnectionState);
</script>
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files.enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
- import { config } from '$lib/stores/settings.svelte';
+ import { settingsStore } from '$lib/stores';
import type { DatabaseMessageExtra } from '$lib/types/database';
import {
copyCodeToClipboard,
<div
bind:this={containerRef}
onclick={handleMermaidClick}
- class="markdown-content {className}{config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
+ class="markdown-content {className}{settingsStore.config[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
? ' full-height-code-blocks'
: ''}"
>
PATH_SEPARATOR,
SETTINGS_KEYS
} from '$lib/constants';
-import { settingsStore } from '$lib/stores/settings.svelte';
-import { toolsStore } from '$lib/stores/tools.svelte';
+import { settingsStore, toolsStore } from '$lib/stores';
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
<script lang="ts">
import { SETTINGS_KEYS } from '$lib/constants';
- import { settingsStore } from '$lib/stores/settings.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { settingsStore, toolsStore } from '$lib/stores';
import {
getMentionBadgeIconPaths,
getMentionBadgeLabel,
import * as Dialog from '$lib/components/ui/dialog';
import { DEFAULT_RESOURCE_FILENAME, MIME_TYPE_SUBSTRINGS } from '$lib/constants';
import { MimeTypeText } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import {
downloadResourceContent,
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import {
- mcpResources,
- mcpResourceStore,
- mcpTotalResourceCount
- } from '$lib/stores/mcp-resources.svelte';
+ import { conversationsStore, mcpResourceStore, mcpStore } from '$lib/stores';
import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
let templatePreviewLoading = $state(false);
let templatePreviewError = $state<string | null>(null);
- const totalCount = $derived(mcpTotalResourceCount());
+ const totalCount = $derived(mcpResourceStore.totalResourceCount);
$effect(() => {
if (open) {
function getAllResourcesFlatInTreeOrder(): MCPResourceInfo[] {
const allResources: MCPResourceInfo[] = [];
- const resourcesMap = mcpResources();
+ const resourcesMap = mcpResourceStore.serverResources;
for (const [serverName, serverRes] of resourcesMap.entries()) {
for (const resource of serverRes.resources) {
RECOMMENDED_MCP_SERVERS
} from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { conversationsStore, mcpStore } from '$lib/stores';
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
interface Props {
import { ActionIconCopyToClipboard, BadgesModality } from '$lib/components/app';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
- import { modelOptions, modelsLoading, modelsStore } from '$lib/stores/models.svelte';
- import { serverStore } from '$lib/stores/server.svelte';
+ import { modelsStore, serverStore } from '$lib/stores';
import type { ApiLlamaCppServerProps } from '$lib/types';
import { formatFileSize, formatNumber, formatParameters } from '$lib/utils';
let serverProps = $derived(isRouter && modelId ? routerModelProps : serverStore.props);
let modelName = $derived(isRouter && modelId ? modelId : modelsStore.singleModelName);
- let models = $derived(modelOptions());
- let isLoadingModels = $derived(modelsLoading());
+ let models = $derived(modelsStore.models);
+ let isLoadingModels = $derived(modelsStore.loading);
// in router mode, find the model option matching modelId
// in single mode, use the first model as before
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT, MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { conversationsStore, mcpStore } from '$lib/stores';
interface Props {
class?: string;
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { MCPResourceContent, MCPResourceInfo } from '$lib/types';
import {
createBase64DataUrl,
import { Button } from '$lib/components/ui/button';
import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { MCPResourceTemplateInfo } from '$lib/types';
import {
debounce,
import McpResourcesBrowserEmptyState from './McpResourcesBrowserEmptyState.svelte';
import McpResourcesBrowserHeader from './McpResourcesBrowserHeader.svelte';
import McpResourcesBrowserServerItem from './McpResourcesBrowserServerItem.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
+ import { mcpResourceStore, mcpStore } from '$lib/stores';
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { parseResourcePath } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
let expandedFolders = new SvelteSet<string>();
let searchQuery = $state('');
- const resources = $derived(mcpResources());
- const isLoading = $derived(mcpResourcesLoading());
+ const resources = $derived(mcpResourceStore.serverResources);
+ const isLoading = $derived(mcpResourceStore.isLoading);
const filteredResources = $derived.by(() => {
if (!searchQuery.trim()) {
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { getDisplayName, getResourceIcon } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
import { Skeleton } from '$lib/components/ui/skeleton';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { HealthCheckState, MCPServerSettingsEntry } from '$lib/types';
import { tick } from 'svelte';
import { Switch } from '$lib/components/ui/switch';
import { CLI_FLAGS, HEADERS, MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
import { UrlProtocol } from '$lib/enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
+ import { mcpStore } from '$lib/stores';
import type { KeyValuePair } from '$lib/types';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
import { Package } from '@lucide/svelte';
import { ActionIconCopyToClipboard, BadgeInfo } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { modelsStore } from '$lib/stores/models.svelte';
- import { serverStore } from '$lib/stores/server.svelte';
+ import { modelsStore, serverStore } from '$lib/stores';
interface Props {
class?: string;
<script lang="ts">
import { TruncatedText } from '$lib/components/app';
import { ModelsService } from '$lib/services/models.service';
- import { config } from '$lib/stores/settings.svelte';
+ import { settingsStore } from '$lib/stores';
interface Props {
modelId: string;
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
let parsed = $derived(ModelsService.parseModelId(modelId));
- let resolvedShowRaw = $derived(showRaw ?? (config().showRawModelNames as boolean) ?? false);
- let resolvedHideQuantization = $derived(hideQuantization ?? !config().showModelQuantization);
- let resolvedHideTags = $derived(hideTags ?? !config().showModelTags);
+ let resolvedShowRaw = $derived(
+ showRaw ?? (settingsStore.config.showRawModelNames as boolean) ?? false
+ );
+ let resolvedHideQuantization = $derived(
+ hideQuantization ?? !settingsStore.config.showModelQuantization
+ );
+ let resolvedHideTags = $derived(hideTags ?? !settingsStore.config.showModelTags);
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
- import { modelsStore, routerModels } from '$lib/stores/models.svelte';
+ import { modelsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
interface Props {
return;
}
- const model = routerModels().find((m) => m.id === modelId);
+ const model = modelsStore.routerModels.find((m) => m.id === modelId);
const status = model?.status?.value as ServerModelStatus | undefined;
if (status === ServerModelStatus.LOADING) return;
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
{@const triggerStatus = triggerModel
- ? routerModels().find((m) => m.id === triggerModel)?.status?.value
+ ? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
: undefined}
{@const triggerLoading =
!!triggerModel &&
<script lang="ts">
import type { GroupedModelOptions, ModelItem } from './utils';
import { ModelsSelectorOption } from '$lib/components/app';
- import { modelsStore } from '$lib/stores/models.svelte';
+ import { modelsStore } from '$lib/stores';
interface Props {
groups: GroupedModelOptions;
import { ActionIcon, ModelId } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
- import { modelsStore, routerModels } from '$lib/stores/models.svelte';
+ import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
option
}: Props = $props();
- let currentRouterModels = $derived(routerModels());
+ let currentRouterModels = $derived(modelsStore.routerModels);
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
import * as Sheet from '$lib/components/ui/sheet';
import { ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
- import { modelsStore, routerModels } from '$lib/stores/models.svelte';
+ import { modelsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
interface Props {
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
{@const triggerStatus = triggerModel
- ? routerModels().find((m) => m.id === triggerModel)?.status?.value
+ ? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
: undefined}
{@const triggerLoading =
!!triggerModel &&
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
import { RouterService } from '$lib/services/router.service';
- import { chatStore } from '$lib/stores/chat.svelte';
- import {
- buildConversationTree,
- conversations,
- conversationsStore
- } from '$lib/stores/conversations.svelte';
- import { device } from '$lib/stores/device.svelte';
- import { config } from '$lib/stores/settings.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { chatStore, conversationsStore, device, isMobile, settingsStore } from '$lib/stores';
+ import { buildConversationTree } from '$lib/utils';
import { circIn } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
const isOnMobile = $derived(isMobile.current);
- const alwaysShowOnDesktop = $derived(config().alwaysShowSidebarOnDesktop as boolean);
+ const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
$effect(() => {
if (alwaysShowOnDesktop && !isOnMobile) {
let filteredConversations = $derived.by(() => {
if (isSearchModeActive) {
if (searchQuery.trim().length > 0) {
- return conversations().filter((conversation: { name: string }) =>
+ return conversationsStore.conversations.filter((conversation: { name: string }) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
return [];
}
- return conversations();
+ return conversationsStore.conversations;
});
let isSelectionMode = $state(false);
const allSelectedArePinned = $derived.by(() => {
if (selectedIds.size === 0) return false;
- const convs = conversations();
+ const convs = conversationsStore.conversations;
for (const id of selectedIds) {
const c = convs.find((conv) => conv.id === id);
const pinStateIsMixed = $derived.by(() => {
if (selectedIds.size === 0) return false;
- const convs = conversations();
+ const convs = conversationsStore.conversations;
let anyPinned = false;
let anyUnpinned = false;
}
async function handleEditConversation(id: string) {
- const conversation = conversations().find((conv) => conv.id === id);
+ const conversation = conversationsStore.conversations.find((conv) => conv.id === id);
if (!conversation) return;
}
async function handleDeleteConversation(id: string) {
- const conversation = conversations().find((conv) => conv.id === id);
+ const conversation = conversationsStore.conversations.find((conv) => conv.id === id);
if (!conversation) return;
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { isMobile } from '$lib/stores';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FORK_TREE_DEPTH_PADDING, ICON_CLASS_DEFAULT } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
- import { getAllLoadingChats } from '$lib/stores/chat.svelte';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { chatStore, conversationsStore } from '$lib/stores';
import { onMount } from 'svelte';
interface Props {
let renderActionsDropdown = $state(false);
let dropdownOpen = $state(false);
- let isLoading = $derived(getAllLoadingChats().includes(conversation.id));
+ let isLoading = $derived(chatStore.getAllLoadingChats().includes(conversation.id));
function handleEdit(event: Event) {
event.stopPropagation();
import SidebarNavigationSearchResults from './SidebarNavigationSearchResults.svelte';
import SidebarNavigationSelectionBar from './SidebarNavigationSelectionBar.svelte';
import { Pin } from '@lucide/svelte';
- import { buildConversationTree } from '$lib/stores/conversations.svelte';
+ import { buildConversationTree } from '$lib/utils';
interface Props {
class: string;
<script lang="ts">
import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
- import { buildConversationTree } from '$lib/stores/conversations.svelte';
+ import { buildConversationTree } from '$lib/utils';
interface Props {
class?: string;
import Label from '$lib/components/ui/label/label.svelte';
import { HEADERS, ICON_CLASS_DEFAULT, ROUTES, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
- import { serverLoading, serverStore } from '$lib/stores/server.svelte';
- import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { serverStore, settingsStore } from '$lib/stores';
import { fade, fly, scale } from 'svelte/transition';
interface Props {
showTroubleshooting = false
}: Props = $props();
- let isServerLoading = $derived(serverLoading());
+ let isServerLoading = $derived(serverStore.loading);
let isAccessDeniedError = $derived(
error.toLowerCase().includes('access denied') ||
error.toLowerCase().includes('invalid api key') ||
function handleShowApiKeyInput() {
showApiKeyInput = true;
// Pre-fill with current API key if it exists
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
apiKeyInput = currentConfig.apiKey?.toString() || '';
}
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { singleModelName } from '$lib/stores/models.svelte';
- import { serverError, serverLoading, serverProps } from '$lib/stores/server.svelte';
+ import { modelsStore, serverStore } from '$lib/stores';
interface Props {
class?: string;
let { class: className = '', showActions = false }: Props = $props();
- let error = $derived(serverError());
- let loading = $derived(serverLoading());
- let model = $derived(singleModelName());
- let serverData = $derived(serverProps());
+ let error = $derived(serverStore.error);
+ let loading = $derived(serverStore.loading);
+ let model = $derived(modelsStore.singleModelName);
+ let serverData = $derived(serverStore.props);
function getStatusColor() {
if (loading) return 'bg-yellow-500';
import { setChatSettingsConfigContext } from '$lib/contexts';
import { ColorMode } from '$lib/enums/ui.enums';
import { RouterService } from '$lib/services/router.service';
- import { modelsStore } from '$lib/stores/models.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
- import { config, settingsStore } from '$lib/stores/settings.svelte';
- import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
+ import { modelsStore, serverStore, settingsReferrer, settingsStore } from '$lib/stores';
import type { SettingsSection } from '$lib/types';
import { setMode } from 'mode-watcher';
import { fade } from 'svelte/transition';
SETTINGS_CHAT_SECTIONS[0]
);
- let localConfig: SettingsConfigType = $state({ ...config() });
+ let localConfig: SettingsConfigType = $state({ ...settingsStore.config });
let mobileHeader: { updateCarousel: () => void } | undefined;
let fetchInitiated = false;
$effect(() => {
- if (isRouterMode() && currentSection.fields && !fetchInitiated) {
+ if (serverStore.isRouterMode && currentSection.fields && !fetchInitiated) {
fetchInitiated = true;
void modelsStore
}
function handleReset() {
- localConfig = { ...config() };
+ localConfig = { ...settingsStore.config };
setMode(localConfig.theme as ColorMode);
mobileHeader?.updateCarousel();
}
}
export function reset() {
- localConfig = { ...config() };
+ localConfig = { ...settingsStore.config };
}
setChatSettingsConfigContext({
import { Textarea } from '$lib/components/ui/textarea';
import { ICON_CLASS_DEFAULT, SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
import { SettingsFieldType } from '$lib/enums/settings.enums';
- import { modelsStore, propsCacheVersion, selectedModelName } from '$lib/stores/models.svelte';
- import { serverStore } from '$lib/stores/server.svelte';
- import { settingsStore } from '$lib/stores/settings.svelte';
+ import { modelsStore, serverStore, settingsStore } from '$lib/stores';
import { normalizeFloatingPoint } from '$lib/utils/precision';
import type { Component } from 'svelte';
let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
let currentModelParams = $derived.by(() => {
- propsCacheVersion();
+ void modelsStore.propsCacheVersion;
if (serverStore.isRouterMode) {
- const currentModelName = selectedModelName();
+ const currentModelName = modelsStore.selectedModelName;
if (currentModelName) {
const currentModelProps = modelsStore.getModelProps(currentModelName);
} from '$lib/components/app';
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
import { ConversationSelectionMode, FileExtensionText, HtmlInputType } from '$lib/enums';
- import { conversations, conversationsStore } from '$lib/stores/conversations.svelte';
- import { settingsStore } from '$lib/stores/settings.svelte';
+ import { conversationsStore, settingsStore } from '$lib/stores';
import { createMessageCountMap } from '$lib/utils';
import { fade } from 'svelte/transition';
import { toast } from 'svelte-sonner';
async function handleExportClick() {
try {
- const allConversations = conversations();
+ const allConversations = conversationsStore.conversations;
if (allConversations.length === 0) {
toast.info('No conversations to export');
async function handleDeleteAllClick() {
try {
- const allConversations = conversations();
+ const allConversations = conversationsStore.conversations;
if (allConversations.length === 0) {
toast.info('No conversations to delete');
import * as Collapsible from '$lib/components/ui/collapsible';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ToolSource } from '$lib/enums/tools.enums';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { permissionsStore } from '$lib/stores/permissions.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { mcpStore, permissionsStore, toolsStore } from '$lib/stores';
import { getBuiltinToolUi } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
import { RotateCcw } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
- import { settingsStore } from '$lib/stores/settings.svelte';
+ import { settingsStore } from '$lib/stores';
interface Props {
onReset?: () => void;
import * as Empty from '$lib/components/ui/empty';
import { ROUTES } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
- import { conversationsStore } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { toolsStore } from '$lib/stores/tools.svelte';
+ import { conversationsStore, mcpStore, toolsStore } from '$lib/stores';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
* demand if they aren't cached yet.
*/
-import { chatStore } from '$lib/stores/chat.svelte';
-import { activeMessages } from '$lib/stores/conversations.svelte';
-import { modelOptions, modelsStore, selectedModelId } from '$lib/stores/models.svelte';
-import { isRouterMode } from '$lib/stores/server.svelte';
+import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
export function useChatScreenActiveModel() {
- const isRouter = $derived(isRouterMode());
+ const isRouter = $derived(serverStore.isRouterMode);
const conversationModel = $derived(
- chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
const activeModelId = $derived.by(() => {
- const options = modelOptions();
+ const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
- const selectedId = selectedModelId();
+ const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
* caller's onDrop callback.
*/
-import { getAddFilesHandler, isEditing } from '$lib/stores/chat.svelte';
+import { chatStore } from '$lib/stores';
interface UseChatScreenDragAndDropOptions {
/** Called when the user drops files and no message is being edited. */
const files = Array.from(event.dataTransfer.files);
- if (isEditing()) {
- const handler = getAddFilesHandler();
+ if (chatStore.isEditing()) {
+ const handler = chatStore.getAddFilesHandler();
if (handler) {
handler(files);
}
return {
- fileErrorData,
+ get fileErrorData() {
+ return fileErrorData;
+ },
handleFileRemove,
handleFileUpload,
get showFileErrorDialog() {
/**
- * Reactive state for the context usage gauge: resolves the active model,
- * fetches its cached props, parses live server stats, and exposes per-turn
- * read / fresh / cache / output and cumulative token counts.
+ * View layer over contextStatsStore for the context usage gauge: adds
+ * color levels, transient detail formatting, on-demand /props fetching
+ * and model loading on top of the store's token stats.
*/
import { useProcessingState } from './use-processing-state.svelte';
import { colorLevelFromPercent } from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
import { STATS_UNITS } from '$lib/constants';
-import { ColorLevel, MessageRole } from '$lib/enums';
-import { chatStore } from '$lib/stores/chat.svelte';
-import { activeMessages } from '$lib/stores/conversations.svelte';
-import {
- modelOptions,
- modelsStore,
- selectedModelId,
- singleModelName
-} from '$lib/stores/models.svelte';
-import { isRouterMode } from '$lib/stores/server.svelte';
-import type { ChatMessageTimings, DatabaseMessage } from '$lib/types';
-
-interface LiveStats {
- freshTokens: number;
- promptTokens: number;
- cacheTokens: number;
- outputTokens: number;
-}
+import { ColorLevel } from '$lib/enums';
+import { contextStatsStore, modelsStore } from '$lib/stores';
export interface UseContextGaugeReturn {
readonly activeModelId: string | null;
readonly isActiveModelLoading: boolean;
readonly contextTotal: number | null;
readonly contextUsed: number;
+ readonly contextAvailable: number | null;
readonly currentRead: number;
readonly currentFresh: number;
readonly currentCache: number;
startMonitoring(): void;
}
-function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined {
- for (let i = messages.length - 1; i >= 0; i--) {
- const m = messages[i];
-
- if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings;
- }
-
- return undefined;
-}
-
-function deriveLiveStats(
- state: ReturnType<typeof useProcessingState>['processingState']
-): LiveStats | null {
- if (!state || (state.status !== 'preparing' && state.status !== 'generating')) {
- return null;
- }
-
- const promptTokens = state.promptTokens ?? 0;
- const cacheTokens = state.cacheTokens ?? 0;
-
- return {
- cacheTokens,
- freshTokens: promptTokens,
- outputTokens: state.outputTokensUsed ?? 0,
- promptTokens: promptTokens + cacheTokens
- };
-}
-
const TRANSIENT_DETAILS_EXCLUDED_PREFIXES = ['Context:', 'Output:'];
function filterTransientDetails(raw: string[]): string[] {
export function useContextGauge(): UseContextGaugeReturn {
const processingState = useProcessingState();
- // Resolve the model the gauge reports context for: explicit selection >
- // last assistant model > single-model mode (mirrors useChatScreenActiveModel).
- const activeModelId = $derived.by(() => {
- if (!isRouterMode()) {
- return singleModelName();
- }
-
- const selectedId = selectedModelId();
-
- if (selectedId) {
- const model = modelOptions().find((m) => m.id === selectedId);
-
- if (model) return model.model;
- }
-
- return chatStore.getConversationModel(activeMessages() as DatabaseMessage[]);
- });
- const isActiveModelLoaded = $derived(
- activeModelId !== null && (!isRouterMode() || modelsStore.isModelLoaded(activeModelId))
- );
- const isActiveModelLoading = $derived(
- activeModelId !== null && modelsStore.isModelOperationInProgress(activeModelId)
- );
// Pull /props on demand so n_ctx surfaces before the first chat request.
$effect(() => {
- if (activeModelId && isActiveModelLoaded) {
- const cached = modelsStore.getModelProps(activeModelId);
+ const modelId = contextStatsStore.activeModelId;
+
+ if (modelId && contextStatsStore.isActiveModelLoaded) {
+ const cached = modelsStore.getModelProps(modelId);
if (!cached) {
- void modelsStore.fetchModelProps(activeModelId);
+ void modelsStore.fetchModelProps(modelId);
}
}
});
- const contextTotal = $derived.by(() => {
- void modelsStore.propsCacheVersion;
-
- return activeModelId ? modelsStore.getModelContextSize(activeModelId) : null;
- });
- const liveStats = $derived(deriveLiveStats(processingState.processingState));
- const currentRead = $derived.by(() => {
- const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
-
- let read = 0;
-
- if (timings) {
- read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0);
- }
-
- // live.promptTokens is already the combined reading (prompt + cache),
- // so do not also add live.cacheTokens.
- if (liveStats && liveStats.promptTokens > 0) {
- read = Math.max(read, liveStats.promptTokens);
- }
-
- return read;
- });
- const currentFresh = $derived.by(() => {
- const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
- const fresh = timings?.prompt_n ?? 0;
-
- return Math.max(fresh, liveStats?.freshTokens ?? 0);
- });
- const currentCache = $derived.by(() => {
- const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
- const cached = timings?.cache_n ?? 0;
-
- if (liveStats && liveStats.promptTokens > 0) {
- return Math.max(cached, liveStats.cacheTokens);
- }
-
- return cached;
- });
- const currentOutput = $derived.by(() => {
- if (liveStats && liveStats.outputTokens > 0) return liveStats.outputTokens;
-
- const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
-
- return timings?.predicted_n ?? 0;
- });
- const kvTotal = $derived(currentRead + currentOutput);
- const contextUsed = $derived(currentRead + currentOutput);
- const cumulative = $derived.by(() => {
- const messages = activeMessages() as DatabaseMessage[];
- // Agentic sessions stamp the same agentic.llm totals onto every
- // assistant message; cache_n is never per-turn so cache_total stays 0.
- const agenticMessages = messages.filter(
- (m) => m.role === MessageRole.ASSISTANT && m.timings?.agentic?.llm?.predicted_n != null
- );
-
- if (agenticMessages.length > 0) {
- const llm = agenticMessages[agenticMessages.length - 1].timings!.agentic!.llm;
- const output = llm.predicted_n ?? 0;
- const outputMs = llm.predicted_ms ?? 0;
- const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
-
- return {
- averageTokensPerSecond,
- cacheTotal: 0,
- output,
- read: llm.prompt_n ?? 0
- };
- }
-
- let read = 0;
- let output = 0;
- let outputMs = 0;
- let cacheTotal = 0;
-
- for (const m of messages) {
- if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
-
- read += m.timings.prompt_n ?? 0;
- cacheTotal += m.timings.cache_n ?? 0;
- output += m.timings.predicted_n ?? 0;
- outputMs += m.timings.predicted_ms ?? 0;
- }
- const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
-
- return { averageTokensPerSecond, cacheTotal, output, read };
- });
- const contextPercent = $derived.by(() => {
- if (contextTotal === null || contextTotal <= 0) return null;
-
- return Math.round((contextUsed / contextTotal) * 100);
- });
- const colorLevel = $derived(colorLevelFromPercent(contextPercent));
+ const colorLevel = $derived(colorLevelFromPercent(contextStatsStore.contextPercent));
// Drop lines the surrounding Context / Output / speed rows already render.
const transientDetails = $derived(filterTransientDetails(processingState.getTechnicalDetails()));
const hasAnyUsage = $derived(
- cumulative.read > 0 ||
- cumulative.output > 0 ||
- currentRead > 0 ||
- currentOutput > 0 ||
- cumulative.averageTokensPerSecond !== null ||
+ contextStatsStore.cumulativeRead > 0 ||
+ contextStatsStore.cumulativeOutput > 0 ||
+ contextStatsStore.currentRead > 0 ||
+ contextStatsStore.currentOutput > 0 ||
+ contextStatsStore.averageTokensPerSecond !== null ||
transientDetails.length > 0
);
async function loadModel() {
- if (!activeModelId || isActiveModelLoading) return;
+ const modelId = contextStatsStore.activeModelId;
+
+ if (!modelId || contextStatsStore.isActiveModelLoading) return;
try {
- await modelsStore.loadModel(activeModelId);
+ await modelsStore.loadModel(modelId);
} catch {
// toast already surfaced by modelsStore.loadModel
}
return {
get activeModelId() {
- return activeModelId;
+ return contextStatsStore.activeModelId;
},
get averageTokensPerSecond() {
- return cumulative.averageTokensPerSecond;
+ return contextStatsStore.averageTokensPerSecond;
},
get colorLevel() {
return colorLevel;
},
+ get contextAvailable() {
+ return contextStatsStore.contextAvailable;
+ },
get contextPercent() {
- return contextPercent;
+ return contextStatsStore.contextPercent;
},
get contextTotal() {
- return contextTotal;
+ return contextStatsStore.contextTotal;
},
get contextUsed() {
- return contextUsed;
+ return contextStatsStore.contextUsed;
},
get cumulativeCacheTotal() {
- return cumulative.cacheTotal;
+ return contextStatsStore.cumulativeCacheTotal;
},
get cumulativeOutput() {
- return cumulative.output;
+ return contextStatsStore.cumulativeOutput;
},
get cumulativeRead() {
- return cumulative.read;
+ return contextStatsStore.cumulativeRead;
},
get currentCache() {
- return currentCache;
+ return contextStatsStore.currentCache;
},
get currentFresh() {
- return currentFresh;
+ return contextStatsStore.currentFresh;
},
get currentOutput() {
- return currentOutput;
+ return contextStatsStore.currentOutput;
},
get currentRead() {
- return currentRead;
+ return contextStatsStore.currentRead;
},
get hasAnyUsage() {
return hasAnyUsage;
},
get isActiveModelLoaded() {
- return isActiveModelLoaded;
+ return contextStatsStore.isActiveModelLoaded;
},
get isActiveModelLoading() {
- return isActiveModelLoading;
+ return contextStatsStore.isActiveModelLoading;
},
get kvTotal() {
- return kvTotal;
+ return contextStatsStore.kvTotal;
},
loadModel,
startMonitoring: () => processingState.startMonitoring(),
import { afterNavigate, beforeNavigate } from '$app/navigation';
-import { draftMessagesStore } from '$lib/stores/draft-messages.svelte';
+import { draftMessagesStore } from '$lib/stores';
import { onMount } from 'svelte';
interface UseDraftMessagesOptions {
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
-import {
- modelOptions,
- modelsLoading,
- modelsStore,
- modelsUpdating,
- selectedModelId,
- singleModelName
-} from '$lib/stores/models.svelte';
-import { isRouterMode } from '$lib/stores/server.svelte';
+import { modelsStore, serverStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { onMount } from 'svelte';
*/
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
const options = $derived(
- modelOptions().filter((option) => {
+ modelsStore.models.filter((option) => {
const modelProps = modelsStore.getModelProps(option.model);
return modelProps?.ui !== false;
})
);
- const loading = $derived(modelsLoading());
- const updating = $derived(modelsUpdating());
- const activeId = $derived(selectedModelId());
- const isRouter = $derived(isRouterMode());
- const serverModel = $derived(singleModelName());
+ const loading = $derived(modelsStore.loading);
+ const updating = $derived(modelsStore.updating);
+ const activeId = $derived(modelsStore.selectedModelId);
+ const isRouter = $derived(serverStore.isRouterMode);
+ const serverModel = $derived(modelsStore.singleModelName);
const currentModel = $derived(opts.currentModel());
const onModelChange = $derived(opts.onModelChange?.());
const isHighlightedCurrentModelActive = $derived.by(() => {
import { STATS_UNITS } from '$lib/constants';
-import { activeProcessingState } from '$lib/stores/chat.svelte';
+import { chatStore } from '$lib/stores';
import type { ApiProcessingState, LiveGenerationStats, LiveProcessingStats } from '$lib/types';
export interface UseProcessingStateReturn {
return lastKnownState;
}
- // Read directly from the reactive state export
- return activeProcessingState();
+ // Read directly from the reactive state
+ return chatStore.activeProcessingState;
});
$effect(() => {
import { browser } from '$app/environment';
import { BUILD_VERSION_LOCALSTORAGE_KEY, SW_CONFIG } from '$lib/constants';
-import { versionStore } from '$lib/stores/version.svelte';
+import { versionStore } from '$lib/stores';
import { useRegisterSW } from 'virtual:pwa-register/svelte';
/**
import { REASONING_EFFORT_LEVELS, REASONING_EFFORT_TOKENS } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
-import { chatStore } from '$lib/stores/chat.svelte';
-import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
-import {
- checkModelSupportsThinking,
- loadedModelIds,
- modelsStore,
- propsCacheVersion,
- supportsThinking
-} from '$lib/stores/models.svelte';
-import { isRouterMode } from '$lib/stores/server.svelte';
+import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import type { ReasoningEffortLevel } from '$lib/types';
import type { DatabaseMessage } from '$lib/types/database';
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
const conversationModel = $derived(
- chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
// a router chat can carry reasoning from an earlier turn before the props
// cache is primed, so a model that already produced thinking still qualifies
const modelSupportsThinkingFromMessages = $derived.by(() => {
- const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
+ const modelId = serverStore.isRouterMode
+ ? modelsStore.selectedModelName || conversationModel
+ : null;
if (!modelId) return false;
);
});
const modelSupportsThinking = $derived.by(() => {
- loadedModelIds();
- propsCacheVersion();
+ void modelsStore.loadedModelIds;
+ void modelsStore.propsCacheVersion;
- if (isRouterMode()) {
+ if (serverStore.isRouterMode) {
const modelId = modelsStore.selectedModelName || conversationModel;
- return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
+ return (
+ modelsStore.checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages
+ );
}
- return supportsThinking() || modelSupportsThinkingFromMessages;
+ return modelsStore.supportsThinking || modelSupportsThinkingFromMessages;
});
const currentEffort = $derived(conversationsStore.getReasoningEffort());
const thinkingEnabled = $derived(
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ROUTES } from '$lib/constants';
-import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
+import { settingsReferrer } from '$lib/stores';
export interface ChatSettings {
reset: () => void;
import { CLI_FLAGS } from '$lib/constants';
import { ToolSource } from '$lib/enums';
-import { conversationsStore } from '$lib/stores/conversations.svelte';
-import { mcpStore } from '$lib/stores/mcp.svelte';
-import { toolsStore } from '$lib/stores/tools.svelte';
+import { conversationsStore, mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
SANDBOX_TOOL_NAME,
SANDBOX_TRUNCATION_NOTICE
} from '$lib/constants';
-import { config } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import type { ToolExecutionResult } from '$lib/types';
/** Cached harnesses keyed by whether nerdamer is included. */
* prelude. Cached per variant so toggling the setting is instant.
*/
async function getHarness(): Promise<string> {
- const enabled = !!config().symbolicMathEnabled;
+ const enabled = !!settingsStore.config.symbolicMathEnabled;
const key = enabled ? 'nerdamer' : 'plain';
if (!harnessCache[key]) {
import { ReadMediaService } from '$lib/services/read-media.service';
import { SandboxService } from '$lib/services/sandbox.service';
import { ToolsService } from '$lib/services/tools.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { permissionsStore } from '$lib/stores/permissions.svelte';
-import { config } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type {
AgenticConfig,
return {
currentTurn: 0,
executingToolCallId: null,
+ flowRootMessageId: null,
isRunning: false,
lastError: null,
+ liveLlm: null,
pendingPermissionRequest: null,
streamingToolCall: null,
totalToolCalls: 0
return this._sessions.get(conversationId)?.isRunning ?? false;
}
+ // read-only: safe to call from derivations, unlike getSession
+ getLiveLlmTotals(conversationId: string): AgenticSession['liveLlm'] {
+ return this._sessions.get(conversationId)?.liveLlm ?? null;
+ }
+
+ // read-only: safe to call from derivations, unlike getSession
+ getFlowRootMessageId(conversationId: string): string | null {
+ return this._sessions.get(conversationId)?.flowRootMessageId ?? null;
+ }
+
currentTurn(conversationId: string): number {
return this._sessions.get(conversationId)?.currentTurn ?? 0;
}
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
- const { callbacks, conversationId, messages, options = {}, perChatOverrides, signal } = params;
+ const {
+ callbacks,
+ conversationId,
+ flowRootMessageId,
+ messages,
+ options = {},
+ perChatOverrides,
+ signal
+ } = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow
this._pendingPermissions.set(conversationId, null);
await toolsStore.fetchBuiltinTools();
}
- const agenticConfig = this.getConfig(config(), perChatOverrides);
+ const agenticConfig = this.getConfig(settingsStore.config, perChatOverrides);
if (!agenticConfig.enabled) return { handled: false };
this.updateSession(conversationId, {
currentTurn: 0,
+ flowRootMessageId: flowRootMessageId ?? null,
isRunning: true,
lastError: null,
+ liveLlm: null,
totalToolCalls: 0
});
return { error: normalizedError, handled: true };
} finally {
- this.updateSession(conversationId, { isRunning: false });
+ this.updateSession(conversationId, {
+ flowRootMessageId: null,
+ isRunning: false,
+ liveLlm: null
+ });
if (hasMcpServers) {
await mcpStore
if (timings) {
capturedTimings = timings;
turnTimings = timings;
+
+ // completed turns + in-flight turn live counts
+ this.updateSession(conversationId, {
+ liveLlm: {
+ predicted_ms: agenticTimings.llm.predicted_ms + (timings.predicted_ms ?? 0),
+ predicted_n: agenticTimings.llm.predicted_n + (timings.predicted_n ?? 0),
+ prompt_ms: agenticTimings.llm.prompt_ms + (timings.prompt_ms ?? 0),
+ prompt_n: agenticTimings.llm.prompt_n + (timings.prompt_n ?? 0)
+ }
+ });
}
},
onToolCallChunk: (serialized: string) => {
}
export const agenticStore = new AgenticStore();
-
-export function agenticIsRunning(conversationId: string) {
- return agenticStore.isRunning(conversationId);
-}
-
-export function agenticCurrentTurn(conversationId: string) {
- return agenticStore.currentTurn(conversationId);
-}
-
-export function agenticTotalToolCalls(conversationId: string) {
- return agenticStore.totalToolCalls(conversationId);
-}
-
-export function agenticLastError(conversationId: string) {
- return agenticStore.lastError(conversationId);
-}
-
-export function agenticStreamingToolCall(conversationId: string) {
- return agenticStore.streamingToolCall(conversationId);
-}
-
-export function agenticPendingPermissionRequest(conversationId: string) {
- return agenticStore.pendingPermissionRequest(conversationId);
-}
-
-export function agenticResolvePermission(conversationId: string, decision: ToolPermissionDecision) {
- agenticStore.resolvePermission(conversationId, decision);
-}
-
-export function agenticPendingContinueRequest(conversationId: string) {
- return agenticStore.pendingContinueRequest(conversationId);
-}
-
-export function agenticResolveContinue(conversationId: string, shouldContinue: boolean) {
- agenticStore.resolveContinue(conversationId, shouldContinue);
-}
-
-export function agenticHasPendingSteeringMessage(conversationId: string) {
- return agenticStore.hasPendingSteeringMessage(conversationId);
-}
-
-export function agenticInjectSteeringMessage(
- conversationId: string,
- content: string,
- extras?: DatabaseMessageExtra[]
-) {
- agenticStore.injectSteeringMessage(conversationId, content, extras);
-}
-
-export function agenticPendingSteeringMessageContent(conversationId: string) {
- return agenticStore.pendingSteeringMessageContent(conversationId);
-}
-
-export function agenticPendingSteeringMessageExtras(conversationId: string) {
- return agenticStore.pendingSteeringMessageExtras(conversationId);
-}
-
-export function agenticClearSteeringMessage(conversationId: string) {
- agenticStore.clearSteeringMessage(conversationId);
-}
-
-export function agenticIsAnyRunning() {
- return agenticStore.isAnyRunning;
-}
-
-export function agenticExecutingToolCallId(conversationId: string) {
- return agenticStore.executingToolCallId(conversationId);
-}
import {
CONVERSATION_ID_SEPARATOR,
+ CWD_CLEARED_TEXT,
HEADERS,
INACTIVE_CONVERSATION,
STREAM_RESUME_RETRY_MS,
SYSTEM_MESSAGE_PLACEHOLDER,
TITLE_GENERATION
} from '$lib/constants';
-import { MimeTypeApplication } from '$lib/enums';
import {
ContinueIntentKind,
ErrorDialogType,
MessageRole,
MessageType,
+ MimeTypeApplication,
ReasoningEffort,
StreamConnectionState
} from '$lib/enums';
import { ChatService } from '$lib/services/chat.service';
import { DatabaseService } from '$lib/services/database.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
import { agenticStore } from '$lib/stores/agentic.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
-import {
- modelsStore,
- selectedModelContextSize,
- selectedModelName
-} from '$lib/stores/models.svelte';
-import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
-import { config } from '$lib/stores/settings.svelte';
+import { modelsStore } from '$lib/stores/models.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type {
ApiChatMessageData,
ApiProcessingState,
ApiStreamSession,
- DatabaseMessage,
- DatabaseMessageExtra
-} from '$lib/types';
-import type {
ChatMessagePromptProgress,
ChatMessageTimings,
ChatStreamCallbacks,
+ DatabaseMessage,
+ DatabaseMessageExtra,
ErrorDialogState
-} from '$lib/types/chat';
+} from '$lib/types';
import {
- CWD_CLEARED_TEXT,
+ classifyContinueIntent,
filterByLeafNodeId,
findDescendantMessages,
findLeafNode,
findMessageById,
formatCwdMessage,
generateConversationTitle,
+ getAuthHeaders,
isAbortError,
- normalizeModelName
+ normalizeModelName,
+ streamIdentity
} from '$lib/utils';
-import { classifyContinueIntent } from '$lib/utils/agentic';
-import { getAuthHeaders } from '$lib/utils/api-headers';
-import { streamIdentity } from '$lib/utils/stream-identity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface ConversationStateEntry {
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
}
- private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
+ private getChatStreamingState(
+ convId: string
+ ): { response: string; messageId: string } | undefined {
return this.chatStreamingStates.get(convId);
}
syncLoadingStateForChat(convId: string): void {
// fetch the replay stream from byte 0, rebuild the assistant message from scratch.
// resolve the server side identity, fall back to streamIdentity when the caller does not
// pass a streamId. probeServerStream returns the full id (with ::model suffix when present)
- const id = streamId || streamIdentity(convId, selectedModelName());
+ const id = streamId || streamIdentity(convId, modelsStore.selectedModelName);
let response: Response;
// persisted state so the lookup key matches what the server stored. null means a single
// model conv with no ::suffix, only guess from the dropdown with no persisted state
const localState = ChatService.getStreamState(convId);
- const streamId = ChatService.resumeStreamIdentity(convId, localState, selectedModelName());
+ const streamId = ChatService.resumeStreamIdentity(
+ convId,
+ localState,
+ modelsStore.selectedModelName
+ );
// primary path: ask the server which sessions exist for this identity
const serverTarget = await this.probeServerStream(streamId);
}
}
- getChatStreamingPublic(convId: string): { response: string; messageId: string } | undefined {
- return this.getChatStreaming(convId);
+ getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
+ return this.getChatStreamingState(convId);
}
- isChatLoadingPublic(convId: string): boolean {
+ isChatLoading(convId: string): boolean {
return this.chatLoadingStates.get(convId) || false;
}
- isChatReasoningPublic(convId: string): boolean {
- return this.chatReasoningStates.get(convId) || false;
- }
-
private isChatLoadingInternal(convId: string): boolean {
return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId);
}
if (isNewConversation) {
const rootId = await DatabaseService.createRootMessage(currentConv.id);
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
const systemPrompt = currentConfig.systemMessage?.toString().trim();
let sysOrRootId = rootId;
if (isNewConversation && content)
await conversationsStore.updateConversationName(
currentConv.id,
- generateConversationTitle(content, Boolean(config().titleGenerationUseFirstLine))
+ generateConversationTitle(
+ content,
+ Boolean(settingsStore.config.titleGenerationUseFirstLine)
+ )
);
const assistantMessage = await this.createAssistantMessage(userMessage.id);
undefined,
undefined,
undefined,
- config().titleGenerationUseLLM && isNewConversation ? content : undefined
+ settingsStore.config.titleGenerationUseLLM && isNewConversation ? content : undefined
);
} catch (error) {
if (isAbortError(error)) {
// and reattach all agree, regardless of fresh send vs regenerate passing a resolved model
let effectiveModel: string | null | undefined = undefined;
- if (isRouterMode()) {
+ if (serverStore.isRouterMode) {
const conversationModel = this.getConversationModel(allMessages);
- effectiveModel = modelOverride || selectedModelName() || conversationModel;
+ effectiveModel = modelOverride || modelsStore.selectedModelName || conversationModel;
}
- if (isRouterMode() && effectiveModel) {
+ if (serverStore.isRouterMode && effectiveModel) {
if (!modelsStore.getModelProps(effectiveModel))
await modelsStore.fetchModelProps(effectiveModel);
}
if (onComplete) onComplete(streamedContent);
- if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
+ if (serverStore.isRouterMode) modelsStore.fetchRouterModels().catch(console.error);
// Pre-encode conversation in KV cache for faster next turn
- if (config().preEncodeConversation) {
+ if (settingsStore.config.preEncodeConversation) {
this.triggerPreEncode(
allMessages,
assistantMessage,
streamedContent,
effectiveModel,
- !!config().excludeReasoningFromContext
+ !!settingsStore.config.excludeReasoningFromContext
);
}
},
const agenticResult = await agenticStore.runAgenticFlow({
callbacks: streamCallbacks,
conversationId: convId,
+ flowRootMessageId: assistantMessage.id,
messages: allMessages,
options: {
...this.getApiOptions(),
if (onComplete) await onComplete(content);
- if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
+ if (serverStore.isRouterMode) modelsStore.fetchRouterModels().catch(console.error);
// Generate LLM based title for new conversations (avoids stale reference
// issue when user switches conversations while streaming)
assistantContent: string,
convId: string
): Promise<void> {
- const effectiveModel = isRouterMode() && selectedModelName() ? selectedModelName() : undefined;
- const configValue = config();
+ const effectiveModel =
+ serverStore.isRouterMode && modelsStore.selectedModelName
+ ? modelsStore.selectedModelName
+ : undefined;
+ const configValue = settingsStore.config;
const titlePromptTemplate =
typeof configValue.titleGenerationPrompt === 'string' &&
configValue.titleGenerationPrompt.trim()
if (!conversationId) return;
- const streamingState = this.getChatStreaming(conversationId);
+ const streamingState = this.getChatStreamingState(conversationId);
if (!streamingState) return;
if (isFirstUserMessage && newContent.trim())
await conversationsStore.updateConversationName(
activeConv.id,
- generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
+ generateConversationTitle(
+ newContent,
+ Boolean(settingsStore.config.titleGenerationUseFirstLine)
+ )
);
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
await conversationsStore.updateConversationName(
activeConv.id,
- generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
+ generateConversationTitle(
+ newContent,
+ Boolean(settingsStore.config.titleGenerationUseFirstLine)
+ )
);
}
if (isFirstUserMessage && newContent.trim())
await conversationsStore.updateConversationName(
activeConv.id,
- generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
+ generateConversationTitle(
+ newContent,
+ Boolean(settingsStore.config.titleGenerationUseFirstLine)
+ )
);
await conversationsStore.refreshActiveMessages();
if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0)
return activeState.contextTotal;
- if (isRouterMode()) {
- const modelContextSize = selectedModelContextSize();
+ if (serverStore.isRouterMode) {
+ const modelContextSize = modelsStore.selectedModelContextSize;
if (typeof modelContextSize === 'number' && modelContextSize > 0) {
return modelContextSize;
}
} else {
- const propsContextSize = contextSize();
+ const propsContextSize = serverStore.contextSize;
if (typeof propsContextSize === 'number' && propsContextSize > 0) {
return propsContextSize;
| { total: number; cache: number; processed: number; time_ms: number }
| undefined;
const contextTotal = this.getContextTotal();
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
const outputTokensMax = currentConfig.max_tokens || -1;
const contextUsed = promptTokens + cacheTokens + predictedTokens,
outputTokensUsed = predictedTokens;
}
private getApiOptions(): Record<string, unknown> {
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
const hasValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== '';
const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true };
- if (isRouterMode()) {
- const modelName = selectedModelName();
+ if (serverStore.isRouterMode) {
+ const modelName = modelsStore.selectedModelName;
if (modelName) apiOptions.model = modelName;
}
}
export const chatStore = new ChatStore();
-
-export const activeProcessingState = () => chatStore.activeProcessingState;
-export const currentResponse = () => chatStore.currentResponse;
-export const errorDialog = () => chatStore.errorDialogState;
-export const getAddFilesHandler = () => chatStore.getAddFilesHandler();
-export const getAllLoadingChats = () => chatStore.getAllLoadingChats();
-export const getAllStreamingChats = () => chatStore.getAllStreamingChats();
-export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId);
-export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId);
-export const isChatStreaming = () => chatStore.isStreaming();
-export const isEditing = () => chatStore.isEditing();
-export const isLoading = () => chatStore.isLoading;
-export const isReasoning = () => chatStore.isReasoning;
-export const pendingEditMessageId = () => chatStore.pendingEditMessageId;
-export const chatHasPendingMessage = (convId: string) => chatStore.hasPendingMessage(convId);
-export const chatPendingMessageContent = (convId: string) =>
- chatStore.pendingMessageContent(convId);
-export const chatPendingMessageExtras = (convId: string) => chatStore.pendingMessageExtras(convId);
-export const chatClearPendingMessage = (convId: string) => chatStore.clearPendingMessage(convId);
-export const chatInjectPendingMessage = (
- convId: string,
- content: string,
- extras?: DatabaseMessageExtra[]
-) => chatStore.injectPendingMessage(convId, content, extras);
--- /dev/null
+/**
+ * contextStatsStore - Context window usage stats for the active conversation
+ *
+ * Combines token usage persisted in message timings metadata with
+ * server-originating data: model context size from /props (modelsStore)
+ * and live processing state while streaming (chatStore).
+ */
+
+import { MessageRole } from '$lib/enums';
+// direct imports between stores, not via the barrel, to avoid circular deps
+import { agenticStore } from '$lib/stores/agentic.svelte';
+import { chatStore } from '$lib/stores/chat.svelte';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
+import { modelsStore } from '$lib/stores/models.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
+import type { ApiProcessingState, ChatMessageTimings, DatabaseMessage } from '$lib/types';
+
+interface LiveStats {
+ freshTokens: number;
+ promptTokens: number;
+ cacheTokens: number;
+ outputTokens: number;
+}
+
+function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+
+ if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings;
+ }
+
+ return undefined;
+}
+
+function deriveLiveStats(state: ApiProcessingState | null): LiveStats | null {
+ if (!state || (state.status !== 'preparing' && state.status !== 'generating')) {
+ return null;
+ }
+
+ const promptTokens = state.promptTokens ?? 0;
+ const cacheTokens = state.cacheTokens ?? 0;
+
+ return {
+ cacheTokens,
+ freshTokens: promptTokens,
+ outputTokens: state.outputTokensUsed ?? 0,
+ promptTokens: promptTokens + cacheTokens
+ };
+}
+
+class ContextStatsStore {
+ // Resolve the model the stats report context for: explicit selection >
+ // last assistant model > single-model mode (mirrors useChatScreenActiveModel).
+ activeModelId = $derived.by(() => {
+ if (!serverStore.isRouterMode) {
+ return modelsStore.singleModelName;
+ }
+
+ const selectedId = modelsStore.selectedModelId;
+
+ if (selectedId) {
+ const model = modelsStore.models.find((m) => m.id === selectedId);
+
+ if (model) return model.model;
+ }
+
+ return chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[]);
+ });
+
+ isActiveModelLoaded = $derived(
+ this.activeModelId !== null &&
+ (!serverStore.isRouterMode || modelsStore.isModelLoaded(this.activeModelId))
+ );
+
+ isActiveModelLoading = $derived(
+ this.activeModelId !== null && modelsStore.isModelOperationInProgress(this.activeModelId)
+ );
+
+ contextTotal = $derived.by(() => {
+ void modelsStore.propsCacheVersion;
+
+ return this.activeModelId ? modelsStore.getModelContextSize(this.activeModelId) : null;
+ });
+
+ private liveStats = $derived(deriveLiveStats(chatStore.activeProcessingState));
+
+ currentRead = $derived.by(() => {
+ const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
+
+ let read = 0;
+
+ if (timings) {
+ read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0);
+ }
+
+ // live.promptTokens is already the combined reading (prompt + cache),
+ // so do not also add live.cacheTokens.
+ if (this.liveStats && this.liveStats.promptTokens > 0) {
+ read = Math.max(read, this.liveStats.promptTokens);
+ }
+
+ return read;
+ });
+
+ currentFresh = $derived.by(() => {
+ const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
+ const fresh = timings?.prompt_n ?? 0;
+
+ return Math.max(fresh, this.liveStats?.freshTokens ?? 0);
+ });
+
+ currentCache = $derived.by(() => {
+ const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
+ const cached = timings?.cache_n ?? 0;
+
+ if (this.liveStats && this.liveStats.promptTokens > 0) {
+ return Math.max(cached, this.liveStats.cacheTokens);
+ }
+
+ return cached;
+ });
+
+ currentOutput = $derived.by(() => {
+ if (this.liveStats && this.liveStats.outputTokens > 0) return this.liveStats.outputTokens;
+
+ const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
+
+ return timings?.predicted_n ?? 0;
+ });
+
+ kvTotal = $derived(this.currentRead + this.currentOutput);
+
+ contextUsed = $derived(this.currentRead + this.currentOutput);
+
+ contextAvailable = $derived(
+ this.contextTotal !== null ? this.contextTotal - this.contextUsed : null
+ );
+
+ contextPercent = $derived.by(() => {
+ if (this.contextTotal === null || this.contextTotal <= 0) return null;
+
+ return Math.round((this.contextUsed / this.contextTotal) * 100);
+ });
+
+ private cumulative = $derived.by(() => {
+ const messages = conversationsStore.activeMessages as DatabaseMessage[];
+ const convId = conversationsStore.activeConversation?.id;
+ // A running agentic flow stamps llm totals on messages only when it
+ // exits, so read its live session totals instead.
+ const liveLlm = convId ? agenticStore.getLiveLlmTotals(convId) : null;
+
+ if (liveLlm) {
+ const outputMs = liveLlm.predicted_ms;
+ const averageTokensPerSecond =
+ outputMs > 0 && liveLlm.predicted_n > 0 ? (liveLlm.predicted_n / outputMs) * 1000 : null;
+
+ return {
+ averageTokensPerSecond,
+ cacheTotal: 0,
+ output: liveLlm.predicted_n,
+ read: liveLlm.prompt_n
+ };
+ }
+
+ // Agentic sessions stamp the same agentic.llm totals onto every
+ // assistant message; cache_n is never per-turn so cache_total stays 0.
+ const agenticMessages = messages.filter(
+ (m) => m.role === MessageRole.ASSISTANT && m.timings?.agentic?.llm?.predicted_n != null
+ );
+
+ if (agenticMessages.length > 0) {
+ const llm = agenticMessages[agenticMessages.length - 1].timings!.agentic!.llm;
+ const output = llm.predicted_n ?? 0;
+ const outputMs = llm.predicted_ms ?? 0;
+ const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
+
+ return {
+ averageTokensPerSecond,
+ cacheTotal: 0,
+ output,
+ read: llm.prompt_n ?? 0
+ };
+ }
+
+ let read = 0;
+ let output = 0;
+ let outputMs = 0;
+ let cacheTotal = 0;
+
+ for (const m of messages) {
+ if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
+
+ read += m.timings.prompt_n ?? 0;
+ cacheTotal += m.timings.cache_n ?? 0;
+ output += m.timings.predicted_n ?? 0;
+ outputMs += m.timings.predicted_ms ?? 0;
+ }
+ const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
+
+ return { averageTokensPerSecond, cacheTotal, output, read };
+ });
+
+ cumulativeRead = $derived(this.cumulative.read);
+
+ cumulativeOutput = $derived(this.cumulative.output);
+
+ cumulativeCacheTotal = $derived(this.cumulative.cacheTotal);
+
+ averageTokensPerSecond = $derived(this.cumulative.averageTokensPerSecond);
+}
+
+export const contextStatsStore = new ContextStatsStore();
import { DatabaseService } from '$lib/services/database.service';
import { MigrationService } from '$lib/services/migration.service';
import { RouterService } from '$lib/services/router.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp.svelte';
-import { config } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import type { McpServerOverride } from '$lib/types/database';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
-import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
-export interface ConversationTreeItem {
- conversation: DatabaseConversation;
- depth: number;
-}
-
class ConversationsStore {
/**
*
this.activeConversation.id,
generateConversationTitle(
newFirstUserMessage.content,
- Boolean(config().titleGenerationUseFirstLine)
+ Boolean(settingsStore.config.titleGenerationUseFirstLine)
)
);
}
if (browser) {
conversationsStore.init();
}
-
-export const conversations = () => conversationsStore.conversations;
-export const activeConversation = () => conversationsStore.activeConversation;
-export const activeMessages = () => conversationsStore.activeMessages;
-export const pendingCwd = () => conversationsStore.pendingCwd;
-export const isConversationsInitialized = () => conversationsStore.isInitialized;
-
-/**
- * Builds a flat tree of conversations with depth levels for nested forks.
- * Accepts a pre-filtered list so search filtering stays in the component.
- *
- * Output order matches the sidebar render exactly: pinned first, then
- * unpinned by lastModified desc, with forks interleaved under their parents.
- * Range-select / marquee in the sidebar rely on this alignment.
- */
-
-// Pinned conversations first, then by lastModified descending
-const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversation) => {
- if (a.pinned && !b.pinned) return -1;
-
- if (!a.pinned && b.pinned) return 1;
-
- return b.lastModified - a.lastModified;
-};
-
-export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] {
- const childrenByParent = new SvelteMap<string, DatabaseConversation[]>();
- const forkIds = new SvelteSet<string>();
-
- for (const conv of convs) {
- if (conv.forkedFromConversationId) {
- forkIds.add(conv.id);
-
- const siblings = childrenByParent.get(conv.forkedFromConversationId) || [];
-
- siblings.push(conv);
- childrenByParent.set(conv.forkedFromConversationId, siblings);
- }
- }
-
- const result: ConversationTreeItem[] = [];
- const visited = new SvelteSet<string>();
-
- function walk(conv: DatabaseConversation, depth: number) {
- visited.add(conv.id);
- result.push({ conversation: conv, depth });
-
- const children = childrenByParent.get(conv.id);
-
- if (children) {
- children.sort(comparePinnedThenRecent);
-
- for (const child of children) {
- walk(child, depth + 1);
- }
- }
- }
-
- const roots = convs.filter((c) => !forkIds.has(c.id)).sort(comparePinnedThenRecent);
-
- for (const root of roots) {
- walk(root, 0);
- }
-
- for (const conv of convs) {
- if (!visited.has(conv.id)) {
- walk(conv, 1);
- }
- }
-
- return result;
-}
--- /dev/null
+/**
+ * STORES
+ *
+ * Reactive Svelte runes state layer. Stores own application state and
+ * expose it as plain Svelte 5 runes (`$state`, `$derived`, `$effect`),
+ * consumed by components, routes, hooks and services.
+ *
+ * Import from this barrel in leaf consumers:
+ *
+ * ```ts
+ * import { chatStore, modelsStore } from '$lib/stores';
+ * ```
+ *
+ * Store modules keep direct imports between each other (and from services/
+ * utils they depend on) to avoid circular dependency chains.
+ *
+ * Each store below documents its primary responsibility.
+ */
+
+// CHAT / MESSAGING
+export { chatStore } from './chat.svelte';
+
+export { draftMessagesStore } from './draft-messages.svelte';
+
+// AGENTIC (multi-turn tool orchestration)
+export { agenticStore } from './agentic.svelte';
+
+// CONVERSATIONS
+export { conversationsStore } from './conversations.svelte';
+
+// CONTEXT STATS (active conversation context window usage)
+export { contextStatsStore } from './context-stats.svelte';
+
+// MCP
+export { mcpStore } from './mcp.svelte';
+
+export { mcpResourceStore } from './mcp-resources.svelte';
+
+// MODELS
+export { modelsStore } from './models.svelte';
+
+// SERVER
+export { serverStore } from './server.svelte';
+
+// SETTINGS / UI PREFERENCES
+export { settingsStore } from './settings.svelte';
+
+export { settingsReferrer } from './settings-referrer.svelte';
+
+export { permissionsStore } from './permissions.svelte';
+
+// TOOLS
+export { toolsStore } from './tools.svelte';
+
+// ENVIRONMENT / META
+export { buildInfoStore } from './build-info.svelte';
+
+export { versionStore } from './version.svelte';
+
+export { device } from './device.svelte';
+
+export { viewport, isMobile } from './viewport.svelte';
+
+export { theme } from './theme.svelte';
+
+export {
+ gaugePopup,
+ gaugePopupClose,
+ gaugeTriggerPointerDown,
+ gaugeTriggerClick,
+ gaugeTriggerKeydown,
+ gaugeTriggerEnter,
+ gaugeTriggerLeave,
+ gaugeCardEnter,
+ gaugeCardLeave
+} from './context-gauge-popup.svelte';
+
+export { persisted } from './persisted.svelte';
}
export const mcpResourceStore = new MCPResourceStore();
-
-// Export convenience functions
-export const mcpResources = () => mcpResourceStore.serverResources;
-export const mcpResourceAttachments = () => mcpResourceStore.attachments;
-export const mcpResourceAttachmentCount = () => mcpResourceStore.attachmentCount;
-export const mcpHasResourceAttachments = () => mcpResourceStore.hasAttachments;
-export const mcpTotalResourceCount = () => mcpResourceStore.totalResourceCount;
-export const mcpResourcesLoading = () => mcpResourceStore.isLoading;
UrlProtocol
} from '$lib/enums';
import { MCPService } from '$lib/services/mcp.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { serverStore } from '$lib/stores/server.svelte';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import type {
ClientCapabilities,
GetPromptResult,
*/
#requestTimeoutMs(): number {
const seconds =
- Number(config().mcpRequestTimeoutSeconds) || DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
+ Number(settingsStore.config.mcpRequestTimeoutSeconds) ||
+ DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
return Math.round(seconds * 1000);
}
}
get isEnabled(): boolean {
- const mcpConfig = this.#buildMcpClientConfig(config());
+ const mcpConfig = this.#buildMcpClientConfig(settingsStore.config);
return (
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
}
getServers(): MCPServerSettingsEntry[] {
- return parseMcpServerSettings(config().mcpServers);
+ return parseMcpServerSettings(settingsStore.config.mcpServers);
}
/**
}
hasAvailableServers(): boolean {
- return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim());
+ return parseMcpServerSettings(settingsStore.config.mcpServers).some(
+ (s) => s.enabled && s.url.trim()
+ );
}
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
- return Boolean(this.#buildMcpClientConfig(config(), perChatOverrides));
+ return Boolean(this.#buildMcpClientConfig(settingsStore.config, perChatOverrides));
}
getEnabledServersForConversation(
return false;
}
- const mcpConfig = this.#buildMcpClientConfig(config(), perChatOverrides);
+ const mcpConfig = this.#buildMcpClientConfig(settingsStore.config, perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
if (!signature) {
}
export const mcpStore = new MCPStore();
-
-export const mcpIsInitializing = () => mcpStore.isInitializing;
-export const mcpIsInitialized = () => mcpStore.isInitialized;
-export const mcpError = () => mcpStore.error;
-export const mcpIsEnabled = () => mcpStore.isEnabled;
-export const mcpIsProxyAvailable = () => mcpStore.isProxyAvailable;
-export const mcpAvailableTools = () => mcpStore.availableTools;
-export const mcpConnectedServerCount = () => mcpStore.connectedServerCount;
-export const mcpConnectedServerNames = () => mcpStore.connectedServerNames;
-export const mcpToolCount = () => mcpStore.toolCount;
-export const mcpServerInstructions = () => mcpStore.getServerInstructions();
-export const mcpHasServerInstructions = () => mcpStore.hasServerInstructions();
-
-// Resources exports
-export const mcpHasResourcesCapability = () => mcpStore.hasResourcesCapability();
-export const mcpServersWithResources = () => mcpStore.getServersWithResources();
-export const mcpResourceContext = () => mcpStore.getResourceContextForChat();
} from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import { PropsService } from '$lib/services/props.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations.svelte';
-import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
// deep imports, not the '$lib/utils' barrel: it re-exports modules that reach back
// into the stores, and going through it here would read a half-built module
import { getAuthHeaders } from '$lib/utils/api-headers';
* In ROUTER mode, returns null (model is per-conversation).
*/
get singleModelName(): string | null {
- if (isRouterMode()) return null;
+ if (serverStore.isRouterMode) return null;
const props = serverStore.props;
*/
getModelModalities(modelId: string): ModelModalities | null {
- if (!isRouterMode() && serverStore.props?.modalities) {
+ if (!serverStore.isRouterMode && serverStore.props?.modalities) {
return this.buildModalities(serverStore.props.modalities);
}
* triggering an async fetch if not yet cached
*/
get supportsThinking(): boolean {
- if (!isRouterMode()) {
+ if (!serverStore.isRouterMode) {
return detectThinkingSupport(serverStore.props?.chat_template ?? '');
}
* In ROUTER mode, fetches model props if not cached.
*/
checkModelSupportsThinking(modelId: string): boolean {
- if (!isRouterMode()) {
+ if (!serverStore.isRouterMode) {
return detectThinkingSupport(serverStore.props?.chat_template ?? '');
}
* Detailed thinking support detection result with reason for debugging/UI.
*/
get thinkingSupportDetails(): { supported: boolean; reason: string } {
- if (!isRouterMode()) {
+ if (!serverStore.isRouterMode) {
return detectThinkingSupportWithReason(serverStore.props?.chat_template ?? '');
}
await serverStore.fetch();
}
- const router = isRouterMode();
+ const router = serverStore.isRouterMode;
if (router) {
const response = await ModelsService.listRouter();
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
*/
async fetchRouterModels(): Promise<void> {
- if (!isRouterMode()) return;
+ if (!serverStore.isRouterMode) return;
try {
const response = await ModelsService.listRouter();
subscribeStatus(): void {
if (this.statusReaderActive) return;
- if (!isRouterMode()) return;
+ if (!serverStore.isRouterMode) return;
this.statusReaderActive = true;
this.statusAbort = new AbortController();
}
export const modelsStore = new ModelsStore();
-
-export const modelOptions = () => modelsStore.models;
-export const routerModels = () => modelsStore.routerModels;
-export const modelsLoading = () => modelsStore.loading;
-export const modelsUpdating = () => modelsStore.updating;
-export const modelsError = () => modelsStore.error;
-export const selectedModelId = () => modelsStore.selectedModelId;
-export const selectedModelName = () => modelsStore.selectedModelName;
-export const selectedModelOption = () => modelsStore.selectedModel;
-export const loadedModelIds = () => modelsStore.loadedModelIds;
-export const loadingModelIds = () => modelsStore.loadingModelIds;
-export const propsCacheVersion = () => modelsStore.propsCacheVersion;
-export const singleModelName = () => modelsStore.singleModelName;
-export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
-export const favoriteModelIds = () => modelsStore.favoriteModelIds;
-export const supportsThinking = () => modelsStore.supportsThinking;
-export const checkModelSupportsThinking = (modelId: string) =>
- modelsStore.checkModelSupportsThinking(modelId);
-export const thinkingSupportDetails = () => modelsStore.thinkingSupportDetails;
+import { browser } from '$app/environment';
import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
private _tools = $state(new SvelteSet<string>());
constructor() {
+ // browser-only init: skip on SSR to avoid localStorage side effects
+ if (!browser) return;
+
try {
const stored = localStorage.getItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY);
import { ServerRole } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
-import { ApiError } from '$lib/utils/api-fetch';
+import { ApiError } from '$lib/utils';
const LOADING_RETRY_INTERVAL_MS = 1000;
}
export const serverStore = new ServerStore();
-
-export const serverProps = () => serverStore.props;
-export const serverLoading = () => serverStore.loading;
-export const serverError = () => serverStore.error;
-export const serverStatus = () => serverStore.status;
-export const serverRole = () => serverStore.role;
-export const defaultParams = () => serverStore.defaultParams;
-export const contextSize = () => serverStore.contextSize;
-export const isRouterMode = () => serverStore.isRouterMode;
-export const isModelMode = () => serverStore.isModelMode;
} from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { SettingsExportType } from '$lib/types';
}
export const settingsStore = new SettingsStore();
-
-export const config = () => settingsStore.config;
-export const theme = () => settingsStore.config[SETTINGS_KEYS.THEME];
-export const isInitialized = () => settingsStore.isInitialized;
+import { browser } from '$app/environment';
import {
buildReadMediaToolDefinition,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
ToolSource
} from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
+// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp.svelte';
-import { modelsStore, selectedModelName } from '$lib/stores/models.svelte';
-import { config } from '$lib/stores/settings.svelte';
+import { modelsStore } from '$lib/stores/models.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { buildSandboxToolDefinition } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
private _serverHome = $state<string | null | undefined>(undefined);
constructor() {
+ // browser-only init: skip on SSR to avoid localStorage/fetch side effects
+ if (!browser) return;
+
try {
const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
get frontendTools(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [];
- if (config().jsSandboxEnabled) {
- tools.push(buildSandboxToolDefinition(!!config().symbolicMathEnabled));
+ if (settingsStore.config.jsSandboxEnabled) {
+ tools.push(buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled));
}
const readMedia = this.readMediaTool();
if (!hasReadFile) return null;
- const model = selectedModelName() ?? modelsStore.models[0]?.model ?? '';
+ const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
if (!model) return null;
}
get customTools(): OpenAIToolDefinition[] {
- const raw = config().customJson;
+ const raw = settingsStore.config.customJson;
if (!raw || typeof raw !== 'string') return [];
}
export const toolsStore = new ToolsStore();
-
-export const allTools = () => toolsStore.allTools;
-export const allToolDefinitions = () => toolsStore.allToolDefinitions;
-export const toolGroups = () => toolsStore.toolGroups;
ApiChatMessageContentPart,
ApiChatMessageData
} from './api';
-import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
+import type {
+ ChatMessageAgenticTimings,
+ ChatMessagePromptProgress,
+ ChatMessageTimings
+} from './chat';
import type {
DatabaseMessage,
DatabaseMessageExtra,
* matching tool renderer flip into live-update mode while chunks
* arrive; cleared when the tool's terminal event lands. */
executingToolCallId: string | null;
+ /** Live LLM token totals of the running flow: completed turns plus the
+ * in-flight turn's streamed counts; null when idle. */
+ liveLlm: ChatMessageAgenticTimings['llm'] | null;
+ /** ID of the flow's first assistant message (the one the UI groups the
+ * whole run under); null when idle. */
+ flowRootMessageId: string | null;
}
/**
*/
export interface AgenticFlowParams {
conversationId: string;
+ /** ID of the flow's first assistant message, used to keep its stats live */
+ flowRootMessageId?: string;
messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[];
options?: AgenticFlowOptions;
callbacks: AgenticFlowCallbacks;
import { redactValue } from './redact';
import { CORS_PROXY, HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
-import { config } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
/**
* Get authorization headers for API requests
* Includes Bearer token if API key is configured
*/
export function getAuthHeaders(): Record<string, string> {
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
const apiKey = currentConfig.apiKey?.toString().trim();
return apiKey ? { [HEADERS.AUTHORIZATION]: `${HEADERS.BEARER}${apiKey}` } : {};
import { base } from '$app/paths';
import { HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
-import { config } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
/**
* Validates API key by making a request to the server props endpoint
return;
}
- const apiKey = config().apiKey;
+ const apiKey = settingsStore.config.apiKey;
try {
const headers: Record<string, string> = {
export function getMessageCount(conversationId: string, countMap: Map<string, number>): number {
return countMap.get(conversationId) ?? 0;
}
+
+export interface ConversationTreeItem {
+ conversation: DatabaseConversation;
+ depth: number;
+}
+
+// Pinned conversations first, then by lastModified descending
+const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversation) => {
+ if (a.pinned && !b.pinned) return -1;
+
+ if (!a.pinned && b.pinned) return 1;
+
+ return b.lastModified - a.lastModified;
+};
+
+/**
+ * Builds a flat tree of conversations with depth levels for nested forks.
+ * Accepts a pre-filtered list so search filtering stays in the component.
+ *
+ * Output order matches the sidebar render exactly: pinned first, then
+ * unpinned by lastModified desc, with forks interleaved under their parents.
+ * Range-select / marquee in the sidebar rely on this alignment.
+ */
+export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] {
+ const childrenByParent = new Map<string, DatabaseConversation[]>();
+ const forkIds = new Set<string>();
+
+ for (const conv of convs) {
+ if (conv.forkedFromConversationId) {
+ forkIds.add(conv.id);
+
+ const siblings = childrenByParent.get(conv.forkedFromConversationId) || [];
+
+ siblings.push(conv);
+ childrenByParent.set(conv.forkedFromConversationId, siblings);
+ }
+ }
+
+ const result: ConversationTreeItem[] = [];
+ const visited = new Set<string>();
+
+ function walk(conv: DatabaseConversation, depth: number) {
+ visited.add(conv.id);
+ result.push({ conversation: conv, depth });
+
+ const children = childrenByParent.get(conv.id);
+
+ if (children) {
+ children.sort(comparePinnedThenRecent);
+
+ for (const child of children) {
+ walk(child, depth + 1);
+ }
+ }
+ }
+
+ const roots = convs.filter((c) => !forkIds.has(c.id)).sort(comparePinnedThenRecent);
+
+ for (const root of roots) {
+ walk(root, 0);
+ }
+
+ for (const conv of convs) {
+ if (!visited.has(conv.id)) {
+ walk(conv, 1);
+ }
+ }
+
+ return result;
+}
import { SETTINGS_KEYS } from '$lib/constants';
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import type { ChatUploadedFile, DatabaseMessageExtra, FileProcessingResult } from '$lib/types';
import { getFileTypeCategory } from '$lib/utils';
import { toast } from 'svelte-sonner';
try {
// Always get base64 data for preview functionality
const base64Data = await readFileAsBase64(file.file);
- const currentConfig = config();
+ const currentConfig = settingsStore.config;
// Use per-model vision check for router mode
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
// API utilities
export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers';
-export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './api-fetch';
+export { ApiError, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
export { validateApiKey } from './api-key-validation';
// Attachment utilities
export { modelLoadFraction, modelLoadProgressText } from './progress';
// Conversation utilities
-export { createMessageCountMap, getMessageCount } from './conversation-utils';
+export {
+ createMessageCountMap,
+ getMessageCount,
+ buildConversationTree,
+ type ConversationTreeItem
+} from './conversation-utils';
// Clipboard utilities
export {
// SSE-with-JSON stream iterator (used by built-in tool streaming, decoupled
// from chat.service.ts which embeds its own SSE parser for resume support)
-export { parseSseJsonStream, type SseJsonEvent } from './sse';
+export { parseSseJsonStream } from './sse';
+
+// Stream session identity (conversation-id based)
+export { streamIdentity } from './stream-identity';
// MCP utilities
export {
buildMentionInsertion
} from './mention-badge';
+// Chat template utilities
+export {
+ detectThinkingSupport,
+ detectThinkingSupportWithReason
+} from './chat-template-thinking-detector';
+
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
parseToolResultWithMedia,
splitSearchSummaryList,
hasAgenticContent,
- classifyToolResult
+ classifyToolResult,
+ classifyContinueIntent
} from './agentic';
// Line-level unified diff for tool result rendering (`edit_file` block)
} from './search-results';
// Cache utilities
-export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
+export { TTLCache, ReactiveTTLMap } from './cache-ttl';
// Redaction utilities
export { redactValue } from './redact';
import { page } from '$app/state';
import { DialogModelNotAvailable } from '$lib/components/app';
import { APP_NAME, URL_PARAMS } from '$lib/constants';
- import { chatStore } from '$lib/stores/chat.svelte';
- import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
- import { modelOptions, modelsStore } from '$lib/stores/models.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
+ import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { onMount } from 'svelte';
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
// Dialog state for model not available error
let showModelNotAvailable = $state(false);
let requestedModelName = $state('');
- let availableModelNames = $derived(modelOptions().map((m) => m.model));
+ let availableModelNames = $derived(modelsStore.models.map((m) => m.model));
/**
* Clear URL params after message is sent to prevent re-sending on refresh
// with ?load=true, start loading right away so the model is ready sooner;
// not awaited, so the UI stays usable during the load
- if (loadParam === 'true' && isRouterMode() && !modelsStore.isModelLoaded(model.id)) {
+ if (
+ loadParam === 'true' &&
+ serverStore.isRouterMode &&
+ !modelsStore.isModelLoaded(model.id)
+ ) {
modelsStore
.loadModel(model.id)
.catch((error) => console.error('Failed to load model:', error));
}
onMount(async () => {
- if (!isConversationsInitialized()) {
+ if (!conversationsStore.isInitialized) {
await conversationsStore.initialize();
}
import { page } from '$app/state';
import { DialogModelNotAvailable } from '$lib/components/app';
import { APP_NAME, ROUTES, URL_PARAMS } from '$lib/constants';
- import { chatStore } from '$lib/stores/chat.svelte';
- import { activeConversation, conversationsStore } from '$lib/stores/conversations.svelte';
- import { modelOptions, modelsStore } from '$lib/stores/models.svelte';
+ import { chatStore, conversationsStore, modelsStore } from '$lib/stores';
let chatId = $derived(page.params.id);
let currentChatId: string | undefined = undefined;
// Dialog state for model not available error
let showModelNotAvailable = $state(false);
let requestedModelName = $state('');
- let availableModelNames = $derived(modelOptions().map((m) => m.model));
+ let availableModelNames = $derived(modelsStore.models.map((m) => m.model));
// Track if URL params have been processed for this chat
let urlParamsProcessed = $state(false);
urlParamsProcessed = false; // Reset for new chat
// Skip loading if this conversation is already active (e.g., just created)
- if (activeConversation()?.id === chatId) {
+ if (conversationsStore.activeConversation?.id === chatId) {
void chatStore.discoverActiveStream(chatId);
if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
</script>
<svelte:head>
- <title>{activeConversation()?.name || 'Chat'} - {APP_NAME}</title>
+ <title>{conversationsStore.activeConversation?.name || 'Chat'} - {APP_NAME}</title>
</svelte:head>
<DialogModelNotAvailable
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { usePwa } from '$lib/hooks/use-pwa.svelte';
import { RouterService } from '$lib/services/router.service';
- import { buildInfoStore } from '$lib/stores/build-info.svelte';
- import { chatStore } from '$lib/stores/chat.svelte';
- import { conversations } from '$lib/stores/conversations.svelte';
- import { mcpStore } from '$lib/stores/mcp.svelte';
- import { modelsStore } from '$lib/stores/models.svelte';
- import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
- import { config, settingsStore } from '$lib/stores/settings.svelte';
- import { theme } from '$lib/stores/theme.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import {
+ buildInfoStore,
+ chatStore,
+ conversationsStore,
+ isMobile,
+ mcpStore,
+ modelsStore,
+ serverStore,
+ settingsStore,
+ theme
+ } from '$lib/stores';
import { ModeWatcher } from 'mode-watcher';
import { untrack } from 'svelte';
import { onMount } from 'svelte';
}
| undefined = $state();
- let showBuildVersion = $derived(config()[SETTINGS_KEYS.SHOW_BUILD_VERSION] as boolean);
+ let showBuildVersion = $derived(
+ settingsStore.config[SETTINGS_KEYS.SHOW_BUILD_VERSION] as boolean
+ );
// Keep the hook object intact: destructuring needRefreshByStorage reads the getter once and freezes it
const pwa = usePwa();
}
function navigateToConversation(direction: -1 | 1) {
- const allConvs = conversations();
+ const allConvs = conversationsStore.conversations;
if (allConvs.length === 0) return;
});
function checkApiKey() {
- const apiKey = config().apiKey;
+ const apiKey = settingsStore.config.apiKey;
// Without a stored key there is nothing to re-validate here; the keyless
// 401 case is handled by validateApiKey() at navigation time, and the
// textContent keeps the value as text, never parsed as HTML
function customCss(node: HTMLStyleElement) {
$effect(() => {
- node.textContent = (config().customCss as string | undefined) ?? '';
+ node.textContent = (settingsStore.config.customCss as string | undefined) ?? '';
});
}
let routerModelsFetched = false;
$effect(() => {
- const isRouter = isRouterMode();
+ const isRouter = serverStore.isRouterMode;
const modelsCount = modelsStore.models.length;
// Only fetch router models once when we have models loaded and in router mode
$effect(() => {
if (!browser) return;
- if (!isRouterMode()) return;
+ if (!serverStore.isRouterMode) return;
untrack(() => {
modelsStore.subscribeStatus();
<meta name="theme-color" content={pwaAssetsHead.themeColor.content} />
{/if}
- {#if config().customCss}
+ {#if settingsStore.config.customCss}
<style use:customCss></style>
{/if}
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
import { ROUTES } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
- import { chatStore } from '$lib/stores/chat.svelte';
- import { conversations, conversationsStore } from '$lib/stores/conversations.svelte';
- import { isMobile } from '$lib/stores/viewport.svelte';
+ import { chatStore, conversationsStore, isMobile } from '$lib/stores';
let searchQuery = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null);
if (query.length === 0) return [];
- return conversations().filter((c) => c.name.toLowerCase().includes(query));
+ return conversationsStore.conversations.filter((c) => c.name.toLowerCase().includes(query));
});
// Search page is intended for mobile; on desktop the sidebar already exposes
}
async function handleEditConversation(id: string) {
- const conversation = conversations().find((c) => c.id === id);
+ const conversation = conversationsStore.conversations.find((c) => c.id === id);
if (!conversation) return;
}
async function handleDeleteConversation(id: string) {
- const conversation = conversations().find((c) => c.id === id);
+ const conversation = conversationsStore.conversations.find((c) => c.id === id);
if (!conversation) return;
import { CONFIG_LOCALSTORAGE_KEY, SETTING_CONFIG_DEFAULT } from '$lib/constants';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import type { SettingsConfigType } from '$lib/types';
import { beforeEach, describe, expect, it } from 'vitest';
const setUser = (key: string, value: Primitive) =>
settingsStore.updateConfig(key as keyof SettingsConfigType, value as never);
-const current = (key: string) => (config() as Record<string, unknown>)[key];
+const current = (key: string) => (settingsStore.config as Record<string, unknown>)[key];
describe('registry-wide invariants', () => {
beforeEach(() => {
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
import { MigrationService } from '$lib/services/migration.service';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import { beforeEach, describe, expect, it } from 'vitest';
const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
it('maps raw text to user content as plain text', async () => {
await seedConfig({ renderContentAsRawText: true });
- expect(config().renderUserContentAsMarkdown).toBe(false);
+ expect(settingsStore.config.renderUserContentAsMarkdown).toBe(false);
});
it('maps markdown to user content as markdown', async () => {
await seedConfig({ renderContentAsRawText: false });
- expect(config().renderUserContentAsMarkdown).toBe(true);
+ expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
});
it('leaves thinking on its own default', async () => {
await seedConfig({ renderContentAsRawText: true });
- expect(config().renderThinkingAsMarkdown).toBe(true);
+ expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true);
});
it('keeps an explicit user preference over the toggle', async () => {
await seedConfig({ renderContentAsRawText: true, renderUserContentAsMarkdown: true });
- expect(config().renderUserContentAsMarkdown).toBe(true);
+ expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
});
it('drops the toggle from the persisted config', async () => {
it('leaves both surfaces on markdown when nothing is stored', async () => {
await seedConfig({});
- expect(config().renderUserContentAsMarkdown).toBe(true);
- expect(config().renderThinkingAsMarkdown).toBe(true);
+ expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
+ expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true);
});
});
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
import { serverStore } from '$lib/stores/server.svelte';
-import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { settingsStore } from '$lib/stores/settings.svelte';
import { beforeEach, describe, expect, it } from 'vitest';
function mockProps(uiSettings: Record<string, string | number | boolean>) {
settingsStore.syncWithServerDefaults();
- expect(config().theme).toBe('dark');
+ expect(settingsStore.config.theme).toBe('dark');
});
it('never reapplies on later loads: the user config diverges freely', () => {
settingsStore.syncWithServerDefaults();
settingsStore.syncWithServerDefaults();
- expect(config().theme).toBe('light');
- expect(config().apiKey).toBe('sk-user-key');
+ expect(settingsStore.config.theme).toBe('light');
+ expect(settingsStore.config.apiKey).toBe('sk-user-key');
const stored = JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
expect(stored.apiKey).toBe('sk-user-key');
settingsStore.forceSyncWithServerDefaults();
- expect(config().theme).toBe('dark');
- expect(config().apiKey).toBe('');
+ expect(settingsStore.config.theme).toBe('dark');
+ expect(settingsStore.config.apiKey).toBe('');
});
});