Merge commit '873e5d8e39feb34a376e0efd01bf3f665dfffeb5' into concedo_experimental

# Conflicts:
#	.github/workflows/build-cmake-pkg.yml
#	.github/workflows/build-cpu.yml
#	.github/workflows/make-release.yml
#	.github/workflows/release.yml
#	.pi/gg/SYSTEM.md
#	CMakeLists.txt
#	cmake/arm64-windows-llvm.cmake
#	docs/backend/ET.md
#	docs/build.md
#	docs/development/HOWTO-add-model.md
#	ggml/CMakeLists.txt
#	ggml/src/CMakeLists.txt
#	ggml/src/ggml-cpu/CMakeLists.txt
#	ggml/src/ggml-cpu/kleidiai/kernels.cpp
#	ggml/src/ggml-cpu/kleidiai/kleidiai.cpp
#	ggml/src/ggml-hexagon/ggml-hexagon.cpp
#	ggml/src/ggml-hexagon/htp/rope-ops.c
#	ggml/src/ggml-opencl/ggml-opencl.cpp
#	ggml/src/ggml-opencl/kernels/mul_mv_q6_k_f32_flat.cl
#	ggml/src/ggml-opencl/kernels/rope.cl
#	ggml/src/ggml-sycl/dmmv.cpp
#	ggml/src/ggml-sycl/dpct/helper.hpp
#	ggml/src/ggml-sycl/element_wise.cpp
#	ggml/src/ggml-sycl/esimd.hpp
#	ggml/src/ggml-sycl/fattn-mkl.cpp
#	ggml/src/ggml-sycl/fattn-onednn.cpp
#	ggml/src/ggml-sycl/ggml-sycl.cpp
#	ggml/src/ggml-sycl/im2col.cpp
#	ggml/src/ggml-sycl/norm.cpp
#	ggml/src/ggml-sycl/rope.cpp
#	ggml/src/ggml-sycl/set_rows.cpp
#	ggml/src/ggml-vulkan/CMakeLists.txt
#	ggml/src/ggml-webgpu/ggml-webgpu.cpp
#	ggml/src/ggml-webgpu/wgsl-shaders/rope.wgsl
#	ggml/src/ggml-zendnn/CMakeLists.txt
#	scripts/make-release-desc.sh
#	scripts/sync-ggml.last
#	tests/test-backend-ops.cpp
#	tests/test-json-schema-to-grammar.cpp
#	tools/cli/README.md
#	tools/server/README.md
#	tools/ui/src/lib/constants/settings.constants.ts
#	tools/ui/src/lib/services/chat.service.ts
This commit is contained in:
Concedo
2026-08-24 21:15:18 +08:00
172 changed files with 12496 additions and 13725 deletions
@@ -139,7 +139,7 @@
let fileSize = $derived(currentItem?.size ? formatFileSize(currentItem.size) : '');
let hasVisionModality = $derived(
currentItem && activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
currentItem && activeModelId ? modelsStore.props.modelSupportsVision(activeModelId) : false
);
let audioSrc = $derived(
@@ -28,7 +28,6 @@
import {
chatStore,
conversationsStore,
mcpResourceStore,
mcpStore,
modelsStore,
serverStore,
@@ -140,7 +139,9 @@
// float above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
let cwd = $derived(conversationsStore.activeConversation?.cwd ?? conversationsStore.pendingCwd);
let cwd = $derived(
conversationsStore.activeConversation?.cwd ?? conversationsStore.preferences.pendingCwd
);
const pickers = useChatFormPickers({
focusInput: refocusInput,
@@ -151,7 +152,8 @@
getShowModelSelector: () => showModelSelector,
getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
hasPrompts: () =>
mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
@@ -170,7 +172,7 @@
onValueChange?.('');
}
await conversationsStore.setCwd(newDir);
await conversationsStore.preferences.setCwd(newDir);
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(newDir?.trim() || null);
@@ -595,7 +597,7 @@
{useRichInput}
/>
{#if mcpResourceStore.hasAttachments}
{#if mcpStore.resources.hasAttachments}
<ChatFormMcpResourcesList
class="mb-3"
onResourceClick={(uri) => {
@@ -38,11 +38,11 @@
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
return conversationsStore.preferences.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
await conversationsStore.preferences.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
@@ -218,12 +218,15 @@
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const displayName = mcpStore.getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
{@const isEnabled = conversationsStore.isMcpServerEnabledForChat(server.id)}
{@const isEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
server.id
)}
<button
type="button"
class={sheetItemRowClass}
onclick={() => !hasError && conversationsStore.toggleMcpServerForChat(server.id)}
onclick={() =>
!hasError && conversationsStore.preferences.toggleMcpServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
@@ -250,7 +253,8 @@
{:else}
<Switch
checked={isEnabled}
onCheckedChange={() => conversationsStore.toggleMcpServerForChat(server.id)}
onCheckedChange={() =>
conversationsStore.preferences.toggleMcpServerForChat(server.id)}
/>
{/if}
</button>
@@ -81,10 +81,10 @@
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
const cached = modelsStore.props.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelsStore.props.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
@@ -94,19 +94,21 @@
$effect(() => {
void modelPropsVersion;
hasAudioModality = activeModelId ? modelsStore.modelSupportsAudio(activeModelId) : false;
hasAudioModality = activeModelId ? modelsStore.props.modelSupportsAudio(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
hasVideoModality = activeModelId ? modelsStore.modelSupportsVideo(activeModelId) : false;
hasVideoModality = activeModelId ? modelsStore.props.modelSupportsVideo(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
hasVisionModality = activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false;
hasVisionModality = activeModelId
? modelsStore.props.modelSupportsVision(activeModelId)
: false;
});
$effect(() => {
@@ -58,13 +58,13 @@
let currentConfig = $derived(settingsStore.config);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasPromptsCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasResourcesCapability(perChatOverrides);
});
@@ -121,7 +121,7 @@
if (!chatStore.isLoading && !chatStore.isStreaming()) return false;
const processingState = chatStore.activeProcessingState;
const processingState = chatStore.processing.activeState;
if (!processingState) return false;
@@ -16,7 +16,7 @@
$effect(() => {
const conv = conversationsStore.activeConversation;
untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
untrack(() => chatStore.processing.setActiveConversation(conv?.id ?? null));
});
$effect(() => {
@@ -28,12 +28,12 @@
if (chatStore.isLoading || chatStore.isStreaming()) return;
if (messages.length === 0) {
untrack(() => chatStore.clearProcessingState(conv.id));
untrack(() => chatStore.processing.setState(conv.id, null));
return;
}
untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conv.id));
untrack(() => chatStore.processing.restoreFromMessages(messages, conv.id));
});
$effect(() => {
@@ -3,7 +3,7 @@
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
import { mcpResourceStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
interface Props {
class?: string;
@@ -12,8 +12,8 @@
let { class: className, onResourceClick }: Props = $props();
const attachments = $derived(mcpResourceStore.attachments);
const hasAttachments = $derived(mcpResourceStore.hasAttachments);
const attachments = $derived(mcpStore.resources.attachments);
const hasAttachments = $derived(mcpStore.resources.hasAttachments);
function handleRemove(attachmentId: string) {
mcpStore.removeResourceAttachment(attachmentId);
@@ -87,7 +87,7 @@
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
@@ -59,7 +59,7 @@
message.model ?? chatStore.getResumeModel(message.convId) ?? modelsStore.selectedModelName
);
let modelLoadProgress = $derived(
isRouter && loadTargetModel ? modelsStore.getLoadProgress(loadTargetModel) : null
isRouter && loadTargetModel ? modelsStore.status.getLoadProgress(loadTargetModel) : null
);
let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
@@ -31,7 +31,7 @@
pendingModel = modelId;
try {
await modelsStore.loadModel(modelId);
await modelsStore.status.load(modelId);
} finally {
pendingModel = null;
}
@@ -43,14 +43,14 @@
);
const hasReasoningError = $derived(
isLastAssistantMessage ? !!agenticStore.lastError(message.convId) : false
isLastAssistantMessage ? !!agenticStore.getLastError(message.convId) : false
);
let permissionDismissed = $state(false);
const pendingPermission = $derived(
isStreaming && isLastAssistantMessage
? agenticStore.pendingPermissionRequest(message.convId)
? agenticStore.getPendingPermissionRequest(message.convId)
: null
);
@@ -74,7 +74,7 @@
const pendingContinue = $derived(
isStreaming && isLastAssistantMessage
? agenticStore.pendingContinueRequest(message.convId)
? agenticStore.getPendingContinueRequest(message.convId)
: false
);
@@ -97,7 +97,7 @@
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
const currentlyExecutingToolCallId = $derived(
isStreaming ? agenticStore.executingToolCallId(message.convId) : null
isStreaming ? agenticStore.getExecutingToolCallId(message.convId) : null
);
type TurnGroup = {
@@ -238,30 +238,30 @@
/>
{/each}
{#if conversationsStore.activeConversation && agenticStore.pendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
{#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
{@const convId = conversationsStore.activeConversation!.id}
{@const pendingContent = agenticStore.pendingSteeringMessageContent(convId)}
{@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={agenticStore.pendingSteeringMessageExtras(convId)}
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) =>
agenticStore.injectSteeringMessage(convId, newContent, extras)}
onDelete={() => agenticStore.clearSteeringMessage(convId)}
/>
{/if}
{:else if conversationsStore.activeConversation && chatStore.pendingMessageContent(conversationsStore.activeConversation!.id)}
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
{@const convId = conversationsStore.activeConversation!.id}
{@const pendingContent = chatStore.pendingMessageContent(convId)}
{@const pendingContent = chatStore.getPendingMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={chatStore.pendingMessageExtras(convId)}
extras={chatStore.getPendingMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
onDelete={() => chatStore.clearPendingMessage(convId)}
@@ -8,7 +8,7 @@
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { conversationsStore, mcpResourceStore, mcpStore } from '$lib/stores';
import { conversationsStore, mcpStore } from '$lib/stores';
import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
@@ -33,7 +33,7 @@
let templatePreviewLoading = $state(false);
let templatePreviewError = $state<string | null>(null);
const totalCount = $derived(mcpResourceStore.totalResourceCount);
const totalCount = $derived(mcpStore.resources.totalResourceCount);
$effect(() => {
if (open) {
@@ -48,7 +48,7 @@
});
async function loadResources() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (initialized) {
@@ -126,16 +126,16 @@
isAttaching = true;
try {
const knownResource = mcpResourceStore.findResourceByUri(templatePreviewUri);
const knownResource = mcpStore.resources.findResourceByUri(templatePreviewUri);
if (knownResource) {
if (!mcpResourceStore.isAttached(knownResource.uri)) {
if (!mcpStore.resources.isAttached(knownResource.uri)) {
await mcpStore.attachResource(knownResource.uri);
}
toast.success(`Resource attached: ${knownResource.title || knownResource.name}`);
} else {
if (mcpResourceStore.isAttached(templatePreviewUri)) {
if (mcpStore.resources.isAttached(templatePreviewUri)) {
toast.info('Resource already attached');
handleOpenChange(false);
@@ -147,9 +147,9 @@
serverName: selectedTemplate.serverName,
uri: templatePreviewUri
};
const attachment = mcpResourceStore.addAttachment(resourceInfo);
const attachment = mcpStore.resources.addAttachment(resourceInfo);
mcpResourceStore.updateAttachmentContent(attachment.id, templatePreviewContent);
mcpStore.resources.updateAttachmentContent(attachment.id, templatePreviewContent);
toast.success(`Resource attached: ${resourceInfo.name}`);
}
@@ -199,7 +199,7 @@
function getAllResourcesFlatInTreeOrder(): MCPResourceInfo[] {
const allResources: MCPResourceInfo[] = [];
const resourcesMap = mcpResourceStore.serverResources;
const resourcesMap = mcpStore.resources.serverResources;
for (const [serverName, serverRes] of resourcesMap.entries()) {
for (const resource of serverRes.resources) {
@@ -234,7 +234,7 @@
useProxy: newServerUseProxy
});
conversationsStore.setMcpServerOverride(newServerId, true);
conversationsStore.preferences.setMcpServerOverride(newServerId, true);
handleOpenChange(false);
}
@@ -42,7 +42,7 @@
let modalities = $derived.by(() => {
if (!firstModel?.id) return [];
return modelsStore.getModelModalitiesArray(firstModel.id);
return modelsStore.props.getModelModalitiesArray(firstModel.id);
});
// Ensure models are fetched when dialog opens
@@ -56,7 +56,7 @@
$effect(() => {
if (open && isRouter && modelId) {
isLoadingRouterProps = true;
modelsStore
modelsStore.props
.fetchModelProps(modelId)
.then((props) => {
routerModelProps = props;
@@ -14,7 +14,9 @@
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
mcpServers.filter(
(s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim()
)
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
@@ -2,7 +2,7 @@
import McpResourcesBrowserEmptyState from './McpResourcesBrowserEmptyState.svelte';
import McpResourcesBrowserHeader from './McpResourcesBrowserHeader.svelte';
import McpResourcesBrowserServerItem from './McpResourcesBrowserServerItem.svelte';
import { mcpResourceStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { parseResourcePath } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
@@ -31,8 +31,8 @@
let expandedFolders = new SvelteSet<string>();
let searchQuery = $state('');
const resources = $derived(mcpResourceStore.serverResources);
const isLoading = $derived(mcpResourceStore.isLoading);
const resources = $derived(mcpStore.resources.serverResources);
const isLoading = $derived(mcpStore.resources.isLoading);
const filteredResources = $derived.by(() => {
if (!searchQuery.trim()) {
@@ -116,7 +116,7 @@
if (status === ServerModelStatus.LOADING) return;
await modelsStore.unloadModel(modelId);
await modelsStore.status.unload(modelId);
}
export function open() {
@@ -174,9 +174,9 @@
{@const triggerLoading =
!!triggerModel &&
(triggerStatus === ServerModelStatus.LOADING ||
modelsStore.isModelOperationInProgress(triggerModel))}
modelsStore.status.isOperationInProgress(triggerModel))}
{@const triggerLoadPercent = triggerLoading
? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100)
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
@@ -47,7 +47,7 @@
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model));
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
let isLoaded = $derived(
@@ -55,7 +55,7 @@
);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
let loadProgress = $derived(isLoading ? modelsStore.getLoadProgress(option.model) : null);
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
</script>
@@ -138,7 +138,7 @@
icon={RotateCw}
tooltip="Retry loading model"
class="h-3 w-3 text-red-500 hover:text-foreground"
onclick={() => modelsStore.loadModel(option.model)}
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
/>
</div>
@@ -157,7 +157,7 @@
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
onclick={(e) => {
e?.stopPropagation();
modelsStore.unloadModel(option.model);
modelsStore.status.unload(option.model);
}}
/>
</div>
@@ -174,7 +174,7 @@
icon={PowerOff}
tooltip="Unload model"
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
onclick={() => modelsStore.unloadModel(option.model)}
onclick={() => modelsStore.status.unload(option.model)}
stopPropagationOnClick
/>
</div>
@@ -191,7 +191,7 @@
icon={Power}
tooltip="Load model"
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
onclick={() => modelsStore.loadModel(option.model)}
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
/>
</div>
@@ -72,9 +72,9 @@
{@const triggerLoading =
!!triggerModel &&
(triggerStatus === ServerModelStatus.LOADING ||
modelsStore.isModelOperationInProgress(triggerModel))}
modelsStore.status.isOperationInProgress(triggerModel))}
{@const triggerLoadPercent = triggerLoading
? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100)
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
@@ -15,7 +15,7 @@
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_CHAT_SECTIONS,
SETTINGS_SECTION_TITLES
SETTINGS_SECTION_SLUGS
} from '$lib/constants';
import { ColorMode } from '$lib/enums/ui.enums';
import { RouterService } from '$lib/services/router.service';
@@ -46,13 +46,13 @@
let fetchInitiated = false;
$effect(() => {
if (serverStore.isRouterMode && currentSection.fields && !fetchInitiated) {
if (serverStore.isRouterMode && currentSection.fields?.length && !fetchInitiated) {
fetchInitiated = true;
void modelsStore
.fetch()
.then(() => modelsStore.fetchRouterModels())
.then(() => modelsStore.fetchModalitiesForLoadedModels())
.then(() => modelsStore.props.fetchModalitiesForLoadedModels())
.then(() => modelsStore.ensureFirstModelSelected());
}
});
@@ -148,9 +148,9 @@
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
{#if currentSection.title === SETTINGS_SECTION_TITLES.TOOLS}
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.TOOLS}
<SettingsChatToolsTab />
{:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
<SettingsChatImportExportTab />
{:else if currentSection.fields}
<div class="space-y-6">
@@ -161,7 +161,7 @@
onThemeChange={handleThemeChange}
/>
{#if currentSection.title === SETTINGS_SECTION_TITLES.GENERAL}
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.GENERAL}
<div class="flex justify-end">
<Button variant="outline" onclick={() => window.location.reload()}>
<RefreshCw class="h-3 w-3" />
@@ -23,13 +23,13 @@
let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
let currentModelParams = $derived.by(() => {
void modelsStore.propsCacheVersion;
void modelsStore.props.cacheVersion;
if (serverStore.isRouterMode) {
const currentModelName = modelsStore.selectedModelName;
if (currentModelName) {
const currentModelProps = modelsStore.getModelProps(currentModelName);
const currentModelProps = modelsStore.props.getModelProps(currentModelName);
return (currentModelProps?.default_generation_settings?.params ?? {}) as Record<
string,
@@ -121,11 +121,13 @@
{:else}
<McpServerCard
{server}
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
enabled={conversationsStore.preferences.isMcpServerEnabledForChat(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
const wasEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
server.id
);
await conversationsStore.toggleMcpServerForChat(server.id);
await conversationsStore.preferences.toggleMcpServerForChat(server.id);
if (!wasEnabled) {
// Promote the connection so tools/prompts/resources become
@@ -74,7 +74,7 @@ export const ATTACHMENT_PROMPT_ITEMS: AttachmentMenuItem[] = [
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
icon: Zap,
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompt',
label: 'MCP Prompts',
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
}
];
@@ -32,13 +32,3 @@ export const MCP_RESOURCE_CACHE = {
/** TTL for MCP resource cache entries in milliseconds (5 minutes) */
TTL_MS: 5 * 60 * 1000
} as const;
/**
* Limits for pruning inactive conversation states held in memory.
*/
export const INACTIVE_CONVERSATION = {
/** Maximum age (in ms) for inactive conversation states before cleanup (30 minutes) */
MAX_AGE_MS: 30 * 60 * 1000,
/** Maximum number of inactive conversation states to keep in memory */
MAX_STATES: 10
} as const;
+1 -1
View File
@@ -48,7 +48,7 @@ export * from './pwa.constants';
export * from './routes.constants';
export * from './sandbox.constants';
export * from './settings-keys.constants';
export * from './settings-registry.constants';
export * from './settings.constants';
export * from './special-characters.constants';
export * from './stream.constants';
export * from './supported-file-types.constants';
+2 -12
View File
@@ -10,18 +10,6 @@ export const URL_PARAMS = {
QUERY: 'q'
} as const;
/** Settings section slugs — used for routes and navigation. */
export const SETTINGS_SECTION_SLUGS = {
AGENTIC: 'agentic',
DEVELOPER: 'developer',
DISPLAY: 'display',
GENERAL: 'general',
IMPORT_EXPORT: 'import-export',
PENALTIES: 'penalties',
SAMPLING: 'sampling',
TOOLS: 'tools'
} as const;
export const ROUTES = {
/** Chat base — for dynamic chat URLs use RouterService. */
CHAT: '#/chat',
@@ -33,6 +21,8 @@ export const ROUTES = {
SEARCH: '#/search',
/** Settings base — for dynamic settings URLs use RouterService. */
SETTINGS: '#/settings',
/** Exit destination for the settings view (fallback when no referrer). */
SETTINGS_EXIT: '#/',
/** Root — start of the app. */
START: '#/'
} as const;
@@ -1,3 +1,5 @@
import { UrlProtocol } from '$lib/enums';
const STD = ['com', 'net', 'org', 'gov', 'edu'] as const;
const STD_MIL = [...STD, 'mil'] as const;
const ccTLD_PREFIXES: Record<string, readonly string[]> = {
@@ -184,3 +186,7 @@ export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES);
// Matches one or more trailing "/" characters at the end of a URL/path.
export const TRAILING_SLASHES_REGEX = /\/+$/;
// Protocols that apiFetch treats as absolute and passes through untouched.
// Add a protocol here when a caller needs to fetch an absolute URL with it.
export const API_ABSOLUTE_URL_PROTOCOLS = [UrlProtocol.HTTP, UrlProtocol.HTTPS] as const;
@@ -14,18 +14,14 @@ export interface AutoScrollOptions {
*/
export class AutoScrollController {
private _autoScrollEnabled = $state(true);
private _userScrolledUp = $state(false);
private _lastScrollTop = $state(0);
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
private _container: HTMLElement | undefined;
private _disabled: boolean;
private _lastScrollTop = $state(0);
private _mutationObserver: MutationObserver | null = null;
private _rafPending = false;
private _observerEnabled = false;
constructor(options: AutoScrollOptions = {}) {
this._disabled = options.disabled ?? false;
}
private _rafPending = false;
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
private _userScrolledUp = $state(false);
get autoScrollEnabled(): boolean {
return this._autoScrollEnabled;
}
@@ -34,6 +30,71 @@ export class AutoScrollController {
return this._userScrolledUp;
}
constructor(options: AutoScrollOptions = {}) {
this._disabled = options.disabled ?? false;
}
/**
* Cleans up resources. Call this in onDestroy or when the component unmounts.
*/
destroy(): void {
this.stopInterval();
this._doStopObserving();
}
/**
* Enables auto-scroll (e.g., when user sends a message).
*/
enable(): void {
if (this._disabled) return;
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
/**
* Handles scroll events to detect user scroll direction and toggle auto-scroll.
*/
handleScroll(): void {
if (this._disabled || !this._container) return;
const { clientHeight, scrollHeight, scrollTop } = this._container;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
if (isScrollingUp && !isAtBottom) {
this._userScrolledUp = true;
this._autoScrollEnabled = false;
} else if (isAtBottom && this._userScrolledUp) {
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
this._lastScrollTop = scrollTop;
}
/**
* Resets scroll state when switching conversations.
*/
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = !this._disabled;
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
}
/**
* Scrolls the container to the bottom instantly.
*/
scrollToBottom(): void {
if (this._disabled || !this._container) return;
this._container.scrollTop = this._container.scrollHeight;
}
/**
* Binds the controller to a scrollable container element.
*/
@@ -63,59 +124,6 @@ export class AutoScrollController {
}
}
/**
* Handles scroll events to detect user scroll direction and toggle auto-scroll.
*/
handleScroll(): void {
if (this._disabled || !this._container) return;
const { clientHeight, scrollHeight, scrollTop } = this._container;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
if (isScrollingUp && !isAtBottom) {
this._userScrolledUp = true;
this._autoScrollEnabled = false;
} else if (isAtBottom && this._userScrolledUp) {
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
this._lastScrollTop = scrollTop;
}
/**
* Scrolls the container to the bottom instantly.
*/
scrollToBottom(): void {
if (this._disabled || !this._container) return;
this._container.scrollTop = this._container.scrollHeight;
}
/**
* Enables auto-scroll (e.g., when user sends a message).
*/
enable(): void {
if (this._disabled) return;
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
/**
* Resets scroll state when switching conversations.
*/
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = !this._disabled;
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
}
/**
* Starts the auto-scroll interval for continuous scrolling during streaming.
*/
@@ -127,6 +135,18 @@ export class AutoScrollController {
}, AUTO_SCROLL_INTERVAL);
}
/**
* Starts a MutationObserver on the container that auto-scrolls to bottom
* on content changes. More responsive than interval-based polling.
*/
startObserving(): void {
this._observerEnabled = true;
if (this._container && !this._disabled && !this._mutationObserver) {
this._doStartObserving();
}
}
/**
* Stops the auto-scroll interval.
*/
@@ -137,6 +157,14 @@ export class AutoScrollController {
}
}
/**
* Stops the MutationObserver.
*/
stopObserving(): void {
this._observerEnabled = false;
this._doStopObserving();
}
/**
* Updates the auto-scroll interval based on streaming state.
* Call this in a $effect to automatically manage the interval.
@@ -157,34 +185,6 @@ export class AutoScrollController {
}
}
/**
* Cleans up resources. Call this in onDestroy or when the component unmounts.
*/
destroy(): void {
this.stopInterval();
this._doStopObserving();
}
/**
* Starts a MutationObserver on the container that auto-scrolls to bottom
* on content changes. More responsive than interval-based polling.
*/
startObserving(): void {
this._observerEnabled = true;
if (this._container && !this._disabled && !this._mutationObserver) {
this._doStartObserving();
}
}
/**
* Stops the MutationObserver.
*/
stopObserving(): void {
this._observerEnabled = false;
this._doStopObserving();
}
private _doStartObserving(): void {
if (!this._container || this._mutationObserver) return;
@@ -22,10 +22,10 @@ export function useChatScreenActiveModel() {
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
const cached = modelsStore.props.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelsStore.props.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
@@ -36,7 +36,7 @@ export function useChatScreenActiveModel() {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
return modelsStore.props.modelSupportsAudio(activeModelId);
}
return false;
@@ -45,7 +45,7 @@ export function useChatScreenActiveModel() {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVideo(activeModelId);
return modelsStore.props.modelSupportsVideo(activeModelId);
}
return false;
@@ -54,7 +54,7 @@ export function useChatScreenActiveModel() {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVision(activeModelId);
return modelsStore.props.modelSupportsVision(activeModelId);
}
return false;
@@ -54,10 +54,10 @@ export function useContextGauge(): UseContextGaugeReturn {
const modelId = contextStatsStore.activeModelId;
if (modelId && contextStatsStore.isActiveModelLoaded) {
const cached = modelsStore.getModelProps(modelId);
const cached = modelsStore.props.getModelProps(modelId);
if (!cached) {
void modelsStore.fetchModelProps(modelId);
void modelsStore.props.fetchModelProps(modelId);
}
}
});
@@ -80,9 +80,9 @@ export function useContextGauge(): UseContextGaugeReturn {
if (!modelId || contextStatsStore.isActiveModelLoading) return;
try {
await modelsStore.loadModel(modelId);
await modelsStore.status.load(modelId);
} catch {
// toast already surfaced by modelsStore.loadModel
// toast already surfaced by modelsStore.status.load
}
}
@@ -47,7 +47,7 @@ export interface UseModelsSelectorReturn {
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
const options = $derived(
modelsStore.models.filter((option) => {
const modelProps = modelsStore.getModelProps(option.model);
const modelProps = modelsStore.props.getModelProps(option.model);
return modelProps?.ui !== false;
})
@@ -102,7 +102,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
if (open) {
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
modelsStore.props.fetchModalitiesForLoadedModels();
});
}
@@ -142,8 +142,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
modelsStore.status
.load(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
@@ -43,7 +43,7 @@ export function useProcessingState(): UseProcessingStateReturn {
}
// Read directly from the reactive state
return chatStore.activeProcessingState;
return chatStore.processing.activeState;
});
$effect(() => {
@@ -42,19 +42,20 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
});
const modelSupportsThinking = $derived.by(() => {
void modelsStore.loadedModelIds;
void modelsStore.propsCacheVersion;
void modelsStore.props.cacheVersion;
if (serverStore.isRouterMode) {
const modelId = modelsStore.selectedModelName || conversationModel;
return (
modelsStore.checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages
modelsStore.props.checkModelSupportsThinking(modelId ?? '') ||
modelSupportsThinkingFromMessages
);
}
return modelsStore.supportsThinking || modelSupportsThinkingFromMessages;
return modelsStore.props.supportsThinking || modelSupportsThinkingFromMessages;
});
const currentEffort = $derived(conversationsStore.getReasoningEffort());
const currentEffort = $derived(conversationsStore.preferences.getReasoningEffort());
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
@@ -76,7 +77,7 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
return modelSupportsThinking;
},
select(level: ReasoningEffortLevel): void {
conversationsStore.setReasoningEffort(level.value as ReasoningEffort);
conversationsStore.preferences.setReasoningEffort(level.value as ReasoningEffort);
},
get thinkingEnabled() {
return thinkingEnabled;
@@ -35,7 +35,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
conversationsStore.preferences.isMcpServerEnabledForChat(g.serverId)
)
);
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
@@ -72,7 +72,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
!conversationsStore.preferences.isMcpServerEnabledForChat(group.serverId)
);
}
File diff suppressed because it is too large Load Diff
@@ -16,187 +16,6 @@ import {
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
export class ConversationTransferService {
/**
*
*
* JSONL Session Format
*
*
*/
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
static serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
static parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private static isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (ConversationTransferService.isSessionsJsonl(text)) {
return ConversationTransferService.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
*
*
* Downloads
*
*
*/
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
static generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Triggers a browser download of the provided exported conversation data
* @param data - The exported conversation payload (a single conversation with its messages)
@@ -262,6 +81,171 @@ export class ConversationTransferService {
ConversationTransferService.triggerDownload(blob, archiveName);
}
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
static generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (ConversationTransferService.isSessionsJsonl(text)) {
return ConversationTransferService.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
static parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
static serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private static isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Triggers a browser download of a blob under the given filename.
*/
+366 -399
View File
@@ -1,3 +1,11 @@
/**
* DatabaseService - IndexedDB persistence for conversations and messages
*
* Thin Dexie layer over the conversations/messages tables: CRUD, tree
* navigation (descendants, reparenting) and cascading deletes. No reactive
* state; consumed by conversationsStore and the chat flows.
*/
import { IDXDB_STORES, IDXDB_TABLES, STORAGE_APP_NAME } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
@@ -20,12 +28,99 @@ const db = new LlamaUiDatabase();
export class DatabaseService {
/**
* Deletes multiple conversations in a single transaction. Each deleted
* conversation has its direct children reparented to the nearest surviving
* ancestor (or promoted to top-level). Children also in `ids` are dropped
* entirely rather than reparented.
*
*
* Conversations
*
*
* @param ids - Conversation IDs to delete
*/
static async bulkDeleteConversations(ids: string[]): Promise<void> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return;
const idSet = new Set(cleanIds);
await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
// Pre-load each to-delete conversation so the per-id reparent
// walk-up doesn't ping-pong the same ancestry chain.
const prefetched = new Map<string, DatabaseConversation>();
let frontier = [...cleanIds];
const requested = new Set<string>(frontier);
while (frontier.length > 0) {
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
frontier = [];
for (let i = 0; i < fetched.length; i++) {
const conv = fetched[i];
if (!conv || !conv.id) continue;
prefetched.set(conv.id, conv);
const ancestor = conv.forkedFromConversationId;
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
frontier.push(ancestor);
requested.add(ancestor);
}
}
}
for (const id of cleanIds) {
await this.reparentDirectChildren(id, idSet, prefetched);
}
await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
}
);
}
/**
* Toggles the pinned status of each conversation in `ids` inside a single
* transaction. Treats `pinned === undefined` as `false`, matching the
* semantics of {@link toggleConversationPin} where `!undefined` evaluates
* to `true`. Returns the resulting pinned state for every id that was
* updated; missing ids are omitted from the map.
*
* @param ids - Conversation IDs to toggle
* @returns Map of id -> new pinned state
*/
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
const result = new Map<string, boolean>();
if (cleanIds.length === 0) return result;
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
const updates: DatabaseConversation[] = [];
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const newPinned = !conv.pinned;
updates.push({ ...conv, pinned: newPinned });
result.set(cleanIds[i], newPinned);
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
});
return result;
}
/**
* Creates a new conversation.
@@ -51,14 +146,6 @@ export class DatabaseService {
return conversation;
}
/**
*
*
* Messages
*
*
*/
/**
* Creates a new message branch by adding a message and updating parent/child relationships.
* Also updates the conversation's currNode to point to the new message.
@@ -96,13 +183,7 @@ export class DatabaseService {
// Update parent's children array if parent exists
if (parentId !== null) {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
if (parentMessage) {
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parentMessage.children, newMessage.id]
});
}
await this.addChildToParent(parentId, newMessage.id);
}
await this.updateConversation(message.convId, {
@@ -178,9 +259,7 @@ export class DatabaseService {
};
await db[IDXDB_TABLES.messages].add(systemMessage);
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parentMessage.children, systemMessage.id]
});
await this.addChildToParent(parentId, systemMessage.id);
return systemMessage;
});
@@ -230,121 +309,6 @@ export class DatabaseService {
);
}
/**
* Reparents direct children of `parentId` to the nearest surviving
* ancestor (or promotes them to top-level when the immediate parent was
* top-level). Walking skips any ancestor listed in `excludeIds`, since
* those will be deleted in the same batch leaving a grandchild pointing
* at an `excludeIds` entry would orphan it. Children whose own id is in
* `excludeIds` are dropped from the updates (the bulk-delete pass will
* remove them). `prefetched` may carry a pre-fetched ancestor map to
* avoid repeat reads inside a bulk transaction.
*/
private static async reparentDirectChildren(
parentId: string,
excludeIds: ReadonlySet<string> = new Set(),
prefetched?: ReadonlyMap<string, DatabaseConversation>
): Promise<void> {
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
if (!conv) return;
let newParent = conv.forkedFromConversationId;
const visited = new Set<string>([parentId]);
while (newParent && excludeIds.has(newParent)) {
if (visited.has(newParent)) {
newParent = undefined;
break;
}
visited.add(newParent);
const next =
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
if (!next) {
newParent = undefined;
break;
}
newParent = next.forkedFromConversationId;
}
const directChildren = await db[IDXDB_TABLES.conversations]
.filter((c) => c.forkedFromConversationId === parentId)
.toArray();
const updates: DatabaseConversation[] = [];
for (const child of directChildren) {
if (excludeIds.has(child.id)) continue;
updates.push({ ...child, forkedFromConversationId: newParent });
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
}
/**
* Deletes multiple conversations in a single transaction. Each deleted
* conversation has its direct children reparented to the nearest surviving
* ancestor (or promoted to top-level). Children also in `ids` are dropped
* entirely rather than reparented.
*
* @param ids - Conversation IDs to delete
*/
static async bulkDeleteConversations(ids: string[]): Promise<void> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return;
const idSet = new Set(cleanIds);
await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
// Pre-load each to-delete conversation so the per-id reparent
// walk-up doesn't ping-pong the same ancestry chain.
const prefetched = new Map<string, DatabaseConversation>();
let frontier = [...cleanIds];
const requested = new Set<string>(frontier);
while (frontier.length > 0) {
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
frontier = [];
for (let i = 0; i < fetched.length; i++) {
const conv = fetched[i];
if (!conv || !conv.id) continue;
prefetched.set(conv.id, conv);
const ancestor = conv.forkedFromConversationId;
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
frontier.push(ancestor);
requested.add(ancestor);
}
}
}
for (const id of cleanIds) {
await this.reparentDirectChildren(id, idSet, prefetched);
}
await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
}
);
}
/**
* Deletes a message and removes it from its parent's children array.
*
@@ -356,17 +320,8 @@ export class DatabaseService {
if (!message) return;
// Remove this message from its parent's children array
if (message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
await this.removeChildFromParent(messageId);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
}
}
// Delete the message
await db[IDXDB_TABLES.messages].delete(messageId);
});
}
@@ -389,20 +344,10 @@ export class DatabaseService {
.where('convId')
.equals(conversationId)
.toArray();
// Find all descendant messages
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
// Get the message to delete for parent cleanup
const message = await db[IDXDB_TABLES.messages].get(messageId);
if (message && message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
}
}
await this.removeChildFromParent(messageId);
// Delete all messages in the branch
await db[IDXDB_TABLES.messages].bulkDelete(allToDelete);
@@ -411,243 +356,6 @@ export class DatabaseService {
});
}
/**
* Gets all conversations, sorted by last modified time (newest first).
*
* @returns Array of conversations
*/
static async getAllConversations(): Promise<DatabaseConversation[]> {
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
}
/**
* Gets a conversation by ID.
*
* @param id - Conversation ID
* @returns The conversation if found, otherwise undefined
*/
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
return await db[IDXDB_TABLES.conversations].get(id);
}
/**
* Gets all messages in a conversation, sorted by timestamp (oldest first).
*
* @param convId - Conversation ID
* @returns Array of messages in the conversation
*/
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
}
/**
* Loads multiple conversations with all of their messages in two bulk
* reads. Missing conversations are silently omitted from the result.
*
* @param convIds - Conversation IDs to load
* @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
*/
static async getConversationsWithMessages(
convIds: string[]
): Promise<Map<string, ExportedConversation>> {
const result = new Map<string, ExportedConversation>();
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return result;
const [convs, allMessages] = await Promise.all([
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
]);
const messagesByConv = new Map<string, DatabaseMessage[]>();
for (const msg of allMessages) {
const bucket = messagesByConv.get(msg.convId);
if (bucket) bucket.push(msg);
else messagesByConv.set(msg.convId, [msg]);
}
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const messages = (messagesByConv.get(conv.id) ?? []).sort(
(a, b) => a.timestamp - b.timestamp
);
result.set(conv.id, { conv, messages });
}
return result;
}
/**
* Updates a conversation. `lastModified` is never stamped implicitly;
* pass it in `updates` to bump the conversation in recency ordering.
*
* @param id - Conversation ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the conversation is updated
*/
static async updateConversation(
id: string,
updates: Partial<Omit<DatabaseConversation, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.conversations].update(id, updates);
}
/**
*
*
* Navigation
*
*
*/
/**
* Toggles the pinned status of a conversation.
*
* @param id - Conversation ID
* @returns The new pinned status
*/
static async toggleConversationPin(id: string): Promise<boolean> {
const conversation = await db[IDXDB_TABLES.conversations].get(id);
if (!conversation) {
throw new Error(`Conversation ${id} not found`);
}
const newPinnedState = !conversation.pinned;
await this.updateConversation(id, { pinned: newPinnedState });
return newPinnedState;
}
/**
* Toggles the pinned status of each conversation in `ids` inside a single
* transaction. Treats `pinned === undefined` as `false`, matching the
* semantics of {@link toggleConversationPin} where `!undefined` evaluates
* to `true`. Returns the resulting pinned state for every id that was
* updated; missing ids are omitted from the map.
*
* @param ids - Conversation IDs to toggle
* @returns Map of id -> new pinned state
*/
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
const result = new Map<string, boolean>();
if (cleanIds.length === 0) return result;
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
const updates: DatabaseConversation[] = [];
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const newPinned = !conv.pinned;
updates.push({ ...conv, pinned: newPinned });
result.set(cleanIds[i], newPinned);
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
});
return result;
}
/**
* Updates the conversation's current node (active branch).
* This determines which conversation path is currently being viewed.
*
* @param convId - Conversation ID
* @param nodeId - Message ID to set as current node
*/
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
await this.updateConversation(convId, {
currNode: nodeId
});
}
/**
* Updates a message.
*
* @param id - Message ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the message is updated
*/
static async updateMessage(
id: string,
updates: Partial<Omit<DatabaseMessage, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.messages].update(id, updates);
}
/**
*
*
* Import
*
*
*/
/**
* Imports multiple conversations and their messages.
* Skips conversations that already exist.
*
* @param data - Array of { conv, messages } objects
* @returns The conversations written to the database and the ones skipped
*/
static async importConversations(
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
const imported: DatabaseConversation[] = [];
const skipped: DatabaseConversation[] = [];
return await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
for (const item of data) {
const { conv, messages } = item;
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
if (existing) {
skipped.push(conv);
continue;
}
await db[IDXDB_TABLES.conversations].add(conv);
for (const msg of messages) {
await db[IDXDB_TABLES.messages].put(msg);
}
imported.push(conv);
}
return { imported, skipped };
}
);
}
/**
*
*
* Forking
*
*
*/
/**
* Forks a conversation at a specific message, creating a new conversation
* containing all messages from the root up to (and including) the target message.
@@ -726,13 +434,272 @@ export class DatabaseService {
};
await db[IDXDB_TABLES.conversations].add(newConv);
for (const msg of clonedMessages) {
await db[IDXDB_TABLES.messages].add(msg);
}
await db[IDXDB_TABLES.messages].bulkAdd(clonedMessages);
return newConv;
}
);
}
/**
* Gets all conversations, sorted by last modified time (newest first).
*
* @returns Array of conversations
*/
static async getAllConversations(): Promise<DatabaseConversation[]> {
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
}
/**
* Gets a conversation by ID.
*
* @param id - Conversation ID
* @returns The conversation if found, otherwise undefined
*/
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
return await db[IDXDB_TABLES.conversations].get(id);
}
/**
* Gets all messages in a conversation, sorted by timestamp (oldest first).
*
* @param convId - Conversation ID
* @returns Array of messages in the conversation
*/
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
}
/**
* Loads multiple conversations with all of their messages in two bulk
* reads. Missing conversations are silently omitted from the result.
*
* @param convIds - Conversation IDs to load
* @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
*/
static async getConversationsWithMessages(
convIds: string[]
): Promise<Map<string, ExportedConversation>> {
const result = new Map<string, ExportedConversation>();
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return result;
const [convs, allMessages] = await Promise.all([
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
]);
const messagesByConv = new Map<string, DatabaseMessage[]>();
for (const msg of allMessages) {
const bucket = messagesByConv.get(msg.convId);
if (bucket) bucket.push(msg);
else messagesByConv.set(msg.convId, [msg]);
}
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const messages = (messagesByConv.get(conv.id) ?? []).sort(
(a, b) => a.timestamp - b.timestamp
);
result.set(conv.id, { conv, messages });
}
return result;
}
/**
* Imports multiple conversations and their messages.
* Skips conversations that already exist.
*
* @param data - Array of { conv, messages } objects
* @returns The conversations written to the database and the ones skipped
*/
static async importConversations(
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
const imported: DatabaseConversation[] = [];
const skipped: DatabaseConversation[] = [];
return await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
for (const item of data) {
const { conv, messages } = item;
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
if (existing) {
skipped.push(conv);
continue;
}
await db[IDXDB_TABLES.conversations].add(conv);
for (const msg of messages) {
await db[IDXDB_TABLES.messages].put(msg);
}
imported.push(conv);
}
return { imported, skipped };
}
);
}
/**
* Toggles the pinned status of a conversation.
*
* @param id - Conversation ID
* @returns The new pinned status
*/
static async toggleConversationPin(id: string): Promise<boolean> {
const conversation = await db[IDXDB_TABLES.conversations].get(id);
if (!conversation) {
throw new Error(`Conversation ${id} not found`);
}
const newPinnedState = !conversation.pinned;
await this.updateConversation(id, { pinned: newPinnedState });
return newPinnedState;
}
/**
* Updates a conversation. `lastModified` is never stamped implicitly;
* pass it in `updates` to bump the conversation in recency ordering.
*
* @param id - Conversation ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the conversation is updated
*/
static async updateConversation(
id: string,
updates: Partial<Omit<DatabaseConversation, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.conversations].update(id, updates);
}
/**
* Updates the conversation's current node (active branch).
* This determines which conversation path is currently being viewed.
*
* @param convId - Conversation ID
* @param nodeId - Message ID to set as current node
*/
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
await this.updateConversation(convId, {
currNode: nodeId
});
}
/**
* Updates a message.
*
* @param id - Message ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the message is updated
*/
static async updateMessage(
id: string,
updates: Partial<Omit<DatabaseMessage, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.messages].update(id, updates);
}
/**
* Appends a child id to a parent message's children array.
*/
private static async addChildToParent(parentId: string, childId: string): Promise<void> {
const parent = await db[IDXDB_TABLES.messages].get(parentId);
if (!parent) return;
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parent.children, childId]
});
}
/**
* Removes a child id from its parent message's children array.
*/
private static async removeChildFromParent(messageId: string): Promise<void> {
const message = await db[IDXDB_TABLES.messages].get(messageId);
if (!message?.parent) return;
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
if (!parent) return;
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
}
/**
* Reparents direct children of `parentId` to the nearest surviving
* ancestor (or promotes them to top-level when the immediate parent was
* top-level). Walking skips any ancestor listed in `excludeIds`, since
* those will be deleted in the same batch leaving a grandchild pointing
* at an `excludeIds` entry would orphan it. Children whose own id is in
* `excludeIds` are dropped from the updates (the bulk-delete pass will
* remove them). `prefetched` may carry a pre-fetched ancestor map to
* avoid repeat reads inside a bulk transaction.
*/
private static async reparentDirectChildren(
parentId: string,
excludeIds: ReadonlySet<string> = new Set(),
prefetched?: ReadonlyMap<string, DatabaseConversation>
): Promise<void> {
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
if (!conv) return;
let newParent = conv.forkedFromConversationId;
const visited = new Set<string>([parentId]);
while (newParent && excludeIds.has(newParent)) {
if (visited.has(newParent)) {
newParent = undefined;
break;
}
visited.add(newParent);
const next =
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
if (!next) {
newParent = undefined;
break;
}
newParent = next.forkedFromConversationId;
}
const directChildren = await db[IDXDB_TABLES.conversations]
.filter((c) => c.forkedFromConversationId === parentId)
.toArray();
const updates: DatabaseConversation[] = [];
for (const child of directChildren) {
if (excludeIds.has(child.id)) continue;
updates.push({ ...child, forkedFromConversationId: newParent });
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
}
}
+24 -14
View File
@@ -53,9 +53,9 @@
* - Reasoning content stripping from prompt history to avoid KV cache pollution
* - Error translation (network, timeout, server errors user-friendly messages)
*
* @see chatStore in stores/chat.svelte.ts primary consumer for chat state management
* @see agenticStore in stores/agentic.svelte.ts uses ChatService for agentic loop streaming
* @see conversationsStore in stores/conversations.svelte.ts provides message context
* @see chatStore in stores/chat/index.svelte.ts primary consumer for chat state management
* @see agenticStore in stores/agentic/index.svelte.ts uses ChatService for agentic loop streaming
* @see conversationsStore in stores/conversations/index.svelte.ts provides message context
*/
export { ChatService } from './chat.service';
@@ -98,8 +98,8 @@ export { ChatService } from './chat.service';
* enabling conversation branching and alternative response paths. The conversation's
* `currNode` tracks the currently active branch endpoint.
*
* @see conversationsStore in stores/conversations.svelte.ts reactive layer on top of DatabaseService
* @see chatStore in stores/chat.svelte.ts uses DatabaseService directly for message CRUD during streaming
* @see conversationsStore in stores/conversations/index.svelte.ts reactive layer on top of DatabaseService
* @see chatStore in stores/chat/index.svelte.ts uses DatabaseService directly for message CRUD during streaming
*/
export { DatabaseService } from './database.service';
@@ -143,7 +143,7 @@ export { ConversationTransferService } from './conversation-transfer.service';
* - `POST /models/load` Load a model (ROUTER mode only)
* - `POST /models/unload` Unload a model (ROUTER mode only)
*
* @see modelsStore in stores/models.svelte.ts primary consumer for reactive model state
* @see modelsStore in stores/models/index.svelte.ts primary consumer for reactive model state
*/
export { ModelsService } from './models.service';
@@ -174,8 +174,8 @@ export { ModelsService } from './models.service';
* - `&autoload=false` Prevents model auto-loading when querying props
*
* @see serverStore in stores/server.svelte.ts consumes global server props
* @see modelsStore in stores/models.svelte.ts consumes per-model props for modalities
* @see settingsStore in stores/settings.svelte.ts syncs default generation params from props
* @see modelsStore in stores/models/index.svelte.ts consumes per-model props for modalities
* @see settingsStore in stores/settings/index.svelte.ts syncs default generation params from props
*/
export { PropsService } from './props.service';
@@ -217,7 +217,7 @@ export { PropsService } from './props.service';
* - `ParameterSyncService` class static methods for sync logic
* - `SYNCABLE_PARAMETERS` mapping of UI setting keys to server parameter keys
*
* @see settingsStore in stores/settings.svelte.ts primary consumer for settings sync
* @see settingsStore in stores/settings/index.svelte.ts primary consumer for settings sync
* @see SettingsChatParameterSourceIndicator displays parameter source badges in UI
*/
export { ParameterSyncService } from './parameter-sync.service';
@@ -241,7 +241,7 @@ export { ParameterSyncService } from './parameter-sync.service';
* - Manages connection lifecycle, health checks, reconnection
* - Handles tool name conflict resolution and server coordination
*
* - **mcpResourceStore**: Reactive resource state
* - **mcpResourceStore** (composed as mcpStore.resources): Reactive resource state
* - Receives resource data fetched via MCPService
* - Manages resource caching, subscriptions, and attachments
*
@@ -263,9 +263,9 @@ export { ParameterSyncService } from './parameter-sync.service';
* 2. **StreamableHTTP** modern HTTP-based, supports CORS proxy
* 3. **SSE** legacy fallback, supports CORS proxy
*
* @see mcpStore in stores/mcp.svelte.ts reactive business logic facade on top of MCPService
* @see mcpResourceStore in stores/mcp-resources.svelte.ts reactive resource state management
* @see agenticStore in stores/agentic.svelte.ts uses MCPService (via mcpStore) for tool execution
* @see mcpStore in stores/mcp/index.svelte.ts reactive business logic facade on top of MCPService
* @see mcpStore.resources in stores/mcp/resources.svelte.ts reactive resource state management
* @see agenticStore in stores/agentic/index.svelte.ts uses MCPService (via mcpStore) for tool execution
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18
*/
export { MCPService } from './mcp.service';
@@ -286,7 +286,7 @@ export { MCPService } from './mcp.service';
* - **agenticStore**: Dispatches ToolSource.BROWSER calls here
*
* @see buildSandboxToolDefinition in utils/sandbox-tool - tool schema sent to the LLM
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch
* @see agenticStore in stores/agentic/index.svelte.ts - tool dispatch
*/
export { SandboxService } from './sandbox.service';
@@ -340,3 +340,13 @@ export { RouterService } from './router.service';
* @see migration.service.ts full implementation (non-destructive)
*/
export { MigrationService } from './migration.service';
/**
* **SettingsService** - localStorage persistence layer for settings
*
* Stateless read/write of the settings config and user-override keys. Business
* logic (default merging, mobile defaults, theme migration) stays in the store.
*
* @see settingsStore in stores/settings/index.svelte.ts - reactive state + business logic
*/
export { SettingsService } from './settings.service';
File diff suppressed because it is too large Load Diff
+6 -15
View File
@@ -1,20 +1,11 @@
/**
* Migration Service - Unified data migration hook
* MigrationService - Unified data migration hook
*
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats) into a single
* initialization point. Each migration copies data to new format WITHOUT deleting the old.
*
* **Architecture:**
* - Migrations are defined as objects with `id` and `run()` methods
* - Migration state is tracked in localStorage to avoid re-running
* - `runAllMigrations()` should be called once at app startup
* - All migrations are NON-DESTRUCTIVE - legacy data is preserved for downgrade compatibility
*
* **Current Migrations:**
* 1. localStorage prefix: Copy LlamaCppWebui.* LlamaUi.* (both preserved)
* 2. IndexedDB database: Copy LlamacppWebui LlamaUi (both preserved)
* 3. Legacy message format: Transform in-place (preserves structure, migrates markers)
* 4. Theme key: Copy standalone `theme` config object (both preserved)
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats)
* into a single initialization point. Each migration copies data to the new
* format WITHOUT deleting the old, and state is tracked in localStorage so
* `runAllMigrations()` (called once at startup) never re-runs a completed
* migration. All migrations are non-destructive for downgrade compatibility.
*/
import {
+119 -148
View File
@@ -1,25 +1,55 @@
/**
* ModelsService - Stateless model management API layer
*
* Wraps the /models endpoints (list, load, unload) and the /models/sse
* status feed in MODEL and ROUTER modes. No reactive state; consumed by
* modelsStore and its status manager.
*/
import { base } from '$app/paths';
import {
API_MODELS,
MODEL_ID,
SSE_DATA_PREFIX,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
} from '$lib/constants';
import { API_MODELS, MODEL_ID } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
import {
apiFetch,
apiPost,
extractSseDataPayload,
normalizeModelName,
splitSseRecords
} from '$lib/utils';
import { getAuthHeaders } from '$lib/utils/api-headers';
export class ModelsService {
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Check if a model is loaded based on its metadata.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADED
*/
static isModelLoaded(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADED;
}
/**
*
*
* Listing
* Load/Unload
*
*
*/
/**
* Check if a model is currently loading.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADING
*/
static isModelLoading(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADING;
}
/**
* Fetch list of models from OpenAI-compatible endpoint.
* Works in both MODEL and ROUTER modes.
@@ -41,14 +71,6 @@ export class ModelsService {
return apiFetch<ApiRouterModelsListResponse>(API_MODELS.LIST);
}
/**
*
*
* Load/Unload
*
*
*/
/**
* Load a model (ROUTER mode only).
* Sends POST request to `/models/load`. Note: the endpoint returns success
@@ -68,137 +90,6 @@ export class ModelsService {
return apiPost<ApiRouterModelsLoadResponse>(API_MODELS.LOAD, payload);
}
/**
* Unload a model (ROUTER mode only).
* Sends POST request to `/models/unload`. Note: the endpoint returns success
* before unloading completes use polling to await actual unload status.
*
* @param modelId - Model identifier to unload
* @returns Unload response from the server
*/
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
}
/**
*
*
* Status
*
*
*/
/**
* Check if a model is loaded based on its metadata.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADED
*/
static isModelLoaded(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADED;
}
/**
* Check if a model is currently loading.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADING
*/
static isModelLoading(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADING;
}
/**
*
*
* Status Feed
*
*
*/
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
* Reconnects on network drops until the signal aborts. Splits the byte
* stream into SSE records on the blank line boundary; the payload rides in
* the data lines as a JSON envelope with its own model, event and data fields.
*/
static async watchModelEvents(
signal: AbortSignal,
onEvent: (event: ApiModelsSseEvent) => void
): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
while (boundary !== -1) {
const event = ModelsService.parseStatusRecord(buffer.slice(0, boundary));
if (event) onEvent(event);
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record into its JSON envelope, or null when the record
* carries no data payload or malformed JSON.
*/
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
const payload = record
.split(SSE_LINE_SEPARATOR)
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
.join(SSE_LINE_SEPARATOR);
if (payload.length === 0) return null;
try {
return JSON.parse(payload) as ApiModelsSseEvent;
} catch {
return null;
}
}
/**
*
*
* Parsing
*
*
*/
/**
* Parse a model ID string into its structured components.
*
@@ -311,4 +202,84 @@ export class ModelsService {
return result;
}
/**
* Unload a model (ROUTER mode only).
* Sends POST request to `/models/unload`. Note: the endpoint returns success
* before unloading completes use polling to await actual unload status.
*
* @param modelId - Model identifier to unload
* @returns Unload response from the server
*/
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
}
/**
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
* Reconnects on network drops until the signal aborts. Splits the byte
* stream into SSE records on the blank line boundary; the payload rides in
* the data lines as a JSON envelope with its own model, event and data fields.
*/
static async watchModelEvents(
signal: AbortSignal,
onEvent: (event: ApiModelsSseEvent) => void
): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { records, rest } = splitSseRecords(buffer);
buffer = rest;
for (const record of records) {
const event = ModelsService.parseStatusRecord(record);
if (event) onEvent(event);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record into its JSON envelope, or null when the record
* carries no data payload or malformed JSON.
*/
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
const payload = extractSseDataPayload(record);
if (payload.length === 0) return null;
try {
return JSON.parse(payload) as ApiModelsSseEvent;
} catch {
return null;
}
}
}
@@ -1,26 +1,71 @@
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
/**
* ParameterSyncService - Syncs sampling parameters with the server
*
* Decides for each sampling parameter whether the user's setting is an
* override of the server default, and normalizes floating-point values.
* No reactive state; consumed by settingsStore.
*/
import { SETTINGS_KEYS, SETTINGS_REGISTRY } from '$lib/constants';
import { ParameterSource, SyncableParameterType } from '$lib/enums';
import type { ParameterInfo, ParameterRecord, ParameterValue } from '$lib/types';
import type { ParameterInfo, ParameterRecord, ParameterValue, SyncableParameter } from '$lib/types';
import { normalizeFloatingPoint } from '$lib/utils';
/** Mapping of UI setting keys to server parameter keys, derived from the registry. */
export const SYNCABLE_PARAMETERS: SyncableParameter[] = SETTINGS_REGISTRY.flatMap(
(section) => section.settings
)
.filter((s) => s.sync !== undefined)
.map((s) => ({
canSync: true,
key: s.key,
serverKey: s.sync!.serverKey,
type: s.sync!.paramType
}));
export class ParameterSyncService {
/**
* Check if a parameter can be synced from server.
*
*
* Extraction
*
*
* @param key - The parameter key to check
* @returns True if the parameter is in the syncable parameters list
*/
static canSyncParameter(key: string): boolean {
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
}
/**
* Round floating-point numbers to avoid JavaScript precision issues.
* E.g., 0.1 + 0.2 = 0.30000000000000004 0.3
* Create a diff between current settings and server defaults.
* Shows which parameters differ from server values, useful for debugging
* and for the "Reset to defaults" functionality.
*
* @param value - Parameter value to normalize
* @returns Precision-normalized value
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @returns Record of parameter diffs with current value, server value, and whether they differ
*/
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
return normalizeFloatingPoint(value) as ParameterValue;
static createParameterDiff(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
const diff: Record<
string,
{ current: ParameterValue; server: ParameterValue; differs: boolean }
> = {};
for (const key of this.getSyncableParameterKeys()) {
const currentValue = currentSettings[key];
const serverValue = serverDefaults[key];
if (serverValue !== undefined) {
diff[key] = {
current: currentValue,
differs: currentValue !== serverValue,
server: serverValue
};
}
}
return diff;
}
/**
@@ -59,49 +104,6 @@ export class ParameterSyncService {
return extracted;
}
/**
*
*
* Merging
*
*
*/
/**
* Merge server defaults with current user settings.
* User overrides always take priority only parameters not in `userOverrides`
* set will be updated from server defaults.
*
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @param userOverrides - Set of parameter keys explicitly overridden by the user
* @returns Merged parameter record with user overrides preserved
*/
static mergeWithServerDefaults(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord,
userOverrides: Set<string> = new Set()
): ParameterRecord {
const merged = { ...currentSettings };
for (const [key, serverValue] of Object.entries(serverDefaults)) {
// Only update if user hasn't explicitly overridden this parameter
if (!userOverrides.has(key)) {
merged[key] = this.roundFloatingPoint(serverValue);
}
}
return merged;
}
/**
*
*
* Info
*
*
*/
/**
* Get parameter information including source and values.
* Used by SettingsChatParameterSourceIndicator to display the correct badge
@@ -132,16 +134,6 @@ export class ParameterSyncService {
};
}
/**
* Check if a parameter can be synced from server.
*
* @param key - The parameter key to check
* @returns True if the parameter is in the syncable parameters list
*/
static canSyncParameter(key: string): boolean {
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
}
/**
* Get all syncable parameter keys.
*
@@ -151,6 +143,33 @@ export class ParameterSyncService {
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
}
/**
* Merge server defaults with current user settings.
* User overrides always take priority only parameters not in `userOverrides`
* set will be updated from server defaults.
*
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @param userOverrides - Set of parameter keys explicitly overridden by the user
* @returns Merged parameter record with user overrides preserved
*/
static mergeWithServerDefaults(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord,
userOverrides: Set<string> = new Set()
): ParameterRecord {
const merged = { ...currentSettings };
for (const [key, serverValue] of Object.entries(serverDefaults)) {
// Only update if user hasn't explicitly overridden this parameter
if (!userOverrides.has(key)) {
merged[key] = this.roundFloatingPoint(serverValue);
}
}
return merged;
}
/**
* Validate a server parameter value against its expected type.
*
@@ -176,44 +195,13 @@ export class ParameterSyncService {
}
/**
* Round floating-point numbers to avoid JavaScript precision issues.
* E.g., 0.1 + 0.2 = 0.30000000000000004 0.3
*
*
* Diff
*
*
* @param value - Parameter value to normalize
* @returns Precision-normalized value
*/
/**
* Create a diff between current settings and server defaults.
* Shows which parameters differ from server values, useful for debugging
* and for the "Reset to defaults" functionality.
*
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @returns Record of parameter diffs with current value, server value, and whether they differ
*/
static createParameterDiff(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
const diff: Record<
string,
{ current: ParameterValue; server: ParameterValue; differs: boolean }
> = {};
for (const key of this.getSyncableParameterKeys()) {
const currentValue = currentSettings[key];
const serverValue = serverDefaults[key];
if (serverValue !== undefined) {
diff[key] = {
current: currentValue,
differs: currentValue !== serverValue,
server: serverValue
};
}
}
return diff;
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
return normalizeFloatingPoint(value) as ParameterValue;
}
}
+8 -8
View File
@@ -1,14 +1,14 @@
/**
* PropsService - Fetches server properties from /props
*
* Returns global server settings and capabilities, including per-model
* modalities in MODEL mode. No reactive state; consumed by serverStore and
* the model props manager.
*/
import { apiFetchWithParams } from '$lib/utils';
export class PropsService {
/**
*
*
* Fetching
*
*
*/
/**
* Fetches global server properties from the `/props` endpoint.
* In MODEL mode, returns modalities for the single loaded model.
@@ -1,3 +1,10 @@
/**
* ReadMediaService - Reads local media files for the read_media tool
*
* Encodes image and audio files as base64 data URLs with the metadata the
* model needs. No reactive state; consumed by toolsStore.
*/
import { ToolsService } from './tools.service';
import {
FILE_EXTENSION_SEPARATOR,
@@ -40,7 +47,7 @@ function fileExtension(path: string): string {
* actually use the result - the server has no idea which model is selected.
*
* @see buildReadMediaToolDefinition in constants/read-media.ts - tool schema sent to the LLM
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch and attachment extraction
* @see agenticStore in stores/agentic/index.svelte.ts - tool dispatch and attachment extraction
*/
export class ReadMediaService {
static async executeTool(
@@ -1,3 +1,10 @@
/**
* RouterService - Builds app route paths
*
* Returns chat and settings route strings from a single source of truth
* (ROUTES). No state.
*/
import { ROUTES } from '$lib/constants';
export class RouterService {
@@ -1,3 +1,10 @@
/**
* Sandbox harness - builds the srcdoc document for the sandboxed iframe
*
* Produces the HTML/CSP/worker shim that runs untrusted model code in an
* opaque origin. Consumed by sandbox.service.
*/
import WORKER_SHIM from './sandbox-worker.js?raw';
import { NEWLINE } from '$lib/constants';
+9 -1
View File
@@ -1,3 +1,11 @@
/**
* SandboxService - Runs untrusted code in a sandboxed worker
*
* Executes model-generated code inside a CSP-restricted, opaque-origin
* iframe worker with output and timeout limits. No reactive state; consumed
* by toolsStore for code-execution tools.
*/
import { buildSandboxHarness } from './sandbox-harness';
import {
NEWLINE,
@@ -8,7 +16,7 @@ import {
SANDBOX_TOOL_NAME,
SANDBOX_TRUNCATION_NOTICE
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { ToolExecutionResult } from '$lib/types';
/** Cached harnesses keyed by whether nerdamer is included. */
@@ -0,0 +1,76 @@
import { browser } from '$app/environment';
import { CONFIG_LOCALSTORAGE_KEY, USER_OVERRIDES_LOCALSTORAGE_KEY } from '$lib/constants';
/**
* SettingsService - localStorage persistence layer for settings
*
* Stateless read/write of the settings config and user-override keys. Business
* logic (default merging, mobile defaults, theme migration) stays in the store.
*
* **Architecture & Relationships:**
* - **settingsStore**: Primary consumer - loads config on init and persists on change
*
* @see settingsStore in stores/settings/index.svelte.ts - reactive state + business logic
*/
export class SettingsService {
/**
* Read the raw config and user overrides from localStorage.
* @returns Parsed values, or empty defaults when nothing is stored or parsing fails.
*/
static loadConfig(): {
config: Record<string, unknown>;
userOverrides: string[];
isFirstVisit: boolean;
} {
if (!browser) {
return { config: {}, isFirstVisit: false, userOverrides: [] };
}
try {
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
const isFirstVisit = storedConfigRaw === null;
const config = JSON.parse(storedConfigRaw || '{}') as Record<string, unknown>;
const userOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
) as string[];
return { config, isFirstVisit, userOverrides };
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
return { config: {}, isFirstVisit: false, userOverrides: [] };
}
}
/**
* Migrate the legacy un-namespaced "theme" localStorage key.
* Returns the legacy theme value (and removes the key) when present, else null.
*/
static migrateLegacyTheme(): string | null {
if (!browser) return null;
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme) {
localStorage.removeItem('theme');
return legacyTheme;
}
return null;
}
/**
* Persist the config and user overrides to localStorage.
*/
static saveConfig(config: Record<string, unknown>, userOverrides: string[]): void {
if (!browser) return;
try {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
localStorage.setItem(USER_OVERRIDES_LOCALSTORAGE_KEY, JSON.stringify(userOverrides));
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
}
}
+16 -9
View File
@@ -1,3 +1,10 @@
/**
* ToolsService - Stateless server tools API layer
*
* Fetches the server's /tools listing and streams tool execution results.
* No reactive state; consumed by toolsStore.
*/
import { base } from '$app/paths';
import { API_TOOLS, HEADERS } from '$lib/constants';
import { ToolResponseField } from '$lib/enums';
@@ -7,15 +14,6 @@ import { getJsonHeaders } from '$lib/utils/api-headers';
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
export class ToolsService {
/**
* Fetch the list of server tools from the server.
*
* @returns Array of tool definitions in OpenAI-compatible format
*/
static async list(): Promise<ServerToolInfo[]> {
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
}
/**
* Execute a server tool on the server.
*
@@ -76,6 +74,15 @@ export class ToolsService {
});
}
/**
* Fetch the list of server tools from the server.
*
* @returns Array of tool definitions in OpenAI-compatible format
*/
static async list(): Promise<ServerToolInfo[]> {
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
}
/**
* Stream a server tool's output chunks from the server. The server
* `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
@@ -0,0 +1,208 @@
/**
* AgenticGates - User interaction gates for the agentic loop
*
* Owns the state the loop waits on between turns: tool permission requests,
* turn-limit continue prompts and queued steering messages. The loop awaits
* requestPermission/requestContinue; the UI resolves them through
* resolvePermission/resolveContinue. Owned by agenticStore, no host coupling.
*/
import { ToolPermissionDecision } from '$lib/enums';
// direct imports between stores, not via the barrel, to avoid circular deps
import { permissionsStore } from '$lib/stores/permissions.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { DatabaseMessageExtra, SteeringMessage } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
export class AgenticGates {
/** Resolve functions for pending continue Promises; nothing derives from this map */
private continueResolvers = new SvelteMap<string, (shouldContinue: boolean) => void>();
/** Dedicated reactive state for pending continue requests (turn limit reached) */
private pendingContinueRequests = new SvelteMap<string, boolean>();
/** Dedicated reactive state for pending permission requests (ensures immediate UI updates) */
private pendingPermissions = new SvelteMap<
string,
{ toolName: string; serverLabel: string } | null
>();
/** Resolve functions for pending permission Promises; nothing derives from this map */
private permissionResolvers = new SvelteMap<string, (decision: ToolPermissionDecision) => void>();
/** Reactive: queued steering messages to inject between turns */
private steeringMessages = new SvelteMap<string, SteeringMessage>();
/**
* Drop all pending gate state for a conversation, e.g. when a flow exits.
*/
clear(conversationId: string): void {
this.pendingPermissions.set(conversationId, null);
this.permissionResolvers.delete(conversationId);
this.pendingContinueRequests.set(conversationId, false);
this.continueResolvers.delete(conversationId);
this.steeringMessages.delete(conversationId);
}
/**
* Clear the pending steering message without consuming it.
*/
clearSteeringMessage(conversationId: string): void {
this.steeringMessages.delete(conversationId);
}
/**
* Consume and return the pending steering message for re-sending.
* Called by chatStore after the agentic flow exits.
*/
consumePendingSteeringMessage(conversationId: string): SteeringMessage | null {
const msg = this.steeringMessages.get(conversationId);
if (!msg) return null;
this.steeringMessages.delete(conversationId);
return msg;
}
getPendingContinueRequest(conversationId: string): boolean {
return this.pendingContinueRequests.get(conversationId) ?? false;
}
getPendingPermissionRequest(
conversationId: string
): { toolName: string; serverLabel: string } | null {
return this.pendingPermissions.get(conversationId) ?? null;
}
getPendingSteeringMessageContent(conversationId: string): string | null {
return this.steeringMessages.get(conversationId)?.content ?? null;
}
getPendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined {
return this.steeringMessages.get(conversationId)?.extras;
}
hasPendingSteeringMessage(conversationId: string): boolean {
return this.steeringMessages.has(conversationId);
}
/**
* Queue a steering message. When the current agentic turn completes,
* the flow exits and the caller re-sends the message as a normal chat message.
*/
injectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
): void {
this.steeringMessages.set(conversationId, { content, extras });
}
async requestContinue(conversationId: string, signal?: AbortSignal): Promise<boolean> {
this.pendingContinueRequests.set(conversationId, true);
return new Promise<boolean>((resolve) => {
if (signal?.aborted) {
this.pendingContinueRequests.set(conversationId, false);
resolve(false);
return;
}
this.continueResolvers.set(conversationId, (shouldContinue) => {
this.pendingContinueRequests.set(conversationId, false);
resolve(shouldContinue);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this.continueResolvers.get(conversationId);
if (resolver) {
this.continueResolvers.delete(conversationId);
this.pendingContinueRequests.set(conversationId, false);
resolve(false);
}
},
{ once: true }
);
});
}
async requestPermission(
conversationId: string,
toolName: string,
serverLabel: string,
signal?: AbortSignal
): Promise<ToolPermissionDecision> {
const permissionKey = toolsStore.getPermissionKey(toolName);
if (permissionKey && permissionsStore.hasTool(permissionKey)) {
return ToolPermissionDecision.ONCE;
}
this.pendingPermissions.set(conversationId, { serverLabel, toolName });
return new Promise<ToolPermissionDecision>((resolve) => {
if (signal?.aborted) {
this.pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
return;
}
this.permissionResolvers.set(conversationId, (decision) => {
this.pendingPermissions.set(conversationId, null);
if (decision === ToolPermissionDecision.ALWAYS && permissionKey) {
permissionsStore.allowTool(permissionKey);
} else if (decision === ToolPermissionDecision.ALWAYS_SERVER) {
const serverToolKeys = toolsStore.allTools
.filter((t) =>
t.serverName
? t.serverName === serverLabel
: toolsStore.getToolServerLabel(t.definition.function.name) === serverLabel
)
.map((t) => toolsStore.getPermissionKey(t.definition.function.name)!)
.filter((k): k is string => k !== null);
permissionsStore.allowTools(serverToolKeys);
}
resolve(decision);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this.permissionResolvers.get(conversationId);
if (resolver) {
this.permissionResolvers.delete(conversationId);
this.pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
}
},
{ once: true }
);
});
}
resolveContinue(conversationId: string, shouldContinue: boolean): void {
const resolver = this.continueResolvers.get(conversationId);
if (resolver) {
this.continueResolvers.delete(conversationId);
resolver(shouldContinue);
}
}
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
const resolver = this.permissionResolvers.get(conversationId);
if (resolver) {
this.permissionResolvers.delete(conversationId);
resolver(decision);
}
}
}
@@ -1,23 +1,13 @@
/**
* agenticStore - Reactive State Store for Agentic Loop Orchestration
* AgenticStore - Multi-turn agentic loop orchestration
*
* Manages multi-turn agentic loop with MCP tools:
* - LLM streaming with tool call detection
* - Tool execution via mcpStore
* - Session state management
* - Turn limit enforcement
* Drives the agentic loop over MCP tools: streams each LLM turn, detects
* tool calls, executes them via mcpStore, and enforces the turn limit. Each
* turn produces one assistant message (with tool_calls) and one tool result
* message per executed call, persisted as separate DB rows.
*
* Each agentic turn produces separate DB messages:
* - One assistant message per LLM turn (with tool_calls if any)
* - One tool result message per tool call execution
*
* **Architecture & Relationships:**
* - **ChatService**: Stateless API layer (sendMessage, streaming)
* - **mcpStore**: MCP connection management and tool execution
* - **agenticStore** (this): Reactive state + business logic
*
* @see ChatService in services/chat.service.ts for API operations
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
* Uses ChatService for streaming and mcpStore for tool execution; waits on
* the permission/continue/steering gates owned by {@link AgenticGates}.
*/
import { DEFAULT_AGENTIC_CONFIG, NEWLINE } from '$lib/constants';
@@ -43,11 +33,11 @@ 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 { settingsStore } from '$lib/stores/settings.svelte';
import { AgenticGates } from '$lib/stores/agentic/gates.svelte';
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type {
AgenticConfig,
@@ -152,160 +142,45 @@ function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
}
class AgenticStore {
private _sessions = new SvelteMap<string, AgenticSession>();
/** Dedicated reactive state for pending permission requests (ensures immediate UI updates) */
private _pendingPermissions = new SvelteMap<
string,
{ toolName: string; serverLabel: string } | null
>();
/** Non-reactive: stores resolve functions for pending permission Promises */
private _permissionResolvers = new Map<string, (decision: ToolPermissionDecision) => void>();
// permission, continue and steering gates the loop waits on between turns
private gates = new AgenticGates();
private sessions = new SvelteMap<string, AgenticSession>();
/** Dedicated reactive state for pending continue requests (turn limit reached) */
private _pendingContinueRequests = new SvelteMap<string, boolean>();
/** Non-reactive: stores resolve functions for pending continue Promises */
private _continueResolvers = new Map<string, (shouldContinue: boolean) => void>();
/** Reactive: queued steering messages to inject between turns */
private _steeringMessages = new SvelteMap<string, SteeringMessage>();
get isReady(): boolean {
return true;
}
get isAnyRunning(): boolean {
for (const session of this._sessions.values()) {
for (const session of this.sessions.values()) {
if (session.isRunning) return true;
}
return false;
}
getSession(conversationId: string): AgenticSession {
let session = this._sessions.get(conversationId);
if (!session) {
session = createDefaultSession();
this._sessions.set(conversationId, session);
}
return session;
}
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
const session = this.getSession(conversationId);
this._sessions.set(conversationId, { ...session, ...update });
}
clearSession(conversationId: string): void {
this._sessions.delete(conversationId);
}
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
for (const [conversationId, session] of this._sessions.entries()) {
if (session.isRunning) active.push({ conversationId, session });
}
return active;
}
isRunning(conversationId: string): boolean {
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;
}
totalToolCalls(conversationId: string): number {
return this._sessions.get(conversationId)?.totalToolCalls ?? 0;
}
lastError(conversationId: string): Error | null {
return this._sessions.get(conversationId)?.lastError ?? null;
}
streamingToolCall(conversationId: string): { name: string; arguments: string } | null {
return this._sessions.get(conversationId)?.streamingToolCall ?? null;
}
executingToolCallId(conversationId: string): string | null {
return this._sessions.get(conversationId)?.executingToolCallId ?? null;
}
pendingPermissionRequest(
conversationId: string
): { toolName: string; serverLabel: string } | null {
return this._pendingPermissions.get(conversationId) ?? null;
}
pendingContinueRequest(conversationId: string): boolean {
return this._pendingContinueRequests.get(conversationId) ?? false;
}
resolveContinue(conversationId: string, shouldContinue: boolean): void {
const resolver = this._continueResolvers.get(conversationId);
if (resolver) {
this._continueResolvers.delete(conversationId);
resolver(shouldContinue);
}
}
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
const resolver = this._permissionResolvers.get(conversationId);
if (resolver) {
this._permissionResolvers.delete(conversationId);
resolver(decision);
}
get isReady(): boolean {
return true;
}
clearError(conversationId: string): void {
this.updateSession(conversationId, { lastError: null });
}
hasPendingSteeringMessage(conversationId: string): boolean {
return this._steeringMessages.has(conversationId);
}
pendingSteeringMessageContent(conversationId: string): string | null {
return this._steeringMessages.get(conversationId)?.content ?? null;
}
pendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined {
return this._steeringMessages.get(conversationId)?.extras;
}
/**
* Queue a steering message. When the current agentic turn completes,
* the flow exits and the caller re-sends the message as a normal chat message.
*/
injectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
): void {
this._steeringMessages.set(conversationId, { content, extras });
clearSession(conversationId: string): void {
this.sessions.delete(conversationId);
}
/**
* Clear the pending steering message without consuming it.
*/
clearSteeringMessage(conversationId: string): void {
this._steeringMessages.delete(conversationId);
this.gates.clearSteeringMessage(conversationId);
}
constructor() {
// drop per-conversation session state when the conversation is deleted,
// otherwise every conversation that ever ran a flow leaks a session here
conversationsStore.onConversationsDeleted((convIds) => {
for (const convId of convIds) {
this.sessions.delete(convId);
}
});
}
/**
@@ -313,13 +188,17 @@ class AgenticStore {
* Called by chatStore after the agentic flow exits.
*/
consumePendingSteeringMessage(conversationId: string): SteeringMessage | null {
const msg = this._steeringMessages.get(conversationId);
return this.gates.consumePendingSteeringMessage(conversationId);
}
if (!msg) return null;
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
this._steeringMessages.delete(conversationId);
for (const [conversationId, session] of this.sessions.entries()) {
if (session.isRunning) active.push({ conversationId, session });
}
return msg;
return active;
}
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
@@ -336,105 +215,91 @@ class AgenticStore {
};
}
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'object') return args;
const trimmed = args.trim();
if (trimmed === '') return {};
return JSON.parse(trimmed) as Record<string, unknown>;
getCurrentTurn(conversationId: string): number {
return this.sessions.get(conversationId)?.currentTurn ?? 0;
}
private async requestPermission(
conversationId: string,
toolName: string,
serverLabel: string,
signal?: AbortSignal
): Promise<ToolPermissionDecision> {
const permissionKey = toolsStore.getPermissionKey(toolName);
getExecutingToolCallId(conversationId: string): string | null {
return this.sessions.get(conversationId)?.executingToolCallId ?? null;
}
if (permissionKey && permissionsStore.hasTool(permissionKey)) {
return ToolPermissionDecision.ONCE;
// read-only: safe to call from derivations, unlike getSession
getFlowRootMessageId(conversationId: string): string | null {
return this.sessions.get(conversationId)?.flowRootMessageId ?? null;
}
getLastError(conversationId: string): Error | null {
return this.sessions.get(conversationId)?.lastError ?? null;
}
// read-only: safe to call from derivations, unlike getSession
getLiveLlmTotals(conversationId: string): AgenticSession['liveLlm'] {
return this.sessions.get(conversationId)?.liveLlm ?? null;
}
getPendingContinueRequest(conversationId: string): boolean {
return this.gates.getPendingContinueRequest(conversationId);
}
getPendingPermissionRequest(
conversationId: string
): { toolName: string; serverLabel: string } | null {
return this.gates.getPendingPermissionRequest(conversationId);
}
getPendingSteeringMessageContent(conversationId: string): string | null {
return this.gates.getPendingSteeringMessageContent(conversationId);
}
getPendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined {
return this.gates.getPendingSteeringMessageExtras(conversationId);
}
getSession(conversationId: string): AgenticSession {
let session = this.sessions.get(conversationId);
if (!session) {
session = createDefaultSession();
this.sessions.set(conversationId, session);
}
this._pendingPermissions.set(conversationId, { serverLabel, toolName });
return new Promise<ToolPermissionDecision>((resolve) => {
if (signal?.aborted) {
this._pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
return;
}
this._permissionResolvers.set(conversationId, (decision) => {
this._pendingPermissions.set(conversationId, null);
if (decision === ToolPermissionDecision.ALWAYS && permissionKey) {
permissionsStore.allowTool(permissionKey);
} else if (decision === ToolPermissionDecision.ALWAYS_SERVER) {
const serverToolKeys = toolsStore.allTools
.filter((t) =>
t.serverName
? t.serverName === serverLabel
: toolsStore.getToolServerLabel(t.definition.function.name) === serverLabel
)
.map((t) => toolsStore.getPermissionKey(t.definition.function.name)!)
.filter((k): k is string => k !== null);
permissionsStore.allowTools(serverToolKeys);
}
resolve(decision);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this._permissionResolvers.get(conversationId);
if (resolver) {
this._permissionResolvers.delete(conversationId);
this._pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
}
},
{ once: true }
);
});
return session;
}
private async requestContinue(conversationId: string, signal?: AbortSignal): Promise<boolean> {
this._pendingContinueRequests.set(conversationId, true);
getStreamingToolCall(conversationId: string): { name: string; arguments: string } | null {
return this.sessions.get(conversationId)?.streamingToolCall ?? null;
}
return new Promise<boolean>((resolve) => {
if (signal?.aborted) {
this._pendingContinueRequests.set(conversationId, false);
resolve(false);
getTotalToolCalls(conversationId: string): number {
return this.sessions.get(conversationId)?.totalToolCalls ?? 0;
}
return;
}
hasPendingSteeringMessage(conversationId: string): boolean {
return this.gates.hasPendingSteeringMessage(conversationId);
}
this._continueResolvers.set(conversationId, (shouldContinue) => {
this._pendingContinueRequests.set(conversationId, false);
resolve(shouldContinue);
});
/**
* Queue a steering message. When the current agentic turn completes,
* the flow exits and the caller re-sends the message as a normal chat message.
*/
injectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
): void {
this.gates.injectSteeringMessage(conversationId, content, extras);
}
signal?.addEventListener(
'abort',
() => {
const resolver = this._continueResolvers.get(conversationId);
isRunning(conversationId: string): boolean {
return this.sessions.get(conversationId)?.isRunning ?? false;
}
if (resolver) {
this._continueResolvers.delete(conversationId);
this._pendingContinueRequests.set(conversationId, false);
resolve(false);
}
},
{ once: true }
);
});
resolveContinue(conversationId: string, shouldContinue: boolean): void {
this.gates.resolveContinue(conversationId, shouldContinue);
}
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
this.gates.resolvePermission(conversationId, decision);
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
@@ -449,11 +314,7 @@ class AgenticStore {
} = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow
this._pendingPermissions.set(conversationId, null);
this._permissionResolvers.delete(conversationId);
this._pendingContinueRequests.set(conversationId, false);
this._continueResolvers.delete(conversationId);
this._steeringMessages.delete(conversationId);
this.gates.clear(conversationId);
// Ensure server tools are fetched before checking if agentic is enabled
if (toolsStore.serverTools.length === 0 && !toolsStore.loading) {
@@ -482,26 +343,8 @@ class AgenticStore {
console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
const normalizedMessages: ApiChatMessageData[] = (
await Promise.all(
messages.map((msg) => {
if ('id' in msg && 'convId' in msg && 'timestamp' in msg)
return ChatService.convertDbMessageToApiChatMessageData(
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
);
return msg as ApiChatMessageData;
})
)
).filter((msg: { role: ChatRole; content: string | ApiChatMessageContentPart[] }) => {
if (msg.role === MessageRole.SYSTEM) {
const content = typeof msg.content === 'string' ? msg.content : '';
return content.trim().length > 0;
}
return true;
});
const normalizedMessages: ApiChatMessageData[] =
await ChatService.normalizeMessagesForApi(messages);
this.updateSession(conversationId, {
currentTurn: 0,
@@ -550,6 +393,30 @@ class AgenticStore {
}
}
private buildAttachmentName(mimeType: string, index: number): string {
const extension = mimeType.startsWith(MimeTypePrefix.AUDIO)
? (AUDIO_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_AUDIO_EXTENSION)
: (IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION);
return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`;
}
private buildFinalTimings(
capturedTimings: ChatMessageTimings | undefined,
agenticTimings: ChatMessageAgenticTimings
): ChatMessageTimings | undefined {
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
return {
agentic: agenticTimings,
cache_n: capturedTimings?.cache_n,
predicted_ms: capturedTimings?.predicted_ms,
predicted_n: capturedTimings?.predicted_n,
prompt_ms: capturedTimings?.prompt_ms,
prompt_n: capturedTimings?.prompt_n
};
}
private async executeAgenticLoop(params: {
conversationId: string;
messages: ApiChatMessageData[];
@@ -596,7 +463,7 @@ class AgenticStore {
while (true) {
if (turn >= maxTurns) {
// Turn limit reached - ask user whether to continue
const shouldContinue = await this.requestContinue(conversationId, signal);
const shouldContinue = await this.gates.requestContinue(conversationId, signal);
// Yield to allow Svelte to flush the UI update
await new Promise((r) => setTimeout(r, 0));
@@ -769,7 +636,7 @@ class AgenticStore {
// === Steering check: if a user message was queued during this turn, exit the flow.
// The caller (chatStore) will consume the pending message and re-send it normally.
if (this._steeringMessages.has(conversationId)) {
if (this.gates.hasPendingSteeringMessage(conversationId)) {
console.log('[AgenticStore] Steering message detected after turn, exiting agentic flow');
await onAssistantTurnComplete?.(
turnContent,
@@ -847,7 +714,7 @@ class AgenticStore {
}
// Check for pending steering message - skip remaining tool calls
if (this._steeringMessages.has(conversationId)) {
if (this.gates.hasPendingSteeringMessage(conversationId)) {
console.log(
`[AgenticStore] Steering message detected, skipping ${normalizedCalls.length - i} remaining tool call(s)`
);
@@ -872,7 +739,7 @@ class AgenticStore {
const toolName = toolCall.function.name;
const serverLabel = toolsStore.getToolServerLabel(toolName);
// Ask for permission before executing the tool
const permission = await this.requestPermission(
const permission = await this.gates.requestPermission(
conversationId,
toolName,
serverLabel,
@@ -959,8 +826,8 @@ class AgenticStore {
executionResult = await ReadMediaService.executeTool(
args,
{
audio: modelsStore.modelSupportsAudio(effectiveModel),
vision: modelsStore.modelSupportsVision(effectiveModel)
audio: modelsStore.props.modelSupportsAudio(effectiveModel),
vision: modelsStore.props.modelSupportsVision(effectiveModel)
},
signal,
conversationsStore.activeConversation?.cwd
@@ -1058,7 +925,7 @@ class AgenticStore {
for (const attachment of attachments) {
if (attachment.type === AttachmentType.AUDIO) {
if (modelsStore.modelSupportsAudio(effectiveModel)) {
if (modelsStore.props.modelSupportsAudio(effectiveModel)) {
contentParts.push({
input_audio: {
data: (attachment as DatabaseMessageExtraAudioFile).base64Data,
@@ -1070,7 +937,7 @@ class AgenticStore {
});
}
} else if (attachment.type === AttachmentType.IMAGE) {
if (modelsStore.modelSupportsVision(effectiveModel)) {
if (modelsStore.props.modelSupportsVision(effectiveModel)) {
contentParts.push({
image_url: {
url: (attachment as DatabaseMessageExtraImageFile).base64Url
@@ -1101,7 +968,7 @@ class AgenticStore {
}
// If tools were interrupted by a steering message, exit now instead of starting another LLM turn
if (this._steeringMessages.has(conversationId)) {
if (this.gates.hasPendingSteeringMessage(conversationId)) {
console.log(
'[AgenticStore] Steering message detected after tool execution, exiting agentic flow'
);
@@ -1114,35 +981,6 @@ class AgenticStore {
}
}
private buildFinalTimings(
capturedTimings: ChatMessageTimings | undefined,
agenticTimings: ChatMessageAgenticTimings
): ChatMessageTimings | undefined {
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
return {
agentic: agenticTimings,
cache_n: capturedTimings?.cache_n,
predicted_ms: capturedTimings?.predicted_ms,
predicted_n: capturedTimings?.predicted_n,
prompt_ms: capturedTimings?.prompt_ms,
prompt_n: capturedTimings?.prompt_n
};
}
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
if (!toolCalls) return [];
return toolCalls.map((call, index) => ({
function: {
arguments: call?.function?.arguments ?? '',
name: call?.function?.name ?? ''
},
id: call?.id ?? `tool_${index}`,
type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION
}));
}
private extractBase64Attachments(result: string): {
cleanedResult: string;
attachments: DatabaseMessageExtra[];
@@ -1198,12 +1036,33 @@ class AgenticStore {
return { attachments, cleanedResult: cleanedLines.join(NEWLINE) };
}
private buildAttachmentName(mimeType: string, index: number): string {
const extension = mimeType.startsWith(MimeTypePrefix.AUDIO)
? (AUDIO_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_AUDIO_EXTENSION)
: (IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION);
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
if (!toolCalls) return [];
return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`;
return toolCalls.map((call, index) => ({
function: {
arguments: call?.function?.arguments ?? '',
name: call?.function?.name ?? ''
},
id: call?.id ?? `tool_${index}`,
type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION
}));
}
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'object') return args;
const trimmed = args.trim();
if (trimmed === '') return {};
return JSON.parse(trimmed) as Record<string, unknown>;
}
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
const session = this.getSession(conversationId);
this.sessions.set(conversationId, { ...session, ...update });
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
/**
* ChatActivityStore - Conversation activity ledger
*
* Single owner of the "is this conversation doing something" state:
* - `local` - this browser is piping a stream (send, server-stream attach,
* or resume-wait while the owning model loads)
* - `remote` - the backend reports a running session, no local pipe yet
* (global snapshot on mount / visibilitychange)
*
* The union of both drives the sidebar spinners (`loadingConvs`); `local`
* drives the per-conversation loading flags. When a local pipe ends it is
* the authoritative observer of session end, so it also drops the stale
* remote hint in the same call - no cross-owner cleanup, no ghosted
* spinners waiting for the next visibilitychange snapshot.
*
* Composed under chatStore.activity; not exported from the stores barrel.
*/
import { SvelteSet } from 'svelte/reactivity';
export class ChatActivityStore {
/** Convs this browser is piping a stream for (send, attach, resume-wait). */
private local = new SvelteSet<string>();
/** Convs the backend reports as having a running session (snapshot sync). */
private remote = new SvelteSet<string>();
/** Convs with any activity, the union the sidebar spinners render. */
loadingConvs = $derived.by(() => {
const out = new SvelteSet<string>(this.local);
for (const id of this.remote) out.add(id);
return Array.from(out);
});
/**
* Apply a backend snapshot of running sessions (mount / visibilitychange).
* Diffed so unchanged entries do not re-trigger reactivity.
*/
applyRemoteSnapshot(running: Iterable<string>): void {
const next = new SvelteSet<string>(running);
for (const id of Array.from(this.remote)) {
if (!next.has(id)) this.remote.delete(id);
}
for (const id of next) this.remote.add(id);
}
isLocal(convId: string): boolean {
return this.local.has(convId);
}
isRemote(convId: string): boolean {
return this.remote.has(convId);
}
/**
* A local pipe ended for the conv. Also drops the remote hint: the local
* pipe is the authoritative observer of session end, so the sidebar hint
* goes away right away instead of ghosting until the next snapshot.
*/
localEnded(convId: string): void {
this.local.delete(convId);
this.remote.delete(convId);
}
/** A local pipe (send, attach or resume-wait) started for the conv. */
markLocal(convId: string): void {
this.local.add(convId);
}
}
export const chatActivityStore = new ChatActivityStore();
@@ -1,5 +1,5 @@
/**
* contextStatsStore - Context window usage stats for the active conversation
* 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)
@@ -8,12 +8,17 @@
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 { agenticStore } from '$lib/stores/agentic/index.svelte';
import { chatStore } from '$lib/stores/chat/index.svelte';
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import type { ApiProcessingState, ChatMessageTimings, DatabaseMessage } from '$lib/types';
import type {
ApiProcessingState,
ChatMessageAgenticTimings,
ChatMessageTimings,
DatabaseMessage
} from '$lib/types';
interface LiveStats {
freshTokens: number;
@@ -22,14 +27,46 @@ interface LiveStats {
outputTokens: number;
}
function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
interface AssistantTimingsSummary {
lastAgenticLlm: ChatMessageAgenticTimings['llm'] | undefined;
lastTimings: ChatMessageTimings | undefined;
cacheTotal: number;
output: number;
outputMs: number;
read: number;
}
if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings;
/**
* One forward pass over the messages computing everything the deriveds
* below need: the last assistant timings (per-turn gauges), the last
* agentic llm totals (cumulative gauge) and the cumulative sums. During
* streaming activeMessages churns every chunk, and each of these used to be
* its own O(n) scan re-run per chunk.
*/
function summarizeAssistantTimings(messages: DatabaseMessage[]): AssistantTimingsSummary {
let lastAgenticLlm: ChatMessageAgenticTimings['llm'] | undefined;
let lastTimings: ChatMessageTimings | undefined;
let read = 0;
let cacheTotal = 0;
let output = 0;
let outputMs = 0;
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
lastTimings = m.timings;
if (m.timings.agentic?.llm?.predicted_n != null) {
lastAgenticLlm = m.timings.agentic.llm;
}
read += m.timings.prompt_n ?? 0;
cacheTotal += m.timings.cache_n ?? 0;
output += m.timings.predicted_n ?? 0;
outputMs += m.timings.predicted_ms ?? 0;
}
return undefined;
return { cacheTotal, lastAgenticLlm, lastTimings, output, outputMs, read };
}
function deriveLiveStats(state: ApiProcessingState | null): LiveStats | null {
@@ -52,83 +89,14 @@ class ContextStatsStore {
// The canonical resolution lives in modelsStore.activeModelId.
activeModelId = $derived(modelsStore.activeModelId);
isActiveModelLoaded = $derived(
this.activeModelId !== null &&
(!serverStore.isRouterMode || modelsStore.isModelLoaded(this.activeModelId))
// shared by currentRead/Fresh/Cache/Output and cumulative so a per-chunk
// churn of activeMessages triggers exactly one scan instead of one per
// derived
private assistantTimings = $derived.by(() =>
summarizeAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[])
);
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.
@@ -147,51 +115,107 @@ class ContextStatsStore {
};
}
const { cacheTotal, lastAgenticLlm, output, outputMs, read } = this.assistantTimings;
// 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;
if (lastAgenticLlm) {
const averageTokensPerSecond =
lastAgenticLlm.predicted_ms > 0 && lastAgenticLlm.predicted_n > 0
? (lastAgenticLlm.predicted_n / lastAgenticLlm.predicted_ms) * 1000
: null;
return {
averageTokensPerSecond,
cacheTotal: 0,
output,
read: llm.prompt_n ?? 0
output: lastAgenticLlm.predicted_n ?? 0,
read: lastAgenticLlm.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);
averageTokensPerSecond = $derived(this.cumulative.averageTokensPerSecond);
cumulativeOutput = $derived(this.cumulative.output);
contextTotal = $derived.by(() => {
void modelsStore.props.cacheVersion;
return this.activeModelId ? modelsStore.props.getModelContextSize(this.activeModelId) : null;
});
private liveStats = $derived(deriveLiveStats(chatStore.processing.activeState));
currentOutput = $derived.by(() => {
if (this.liveStats && this.liveStats.outputTokens > 0) return this.liveStats.outputTokens;
return this.assistantTimings.lastTimings?.predicted_n ?? 0;
});
currentRead = $derived.by(() => {
const timings = this.assistantTimings.lastTimings;
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;
});
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);
});
cumulativeCacheTotal = $derived(this.cumulative.cacheTotal);
averageTokensPerSecond = $derived(this.cumulative.averageTokensPerSecond);
cumulativeOutput = $derived(this.cumulative.output);
cumulativeRead = $derived(this.cumulative.read);
currentCache = $derived.by(() => {
const cached = this.assistantTimings.lastTimings?.cache_n ?? 0;
if (this.liveStats && this.liveStats.promptTokens > 0) {
return Math.max(cached, this.liveStats.cacheTokens);
}
return cached;
});
currentFresh = $derived.by(() => {
const fresh = this.assistantTimings.lastTimings?.prompt_n ?? 0;
return Math.max(fresh, this.liveStats?.freshTokens ?? 0);
});
isActiveModelLoaded = $derived(
this.activeModelId !== null &&
(!serverStore.isRouterMode || modelsStore.isModelLoaded(this.activeModelId))
);
isActiveModelLoading = $derived(
this.activeModelId !== null && modelsStore.status.isOperationInProgress(this.activeModelId)
);
kvTotal = $derived(this.currentRead + this.currentOutput);
}
export const contextStatsStore = new ContextStatsStore();
@@ -1,3 +1,11 @@
/**
* DraftMessagesStore - Per-conversation input drafts
*
* Keeps in-memory drafts (message text + files) keyed by conversation id,
* plus a dedicated key for the new-chat screen, so the input box restores
* its content when switching conversations.
*/
import { NEW_CHAT_DRAFT_KEY } from '$lib/constants';
interface DraftMessage {
@@ -8,6 +16,12 @@ interface DraftMessage {
class DraftMessagesStore {
private drafts = new Map<string, DraftMessage>();
clearDraftMessage(chatId: string | undefined): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
this.drafts.delete(key);
}
getDraftMessage(chatId: string | undefined): DraftMessage {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
@@ -23,12 +37,6 @@ class DraftMessagesStore {
this.drafts.delete(key);
}
}
clearDraftMessage(chatId: string | undefined): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
this.drafts.delete(key);
}
}
export const draftMessagesStore = new DraftMessagesStore();
@@ -0,0 +1,794 @@
/**
* ChatMessageFlows - Message-level flows for the active conversation
*
* Owns the operations that mutate chat history and (re)stream a response:
* editing, regeneration, continuation and deletion of messages. Created and
* owned by chatStore; the host exposes the streaming core and the
* per-conversation state setters these flows drive.
*/
import {
ContinueIntentKind,
ErrorDialogType,
MessageRole,
MessageType,
StreamConnectionState
} from '$lib/enums';
import { ChatService } from '$lib/services/chat.service';
import { DatabaseService } from '$lib/services/database.service';
import type { ChatProcessingStore } from '$lib/stores/chat/processing.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import type {
ChatMessagePromptProgress,
ChatMessageTimings,
DatabaseMessage,
DatabaseMessageExtra,
ErrorDialogState
} from '$lib/types';
import {
classifyContinueIntent,
filterByLeafNodeId,
findDescendantMessages,
findLeafNode,
findMessageById,
isAbortError
} from '$lib/utils';
/**
* The slice of chatStore the flows drive. Kept narrow on purpose so the flows
* cannot reach around the host's full surface; chatStore implements this
* structurally.
*/
export interface ChatFlowsHost {
processing: ChatProcessingStore;
streamConnectionState: StreamConnectionState;
cancelPreEncode(): void;
clearChatStreaming(convId: string, messageId?: string): void;
cleanupStreaming(convId: string): void;
createAssistantMessage(parentId?: string): Promise<DatabaseMessage>;
getApiOptions(): Record<string, unknown>;
getOrCreateAbortController(convId: string): AbortController;
isChatLoadingInternal(convId: string): boolean;
setChatLoading(convId: string, loading: boolean): void;
setChatReasoning(convId: string, reasoning: boolean): void;
setChatStreaming(
convId: string,
response: string,
messageId: string,
model?: string | null
): void;
showErrorDialog(state: ErrorDialogState | null): void;
stopGeneration(): Promise<void>;
streamChatCompletion(
allMessages: DatabaseMessage[],
assistantMessage: DatabaseMessage,
onComplete?: (content: string) => Promise<void>,
onError?: (error: Error) => void,
modelOverride?: string | null,
firstUserMessageContent?: string
): Promise<void>;
}
export class ChatMessageFlows {
constructor(private host: ChatFlowsHost) {}
async continueAssistantMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
const { index: idx, message: msg } = result;
// Decide which resume path applies. tool_calls without tool results can
// not be resumed mid sequence by continue_final_message, branch instead.
// tool_calls already paired with tool results need a fresh next turn,
// not a token level continuation of the target assistant.
const intent = classifyContinueIntent(conversationsStore.activeMessages, idx);
if (intent.kind === ContinueIntentKind.RERUN_TURN) {
return this.regenerateMessageWithBranching(messageId);
}
if (intent.kind === ContinueIntentKind.NEXT_TURN) {
return this.continueAsNextAgenticTurn(intent.truncateAfter);
}
try {
this.host.showErrorDialog(null);
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const dbMessage = findMessageById(allMessages, messageId);
if (!dbMessage) {
this.host.setChatLoading(activeConv.id, false);
return;
}
const originalContent = dbMessage.content;
const originalReasoning = dbMessage.reasoningContent || '';
// Hand the persisted DatabaseMessage straight to sendMessage so its
// internal converter preserves tool_calls and extras when present.
// Reconstructing a bare {role, content} here would drop those fields
// and break continue_final_message for messages with tool calls.
const contextWithContinue = conversationsStore.activeMessages.slice(0, idx + 1);
let appendedContent = '';
let appendedReasoning = '';
let hasReceivedContent = false;
const updateStreamingContent = (fullContent: string) => {
this.host.setChatStreaming(msg.convId, fullContent, msg.id);
// resolve the row by id on every write, switching to another conv mid continue makes
// this a no op instead of writing positionally into the now displayed conversation
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
content: fullContent
});
};
const abortController = this.host.getOrCreateAbortController(msg.convId);
await ChatService.sendMessage(
contextWithContinue,
{
...this.host.getApiOptions(),
continueFinalMessage: true,
onChunk: (chunk: string) => {
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
this.host.setChatReasoning(msg.convId, false);
},
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings
) => {
const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || '';
const finalAppendedReasoning = hasReceivedContent
? appendedReasoning
: reasoningContent || '';
const fullContent = originalContent + finalAppendedContent;
const fullReasoning = originalReasoning + finalAppendedReasoning || undefined;
await DatabaseService.updateMessage(msg.id, {
content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(),
timings
});
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(),
timings
});
conversationsStore.updateConversationTimestamp(msg.convId);
this.host.cleanupStreaming(msg.convId);
},
onCompletionId: (id: string) => {
if (!id) return;
// refresh the message id so a later skip targets the live slot after a continue
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
completionId: id
});
DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {});
},
onConnectionState: (state: StreamConnectionState) => {
if (msg.convId === conversationsStore.activeConversation?.id) {
this.host.streamConnectionState = state;
}
},
onError: async (error: Error) => {
if (isAbortError(error)) {
if (hasReceivedContent && appendedContent) {
await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
conversationsStore.updateMessageAtIndex(
conversationsStore.findMessageIndex(msg.id),
{
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
}
);
}
this.host.cleanupStreaming(msg.convId);
return;
}
console.error('Continue generation error:', error);
// keep whatever was appended so far, the message stays in memory and in DB
await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
this.host.cleanupStreaming(msg.convId);
this.host.showErrorDialog({
message: error.message,
type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER
});
},
onReasoningChunk: (chunk: string) => {
appendedReasoning += chunk;
hasReceivedContent = true;
// mark streaming state so a stop mid-thinking can persist the partial reasoning
this.host.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id);
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
reasoningContent: originalReasoning + appendedReasoning
});
this.host.setChatReasoning(msg.convId, true);
},
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
this.host.processing.applyStreamTimings(timings, promptProgress, msg.convId);
}
},
msg.convId,
abortController.signal
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to continue message:', error);
if (activeConv) this.host.setChatLoading(activeConv.id, false);
}
}
async deleteMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const messageToDelete = findMessageById(allMessages, messageId);
if (!messageToDelete) return;
const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false);
const isInCurrentPath = currentPath.some((m) => m.id === messageId);
if (isInCurrentPath && messageToDelete.parent) {
const siblings = allMessages.filter(
(m) => m.parent === messageToDelete.parent && m.id !== messageId
);
if (siblings.length > 0) {
const latestSibling = siblings.reduce((latest, sibling) =>
sibling.timestamp > latest.timestamp ? sibling : latest
);
await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id));
} else if (messageToDelete.parent) {
await conversationsStore.updateCurrentNode(
findLeafNode(allMessages, messageToDelete.parent)
);
}
}
await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
await conversationsStore.refreshActiveMessages();
conversationsStore.updateConversationTimestamp();
} catch (error) {
console.error('Failed to delete message:', error);
}
}
async editAssistantMessage(
messageId: string,
newContent: string,
shouldBranch: boolean
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
const { index: idx, message: msg } = result;
try {
if (shouldBranch) {
const newMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: newContent,
convId: msg.convId,
model: msg.model,
role: msg.role,
timestamp: Date.now(),
toolCalls: msg.toolCalls || '',
type: msg.type
},
msg.parent!
);
await conversationsStore.updateCurrentNode(newMessage.id);
} else {
await DatabaseService.updateMessage(msg.id, { content: newContent });
conversationsStore.updateMessageAtIndex(idx, { content: newContent });
}
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
} catch (error) {
console.error('Failed to edit assistant message:', error);
}
}
async editMessageWithBranching(
messageId: string,
newContent: string,
newExtras?: DatabaseMessageExtra[]
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);
if (!result) return;
const { index: idx, message: msg } = result;
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const isFirstUserMessage =
msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id;
const extrasToUse =
newExtras !== undefined
? JSON.parse(JSON.stringify(newExtras))
: msg.extra
? JSON.parse(JSON.stringify(msg.extra))
: undefined;
let messageIdForResponse: string;
const dbMsg = findMessageById(allMessages, msg.id);
const hasChildren = dbMsg ? dbMsg.children.length > 0 : msg.children.length > 0;
if (!hasChildren) {
// No responses after this message - update in place instead of branching
const updates: Partial<DatabaseMessage> = {
content: newContent,
extra: extrasToUse,
timestamp: Date.now()
};
await DatabaseService.updateMessage(msg.id, updates);
conversationsStore.updateMessageAtIndex(idx, updates);
messageIdForResponse = msg.id;
} else {
// Has children - create a new branch as sibling
const parentId = msg.parent || rootMessage?.id;
if (!parentId) return;
const newMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: newContent,
convId: msg.convId,
extra: extrasToUse,
model: msg.model,
role: msg.role,
timestamp: Date.now(),
toolCalls: msg.toolCalls || '',
type: msg.type
},
parentId
);
await conversationsStore.updateCurrentNode(newMessage.id);
messageIdForResponse = newMessage.id;
}
conversationsStore.updateConversationTimestamp();
if (isFirstUserMessage && newContent.trim())
await conversationsStore.applyTitleFromContent(activeConv.id, newContent);
await conversationsStore.refreshActiveMessages();
if (msg.role === MessageRole.USER)
await this.generateResponseForMessage(messageIdForResponse);
} catch (error) {
console.error('Failed to edit message with branching:', error);
}
}
async editUserMessagePreserveResponses(
messageId: string,
newContent: string,
newExtras?: DatabaseMessageExtra[]
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) return;
const { index: idx, message: msg } = result;
try {
const updateData: Partial<DatabaseMessage> = { content: newContent };
if (newExtras !== undefined) updateData.extra = JSON.parse(JSON.stringify(newExtras));
await DatabaseService.updateMessage(messageId, updateData);
conversationsStore.updateMessageAtIndex(idx, updateData);
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
await conversationsStore.applyTitleFromContent(activeConv.id, newContent);
}
conversationsStore.updateConversationTimestamp();
} catch (error) {
console.error('Failed to edit user message:', error);
}
}
async getDeletionInfo(messageId: string): Promise<{
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
}> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv)
return { assistantMessages: 0, messageTypes: [], totalCount: 0, userMessages: 0 };
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const messageToDelete = findMessageById(allMessages, messageId);
// For system messages, don't count descendants as they will be preserved (reparented to root)
if (messageToDelete?.role === MessageRole.SYSTEM) {
const messagesToDelete = allMessages.filter((m) => m.id === messageId);
let assistantMessages = 0,
userMessages = 0;
const messageTypes: string[] = [];
for (const msg of messagesToDelete) {
if (msg.role === MessageRole.USER) {
userMessages++;
if (!messageTypes.includes('user message')) messageTypes.push('user message');
} else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
}
return { assistantMessages, messageTypes, totalCount: 1, userMessages };
}
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
let assistantMessages = 0,
userMessages = 0;
const messageTypes: string[] = [];
for (const msg of messagesToDelete) {
if (msg.role === MessageRole.USER) {
userMessages++;
if (!messageTypes.includes('user message')) messageTypes.push('user message');
} else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
}
return { assistantMessages, messageTypes, totalCount: allToDelete.length, userMessages };
}
async regenerateMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
this.host.cancelPreEncode();
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
const { index: messageIndex } = result;
try {
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
conversationsStore.sliceActiveMessages(messageIndex);
conversationsStore.updateConversationTimestamp();
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const parentMessageId =
conversationsStore.activeMessages.length > 0
? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
: undefined;
const assistantMessage = await this.host.createAssistantMessage(parentMessageId);
conversationsStore.addMessageToActive(assistantMessage);
await this.host.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
assistantMessage
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to regenerate message:', error);
this.host.setChatLoading(activeConv?.id || '', false);
}
}
async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
this.host.cancelPreEncode();
try {
const idx = conversationsStore.findMessageIndex(messageId);
if (idx === -1) return;
const msg = conversationsStore.activeMessages[idx];
if (msg.role !== MessageRole.ASSISTANT) return;
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const parentMessage = findMessageById(allMessages, msg.parent);
if (!parentMessage) return;
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const newAssistantMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId: msg.convId,
model: null,
role: msg.role,
timestamp: Date.now(),
toolCalls: '',
type: msg.type
},
parentMessage.id
);
await conversationsStore.updateCurrentNode(newAssistantMessage.id);
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
const conversationPath = filterByLeafNodeId(
allMessages,
parentMessage.id,
false
) as DatabaseMessage[];
const modelToUse = modelOverride || msg.model || undefined;
await this.host.streamChatCompletion(
conversationPath,
newAssistantMessage,
undefined,
undefined,
modelToUse
);
} catch (error) {
if (!isAbortError(error))
console.error('Failed to regenerate message with branching:', error);
this.host.setChatLoading(activeConv?.id || '', false);
}
}
async updateMessage(messageId: string, newContent: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
if (this.host.isChatLoadingInternal(activeConv.id)) await this.host.stopGeneration();
const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) return;
const { index: messageIndex, message: messageToUpdate } = result;
const originalContent = messageToUpdate.content;
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
await DatabaseService.updateMessage(messageId, { content: newContent });
if (isFirstUserMessage && newContent.trim())
await conversationsStore.applyTitleFromContent(activeConv.id, newContent);
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
if (messagesToRemove.length > 0)
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
conversationsStore.sliceActiveMessages(messageIndex + 1);
conversationsStore.updateConversationTimestamp();
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const assistantMessage = await this.host.createAssistantMessage();
conversationsStore.addMessageToActive(assistantMessage);
await conversationsStore.updateCurrentNode(assistantMessage.id);
await this.host.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
assistantMessage,
undefined,
() => {
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), {
content: originalContent
});
}
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to update message:', error);
}
}
/**
* Open a fresh assistant turn anchored at the last tool result of a resolved
* agentic round and let streamChatCompletion route through runAgenticFlow.
* Used by continueAssistantMessage when classifyContinueIntent returns
* next_turn, meaning the target assistant already has its tool_calls paired
* with trailing tool results and the next thing to generate is a brand new
* turn rather than a token level continuation.
*/
private async continueAsNextAgenticTurn(anchorIndex: number): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
const anchor = conversationsStore.activeMessages[anchorIndex];
if (!anchor) return;
this.host.cancelPreEncode();
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const anchorMessage = findMessageById(allMessages, anchor.id);
if (!anchorMessage) {
this.host.setChatLoading(activeConv.id, false);
return;
}
const newAssistantMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId: activeConv.id,
model: null,
role: MessageRole.ASSISTANT,
timestamp: Date.now(),
toolCalls: '',
type: MessageType.TEXT
},
anchorMessage.id
);
await conversationsStore.updateCurrentNode(newAssistantMessage.id);
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
const conversationPath = filterByLeafNodeId(
allMessages,
anchorMessage.id,
false
) as DatabaseMessage[];
await this.host.streamChatCompletion(conversationPath, newAssistantMessage);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to continue agentic turn:', error);
this.host.setChatLoading(activeConv.id, false);
}
}
private async generateResponseForMessage(userMessageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
this.host.showErrorDialog(null);
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const conversationPath = filterByLeafNodeId(
allMessages,
userMessageId,
false
) as DatabaseMessage[];
const assistantMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId: activeConv.id,
model: null,
role: MessageRole.ASSISTANT,
timestamp: Date.now(),
toolCalls: '',
type: MessageType.TEXT
},
userMessageId
);
conversationsStore.addMessageToActive(assistantMessage);
await this.host.streamChatCompletion(conversationPath, assistantMessage);
} catch (error) {
console.error('Failed to generate response:', error);
this.host.setChatLoading(activeConv.id, false);
}
}
private getMessageByIdWithRole(
messageId: string,
expectedRole?: MessageRole
): { message: DatabaseMessage; index: number } | null {
const index = conversationsStore.findMessageIndex(messageId);
if (index === -1) return null;
const message = conversationsStore.activeMessages[index];
if (expectedRole && message.role !== expectedRole) return null;
return { index, message };
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
/**
* chatProcessingStore - Per-conversation processing state
*
* Owns the live processing snapshot shown while a conversation streams:
* token counts, tokens/sec, prompt progress. Updated from stream timings,
* restored from persisted message timings when a conversation loads.
*
* Composed under chatStore.processing; not exported from the stores barrel.
*/
import { MessageRole } from '$lib/enums';
// direct imports between stores, not via the barrel, to avoid circular deps
import { modelsStore } from '$lib/stores/models/index.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type {
ApiProcessingState,
ChatMessagePromptProgress,
ChatMessageTimings,
DatabaseMessage
} from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
interface ProcessingTimingData {
cache_n: number;
predicted_n: number;
predicted_per_second: number;
prompt_ms?: number;
prompt_n: number;
prompt_progress?: ChatMessagePromptProgress;
}
export class ChatProcessingStore {
private _activeConversationId = $state<string | null>(null);
private states = new SvelteMap<string, ApiProcessingState>();
/** Processing state of the conversation currently shown in the UI. */
activeState = $derived(
this._activeConversationId ? (this.states.get(this._activeConversationId) ?? null) : null
);
get activeConversationId(): string | null {
return this._activeConversationId;
}
/**
* Applies a stream timings event (tokens/sec + token counts) to the given
* conversation's processing state. Shared by the chat and continue flows.
*/
applyStreamTimings(
timings?: ChatMessageTimings,
promptProgress?: ChatMessagePromptProgress,
conversationId?: string
): void {
const tokensPerSecond =
timings?.predicted_ms && timings?.predicted_n
? (timings.predicted_n / timings.predicted_ms) * 1000
: 0;
this.updateFromTimings(
{
cache_n: timings?.cache_n || 0,
predicted_n: timings?.predicted_n || 0,
predicted_per_second: tokensPerSecond,
prompt_ms: timings?.prompt_ms,
prompt_n: timings?.prompt_n || 0,
prompt_progress: promptProgress
},
conversationId
);
}
getConversationIds(): string[] {
return Array.from(this.states.keys());
}
getState(conversationId: string): ApiProcessingState | null {
return this.states.get(conversationId) ?? null;
}
restoreFromMessages(messages: DatabaseMessage[], conversationId: string): void {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === MessageRole.ASSISTANT && message.timings) {
this.setState(
conversationId,
this.parseTimingData({
cache_n: message.timings.cache_n || 0,
predicted_n: message.timings.predicted_n || 0,
predicted_per_second:
message.timings.predicted_n && message.timings.predicted_ms
? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
: 0,
prompt_ms: message.timings.prompt_ms,
prompt_n: message.timings.prompt_n || 0
})
);
return;
}
}
}
setActiveConversation(conversationId: string | null): void {
this._activeConversationId = conversationId;
}
/** Passing null clears the state for the conversation. */
setState(conversationId: string, state: ApiProcessingState | null): void {
if (state === null) this.states.delete(conversationId);
else this.states.set(conversationId, state);
}
updateFromTimings(timingData: ProcessingTimingData, conversationId?: string): void {
const targetId = conversationId || this._activeConversationId;
if (targetId) {
this.setState(targetId, this.parseTimingData(timingData));
}
}
private getContextTotal(): number | null {
const activeConvId = this._activeConversationId;
const activeState = activeConvId ? this.getState(activeConvId) : null;
if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0)
return activeState.contextTotal;
if (serverStore.isRouterMode) {
const modelContextSize = modelsStore.selectedModelContextSize;
if (typeof modelContextSize === 'number' && modelContextSize > 0) {
return modelContextSize;
}
} else {
const propsContextSize = serverStore.contextSize;
if (typeof propsContextSize === 'number' && propsContextSize > 0) {
return propsContextSize;
}
}
return null;
}
private parseTimingData(timingData: ProcessingTimingData): ApiProcessingState {
const cacheTokens = timingData.cache_n || 0,
predictedTokens = timingData.predicted_n || 0,
promptMs = timingData.prompt_ms || undefined,
promptTokens = timingData.prompt_n || 0,
tokensPerSecond = timingData.predicted_per_second || 0;
const promptProgress = timingData.prompt_progress;
const contextTotal = this.getContextTotal();
const currentConfig = settingsStore.config;
const outputTokensMax = currentConfig.max_tokens || -1;
const contextUsed = promptTokens + cacheTokens + predictedTokens,
outputTokensUsed = predictedTokens;
const progressCache = promptProgress?.cache || 0,
progressActualDone = (promptProgress?.processed ?? 0) - progressCache,
progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
const progressPercent = promptProgress
? Math.round((progressActualDone / progressActualTotal) * 100)
: undefined;
return {
cacheTokens,
contextTotal,
contextUsed,
hasNextToken: predictedTokens > 0,
outputTokensMax,
outputTokensUsed,
progressPercent,
promptMs,
promptProgress,
promptTokens,
speculative: false,
status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
temperature: currentConfig.temperature ?? 0.8,
tokensDecoded: predictedTokens,
tokensPerSecond,
tokensRemaining: outputTokensMax - predictedTokens,
topP: currentConfig.top_p ?? 0.95
};
}
}
export const chatProcessingStore = new ChatProcessingStore();
@@ -0,0 +1,494 @@
/**
* ChatStreamManager - Server-side stream sessions for conversations
*
* Owns the attach lifecycle for streams that live on the server: discovery,
* replay from byte 0, and resume retry while the owning model loads. The
* remote-running snapshot it produces feeds the chat activity ledger
* (chatStore.activity), which owns the actual running-conv state. Created
* and owned by chatStore; the host exposes the per-conversation state setters.
*/
import { CONVERSATION_ID_SEPARATOR, STREAM_RESUME_RETRY_MS } from '$lib/constants';
import { MessageRole, MessageType, StreamConnectionState } from '$lib/enums';
import { ChatService } from '$lib/services/chat.service';
import { DatabaseService } from '$lib/services/database.service';
import type { ChatActivityStore } from '$lib/stores/chat/activity.svelte';
import type { ChatProcessingStore } from '$lib/stores/chat/processing.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import type { ApiStreamSession, ChatMessageTimings, DatabaseMessage } from '$lib/types';
import { streamIdentity } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/**
* The slice of chatStore the manager drives. Kept narrow on purpose so the
* manager cannot reach around the host's full surface; chatStore implements
* this structurally.
*/
export interface ChatStreamHost {
activity: ChatActivityStore;
processing: ChatProcessingStore;
chatStreamingStates: SvelteMap<
string,
{ response: string; messageId: string; model?: string | null }
>;
streamConnectionState: StreamConnectionState;
getOrCreateAbortController(convId: string): AbortController;
setChatLoading(convId: string, loading: boolean): void;
setChatStreaming(
convId: string,
response: string,
messageId: string,
model?: string | null
): void;
clearChatStreaming(convId: string, messageId?: string): void;
}
export class ChatStreamManager {
// in-flight discoverActiveStream guard, keyed by conv id
private discoveringConvs = new SvelteSet<string>();
// convs whose resume waits on a model load: their loading state belongs to the retry loop,
// so discoverActiveStream must not treat it as a live send and bail
private resumePendingConvs = new SvelteSet<string>();
// pending resume retry timers while an owning model loads, one per conv
private resumeRetryTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
/** Kill a pending resume retry, e.g. on explicit stop. */
cancelResumeRetry(convId: string): void {
const timer = this.resumeRetryTimers.get(convId);
if (timer !== undefined) {
clearTimeout(timer);
this.resumeRetryTimers.delete(convId);
}
this.resumePendingConvs.delete(convId);
}
constructor(private host: ChatStreamHost) {}
async discoverActiveStream(convId: string): Promise<void> {
if (!convId) return;
if (this.host.chatStreamingStates.has(convId)) return;
if (this.host.activity.isLocal(convId) && !this.resumePendingConvs.has(convId)) return;
// concurrency guard: another discover may already be running for this conv (typical race
// between mount and visibilitychange on tab switch). a second concurrent fetch on the same
// /v1/stream would duplicate every byte into the DB message, this guard bounces it
if (this.discoveringConvs.has(convId)) return;
this.discoveringConvs.add(convId);
try {
// the model is frozen at POST time, rebuild the exact conv::model identity from the
// 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,
modelsStore.selectedModelName
);
// primary path: ask the server which sessions exist for this identity
const serverTarget = await this.probeServerStream(streamId);
if (serverTarget) {
// pass the full server side identity (may carry a ::model suffix) so the GET routes
// straight to the owning session, no probe or fan out
await this.attachServerStream(convId, serverTarget.conversation_id);
return;
}
// fallback: local state remembers an interrupted byte offset for this conv, the server may
// still have a live session matching that identity (we just lost the bytes mid stream). retry
// with the frozen identity, the server probe inside attachServerStream tells us if it exists
if (!localState) {
return;
}
// quiet status probe first: a full attach flips the loading UI on every try, probing
// keeps the retry loop invisible while the owning model is still loading (503)
const status = await ChatService.probeResumeStatus(streamId);
if (status === 503) {
// make the wait visible: the empty assistant row persisted at send time renders
// the processing info, whose model load percentage flows from the models feed
this.resumePendingConvs.add(convId);
this.host.setChatLoading(convId, true);
if (!this.resumeRetryTimers.has(convId)) {
this.resumeRetryTimers.set(
convId,
setTimeout(() => {
this.resumeRetryTimers.delete(convId);
void this.discoverActiveStream(convId);
}, STREAM_RESUME_RETRY_MS)
);
}
return;
}
if (this.resumePendingConvs.delete(convId) && status !== 200) {
// the wait is over without a session to attach, drop the visible loading state
this.host.setChatLoading(convId, false);
}
if (status === 0) {
// transient network failure, the next mount or visibility change retries
return;
}
if (status !== 200) {
// the session is gone (stopped, TTL expired), nothing to resume anymore
ChatService.clearStreamState(convId);
return;
}
await this.attachServerStream(convId, streamId);
// if attachServerStream failed (session gone, TTL expired), clear the local state to avoid retrying forever
if (!this.host.chatStreamingStates.has(convId) && !this.host.activity.isLocal(convId)) {
ChatService.clearStreamState(convId);
}
} finally {
this.discoveringConvs.delete(convId);
}
}
/**
* Model frozen at send time for a stream awaiting resume, from the persisted stream state.
* The load progress indicator targets it after a reload, when the message row has no model
* yet and the dropdown selection may not be restored.
*/
getResumeModel(convId: string): string | null {
return ChatService.getStreamState(convId)?.model ?? null;
}
/**
* Resync the activity ledger's remote set from the backend. Called by the layout at mount and
* on visibilitychange, no polling. A snapshot semantic: stale entries for sessions that
* finalized while the browser was elsewhere are dropped naturally.
*/
async syncRemoteRunningStreams(): Promise<void> {
// the conversations store loads from IndexedDB asynchronously, the +layout onMount caller
// fires before that finishes. read ids straight from the DB so the result does not depend
// on the store init race, and the sidebar spinners light up at first paint for every conv
// the user owns even if it has not been hydrated into the store yet
let ids: string[];
try {
const all = await DatabaseService.getAllConversations();
ids = all.map((c) => c.id).filter((id) => !!id);
} catch (e) {
console.warn('syncRemoteRunningStreams DB read failed:', e);
return;
}
// only ask about conv ids the user already owns
if (ids.length === 0) {
this.host.activity.applyRemoteSnapshot([]);
return;
}
// rebuild the frozen conv::model identity per conv so a session started with a model still
// matches. the server response is mapped back to the bare id below for the sidebar set
const lookupIds = ids.map((id) =>
ChatService.resumeStreamIdentity(id, ChatService.getStreamState(id), null)
);
let sessions: ApiStreamSession[];
try {
sessions = await ChatService.lookupStreamSessions(lookupIds);
} catch (e) {
console.warn('syncRemoteRunningStreams lookup failed:', e);
return;
}
const running = new SvelteSet<string>();
for (const s of sessions) {
if (s && !s.is_done && typeof s.conversation_id === 'string' && s.conversation_id) {
// strip the optional ::model suffix, the sidebar set is keyed by the bare conv id
const sepIdx = s.conversation_id.indexOf(CONVERSATION_ID_SEPARATOR);
const bareId = sepIdx === -1 ? s.conversation_id : s.conversation_id.slice(0, sepIdx);
running.add(bareId);
}
}
this.host.activity.applyRemoteSnapshot(running);
}
private async attachServerStream(convId: string, streamId?: string): Promise<void> {
if (!convId) return;
if (this.host.chatStreamingStates.has(convId)) return;
// flip the spinner immediately, the user sees activity as soon as the conv becomes active
this.host.setChatLoading(convId, true);
// only set the active processing conv if we are looking at it, otherwise a background
// attach would steal the indicator from the conv the user is currently viewing
if (convId === conversationsStore.activeConversation?.id) {
this.host.processing.setActiveConversation(convId);
}
const unlock = () => {
this.host.setChatLoading(convId, false);
this.host.clearChatStreaming(convId);
};
// 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, modelsStore.selectedModelName);
let response: Response;
try {
response = await ChatService.fetchStreamReplay(id);
} catch (e) {
console.error(`attachServerStream replay failed for conv ${convId}:`, e);
unlock();
return;
}
// load the target conversation messages by id, not via the active store. when multiple
// attaches run in parallel the active store may reflect another conv and writing through
// its index mixes content across convs (CoT flicker, message bleed). by going through the
// DB we stay isolated, and only mirror into the active store when the attached conv is
// the one currently displayed
let messages: DatabaseMessage[];
try {
messages = await DatabaseService.getConversationMessages(convId);
} catch (e) {
console.error('attachServerStream load messages failed:', e);
unlock();
return;
}
// locate the slot to splice into, create a placeholder assistant message if there is none.
// we use the conv-scoped findLastAssistantIdx helpers, they only depend on the array
let targetIdx = this.findLastAssistantIdx(messages);
if (targetIdx === -1) {
const lastUserIdx = this.findLastUserIdx(messages);
if (lastUserIdx === -1) {
console.warn(
`attachServerStream: conv ${convId} has no user or assistant message, cannot splice`
);
unlock();
return;
}
try {
const placeholder = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId,
parent: messages[lastUserIdx].id,
role: MessageRole.ASSISTANT,
timestamp: Date.now(),
toolCalls: '',
type: MessageType.TEXT
} as Omit<DatabaseMessage, 'id'>,
messages[lastUserIdx].id
);
messages = [...messages, placeholder];
targetIdx = messages.length - 1;
// only push into the active store when this conv is the one displayed right now
if (convId === conversationsStore.activeConversation?.id) {
conversationsStore.addMessageToActive(placeholder);
}
} catch (e) {
console.error('attachServerStream placeholder creation failed:', e);
unlock();
return;
}
}
if (targetIdx === -1) {
unlock();
return;
}
const targetMessage = messages[targetIdx];
const targetMessageId = targetMessage.id;
// when the assistant slot already has content, the running session is a continue or
// another append flow and its buffer holds only the appended deltas. preserve the prefix
// and let the replay add to it. when the slot is empty the session buffer holds the whole
// message so we wipe and rebuild from byte 0
const existingContent = targetMessage.content ?? '';
const existingReasoning = targetMessage.reasoningContent ?? '';
const isAppendMode = existingContent.length > 0;
// helper: write to the active store only when the attached conv is currently displayed.
// the lookup by message id is robust to reordering of activeMessages, two parallel attaches
// can no longer step on each other's indices
const writeActive = (updates: Partial<DatabaseMessage>) => {
if (convId !== conversationsStore.activeConversation?.id) {
return;
}
const liveIdx = conversationsStore.findMessageIndex(targetMessageId);
if (liveIdx === -1) return;
conversationsStore.updateMessageAtIndex(liveIdx, updates);
};
if (!isAppendMode) {
writeActive({ content: '', reasoningContent: undefined });
}
// extract the model suffix, the resume calls in handleStreamResponse must reuse the model
// the session was tagged with, not the live dropdown
const sepIdx = id.indexOf(CONVERSATION_ID_SEPARATOR);
const attachedModel: string | null = sepIdx === -1 ? null : id.slice(sepIdx + 2);
this.host.setChatStreaming(convId, existingContent, targetMessageId, attachedModel);
const abortController = this.host.getOrCreateAbortController(convId);
let streamedContent = '';
let streamedReasoningContent = '';
const cleanup = () => {
unlock();
this.host.processing.setState(convId, null);
};
try {
await ChatService.handleStreamResponse(
response,
(chunk: string) => {
streamedContent += chunk;
const displayed = isAppendMode ? existingContent + streamedContent : streamedContent;
writeActive({ content: displayed });
this.host.setChatStreaming(convId, displayed, targetMessageId);
},
async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => {
const streamed = streamedContent || finalContent || '';
const streamedR = streamedReasoningContent || reasoningContent || '';
const content = isAppendMode ? existingContent + streamed : streamed;
const reasoning = isAppendMode ? existingReasoning + streamedR : streamedR;
// the DB write is the source of truth, mirror to the active store only when
// the conv is currently displayed
await DatabaseService.updateMessage(targetMessageId, {
content,
reasoningContent: reasoning || undefined,
timings,
toolCalls: toolCalls || ''
});
writeActive({
content,
reasoningContent: reasoning || undefined,
timings
});
cleanup();
},
(err: Error) => {
console.error('attachServerStream pipe error:', err);
cleanup();
},
(chunk: string) => {
streamedReasoningContent += chunk;
const displayed = isAppendMode
? existingReasoning + streamedReasoningContent
: streamedReasoningContent;
writeActive({ reasoningContent: displayed });
},
undefined,
undefined,
undefined,
undefined,
convId,
abortController.signal,
(connState: StreamConnectionState) => {
if (convId === conversationsStore.activeConversation?.id) {
this.host.streamConnectionState = connState;
}
},
attachedModel
);
} catch (e) {
console.error('attachServerStream pipe crashed:', e);
cleanup();
}
}
private findLastAssistantIdx(messages: DatabaseMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MessageRole.ASSISTANT) return i;
}
return -1;
}
private findLastUserIdx(messages: DatabaseMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MessageRole.USER) return i;
}
return -1;
}
/**
* Server side stream discovery, split in three pieces:
*
* probeServerStream(convId) -> hits POST /v1/streams/lookup with the conv id, returns the session to attach
* to or null. Pure read, no side effect, no UI lock. Safe to fire in parallel with anything.
*
* attachServerStream(convId) -> flips the spinner immediately, fetches the replay stream
* from byte 0, finds the assistant slot to splice into (creates a placeholder if the conv has
* no assistant message yet, for cross device or fresh local DB cases), and pipes the SSE bytes
* into the message via handleStreamResponse.
*
* discoverActiveStream(convId) -> probe + attach in one call. Used by callers that do not need
* to overlap the probe with other async work.
*
* The chat page in +page.svelte calls discoverActiveStream once the conversation is active
* (immediately if it already is, after loadConversation settles otherwise), and re-runs it on
* visibilitychange. Attaching only after the conversation is loaded gives the earliest
* possible time to spinner and avoids racing against an empty activeMessages array.
*/
private async probeServerStream(convId: string): Promise<ApiStreamSession | null> {
if (!convId) return null;
let sessions: ApiStreamSession[];
try {
sessions = await ChatService.lookupStreamSessions([convId]);
} catch (e) {
console.warn(`probeServerStream failed for conv ${convId}:`, e);
return null;
}
return ChatService.selectActiveStream(sessions);
}
}
@@ -0,0 +1,254 @@
/**
* ConversationPreferences - Per-chat options with global fallback
*
* Owns the options that resolve per conversation: MCP server overrides,
* reasoning effort, and the working directory. Cwd and reasoning effort are
* buffered as pending state and threaded into the next created conversation
* by the host; MCP server overrides edit the sparse `mcpServerOverrides`
* list on the active row (new-chat toggles edit the server's global flag).
* Created and owned by conversationsStore; the host owns the conversation
* rows these options persist onto.
*/
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import type { McpServerOverride } from '$lib/types/database';
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
function loadReasoningEffortDefault(): ReasoningEffort {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
try {
const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY);
return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT;
} catch {
return ReasoningEffort.DEFAULT;
}
}
/** Persist reasoning effort default to localStorage */
function saveReasoningEffortDefault(effort: ReasoningEffort): void {
if (typeof globalThis.localStorage === 'undefined') return;
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, effort);
}
/**
* The slice of conversationsStore the preferences read and write. Kept narrow
* on purpose so they cannot reach around the host's full surface;
* conversationsStore implements this structurally.
*/
export interface ConversationsPreferencesHost {
activeConversation: DatabaseConversation | null;
conversations: DatabaseConversation[];
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
}
export class ConversationPreferences {
/**
* Working directory picked on the empty new-chat screen, before any
* conversation exists. Consumed by `chatStore.sendMessage()`, which
* records it into chat history as a synthetic message on first send.
* Cleared by `loadConversation` and `clearActiveConversation` so a
* stale pick can't bleed onto an unrelated chat.
*/
pendingCwd = $state<string | null>(null);
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
constructor(private host: ConversationsPreferencesHost) {}
/**
* Gets the effective override list for the current conversation:
* one entry per configured server, resolved per server. The stored
* per-conversation list is sparse and only holds explicit toggles.
*/
getAllMcpServerOverrides(): McpServerOverride[] {
const overrides = this.host.activeConversation?.mcpServerOverrides;
return mcpStore.getServers().map((s) => {
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
return { enabled: override?.enabled ?? s.enabled, serverId: s.id };
});
}
/**
* Gets the effective MCP server override for a specific server.
* A per-conversation override wins when present; a server without one
* resolves to its `mcpServers[i].enabled` default.
*/
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
const override = this.host.activeConversation?.mcpServerOverrides?.find(
(o: McpServerOverride) => o.serverId === serverId
);
if (override) return override;
return this.getDefaultOverride(serverId);
}
/**
* Gets the effective reasoning effort for the active conversation.
* Returns the conversation override if set, otherwise the global default.
* DEFAULT means no override is sent and the server decides.
*/
getReasoningEffort(): ReasoningEffort {
if (this.host.activeConversation) {
if (this.host.activeConversation.reasoningEffort !== undefined) {
return this.host.activeConversation.reasoningEffort;
}
// conversations created before the tri-state store an explicit
// opt-out only as thinkingEnabled = false
if (this.host.activeConversation.thinkingEnabled === false) {
return ReasoningEffort.OFF;
}
}
return this.pendingReasoningEffort;
}
/** Checks if an MCP server is enabled for the active conversation. */
isMcpServerEnabledForChat(serverId: string): boolean {
const override = this.getMcpServerOverride(serverId);
return override?.enabled ?? false;
}
/** Removes MCP server override for the active conversation. */
async removeMcpServerOverride(serverId: string): Promise<void> {
await this.setMcpServerOverride(serverId, undefined);
}
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
resetPending(): void {
this.pendingReasoningEffort = loadReasoningEffortDefault();
this.pendingCwd = null;
}
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
*
* On the empty new-chat screen (no active conversation yet), the value
* is buffered into `pendingCwd` so the user can pick before
* sending the first message; `createConversation()` consumes it.
*
* @param value - Absolute server-side path to the working directory, or null to clear
*/
async setCwd(value: string | null): Promise<void> {
const trimmed = value?.trim() || undefined;
// No chat yet - buffer for the first chat the user creates.
if (!this.host.activeConversation) {
this.pendingCwd = trimmed ?? null;
return;
}
this.host.applyConversationUpdate(this.host.activeConversation.id, {
cwd: trimmed
});
await DatabaseService.updateConversation(this.host.activeConversation.id, {
cwd: trimmed
});
this.pendingCwd = null;
}
/**
* Sets or removes MCP server override for the active conversation.
* If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
* (the single source of truth for new-chat defaults).
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
if (!this.host.activeConversation) {
if (enabled !== undefined) {
mcpStore.updateServer(serverId, { enabled });
}
return;
}
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
const currentOverrides = (this.host.activeConversation.mcpServerOverrides || []).map(
(o: McpServerOverride) => ({
enabled: o.enabled,
serverId: o.serverId
})
);
let newOverrides: McpServerOverride[];
if (enabled === undefined) {
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
} else {
const existingIndex = currentOverrides.findIndex(
(o: McpServerOverride) => o.serverId === serverId
);
if (existingIndex >= 0) {
newOverrides = [...currentOverrides];
newOverrides[existingIndex] = { enabled, serverId };
} else {
newOverrides = [...currentOverrides, { enabled, serverId }];
}
}
await DatabaseService.updateConversation(this.host.activeConversation.id, {
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
});
this.host.applyConversationUpdate(this.host.activeConversation.id, {
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
});
}
/**
* Sets the reasoning effort for the active conversation.
* If no conversation exists, stores the global default.
* @param effort - The effort level ('default' | 'off' | 'low' | 'medium' | 'high' | 'max')
*/
async setReasoningEffort(effort: ReasoningEffort): Promise<void> {
if (!this.host.activeConversation) {
this.pendingReasoningEffort = effort;
saveReasoningEffortDefault(effort);
return;
}
this.host.applyConversationUpdate(this.host.activeConversation.id, {
reasoningEffort: effort
});
await DatabaseService.updateConversation(this.host.activeConversation.id, {
reasoningEffort: effort
});
}
/** Toggles MCP server enabled state for the active conversation. */
async toggleMcpServerForChat(serverId: string): Promise<void> {
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
await this.setMcpServerOverride(serverId, !currentEnabled);
}
/**
* Resolve the default enabled value for a server: its own `enabled`
* flag in `mcpServers`, so the global on/off state lives in one place.
*/
private getDefaultOverride(serverId: string): McpServerOverride | undefined {
const server = mcpStore.getServers().find((s) => s.id === serverId);
if (!server) return undefined;
return { enabled: server.enabled, serverId };
}
}
+2 -2
View File
@@ -34,11 +34,11 @@ class DeviceStore {
readonly isIOSDevice: boolean = false;
/** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */
readonly isIOSSafari: boolean = false;
/** PWA standalone mode: the page was launched from the home screen icon. */
isStandalone = $state(false);
/** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
* third-party iOS browsers (all of which share the WKWebView engine). */
readonly isWKWebView: boolean = false;
/** PWA standalone mode: the page was launched from the home screen icon. */
isStandalone = $state(false);
/** OS color scheme preference; the user override lives in settingsStore. */
readonly systemTheme = $state({ isDark: false });
+13 -15
View File
@@ -18,34 +18,32 @@
*/
// CHAT / MESSAGING
export { chatStore } from './chat.svelte';
export { chatStore } from './chat/index.svelte';
export { draftMessagesStore } from './draft-messages.svelte';
// AGENTIC (multi-turn tool orchestration)
export { agenticStore } from './agentic.svelte';
// CONVERSATIONS
export { conversationsStore } from './conversations.svelte';
export { draftMessagesStore } from './chat/drafts.svelte';
// CONTEXT STATS (active conversation context window usage)
export { contextStatsStore } from './context-stats.svelte';
export { contextStatsStore } from './chat/context-stats.svelte';
// AGENTIC (multi-turn tool orchestration)
export { agenticStore } from './agentic/index.svelte';
// CONVERSATIONS
export { conversationsStore } from './conversations/index.svelte';
// MCP
export { mcpStore } from './mcp.svelte';
export { mcpResourceStore } from './mcp-resources.svelte';
export { mcpStore } from './mcp/index.svelte';
// MODELS
export { modelsStore } from './models.svelte';
export { modelsStore } from './models/index.svelte';
// SERVER
export { serverStore } from './server.svelte';
// SETTINGS / UI PREFERENCES
export { settingsStore } from './settings.svelte';
export { settingsStore } from './settings/index.svelte';
export { settingsReferrer } from './settings-referrer.svelte';
export { settingsReferrer } from './settings/referrer.svelte';
export { permissionsStore } from './permissions.svelte';
+3 -3
View File
@@ -13,9 +13,9 @@
*/
// direct imports, not via the barrel, to avoid circular deps
import { conversationsStore } from './conversations.svelte';
import { conversationsStore } from './conversations/index.svelte';
import { permissionsStore } from './permissions.svelte';
import { settingsStore } from './settings.svelte';
import { settingsStore } from './settings/index.svelte';
import { toolsStore } from './tools.svelte';
import { versionStore } from './version.svelte';
import { browser } from '$app/environment';
@@ -33,7 +33,7 @@ export function initStores(): Promise<void> {
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
void conversationsStore.init();
void conversationsStore.initialize();
})();
return startup;
@@ -0,0 +1,298 @@
/**
* MCPHealthCheckManager - Health checks for MCP servers
*
* Owns per-server connectivity probes: connection reuse, capability
* snapshots, and promotion of a successful check to an active connection.
* Created and owned by mcpStore; the host owns the connection registry the
* probes draw from and promote into.
*/
import { DEFAULT_MCP_CONFIG } from '$lib/constants';
import { HealthCheckStatus, MCPConnectionPhase, MCPLogLevel } from '$lib/enums';
import { MCPService } from '$lib/services/mcp.service';
import type {
ClientCapabilities,
HealthCheckParams,
HealthCheckState,
MCPCapabilitiesInfo,
MCPConnection,
MCPConnectionLog,
MCPServerConfig,
ServerCapabilities
} from '$lib/types';
import { detectMcpTransportFromUrl } from '$lib/utils';
// module-level so the timestamp is not flagged as reactive state by prefer-svelte-reactivity
function createConnectionErrorLog(message: string): MCPConnectionLog {
return {
level: MCPLogLevel.ERROR,
message: `Connection failed: ${message}`,
phase: MCPConnectionPhase.ERROR,
timestamp: new Date()
};
}
/**
* The slice of mcpStore the probes drive. Kept narrow on purpose so the
* probes cannot reach around the host's full surface; mcpStore implements
* this structurally.
*/
export interface McpHealthHost {
autoReconnect(serverName: string): Promise<void>;
getExistingConnection(serverId: string): MCPConnection | undefined;
getRequestTimeoutMs(): number;
promoteHealthCheckToConnection(serverId: string, connection: MCPConnection): void;
registerServerConfig(name: string, config: MCPServerConfig): void;
removeConnection(serverId: string): void;
}
export class MCPHealthCheckManager {
private _checks = $state<Record<string, HealthCheckState>>({});
/** Raw per-server check states, for host-side capability scans. */
get checks(): Record<string, HealthCheckState> {
return this._checks;
}
clear(serverId: string): void {
const { [serverId]: _removed, ...rest } = this._checks;
this._checks = rest;
}
constructor(private host: McpHealthHost) {}
getState(serverId: string): HealthCheckState {
return this._checks[serverId] ?? { status: HealthCheckStatus.IDLE };
}
hasState(serverId: string): boolean {
return serverId in this._checks && this._checks[serverId].status !== HealthCheckStatus.IDLE;
}
/**
* Run a health check for a server.
* If the server already has an active connection, reuses it instead of creating a new one.
* If promoteToActive is true and server is enabled, the connection will be kept
* and promoted to an active connection instead of being disconnected.
*/
async run(server: HealthCheckParams, promoteToActive = false): Promise<void> {
const existingConnection = this.host.getExistingConnection(server.id);
if (existingConnection) {
// Reuse existing connection - just refresh tools list
try {
const tools = await MCPService.listTools(existingConnection);
const capabilities = this.buildCapabilitiesInfo(
existingConnection.serverCapabilities,
existingConnection.clientCapabilities
);
this.setState(server.id, {
capabilities,
connectionTimeMs: existingConnection.connectionTimeMs,
instructions: existingConnection.instructions,
logs: [],
protocolVersion: existingConnection.protocolVersion,
serverInfo: existingConnection.serverInfo,
status: HealthCheckStatus.SUCCESS,
tools: tools.map((tool) => ({
description: tool.description,
name: tool.name,
title: tool.title
})),
transportType: existingConnection.transportType
});
return;
} catch (error) {
console.warn(
`[MCPStore] Failed to reuse connection for ${server.id}, creating new one:`,
error
);
// Connection may be stale, remove it and create new one
this.host.removeConnection(server.id);
}
}
const trimmedUrl = server.url.trim();
const logs: MCPConnectionLog[] = [];
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
if (!trimmedUrl) {
this.setState(server.id, {
logs: [],
message: 'Please enter a server URL first.',
status: HealthCheckStatus.ERROR
});
return;
}
this.setState(server.id, {
logs: [],
phase: MCPConnectionPhase.TRANSPORT_CREATING,
status: HealthCheckStatus.CONNECTING
});
const timeoutMs = this.host.getRequestTimeoutMs();
const headers = this.parseHeaders(server.headers);
try {
const serverConfig: MCPServerConfig = {
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
headers,
requestTimeoutMs: timeoutMs,
transport: detectMcpTransportFromUrl(trimmedUrl),
url: trimmedUrl,
useProxy: server.useProxy
};
this.host.registerServerConfig(server.id, serverConfig);
const connection = await MCPService.connect(
server.id,
serverConfig,
DEFAULT_MCP_CONFIG.clientInfo,
DEFAULT_MCP_CONFIG.capabilities,
(phase, log) => {
currentPhase = phase;
logs.push(log);
this.setState(server.id, {
logs: [...logs],
phase,
status: HealthCheckStatus.CONNECTING
});
if (phase === MCPConnectionPhase.DISCONNECTED && promoteToActive) {
console.log(
`[MCPStore][${server.id}] Connection lost during health check, starting auto-reconnect`
);
this.host.autoReconnect(server.id);
}
}
);
const tools = connection.tools.map((tool) => ({
description: tool.description,
name: tool.name,
title: tool.title
}));
const capabilities = this.buildCapabilitiesInfo(
connection.serverCapabilities,
connection.clientCapabilities
);
this.setState(server.id, {
capabilities,
connectionTimeMs: connection.connectionTimeMs,
instructions: connection.instructions,
logs,
protocolVersion: connection.protocolVersion,
serverInfo: connection.serverInfo,
status: HealthCheckStatus.SUCCESS,
tools,
transportType: connection.transportType
});
if (promoteToActive && server.enabled) {
this.host.promoteHealthCheckToConnection(server.id, connection);
} else {
await MCPService.disconnect(connection);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
if (logs.at(-1)?.phase !== MCPConnectionPhase.ERROR) {
logs.push(createConnectionErrorLog(message));
}
this.setState(server.id, {
logs,
message,
phase: currentPhase,
status: HealthCheckStatus.ERROR
});
}
}
async runForServers(
servers: {
id: string;
enabled: boolean;
url: string;
headers?: string;
}[],
skipIfChecked = true,
promoteToActive = false
): Promise<void> {
const serversToCheck = skipIfChecked
? servers.filter((s) => !this.hasState(s.id) && s.url.trim())
: servers.filter((s) => s.url.trim());
if (serversToCheck.length === 0) {
return;
}
const BATCH_SIZE = 5;
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
await Promise.allSettled(batch.map((server) => this.run(server, promoteToActive)));
}
}
/**
* Builds capabilities info from server and client capabilities.
*/
private buildCapabilitiesInfo(
serverCaps?: ServerCapabilities,
clientCaps?: ClientCapabilities
): MCPCapabilitiesInfo {
return {
client: {
elicitation: clientCaps?.elicitation
? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url }
: undefined,
roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined,
sampling: !!clientCaps?.sampling,
tasks: !!clientCaps?.tasks
},
server: {
completions: !!serverCaps?.completions,
logging: !!serverCaps?.logging,
prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined,
resources: serverCaps?.resources
? {
listChanged: serverCaps.resources.listChanged,
subscribe: serverCaps.resources.subscribe
}
: undefined,
tasks: !!serverCaps?.tasks,
tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined
}
};
}
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
if (!headersJson?.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
return parsed as Record<string, string>;
} catch {
console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
private setState(serverId: string, state: HealthCheckState): void {
this._checks = { ...this._checks, [serverId]: state };
}
}
File diff suppressed because it is too large Load Diff
@@ -38,32 +38,40 @@ function generateAttachmentId(): string {
}
class MCPResourceStore {
private _serverResources = $state<SvelteMap<string, MCPServerResources>>(new SvelteMap());
private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
private _attachments = $state<MCPResourceAttachment[]>([]);
private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
private _isLoading = $state(false);
private _serverResources = $state<SvelteMap<string, MCPServerResources>>(new SvelteMap());
private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
get serverResources(): Map<string, MCPServerResources> {
return this._serverResources;
}
get cachedResources(): Map<string, MCPCachedResource> {
return this._cachedResources;
}
get subscriptions(): Map<string, MCPResourceSubscription> {
return this._subscriptions;
get attachmentCount(): number {
return this._attachments.length;
}
get attachments(): MCPResourceAttachment[] {
return this._attachments;
}
get cachedResources(): Map<string, MCPCachedResource> {
return this._cachedResources;
}
get hasAttachments(): boolean {
return this._attachments.length > 0;
}
get isLoading(): boolean {
return this._isLoading;
}
get serverResources(): Map<string, MCPServerResources> {
return this._serverResources;
}
get subscriptions(): Map<string, MCPResourceSubscription> {
return this._subscriptions;
}
get totalResourceCount(): number {
let count = 0;
@@ -84,86 +92,183 @@ class MCPResourceStore {
return count;
}
get attachmentCount(): number {
return this._attachments.length;
}
/**
* Add a resource attachment to the current chat context
*/
addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
const attachment: MCPResourceAttachment = {
id: generateAttachmentId(),
loading: true,
resource
};
get hasAttachments(): boolean {
return this._attachments.length > 0;
this._attachments = [...this._attachments, attachment];
console.log(`[MCPResources] Added attachment: ${resource.uri}`);
return attachment;
}
/**
*
*
* Server Resources Management
*
*
* Register a subscription for a resource
*/
/**
* Set resources for a server (called after listResources)
*/
setServerResources(
serverName: string,
resources: MCPResource[],
templates: MCPResourceTemplate[]
): void {
this._serverResources.set(serverName, {
error: undefined,
lastFetched: new Date(),
loading: false,
resources,
addSubscription(uri: string, serverName: string): void {
this._subscriptions.set(uri, {
serverName,
templates
subscribedAt: new Date(),
uri
});
console.log(
`[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
);
}
/**
* Set loading state for a server's resources
*/
setServerLoading(serverName: string, loading: boolean): void {
const existing = this._serverResources.get(serverName);
const cached = this._cachedResources.get(uri);
if (existing) {
this._serverResources.set(serverName, { ...existing, loading });
} else {
this._serverResources.set(serverName, {
error: undefined,
loading,
resources: [],
serverName,
templates: []
});
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: true });
}
console.log(`[MCPResources] Added subscription: ${uri}`);
}
/**
* Set error state for a server's resources
* Cache resource content after reading
*/
setServerError(serverName: string, error: string): void {
const existing = this._serverResources.get(serverName);
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
// Enforce cache size limit
if (this._cachedResources.size >= MCP_RESOURCE_CACHE.MAX_ENTRIES) {
const oldestKey = this._cachedResources.keys().next().value;
if (existing) {
this._serverResources.set(serverName, { ...existing, error, loading: false });
} else {
this._serverResources.set(serverName, {
error,
loading: false,
resources: [],
serverName,
templates: []
});
if (oldestKey) {
this._cachedResources.delete(oldestKey);
}
}
this._cachedResources.set(resource.uri, {
content,
fetchedAt: new Date(),
resource,
subscribed: this._subscriptions.has(resource.uri)
});
console.log(`[MCPResources] Cached content for: ${resource.uri}`);
}
/**
* Get resources for a specific server
* Clear all state (e.g., on full reset)
*/
getServerResources(serverName: string): MCPServerResources | undefined {
return this._serverResources.get(serverName);
clear(): void {
this._serverResources.clear();
this._cachedResources.clear();
this._subscriptions.clear();
this._attachments = [];
this._isLoading = false;
console.log(`[MCPResources] Cleared all state`);
}
/**
* Clear all attachments
*/
clearAttachments(): void {
this._attachments = [];
console.log(`[MCPResources] Cleared all attachments`);
}
/**
* Clear all cached content
*/
clearCache(): void {
this._cachedResources.clear();
console.log(`[MCPResources] Cleared all cached content`);
}
/**
* Clear resources for a server (e.g., when disconnected)
*/
clearServerResources(serverName: string): void {
this._serverResources.delete(serverName);
for (const [uri, cached] of this._cachedResources) {
if (cached.resource.serverName === serverName) {
this._cachedResources.delete(uri);
}
}
for (const [uri, sub] of this._subscriptions) {
if (sub.serverName === serverName) {
this._subscriptions.delete(uri);
}
}
console.log(`[MCPResources][${serverName}] Cleared all resources`);
}
/**
* Find resource info by URI across all servers
*/
findResourceByUri(uri: string): MCPResourceInfo | undefined {
const normalizedUri = normalizeResourceUri(uri);
for (const [serverName, serverRes] of this._serverResources) {
const resource =
serverRes.resources.find((r) => r.uri === uri) ??
serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri);
if (resource) {
return {
annotations: resource.annotations,
description: resource.description,
icons: resource.icons,
mimeType: resource.mimeType,
name: resource.name,
serverName,
title: resource.title,
uri: resource.uri
};
}
}
return undefined;
}
/**
* Find server name for a resource URI
*/
findServerForUri(uri: string): string | undefined {
for (const [serverName, serverRes] of this._serverResources) {
if (serverRes.resources.some((r) => r.uri === uri)) {
return serverName;
}
}
return undefined;
}
/**
* Get resource content as text for chat context
* Formats content for inclusion in LLM prompts
*/
formatAttachmentsForContext(): string {
if (this._attachments.length === 0) return '';
const parts: string[] = [];
for (const attachment of this._attachments) {
if (attachment.error) continue;
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
const serverName = attachment.resource.serverName;
for (const content of attachment.content) {
if ('text' in content && content.text) {
parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`);
} else if ('blob' in content && content.blob) {
// For binary content, just note it exists
parts.push(
`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
);
}
}
}
return parts.join('');
}
/**
@@ -215,57 +320,10 @@ class MCPResourceStore {
}
/**
* Clear resources for a server (e.g., when disconnected)
* Get attachment by ID
*/
clearServerResources(serverName: string): void {
this._serverResources.delete(serverName);
// Also clear cached content for this server's resources
for (const [uri, cached] of this._cachedResources) {
if (cached.resource.serverName === serverName) {
this._cachedResources.delete(uri);
}
}
// Clear subscriptions for this server
for (const [uri, sub] of this._subscriptions) {
if (sub.serverName === serverName) {
this._subscriptions.delete(uri);
}
}
console.log(`[MCPResources][${serverName}] Cleared all resources`);
}
/**
*
*
* Resource Content Caching
*
*
*/
/**
* Cache resource content after reading
*/
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
// Enforce cache size limit
if (this._cachedResources.size >= MCP_RESOURCE_CACHE.MAX_ENTRIES) {
// Remove oldest entry
const oldestKey = this._cachedResources.keys().next().value;
if (oldestKey) {
this._cachedResources.delete(oldestKey);
}
}
this._cachedResources.set(resource.uri, {
content,
fetchedAt: new Date(),
resource,
subscribed: this._subscriptions.has(resource.uri)
});
console.log(`[MCPResources] Cached content for: ${resource.uri}`);
getAttachment(attachmentId: string): MCPResourceAttachment | undefined {
return this._attachments.find((att) => att.id === attachmentId);
}
/**
@@ -276,7 +334,6 @@ class MCPResourceStore {
if (!cached) return undefined;
// Check if cache is still valid
const age = Date.now() - cached.fetchedAt.getTime();
if (age > MCP_RESOURCE_CACHE.TTL_MS && !cached.subscribed) {
@@ -290,100 +347,22 @@ class MCPResourceStore {
}
/**
* Invalidate cached content for a resource (e.g., on update notification)
* Get resources for a specific server
*/
invalidateCache(uri: string): void {
this._cachedResources.delete(uri);
console.log(`[MCPResources] Invalidated cache for: ${uri}`);
}
/**
* Clear all cached content
*/
clearCache(): void {
this._cachedResources.clear();
console.log(`[MCPResources] Cleared all cached content`);
}
/**
*
*
* Subscriptions
*
*
*/
/**
* Register a subscription for a resource
*/
addSubscription(uri: string, serverName: string): void {
this._subscriptions.set(uri, {
serverName,
subscribedAt: new Date(),
uri
});
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: true });
}
console.log(`[MCPResources] Added subscription: ${uri}`);
}
/**
* Remove a subscription for a resource
*/
removeSubscription(uri: string): void {
this._subscriptions.delete(uri);
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: false });
}
console.log(`[MCPResources] Removed subscription: ${uri}`);
}
/**
* Check if a resource is subscribed
*/
isSubscribed(uri: string): boolean {
return this._subscriptions.has(uri);
}
/**
* Handle resource update notification
*/
handleResourceUpdate(uri: string): void {
// Invalidate cache so next read gets fresh content
this.invalidateCache(uri);
// Update subscription last update time
const sub = this._subscriptions.get(uri);
if (sub) {
this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
}
console.log(`[MCPResources] Resource updated: ${uri}`);
getServerResources(serverName: string): MCPServerResources | undefined {
return this._serverResources.get(serverName);
}
/**
* Handle resources list changed notification
*/
handleResourcesListChanged(serverName: string): void {
// Mark server resources as needing refresh
const existing = this._serverResources.get(serverName);
if (existing) {
this._serverResources.set(serverName, {
...existing,
lastFetched: undefined // Mark as stale
lastFetched: undefined
});
}
@@ -399,60 +378,27 @@ class MCPResourceStore {
*/
/**
* Add a resource attachment to the current chat context
* Handle resource update notification
*/
addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
const attachment: MCPResourceAttachment = {
id: generateAttachmentId(),
loading: true,
resource
};
handleResourceUpdate(uri: string): void {
// Invalidate cache so next read gets fresh content
this.invalidateCache(uri);
this._attachments = [...this._attachments, attachment];
console.log(`[MCPResources] Added attachment: ${resource.uri}`);
const sub = this._subscriptions.get(uri);
return attachment;
if (sub) {
this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
}
console.log(`[MCPResources] Resource updated: ${uri}`);
}
/**
* Update attachment with fetched content
* Invalidate cached content for a resource (e.g., on update notification)
*/
updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, content, error: undefined, loading: false } : att
);
}
/**
* Update attachment with error
*/
updateAttachmentError(attachmentId: string, error: string): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, error, loading: false } : att
);
}
/**
* Remove an attachment
*/
removeAttachment(attachmentId: string): void {
this._attachments = this._attachments.filter((att) => att.id !== attachmentId);
console.log(`[MCPResources] Removed attachment: ${attachmentId}`);
}
/**
* Clear all attachments
*/
clearAttachments(): void {
this._attachments = [];
console.log(`[MCPResources] Cleared all attachments`);
}
/**
* Get attachment by ID
*/
getAttachment(attachmentId: string): MCPResourceAttachment | undefined {
return this._attachments.find((att) => att.id === attachmentId);
invalidateCache(uri: string): void {
this._cachedResources.delete(uri);
console.log(`[MCPResources] Invalidated cache for: ${uri}`);
}
/**
@@ -467,12 +413,34 @@ class MCPResourceStore {
}
/**
*
*
* Utility Methods
*
*
* Check if a resource is subscribed
*/
isSubscribed(uri: string): boolean {
return this._subscriptions.has(uri);
}
/**
* Remove an attachment
*/
removeAttachment(attachmentId: string): void {
this._attachments = this._attachments.filter((att) => att.id !== attachmentId);
console.log(`[MCPResources] Removed attachment: ${attachmentId}`);
}
/**
* Remove a subscription for a resource
*/
removeSubscription(uri: string): void {
this._subscriptions.delete(uri);
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: false });
}
console.log(`[MCPResources] Removed subscription: ${uri}`);
}
/**
* Set global loading state
@@ -482,88 +450,62 @@ class MCPResourceStore {
}
/**
* Find resource info by URI across all servers
* Set error state for a server's resources
*/
findResourceByUri(uri: string): MCPResourceInfo | undefined {
const normalizedUri = normalizeResourceUri(uri);
setServerError(serverName: string, error: string): void {
const existing = this._serverResources.get(serverName);
for (const [serverName, serverRes] of this._serverResources) {
const resource =
serverRes.resources.find((r) => r.uri === uri) ??
serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri);
if (resource) {
return {
annotations: resource.annotations,
description: resource.description,
icons: resource.icons,
mimeType: resource.mimeType,
name: resource.name,
serverName,
title: resource.title,
uri: resource.uri
};
}
if (existing) {
this._serverResources.set(serverName, { ...existing, error, loading: false });
} else {
this._serverResources.set(serverName, {
error,
loading: false,
resources: [],
serverName,
templates: []
});
}
return undefined;
}
/**
* Find server name for a resource URI
* Set loading state for a server's resources
*/
findServerForUri(uri: string): string | undefined {
for (const [serverName, serverRes] of this._serverResources) {
if (serverRes.resources.some((r) => r.uri === uri)) {
return serverName;
}
}
setServerLoading(serverName: string, loading: boolean): void {
const existing = this._serverResources.get(serverName);
return undefined;
if (existing) {
this._serverResources.set(serverName, { ...existing, loading });
} else {
this._serverResources.set(serverName, {
error: undefined,
loading,
resources: [],
serverName,
templates: []
});
}
}
/**
* Clear all state (e.g., on full reset)
* Set resources for a server (called after listResources)
*/
clear(): void {
this._serverResources.clear();
this._cachedResources.clear();
this._subscriptions.clear();
this._attachments = [];
this._isLoading = false;
console.log(`[MCPResources] Cleared all state`);
}
/**
* Get resource content as text for chat context
* Formats content for inclusion in LLM prompts
*/
formatAttachmentsForContext(): string {
if (this._attachments.length === 0) return '';
const parts: string[] = [];
for (const attachment of this._attachments) {
if (attachment.error) continue;
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
const serverName = attachment.resource.serverName;
for (const content of attachment.content) {
if ('text' in content && content.text) {
parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`);
} else if ('blob' in content && content.blob) {
// For binary content, just note it exists
parts.push(
`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
);
}
}
}
return parts.join('');
setServerResources(
serverName: string,
resources: MCPResource[],
templates: MCPResourceTemplate[]
): void {
this._serverResources.set(serverName, {
error: undefined,
lastFetched: new Date(),
loading: false,
resources,
serverName,
templates
});
console.log(
`[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
);
}
/**
@@ -605,6 +547,24 @@ class MCPResourceStore {
return extras;
}
/**
* Update attachment with fetched content
*/
updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, content, error: undefined, loading: false } : att
);
}
/**
* Update attachment with error
*/
updateAttachmentError(attachmentId: string, error: string): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, error, loading: false } : att
);
}
}
export const mcpResourceStore = new MCPResourceStore();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
/**
* modelsStore - Model management for MODEL and ROUTER modes
*
* Owns model lists, selection, favorites and load/unload state. Composes the
* per-model props cache (modalities, thinking detection) as
* {@link ModelsStore.props} and the /models/sse status feed as
* {@link ModelsStore.status}; tracks which conversations use which models.
*/
import { FAVORITE_MODELS_LOCALSTORAGE_KEY } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte';
import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { getConversationModel } from '$lib/utils/conversation-utils';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
class ModelsStore implements ModelPropsHost, ModelStatusHost {
error = $state<string | null>(null);
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
loading = $state(false);
models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]);
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
updating = $state(false);
/** Per-model props cache, modalities and thinking detection, composed here. */
private _props = new ModelPropsManager(this);
/** Load/unload operations and the /models/sse status feed, composed here. */
private _status = new ModelStatusManager(this);
// Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
private inflightFetch: Promise<void> | null = null;
/**
* Model the active conversation view resolves to. Router mode: the user's
* selection first, then the conversation's own model. Otherwise the single
* served model, from the models list or the server props as a fallback.
*/
get activeModelId(): string | null {
if (!serverStore.isRouterMode) {
return this.models.length > 0 ? this.models[0].model : this.singleModelName;
}
if (this.selectedModelId) {
const selected = this.models.find((m) => m.id === this.selectedModelId);
if (selected) return selected.model;
}
const conversationModel = getConversationModel(conversationsStore.activeMessages);
if (conversationModel) {
const model = this.models.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
}
get loadedModelIds(): string[] {
return this.routerModels
.filter(
(m) =>
m.status.value === ServerModelStatus.LOADED ||
m.status.value === ServerModelStatus.SLEEPING
)
.map((m) => m.id);
}
get props() {
return this._props;
}
get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null;
return this.models.find((m) => m.id === this.selectedModelId) ?? null;
}
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
return this.props.getModelContextSize(this.selectedModelName);
}
/**
* Get model name in MODEL mode (single model).
* Extracts from model_path or model_alias from server props.
* In ROUTER mode, returns null (model is per-conversation).
*/
get singleModelName(): string | null {
if (serverStore.isRouterMode) return null;
const props = serverStore.props;
if (props?.model_alias) return props.model_alias;
if (!props?.model_path) return null;
return props.model_path.split(/(\\|\/)/).pop() || null;
}
get status() {
return this._status;
}
clearSelection(): void {
this.selectedModelId = null;
this.selectedModelName = null;
}
/**
* Auto-selects the first available model if none is selected.
* Prioritizes:
* 1. Model from active conversation's last assistant response (if loaded)
* 2. Model from active conversation's last assistant response (if not loaded)
* 3. First loaded model (not from active conversation)
* 4. A favorite model
* 5. First available model
*/
async ensureFirstModelSelected(): Promise<void> {
if (this.selectedModelName) return;
const availableModels = this.getVisibleModels();
if (availableModels.length === 0) return;
// Try to select model from last assistant response first
const lastModel = this.getModelFromLastAssistantResponse();
if (lastModel) {
const lastModelOption = availableModels.find((m) => m.model === lastModel);
if (lastModelOption) {
await this.selectModelById(lastModelOption.id);
if (this.isModelLoaded(lastModel)) {
await this.props.fetchModelProps(lastModel);
}
return;
}
}
// Try a loaded model first
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
if (loadedModel) {
await this.selectModelById(loadedModel.id);
await this.props.fetchModelProps(loadedModel.model);
return;
}
// Try loading a favorite model
const favorite = this.favoriteModelIds.values().next()?.value;
if (favorite) {
await this.selectModelById(favorite);
return;
}
// Fall back to the first available model
await this.selectModelById(availableModels[0].id);
}
/**
* Fetch list of models from server and detect server role.
* Also fetches modalities for MODEL mode (single model).
*/
async fetch(force = false): Promise<void> {
if (this.inflightFetch) return this.inflightFetch;
if (this.models.length > 0 && !force) return;
this.inflightFetch = this.runFetch();
try {
await this.inflightFetch;
} finally {
this.inflightFetch = null;
}
}
/**
* Fetch router models with full metadata (ROUTER mode only).
* No-op in router mode fetch() already calls listRouter() internally.
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
*/
async fetchRouterModels(): Promise<void> {
if (!serverStore.isRouterMode) return;
try {
const response = await ModelsService.listRouter();
this.routerModels = response.data;
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
this.selectModelById(visible[0].id);
}
} catch (error) {
console.warn('Failed to fetch router models:', error);
this.routerModels = [];
}
}
findModelById(modelId: string): ModelOption | null {
return this.models.find((model) => model.id === modelId) ?? null;
}
findModelByName(modelName: string): ModelOption | null {
return (
this.models.find(
(model) =>
model.model === modelName || model.id === modelName || model.aliases?.includes(modelName)
) ?? null
);
}
/**
* Gets the model name from the last assistant message in the active conversation.
* Used by both the chat page and settings page to maintain model consistency.
*/
getModelFromLastAssistantResponse(): string | null {
const messages = conversationsStore.activeMessages;
if (!messages || messages.length === 0) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].model) {
return messages[i].model;
}
}
return null;
}
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value ?? null;
}
hasModel(modelName: string): boolean {
return this.models.some((model) => model.model === modelName);
}
isFavorite(modelId: string): boolean {
return this.favoriteModelIds.has(modelId);
}
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
return (
model?.status.value === ServerModelStatus.LOADED ||
model?.status.value === ServerModelStatus.SLEEPING
);
}
async selectModelById(modelId: string): Promise<void> {
if (!modelId || this.updating) return;
if (this.selectedModelId === modelId) return;
const option = this.models.find((model) => model.id === modelId);
if (!option) throw new Error('Selected model is not available');
this.updating = true;
this.error = null;
try {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
} finally {
this.updating = false;
}
}
/**
* Select a model by its model name (used for syncing with conversation model).
*/
selectModelByName(modelName: string): void {
const option = this.models.find((model) => model.model === modelName);
if (option) {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
}
}
/**
* Auto-selects the model from the last assistant response if available and loaded.
* Returns true if a model was selected, false otherwise.
*/
async selectModelFromLastAssistantResponse(): Promise<boolean> {
const lastModel = this.getModelFromLastAssistantResponse();
if (!lastModel || this.selectedModelName === lastModel) return false;
const matchingModel = this.models.find((option) => option.model === lastModel);
if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
try {
await this.selectModelById(matchingModel.id);
console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`);
return true;
} catch (error) {
console.warn('[modelsStore] Failed to automatically select model from last message:', error);
return false;
}
}
toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
return candidate && candidate.trim().length > 0 ? candidate : id;
}
toggleFavorite(modelId: string): void {
const next = new SvelteSet(this.favoriteModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.favoriteModelIds = next;
try {
localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save favorite models to local storage');
}
}
/**
* Build ModelOption[] from an API response.
* Both MODEL and ROUTER modes share the same mapping logic;
* they differ only in which endpoint is called.
*/
private buildModelOptions(
response: ApiModelListResponse | ApiRouterModelsListResponse
): ModelOption[] {
return response.data.map((item: ApiModelDataEntry, index: number) => {
const details = response.models?.[index];
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const modelId = details?.model || item.id;
return {
aliases: item.aliases ?? [],
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
description: details?.description,
details: details?.details,
id: item.id,
meta: item.meta ?? null,
modalities: this.props.buildArchitectureModalities(item.architecture),
model: modelId,
name: this.toDisplayName(displayNameSource),
parsedId: ModelsService.parseModelId(modelId),
tags: item.tags ?? []
};
});
}
/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
private async fetchModelModeInternal(): Promise<ModelOption[]> {
const response = await ModelsService.list();
return this.buildModelOptions(response);
}
/**
* Filter to models visible in the UI (ui !== false).
*/
private getVisibleModels(): ModelOption[] {
return this.models.filter((option) => this.props.getModelProps(option.model)?.ui !== false);
}
private loadFavoritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favorite models from local storage');
return new Set();
}
}
private async runFetch(): Promise<void> {
this.loading = true;
this.error = null;
try {
if (!serverStore.props) {
await serverStore.fetch();
}
const router = serverStore.isRouterMode;
if (router) {
const response = await ModelsService.listRouter();
this.routerModels = response.data;
this.models = this.buildModelOptions(response);
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
this.selectModelById(visible[0].id);
}
} else {
this.models = await this.fetchModelModeInternal();
}
} catch (error) {
this.models = [];
this.error = error instanceof Error ? error.message : 'Failed to load models';
throw error;
} finally {
this.loading = false;
}
}
}
export const modelsStore = new ModelsStore();
@@ -0,0 +1,273 @@
/**
* ModelPropsManager - Per-model props cache, modalities and thinking detection
*
* Owns the /props?model=<id> cache with TTL, the modality views over it,
* and chat-template thinking detection. Created and owned by modelsStore;
* the host owns the model lists that fetched modalities are mirrored onto.
*
* **API Inconsistency Workaround:**
* In MODEL mode, `/props` returns modalities for the single model.
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
*/
import { MODEL_PROPS_CACHE } from '$lib/constants';
import { FileTypeCategory, ModelModality } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
// direct imports between stores, not via the barrel, to avoid circular deps
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 { TTLCache } from '$lib/utils/cache-ttl';
import { detectThinkingSupport } from '$lib/utils/chat-template-thinking-detector';
import { SvelteSet } from 'svelte/reactivity';
/**
* The slice of modelsStore the manager reads. Kept narrow on purpose so it
* cannot reach around the host's full surface; modelsStore implements this
* structurally.
*/
export interface ModelPropsHost {
/** Model rows the manager mirrors fetched modalities onto. */
models: ModelOption[];
readonly selectedModelName: string | null;
readonly loadedModelIds: string[];
isModelLoaded(modelId: string): boolean;
}
export class ModelPropsManager {
/** Version counter for the cache - bumped on writes so $derived consumers recompute. */
cacheVersion = $state(0);
/**
* Model-specific props cache with TTL.
* Key: modelId, Value: props data including modalities.
*/
private cache = new TTLCache<string, ApiLlamaCppServerProps>({
maxEntries: MODEL_PROPS_CACHE.MAX_ENTRIES,
ttlMs: MODEL_PROPS_CACHE.TTL_MS
});
private fetching = new SvelteSet<string>();
/**
* Whether the selected model's chat template supports thinking/reasoning.
* Uses heuristic detection on the model's chat_template from /props.
*
* - MODEL mode: the global /props already describes the single loaded model,
* so its chat_template is used directly and no per-model cache is involved
* - ROUTER mode: fetches /props?model=<id> for the selected model (cached),
* triggering an async fetch if not yet cached
*/
get supportsThinking(): boolean {
if (!serverStore.isRouterMode) {
return detectThinkingSupport(serverStore.props?.chat_template ?? '');
}
const modelId = this.host.selectedModelName;
if (!modelId) return false;
if (!this.cache.get(modelId)) {
this.fetchModelProps(modelId);
}
const props = this.getModelProps(modelId);
return detectThinkingSupport(props?.chat_template ?? '');
}
/** Map the router modalities, the only source available while a model is not loaded. */
buildArchitectureModalities(
architecture: ApiModelDataEntry['architecture']
): ModelModalities | undefined {
if (!architecture) return undefined;
const inputs = architecture.input_modalities;
return {
audio: inputs.includes(FileTypeCategory.AUDIO),
video: inputs.includes(FileTypeCategory.VIDEO),
vision: inputs.includes(FileTypeCategory.IMAGE)
};
}
/**
* Check if a specific model supports thinking.
* In MODEL mode the global /props describes the single loaded model.
* In ROUTER mode, fetches model props if not cached.
*/
checkModelSupportsThinking(modelId: string): boolean {
if (!serverStore.isRouterMode) {
return detectThinkingSupport(serverStore.props?.chat_template ?? '');
}
if (!modelId) return false;
if (!this.cache.get(modelId)) {
this.fetchModelProps(modelId);
}
const props = this.getModelProps(modelId);
return detectThinkingSupport(props?.chat_template ?? '');
}
constructor(private host: ModelPropsHost) {}
/** Fetch modalities for all loaded models from /props endpoint. */
async fetchModalitiesForLoadedModels(): Promise<void> {
const loadedModelIds = this.host.loadedModelIds;
if (loadedModelIds.length === 0) return;
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
try {
const results = await Promise.all(propsPromises);
this.host.models = this.host.models.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
if (modelIndex === -1) return model;
const props = results[modelIndex];
if (!props?.modalities) return model;
return { ...model, modalities: this.buildModalities(props.modalities) };
});
this.cacheVersion++;
} catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error);
}
}
/**
* Fetch props for a specific model from /props endpoint.
* Uses caching to avoid redundant requests.
*
* In ROUTER mode, this only fetches props if the model is loaded,
* since unloaded models return 400 from /props endpoint.
*
* @param modelId - Model identifier to fetch props for
* @returns Props data or null if fetch failed or model not loaded
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
const cached = this.cache.get(modelId);
if (cached) return cached;
if (serverStore.isRouterMode && !this.host.isModelLoaded(modelId)) {
return null;
}
if (this.fetching.has(modelId)) return null;
this.fetching.add(modelId);
try {
const props = await PropsService.fetchForModel(modelId);
this.cache.set(modelId, props);
this.cacheVersion++;
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
return null;
} finally {
this.fetching.delete(modelId);
}
}
getModelContextSize(modelId: string): number | null {
const props = this.getModelProps(modelId);
const nCtx = props?.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' ? nCtx : null;
}
getModelModalities(modelId: string): ModelModalities | null {
if (!serverStore.isRouterMode && serverStore.props?.modalities) {
return this.buildModalities(serverStore.props.modalities);
}
const model = this.host.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
}
const props = this.cache.get(modelId);
if (props?.modalities) {
return this.buildModalities(props.modalities);
}
return null;
}
getModelModalitiesArray(modelId: string): ModelModality[] {
const modalities = this.getModelModalities(modelId);
if (!modalities) return [];
const result: ModelModality[] = [];
if (modalities.vision) result.push(ModelModality.VISION);
if (modalities.audio) result.push(ModelModality.AUDIO);
if (modalities.video) result.push(ModelModality.VIDEO);
return result;
}
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
return this.cache.get(modelId);
}
isModelPropsFetching(modelId: string): boolean {
return this.fetching.has(modelId);
}
modelSupportsAudio(modelId: string): boolean {
return this.getModelModalities(modelId)?.audio ?? false;
}
modelSupportsVideo(modelId: string): boolean {
return this.getModelModalities(modelId)?.video ?? false;
}
modelSupportsVision(modelId: string): boolean {
return this.getModelModalities(modelId)?.vision ?? false;
}
/**
* Update modalities for a specific model.
* Called when a model is loaded or when we need fresh modality data.
*/
async updateModelModalities(modelId: string): Promise<void> {
const props = await this.fetchModelProps(modelId);
if (!props?.modalities) return;
this.host.models = this.host.models.map((model) =>
model.model === modelId
? { ...model, modalities: this.buildModalities(props.modalities!) }
: model
);
this.cacheVersion++;
}
private buildModalities(
modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
): ModelModalities {
return {
audio: modalities.audio ?? false,
video: modalities.video ?? false,
vision: modalities.vision ?? false
};
}
}
@@ -0,0 +1,278 @@
/**
* ModelStatusManager - Model load/unload operations and the /models/sse feed
*
* Owns the status feed subscription, load progress tracking, and the
* awaiters that settle load/unload operations. The feed drives status and
* progress, so it replaces any post-operation polling. Created and owned by
* modelsStore; the host owns the router model rows the feed updates.
*/
import { ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
/**
* The slice of modelsStore the manager drives. Kept narrow on purpose so it
* cannot reach around the host's full surface; modelsStore implements this
* structurally.
*/
export interface ModelStatusHost {
error: string | null;
readonly props: ModelPropsManager;
/** Router model rows the status feed updates. */
routerModels: ApiModelDataEntry[];
fetchRouterModels(): Promise<void>;
isModelLoaded(modelId: string): boolean;
toDisplayName(id: string): string;
}
export class ModelStatusManager {
private loadingStates = new SvelteMap<string, boolean>();
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
// /models/sse feed state, the single source of truth for status and load progress
private statusAbort: AbortController | null = null;
private statusReaderActive = false;
private statusWaiters = new SvelteMap<
string,
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
>();
constructor(private host: ModelStatusHost) {}
async ensureLoaded(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
await this.load(modelId);
}
/**
* Current load progress for a model, or null when not loading.
*/
getLoadProgress(modelId: string): ModelLoadProgress | null {
return this.loadProgress.get(modelId) ?? null;
}
isOperationInProgress(modelId: string): boolean {
return this.loadingStates.get(modelId) ?? false;
}
async load(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
if (this.loadingStates.get(modelId)) return;
this.loadingStates.set(modelId, true);
this.host.error = null;
// the feed drives completion, so it must be live before the request
this.subscribe();
const reachedLoaded = this.waitForStatus(modelId, ServerModelStatus.LOADED);
reachedLoaded.catch(() => {});
try {
await ModelsService.load(modelId);
await reachedLoaded;
toast.success(`Model loaded: ${this.host.toDisplayName(modelId)}`);
} catch (error) {
this.rejectStatus(modelId, error instanceof Error ? error : new Error('load failed'));
this.host.error = error instanceof Error ? error.message : 'Failed to load model';
toast.error(`Failed to load model: ${this.host.toDisplayName(modelId)}`);
throw error;
} finally {
this.loadingStates.set(modelId, false);
}
}
/**
* Open the /models/sse feed and keep it live with auto reconnect.
* Idempotent and router mode only.
*/
subscribe(): void {
if (this.statusReaderActive) return;
if (!serverStore.isRouterMode) return;
this.statusReaderActive = true;
this.statusAbort = new AbortController();
void this.runStatusReader(this.statusAbort.signal);
}
async unload(modelId: string): Promise<void> {
if (!this.host.isModelLoaded(modelId)) return;
if (this.loadingStates.get(modelId)) return;
this.loadingStates.set(modelId, true);
this.host.error = null;
this.subscribe();
const reachedUnloaded = this.waitForStatus(modelId, ServerModelStatus.UNLOADED);
reachedUnloaded.catch(() => {});
try {
await ModelsService.unload(modelId);
await reachedUnloaded;
toast.info(`Model unloaded: ${this.host.toDisplayName(modelId)}`);
} catch (error) {
this.rejectStatus(modelId, error instanceof Error ? error : new Error('unload failed'));
this.host.error = error instanceof Error ? error.message : 'Failed to unload model';
toast.error(`Failed to unload model: ${this.host.toDisplayName(modelId)}`);
throw error;
} finally {
this.loadingStates.set(modelId, false);
}
}
/**
* Close the /models/sse feed and drop transient progress.
*/
unsubscribe(): void {
this.statusReaderActive = false;
this.statusAbort?.abort();
this.statusAbort = null;
this.loadProgress.clear();
}
/**
* Apply a status envelope: update the model row, track or clear progress,
* settle any pending load or unload awaiter.
*/
private applyModelStatus(event: ApiModelsSseEvent): void {
const model = event.model;
const data = event.data;
if (!model || !data?.status) return;
const status = data.status;
this.setRouterModelStatus(model, status);
if (status === ServerModelStatus.LOADING) {
if (data.progress) this.loadProgress.set(model, data.progress);
} else {
this.loadProgress.delete(model);
}
if (status === ServerModelStatus.LOADED) {
void this.host.props.updateModelModalities(model);
}
const failed =
status === ServerModelStatus.FAILED ||
(status === ServerModelStatus.UNLOADED && (data.exit_code ?? 0) !== 0);
if (failed) {
this.rejectStatus(model, new Error(`Model failed: ${this.host.toDisplayName(model)}`));
return;
}
this.settleStatus(model, status);
}
/**
* Route one feed record by event kind. Only the status_* events carry a
* status payload, models_reload triggers a list refresh, model_remove drops
* the row, download_* belong to the download surface, not here.
*/
private applyStatusEvent(event: ApiModelsSseEvent): void {
switch (event.event) {
case ServerModelsSseEventType.STATUS_CHANGE:
case ServerModelsSseEventType.MODEL_STATUS:
case ServerModelsSseEventType.STATUS_UPDATE:
this.applyModelStatus(event);
break;
case ServerModelsSseEventType.MODELS_RELOAD:
void this.host.fetchRouterModels();
break;
case ServerModelsSseEventType.MODEL_REMOVE:
this.removeRouterModel(event.model);
break;
case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
break;
}
}
/**
* Reject and drop the awaiter for a model.
*/
private rejectStatus(modelId: string, error: Error): void {
const waiter = this.statusWaiters.get(modelId);
if (waiter) {
this.statusWaiters.delete(modelId);
waiter.reject(error);
}
}
/**
* Drop a model row reported gone by the feed and settle its awaiters.
*/
private removeRouterModel(modelId: string): void {
if (this.host.routerModels.findIndex((m) => m.id === modelId) === -1) return;
this.host.routerModels = this.host.routerModels.filter((m) => m.id !== modelId);
this.loadProgress.delete(modelId);
this.rejectStatus(modelId, new Error(`Model removed: ${this.host.toDisplayName(modelId)}`));
}
/**
* Read the feed and reconnect until unsubscribed.
*/
private async runStatusReader(signal: AbortSignal): Promise<void> {
await ModelsService.watchModelEvents(signal, (event) => this.applyStatusEvent(event));
}
/**
* Update one model row status in place, reassigning to trigger reactivity.
*/
private setRouterModelStatus(modelId: string, status: ServerModelStatus): void {
const idx = this.host.routerModels.findIndex((m) => m.id === modelId);
if (idx === -1) return;
const current = this.host.routerModels[idx];
if (current.status.value === status) return;
const next = [...this.host.routerModels];
next[idx] = { ...current, status: { ...current.status, value: status } };
this.host.routerModels = next;
}
/**
* Resolve and drop the awaiter when the model reaches its target status.
*/
private settleStatus(modelId: string, status: ServerModelStatus): void {
const waiter = this.statusWaiters.get(modelId);
if (waiter && waiter.target === status) {
this.statusWaiters.delete(modelId);
waiter.resolve();
}
}
/**
* Register an awaiter that resolves when the feed reports target status.
* One operation runs per model at a time, so one awaiter per model is kept.
*/
private waitForStatus(modelId: string, target: ServerModelStatus): Promise<void> {
return new Promise((resolve, reject) => {
this.statusWaiters.set(modelId, { reject, resolve, target });
});
}
}
+28 -20
View File
@@ -1,3 +1,11 @@
/**
* permissionsStore - Allowed tool permissions
*
* Owns the set of tools the user has permanently allowed, persisted to
* localStorage. The agentic loop's permission gates consult it to run a
* tool without prompting.
*/
import { browser } from '$app/environment';
import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
@@ -5,6 +13,24 @@ import { SvelteSet } from 'svelte/reactivity';
class PermissionsStore {
private _tools = $state(new SvelteSet<string>());
get tools(): ReadonlySet<string> {
return this._tools;
}
allowTool(key: string): void {
this._tools.add(key);
this.persist();
}
allowTools(keys: string[]): void {
for (const key of keys) this._tools.add(key);
this.persist();
}
hasTool(key: string): boolean {
return this._tools.has(key);
}
/**
* Load persisted permissions. Called by initStores() after migrations
* have run.
@@ -29,30 +55,12 @@ class PermissionsStore {
}
}
get tools(): ReadonlySet<string> {
return this._tools;
}
hasTool(key: string): boolean {
return this._tools.has(key);
}
allowTool(key: string): void {
this._tools.add(key);
this._persist();
}
allowTools(keys: string[]): void {
for (const key of keys) this._tools.add(key);
this._persist();
}
revokeTool(key: string): void {
this._tools.delete(key);
this._persist();
this.persist();
}
private _persist(): void {
private persist(): void {
try {
localStorage.setItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY, JSON.stringify([...this._tools]));
} catch (err) {
+44 -84
View File
@@ -1,79 +1,57 @@
/**
* serverStore - Server connection state, configuration and role detection
*
* Owns the connection state and properties fetched from /props, plus MODEL
* vs ROUTER role detection and server-wide generation defaults. Uses
* PropsService for the /props fetch.
*/
import { ServerRole } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
import { ApiError } from '$lib/utils';
const LOADING_RETRY_INTERVAL_MS = 1000;
/**
* serverStore - Server connection state, configuration, and role detection
*
* This store manages the server connection state and properties fetched from `/props`.
* It provides reactive state for server configuration and role detection.
*
* **Architecture & Relationships:**
* - **PropsService**: Stateless service for fetching `/props` data
* - **serverStore** (this class): Reactive store for server state
* - **modelsStore**: Independent store for model management (uses PropsService directly)
*
* **Key Features:**
* - **Server State**: Connection status, loading, error handling
* - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Default Params**: Server-wide generation defaults
*/
class ServerStore {
/**
*
*
* State
*
*
*/
props = $state<ApiLlamaCppServerProps | null>(null);
loading = $state(false);
error = $state<string | null>(null);
status = $state<number | null>(null);
loading = $state(false);
props = $state<ApiLlamaCppServerProps | null>(null);
role = $state<ServerRole | null>(null);
status = $state<number | null>(null);
private fetchPromise: Promise<void> | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
/**
*
*
* Getters
*
*
*/
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
}
get contextSize(): number | null {
const nCtx = this.props?.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' ? nCtx : null;
}
get uiSettings(): Record<string, string | number | boolean> | undefined {
return this.props?.ui_settings ?? this.props?.webui_settings;
}
get isRouterMode(): boolean {
return this.role === ServerRole.ROUTER;
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
}
get isModelMode(): boolean {
return this.role === ServerRole.MODEL;
}
/**
*
*
* Data Handling
*
*
*/
get isRouterMode(): boolean {
return this.role === ServerRole.ROUTER;
}
get uiSettings(): Record<string, string | number | boolean> | undefined {
return this.props?.ui_settings ?? this.props?.webui_settings;
}
clear(): void {
this.clearRetryTimer();
this.props = null;
this.error = null;
this.status = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
}
/**
* @param background - Set by the automatic "still loading" poll. Skips the
@@ -124,14 +102,20 @@ class ServerStore {
await fetchPromise;
}
clear(): void {
this.clearRetryTimer();
this.props = null;
this.error = null;
this.status = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
private clearRetryTimer(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
if (this.role !== newRole) {
this.role = newRole;
console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
}
}
private scheduleRetry(): void {
@@ -142,30 +126,6 @@ class ServerStore {
this.fetch({ background: true });
}, LOADING_RETRY_INTERVAL_MS);
}
private clearRetryTimer(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
/**
*
*
* Utilities
*
*
*/
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
if (this.role !== newRole) {
this.role = newRole;
console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
}
}
}
export const serverStore = new ServerStore();
@@ -1,12 +0,0 @@
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
let _url = $state<string>(SETTINGS_FALLBACK_EXIT_ROUTE);
export const settingsReferrer = {
get url() {
return _url;
},
set url(value: string) {
_url = value;
}
};
@@ -1,45 +1,17 @@
/**
* settingsStore - Application configuration and theme management
*
* This store manages all application settings including AI model parameters, UI preferences,
* and theme configuration. It provides persistent storage through localStorage with reactive
* state management using Svelte 5 runes.
*
* **Architecture & Relationships:**
* - **settingsStore** (this class): Configuration state management
* - Manages AI model parameters (temperature, max tokens, etc.)
* - Handles theme switching and persistence
* - Provides localStorage synchronization
* - Offers reactive configuration access
*
* - **ChatService**: Reads model parameters for API requests
* - **UI Components**: Subscribe to theme and configuration changes
*
* **Key Features:**
* - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty
* - **Theme Management**: Auto, light, dark theme switching
* - **Persistence**: Automatic localStorage synchronization
* - **Reactive State**: Svelte 5 runes for automatic UI updates
* - **Default Handling**: Graceful fallback to defaults for missing settings
* - **Batch Updates**: Efficient multi-setting updates
* - **Reset Functionality**: Restore defaults for individual or all settings
*
* **Configuration Categories:**
* - Generation parameters (temperature, tokens, sampling)
* - UI preferences (theme, display options)
* - System settings (model selection, prompts)
* - Advanced options (seed, penalties, context handling)
* Owns generation parameters, UI preferences and theme, persisted to
* localStorage with Svelte 5 runes. Applies the admin's server ui_settings
* as defaults on first visit; sampling parameters sync with the server via
* ParameterSyncService.
*/
import { browser } from '$app/environment';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
SETTINGS_KEYS,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { SETTING_CONFIG_DEFAULT, SETTINGS_KEYS } from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { SettingsService } from '$lib/services/settings.service';
import { deviceStore } from '$lib/stores/device.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
@@ -53,14 +25,6 @@ import {
import { setMode } from 'mode-watcher';
class SettingsStore {
/**
*
*
* State
*
*
*/
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set());
@@ -69,29 +33,182 @@ class SettingsStore {
// application of server ui_settings defaults for new users.
private isFirstVisit = false;
canSyncParameter(key: string): boolean {
return ParameterSyncService.canSyncParameter(key);
}
/**
*
*
* Utilities (private helpers)
*
*
* Clear all user overrides (for debugging)
*/
/**
* Helper method to get server defaults with null safety
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
*
*
* Lifecycle
*
*
* Export all settings as a versioned JSON-compatible object.
* The export captures the full config (excluding sensitive values like API key)
* and user overrides. Sensitive fields are filtered out for security by default.
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
*/
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
// Build config excluding sensitive data unless user opts in
const configToExport: Record<string, string | number | boolean | undefined> =
includeSensitiveData
? { ...this.config }
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
// Handle MCP servers: exclude custom headers unless user opts in
if ('mcpServers' in configToExport && !includeSensitiveData) {
try {
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
Record<string, unknown>
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
return server;
});
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
delete (configToExport as Record<string, unknown>).mcpServers;
}
}
return {
config: configToExport,
timestamp: Date.now(),
userOverrides: Array.from(this.userOverrides),
version: 1
};
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
* Prioritizes Server defaults from /props, falls back to UI defaults
*/
forceSyncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
const value =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, value);
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
this.userOverrides.delete(key);
}
this.saveConfig();
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get diff between current settings and server defaults
*/
getParameterDiff() {
const serverDefaults = this.getServerDefaults();
if (Object.keys(serverDefaults).length === 0) return {};
const configAsRecord = configToParameterRecord(
this.config,
ParameterSyncService.getSyncableParameterKeys()
);
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Get parameter information including source for a specific parameter
*/
getParameterInfo(key: string) {
const propsDefaults = this.getServerDefaults();
const currentValue = getConfigValue(this.config, key);
return ParameterSyncService.getParameterInfo(
key,
currentValue ?? '',
propsDefaults,
this.userOverrides
);
}
/**
* Import settings from a previously exported object.
* Restores config (including theme) and user overrides.
* @param data - The exported settings object
*/
importSettings(data: SettingsExportType): void {
if (!browser) return;
if (!data || !data.config) {
throw new Error('Invalid settings data: missing config');
}
// Restore config (theme is included in config)
this.config = {
...SETTING_CONFIG_DEFAULT,
...data.config
};
// Restore user overrides (derived state — may be stale if server defaults differ)
this.userOverrides = new Set(data.userOverrides ?? []);
// Persist to localStorage
this.saveConfig();
// Apply theme for immediate visual feedback
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
console.log('Settings imported successfully');
}
/**
* Initialize the settings store by loading from localStorage.
@@ -112,165 +229,14 @@ class SettingsStore {
}
/**
* Load configuration from localStorage
* Returns default values for missing keys to prevent breaking changes
* Reset all settings to defaults.
*/
private loadConfig() {
if (!browser) return;
resetAll() {
this.resetConfig();
try {
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
// First visit: no stored config yet. Server ui_settings apply once in
// this state, then the user's config diverges freely.
this.isFirstVisit = storedConfigRaw === null;
const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes
this.config = {
...SETTING_CONFIG_DEFAULT,
...savedVal
};
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
}
// Load user overrides
const savedOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
);
this.userOverrides = new Set(savedOverrides);
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
this.config = { ...SETTING_CONFIG_DEFAULT };
this.userOverrides = new Set();
}
this.resetTheme();
}
/**
* Migrate the legacy un-namespaced "theme" localStorage key into config.
* Previously theme was stored separately in localStorage("theme") now it lives
* inside the config object alongside all other settings.
* After migration the legacy key is removed.
*/
private migrateLegacyTheme() {
if (!browser) return;
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme) {
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
localStorage.removeItem('theme');
this.saveConfig();
setMode(legacyTheme as ColorMode);
}
}
/**
*
*
* Config Updates
*
*
*/
/**
* Update a specific configuration setting
* @param key - The configuration key to update
* @param value - The new value for the configuration key
*/
updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
this.config[key] = value;
if (ParameterSyncService.canSyncParameter(key as string)) {
const propsDefaults = this.getServerDefaults();
const propsDefault = propsDefaults[key as string];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key as string);
} else {
this.userOverrides.add(key as string);
}
}
}
this.saveConfig();
}
/**
* Update multiple configuration settings at once
* @param updates - Object containing the configuration updates
*/
updateMultipleConfig(updates: Partial<SettingsConfigType>) {
Object.assign(this.config, updates);
const propsDefaults = this.getServerDefaults();
for (const [key, value] of Object.entries(updates)) {
if (ParameterSyncService.canSyncParameter(key)) {
const propsDefault = propsDefaults[key];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key);
} else {
this.userOverrides.add(key);
}
}
}
}
this.saveConfig();
}
/**
* Save the current configuration to localStorage
*/
private saveConfig() {
if (!browser) return;
try {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
localStorage.setItem(
USER_OVERRIDES_LOCALSTORAGE_KEY,
JSON.stringify(Array.from(this.userOverrides))
);
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
}
/**
* Update the theme setting.
* @param newTheme - The new theme value
*/
updateTheme(newTheme: string) {
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
setMode(newTheme as ColorMode);
}
/**
*
*
* Reset
*
*
*/
/**
* Reset configuration to defaults
*/
@@ -280,25 +246,6 @@ class SettingsStore {
this.saveConfig();
}
/**
* Reset theme to default value.
* Theme is now stored inside the config object.
*/
resetTheme() {
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
}
/**
* Reset all settings to defaults.
*/
resetAll() {
this.resetConfig();
this.resetTheme();
}
/**
* Reset a parameter to Server default (or UI default if no Server default)
*/
@@ -321,12 +268,14 @@ class SettingsStore {
}
/**
*
*
* Server Sync
*
*
* Reset theme to default value.
* Theme is now stored inside the config object.
*/
resetTheme() {
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
}
/**
* Initialize settings with props defaults when server properties are first loaded
@@ -385,121 +334,32 @@ class SettingsStore {
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
* Prioritizes Server defaults from /props, falls back to UI defaults
* Update a specific configuration setting
* @param key - The configuration key to update
* @param value - The new value for the configuration key
*/
forceSyncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
this.config[key] = value;
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
if (ParameterSyncService.canSyncParameter(key as string)) {
const propsDefaults = this.getServerDefaults();
const propsDefault = propsDefaults[key as string];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key as string);
} else {
this.userOverrides.add(key as string);
}
}
this.userOverrides.delete(key);
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
const value =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, value);
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
this.userOverrides.delete(key);
}
this.saveConfig();
}
/**
*
*
* Utilities
*
*
*/
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
canSyncParameter(key: string): boolean {
return ParameterSyncService.canSyncParameter(key);
}
/**
* Get parameter information including source for a specific parameter
*/
getParameterInfo(key: string) {
const propsDefaults = this.getServerDefaults();
const currentValue = getConfigValue(this.config, key);
return ParameterSyncService.getParameterInfo(
key,
currentValue ?? '',
propsDefaults,
this.userOverrides
);
}
/**
* Get diff between current settings and server defaults
*/
getParameterDiff() {
const serverDefaults = this.getServerDefaults();
if (Object.keys(serverDefaults).length === 0) return {};
const configAsRecord = configToParameterRecord(
this.config,
ParameterSyncService.getSyncableParameterKeys()
);
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
*
*
@@ -509,73 +369,119 @@ class SettingsStore {
*/
/**
* Export all settings as a versioned JSON-compatible object.
* The export captures the full config (excluding sensitive values like API key)
* and user overrides. Sensitive fields are filtered out for security by default.
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
* Update multiple configuration settings at once
* @param updates - Object containing the configuration updates
*/
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
// Build config excluding sensitive data unless user opts in
const configToExport: Record<string, string | number | boolean | undefined> =
includeSensitiveData
? { ...this.config }
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
updateMultipleConfig(updates: Partial<SettingsConfigType>) {
Object.assign(this.config, updates);
// Handle MCP servers: exclude custom headers unless user opts in
if ('mcpServers' in configToExport && !includeSensitiveData) {
try {
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
Record<string, unknown>
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
const propsDefaults = this.getServerDefaults();
return server;
});
for (const [key, value] of Object.entries(updates)) {
if (ParameterSyncService.canSyncParameter(key)) {
const propsDefault = propsDefaults[key];
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
delete (configToExport as Record<string, unknown>).mcpServers;
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key);
} else {
this.userOverrides.add(key);
}
}
}
}
return {
config: configToExport,
timestamp: Date.now(),
userOverrides: Array.from(this.userOverrides),
version: 1
};
this.saveConfig();
}
/**
* Import settings from a previously exported object.
* Restores config (including theme) and user overrides.
* @param data - The exported settings object
* Update the theme setting.
* @param newTheme - The new theme value
*/
importSettings(data: SettingsExportType): void {
updateTheme(newTheme: string) {
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
setMode(newTheme as ColorMode);
}
/**
*
*
* Utilities (private helpers)
*
*
*/
/**
* Helper method to get server defaults with null safety
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
}
/**
* Load configuration from localStorage via the persistence service.
* Returns default values for missing keys to prevent breaking changes.
*/
private loadConfig() {
if (!browser) return;
if (!data || !data.config) {
throw new Error('Invalid settings data: missing config');
}
const {
config: savedVal,
isFirstVisit,
userOverrides: savedOverrides
} = SettingsService.loadConfig();
// Restore config (theme is included in config)
// First visit: no stored config yet. Server ui_settings apply once in
// this state, then the user's config diverges freely.
this.isFirstVisit = isFirstVisit;
// Merge with defaults to prevent breaking changes
this.config = {
...SETTING_CONFIG_DEFAULT,
...data.config
...savedVal
};
// Restore user overrides (derived state — may be stale if server defaults differ)
this.userOverrides = new Set(data.userOverrides ?? []);
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
}
// Persist to localStorage
this.saveConfig();
// Load user overrides
this.userOverrides = new Set(savedOverrides);
}
// Apply theme for immediate visual feedback
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
/**
* Migrate the legacy un-namespaced "theme" localStorage key into config.
* Previously theme was stored separately in localStorage("theme") now it lives
* inside the config object alongside all other settings.
* After migration the legacy key is removed.
*/
private migrateLegacyTheme() {
if (!browser) return;
console.log('Settings imported successfully');
const legacyTheme = SettingsService.migrateLegacyTheme();
if (legacyTheme) {
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
this.saveConfig();
setMode(legacyTheme as ColorMode);
}
}
/**
* Save the current configuration to localStorage via the persistence service.
*/
private saveConfig() {
if (!browser) return;
SettingsService.saveConfig(this.config, Array.from(this.userOverrides));
}
}
@@ -0,0 +1,19 @@
/**
* settingsReferrer - Remembers the settings route to return to after exit
*
* Tracks the last settings section the user was on so the app can return
* there after a fallback exit. Standalone reactive value, no host.
*/
import { ROUTES } from '$lib/constants';
let _url = $state<string>(ROUTES.SETTINGS_EXIT);
export const settingsReferrer = {
get url() {
return _url;
},
set url(value: string) {
_url = value;
}
};
+436 -427
View File
@@ -1,3 +1,12 @@
/**
* toolsStore - Tool registry and enablement
*
* Owns the server tool listing (with working-directory resolution), built-in
* browser tools, MCP tools and per-tool enablement, exposed as a unified
* tool set for the LLM and the tools UI. Consumed by the agentic loop and
* the chat flows.
*/
import { browser } from '$app/environment';
import {
buildBrowserInfoToolDefinition,
@@ -18,9 +27,9 @@ import {
} 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 } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { buildSandboxToolDefinition } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
@@ -28,273 +37,18 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
class ToolsStore {
private _serverTools = $state<OpenAIToolDefinition[]>([]);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
private _error = $state<string | null>(null);
private _loading = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
private _serverTools = $state<OpenAIToolDefinition[]>([]);
private _toolsEndpointUnreachable = $state(false);
// server tools that resolve their paths against the working directory,
// as declared by the server in its `/tools` listing
private _cwdAwareTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
private cwdAwareTools = $state(new SvelteSet<string>());
/**
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
*/
initialize(): void {
// 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);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
for (const key of parsed) {
if (typeof key === 'string') this._disabledTools.add(key);
}
}
}
} catch (err) {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
this.fetchServerTools();
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledTools])
);
} catch {
// ignore storage errors
}
}
private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.BROWSER:
return `browser:${name}`;
default:
return `server:${name}`;
}
}
private inferTypeFromDefault(value: unknown): string | undefined {
if (typeof value === 'string') return 'string';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
if (Array.isArray(value)) return 'array';
if (value !== null && typeof value === 'object') return 'object';
return undefined;
}
/**
* Recursively normalize a JSON Schema object: infers `type` from `default`
* for properties / items that omit it, and descends into nested `properties`
* and `items`. Returns a new object -- does not mutate the input.
*/
private normalizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema;
const normalized: Record<string, unknown> = { ...schema };
if (normalized.properties && typeof normalized.properties === 'object') {
const props = normalized.properties as Record<string, Record<string, unknown>>;
const normalizedProps: Record<string, Record<string, unknown>> = {};
for (const [key, prop] of Object.entries(props)) {
if (!prop || typeof prop !== 'object') {
normalizedProps[key] = prop;
continue;
}
const normalizedProp: Record<string, unknown> = { ...prop };
if (!normalizedProp.type && normalizedProp.default !== undefined) {
const inferred = this.inferTypeFromDefault(normalizedProp.default);
if (inferred) normalizedProp.type = inferred;
}
if (normalizedProp.properties) {
Object.assign(
normalizedProp,
this.normalizeJsonSchema(normalizedProp as Record<string, unknown>)
);
}
if (normalizedProp.items && typeof normalizedProp.items === 'object') {
normalizedProp.items = this.normalizeJsonSchema(
normalizedProp.items as Record<string, unknown>
);
}
normalizedProps[key] = normalizedProp;
}
normalized.properties = normalizedProps;
}
return normalized;
}
private mcpDefinition(
name: string,
description: string | undefined,
schema?: Record<string, unknown>
): OpenAIToolDefinition {
return {
function: {
description,
name,
parameters: schema ?? { properties: {}, required: [], type: JsonSchemaType.OBJECT }
},
type: ToolCallType.FUNCTION
};
}
get serverTools(): OpenAIToolDefinition[] {
return this._serverTools;
}
get serverHome(): string | null {
return this._serverHome ?? null;
}
get mcpTools(): OpenAIToolDefinition[] {
return this.mcpEntries().map((e) => e.definition);
}
get browserTools(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [buildGetDatetimeToolDefinition()];
if (settingsStore.config.jsSandboxEnabled) {
tools.push(buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled));
}
const readMedia = this.readMediaTool();
if (readMedia) tools.push(readMedia);
// provide browser's get_info tool if server doesn't provide one
if (!this.hasServerTool(BuiltInTool.SERVER_GET_INFO)) {
tools.push(buildBrowserInfoToolDefinition());
}
return tools;
}
private hasServerTool(name: BuiltInTool): boolean {
return this._serverTools.some((def) => def.function.name === name);
}
/**
* `read_media` runs in the browser on top of the server's `read_file`, so it
* exists only when that tool is served and the active model can perceive the
* bytes. The server cannot make this call - it does not know which model the
* conversation uses.
*/
private readMediaTool(): OpenAIToolDefinition | null {
if (!this.hasServerTool(BuiltInTool.SERVER_READ_FILE)) return null;
const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
if (!model) return null;
const vision = modelsStore.modelSupportsVision(model);
const audio = modelsStore.modelSupportsAudio(model);
if (!vision && !audio) return null;
return buildReadMediaToolDefinition(vision, audio);
}
get customTools(): OpenAIToolDefinition[] {
const raw = settingsStore.config.customJson;
if (!raw || typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(t: unknown): t is OpenAIToolDefinition =>
typeof t === 'object' &&
t !== null &&
'type' in t &&
(t as OpenAIToolDefinition).type === 'function' &&
'function' in t &&
typeof (t as OpenAIToolDefinition).function?.name === 'string'
);
} catch {
return [];
}
}
/** Normalize MCP tools from live connections when available, fall back to health check data */
private mcpEntries(): {
serverId: string;
serverName: string;
definition: OpenAIToolDefinition;
}[] {
const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
properties: {},
required: [],
type: JsonSchemaType.OBJECT
};
out.push({
definition: {
function: {
description: tool.description,
name: tool.name,
parameters: this.normalizeJsonSchema(rawSchema)
},
type: ToolCallType.FUNCTION
},
serverId,
serverName
});
}
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
out.push({
definition: this.mcpDefinition(tool.name, tool.description),
serverId,
serverName
});
}
}
}
return out;
get allToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools.map((t) => t.definition);
}
/** Canonical flat list of tool entries with source metadata and stable keys, deduped by key */
@@ -353,6 +107,97 @@ class ToolsStore {
return entries;
}
get browserTools(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [buildGetDatetimeToolDefinition()];
if (settingsStore.config.jsSandboxEnabled) {
tools.push(buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled));
}
const readMedia = this.readMediaTool();
if (readMedia) tools.push(readMedia);
// provide browser's get_info tool if server doesn't provide one
if (!this.hasServerTool(BuiltInTool.SERVER_GET_INFO)) {
tools.push(buildBrowserInfoToolDefinition());
}
return tools;
}
get customTools(): OpenAIToolDefinition[] {
const raw = settingsStore.config.customJson;
if (!raw || typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(t: unknown): t is OpenAIToolDefinition =>
typeof t === 'object' &&
t !== null &&
'type' in t &&
(t as OpenAIToolDefinition).type === 'function' &&
'function' in t &&
typeof (t as OpenAIToolDefinition).function?.name === 'string'
);
} catch {
return [];
}
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
get error(): string | null {
return this._error;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the user.
*/
get hasEnabledCwdTools(): boolean {
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this.cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
/** Check if there are any enabled tools available (server, MCP, or custom) */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
}
get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
get loading(): boolean {
return this._loading;
}
get mcpTools(): OpenAIToolDefinition[] {
return this.mcpEntries().map((e) => e.definition);
}
get serverHome(): string | null {
return this._serverHome ?? null;
}
get serverTools(): OpenAIToolDefinition[] {
return this._serverTools;
}
/** Tools grouped by category for tree display, derived from the canonical entries */
get toolGroups(): ToolGroup[] {
const groups: ToolGroup[] = [];
@@ -382,16 +227,47 @@ class ToolsStore {
return groups;
}
private groupLabel(entry: ToolEntry): string {
switch (entry.source) {
case ToolSource.MCP:
return entry.serverName ?? '';
case ToolSource.CUSTOM:
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
case ToolSource.BROWSER:
return TOOL_GROUP_LABELS[ToolSource.BROWSER];
default:
return TOOL_GROUP_LABELS[ToolSource.SERVER];
/** Enable all tools belonging to a specific MCP server */
enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId);
if (!connection) return;
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
this.persistDisabledTools();
}
async fetchServerTools(): Promise<void> {
if (this._loading) return;
this._loading = true;
this._error = null;
this._toolsEndpointUnreachable = false;
try {
const toolInfos = await ToolsService.list();
this._serverTools = toolInfos.map((info) => info.definition);
this.cwdAwareTools = new SvelteSet(
toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
this._error = errorMessage;
// 403 from /tools means the server was started without --tools
// TODO: check status code instead of relying on message
if (errorMessage.includes('this feature is disabled')) {
this._toolsEndpointUnreachable = true;
console.info('[ToolsStore] Server tools are disabled on the server');
} else {
console.error('[ToolsStore] Failed to fetch server tools:', err);
}
} finally {
this._loading = false;
}
}
@@ -430,112 +306,9 @@ class ToolsStore {
return result;
}
get allToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools.map((t) => t.definition);
}
get loading(): boolean {
return this._loading;
}
get error(): string | null {
return this._error;
}
get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
isToolEnabled(key: string): boolean {
return !this._disabledTools.has(key);
}
toggleTool(key: string): void {
if (this._disabledTools.has(key)) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
}
setToolEnabled(key: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
}
/** Enable all tools belonging to a specific MCP server */
enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId);
if (!connection) return;
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
this.persistDisabledTools();
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
const target = !allEnabled;
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
}
this.persistDisabledTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
}
/** Get MCP tools from health check data, used when live connections aren't established yet */
private getMcpToolsFromHealthChecks(): {
serverId: string;
serverName: string;
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
for (const server of mcpStore.getServers()) {
if (!server.enabled) continue;
const health = mcpStore.getHealthCheckState(server.id);
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
result.push({
serverId: server.id,
serverName: mcpStore.getServerLabel(server),
tools: health.tools
});
}
}
return result;
}
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
private findEntryByName(toolName: string): ToolEntry | null {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) return entry;
}
return null;
}
/** Determine the source of a tool by its name */
getToolSource(toolName: string): ToolSource | null {
return this.findEntryByName(toolName)?.source ?? null;
/** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null {
return this.findEntryByName(toolName)?.key ?? null;
}
/** Get the display label for the server that owns a given tool */
@@ -555,61 +328,44 @@ class ToolsStore {
return '';
}
/** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null {
return this.findEntryByName(toolName)?.key ?? null;
}
/** Check if there are any enabled tools available (server, MCP, or custom) */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
/** Determine the source of a tool by its name */
getToolSource(toolName: string): ToolSource | null {
return this.findEntryByName(toolName)?.source ?? null;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the user.
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
*/
get hasEnabledCwdTools(): boolean {
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this._cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
async fetchServerTools(): Promise<void> {
if (this._loading) return;
this._loading = true;
this._error = null;
this._toolsEndpointUnreachable = false;
initialize(): void {
// browser-only init: skip on SSR to avoid localStorage/fetch side effects
if (!browser) return;
try {
const toolInfos = await ToolsService.list();
const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
this._serverTools = toolInfos.map((info) => info.definition);
this._cwdAwareTools = new SvelteSet(
toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (stored) {
const parsed = JSON.parse(stored);
this._error = errorMessage;
// 403 from /tools means the server was started without --tools
// TODO: check status code instead of relying on message
if (errorMessage.includes('this feature is disabled')) {
this._toolsEndpointUnreachable = true;
console.info('[ToolsStore] Server tools are disabled on the server');
} else {
console.error('[ToolsStore] Failed to fetch server tools:', err);
if (Array.isArray(parsed)) {
for (const key of parsed) {
if (typeof key === 'string') this._disabledTools.add(key);
}
}
}
} finally {
this._loading = false;
} catch (err) {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
this.fetchServerTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
}
isToolEnabled(key: string): boolean {
return !this._disabledTools.has(key);
}
/**
@@ -637,6 +393,259 @@ class ToolsStore {
return this._serverHome;
}
setToolEnabled(key: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
const target = !allEnabled;
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
}
this.persistDisabledTools();
}
toggleTool(key: string): void {
if (this._disabledTools.has(key)) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
}
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
private findEntryByName(toolName: string): ToolEntry | null {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) return entry;
}
return null;
}
/** Get MCP tools from health check data, used when live connections aren't established yet */
private getMcpToolsFromHealthChecks(): {
serverId: string;
serverName: string;
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
for (const server of mcpStore.getServers()) {
if (!server.enabled) continue;
const health = mcpStore.getHealthCheckState(server.id);
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
result.push({
serverId: server.id,
serverName: mcpStore.getServerLabel(server),
tools: health.tools
});
}
}
return result;
}
private groupLabel(entry: ToolEntry): string {
switch (entry.source) {
case ToolSource.MCP:
return entry.serverName ?? '';
case ToolSource.CUSTOM:
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
case ToolSource.BROWSER:
return TOOL_GROUP_LABELS[ToolSource.BROWSER];
default:
return TOOL_GROUP_LABELS[ToolSource.SERVER];
}
}
private hasServerTool(name: BuiltInTool): boolean {
return this._serverTools.some((def) => def.function.name === name);
}
private inferTypeFromDefault(value: unknown): string | undefined {
if (typeof value === 'string') return 'string';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
if (Array.isArray(value)) return 'array';
if (value !== null && typeof value === 'object') return 'object';
return undefined;
}
private mcpDefinition(
name: string,
description: string | undefined,
schema?: Record<string, unknown>
): OpenAIToolDefinition {
return {
function: {
description,
name,
parameters: schema ?? { properties: {}, required: [], type: JsonSchemaType.OBJECT }
},
type: ToolCallType.FUNCTION
};
}
/** Normalize MCP tools from live connections when available, fall back to health check data */
private mcpEntries(): {
serverId: string;
serverName: string;
definition: OpenAIToolDefinition;
}[] {
const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
properties: {},
required: [],
type: JsonSchemaType.OBJECT
};
out.push({
definition: {
function: {
description: tool.description,
name: tool.name,
parameters: this.normalizeJsonSchema(rawSchema)
},
type: ToolCallType.FUNCTION
},
serverId,
serverName
});
}
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
out.push({
definition: this.mcpDefinition(tool.name, tool.description),
serverId,
serverName
});
}
}
}
return out;
}
/**
* Recursively normalize a JSON Schema object: infers `type` from `default`
* for properties / items that omit it, and descends into nested `properties`
* and `items`. Returns a new object -- does not mutate the input.
*/
private normalizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema;
const normalized: Record<string, unknown> = { ...schema };
if (normalized.properties && typeof normalized.properties === 'object') {
const props = normalized.properties as Record<string, Record<string, unknown>>;
const normalizedProps: Record<string, Record<string, unknown>> = {};
for (const [key, prop] of Object.entries(props)) {
if (!prop || typeof prop !== 'object') {
normalizedProps[key] = prop;
continue;
}
const normalizedProp: Record<string, unknown> = { ...prop };
if (!normalizedProp.type && normalizedProp.default !== undefined) {
const inferred = this.inferTypeFromDefault(normalizedProp.default);
if (inferred) normalizedProp.type = inferred;
}
if (normalizedProp.properties) {
Object.assign(
normalizedProp,
this.normalizeJsonSchema(normalizedProp as Record<string, unknown>)
);
}
if (normalizedProp.items && typeof normalizedProp.items === 'object') {
normalizedProp.items = this.normalizeJsonSchema(
normalizedProp.items as Record<string, unknown>
);
}
normalizedProps[key] = normalizedProp;
}
normalized.properties = normalizedProps;
}
return normalized;
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledTools])
);
} catch {
// ignore storage errors
}
}
/**
* `read_media` runs in the browser on top of the server's `read_file`, so it
* exists only when that tool is served and the active model can perceive the
* bytes. The server cannot make this call - it does not know which model the
* conversation uses.
*/
private readMediaTool(): OpenAIToolDefinition | null {
if (!this.hasServerTool(BuiltInTool.SERVER_READ_FILE)) return null;
const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
if (!model) return null;
const vision = modelsStore.props.modelSupportsVision(model);
const audio = modelsStore.props.modelSupportsAudio(model);
if (!vision && !audio) return null;
return buildReadMediaToolDefinition(vision, audio);
}
private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.BROWSER:
return `browser:${name}`;
default:
return `server:${name}`;
}
}
}
export const toolsStore = new ToolsStore();
+1 -1
View File
@@ -205,7 +205,7 @@ export interface AgenticSection {
/** ID of the model-side tool call (matches tool_calls[i].id). Lets
* downstream consumers correlate a section with the agentic loop's
* currently-executing tool, e.g. to drive live-streaming UI state
* by matching against agenticStore.executingToolCallId. */
* by matching against agenticStore.getExecutingToolCallId. */
toolCallId?: string;
wasInterrupted?: boolean;
}
+3 -1
View File
@@ -25,13 +25,15 @@ export interface SettingsEntry {
help: string;
defaultValue: SettingsConfigValue;
type: SettingsFieldType;
section?: string;
options?: Array<{ value: string; label: string; icon: Component }>;
/** Options rendered for RADIO fields. Each entry maps a `value` (the radio's selected value) to the underlying config `key` whose boolean state mirrors it. */
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
isExperimental?: boolean;
isPositiveInteger?: boolean;
/** When true, the field is rendered as a password input (e.g. API key). */
isPrivate?: boolean;
/** When false, the setting is stored/synced but has no standalone field; it is rendered by a sibling control or a dedicated page. */
standaloneField?: boolean;
placeholder?: string;
min?: number;
max?: number;
+4 -28
View File
@@ -1,7 +1,6 @@
import { getAuthHeaders, getJsonHeaders } from './api-headers';
import { base } from '$app/paths';
import { ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants';
import { UrlProtocol } from '$lib/enums';
import { API_ABSOLUTE_URL_PROTOCOLS, ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants';
/**
* API Fetch Utilities
@@ -63,10 +62,8 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
const url =
path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
? path
: `${base}${path}`;
// absolute URLs with an allowed protocol pass through untouched; relative paths get the base prefix
const url = API_ABSOLUTE_URL_PROTOCOLS.some((p) => path.startsWith(p)) ? path : `${base}${path}`;
let response;
@@ -117,28 +114,7 @@ export async function apiFetchWithParams<T>(
}
}
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
let response;
try {
response = await fetch(url.toString(), {
...fetchOptions,
headers
});
} catch (e) {
throw new Error(beautifyNetworkError(e));
}
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
throw new ApiError(errorMessage, response.status);
}
return response.json() as Promise<T>;
return apiFetch<T>(url.toString(), options);
}
/**
+1 -1
View File
@@ -1,7 +1,7 @@
import { redactValue } from './redact';
import { CORS_PROXY, HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
/**
* Get authorization headers for API requests
+1 -1
View File
@@ -3,7 +3,7 @@ import { browser } from '$app/environment';
import { base } from '$app/paths';
import { HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
/**
* Validates API key by making a request to the server props endpoint
+29 -29
View File
@@ -14,10 +14,37 @@ import { MimeTypeAudio } from '$lib/enums';
* - Proper cleanup and resource management
*/
export class AudioRecorder {
private mediaRecorder: MediaRecorder | null = null;
private audioChunks: Blob[] = [];
private stream: MediaStream | null = null;
private mediaRecorder: MediaRecorder | null = null;
private recordingState: boolean = false;
private stream: MediaStream | null = null;
cancelRecording(): void {
const recorder = this.mediaRecorder;
const stream = this.stream;
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.recordingState = false;
if (recorder && recorder.state !== 'inactive') {
// Drop the original handlers so the pending stop event does not touch the instance
recorder.onstop = null;
recorder.onerror = null;
recorder.stop();
}
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
}
isRecording(): boolean {
return this.recordingState;
}
async startRecording(): Promise<void> {
try {
@@ -90,33 +117,6 @@ export class AudioRecorder {
});
}
isRecording(): boolean {
return this.recordingState;
}
cancelRecording(): void {
const recorder = this.mediaRecorder;
const stream = this.stream;
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.recordingState = false;
if (recorder && recorder.state !== 'inactive') {
// Drop the original handlers so the pending stop event does not touch the instance
recorder.onstop = null;
recorder.onerror = null;
recorder.stop();
}
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
}
private initializeRecorder(stream: MediaStream): void {
const options: MediaRecorderOptions = {};
+102 -102
View File
@@ -31,9 +31,29 @@ interface CacheEntry<T> {
export class TTLCache<K extends string, V> {
private cache = new Map<K, CacheEntry<V>>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly onEvict?: (key: string, value: unknown) => void;
private readonly ttlMs: number;
/**
* Get the number of entries (including potentially expired ones).
*/
get size(): number {
return this.cache.size;
}
/**
* Clear all entries from cache.
*/
clear(): void {
if (this.onEvict) {
for (const [key, entry] of this.cache) {
this.onEvict(key, entry.value);
}
}
this.cache.clear();
}
constructor(options: TTLCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? CACHE.DEFAULT_TTL_MS;
@@ -41,6 +61,19 @@ export class TTLCache<K extends string, V> {
this.onEvict = options.onEvict;
}
/**
* Delete a specific key from cache.
*/
delete(key: K): boolean {
const entry = this.cache.get(key);
if (entry && this.onEvict) {
this.onEvict(key, entry.value);
}
return this.cache.delete(key);
}
/**
* Get a value from cache. Returns null if expired or not found.
*/
@@ -61,25 +94,6 @@ export class TTLCache<K extends string, V> {
return entry.value;
}
/**
* Set a value in cache with TTL.
*/
set(key: K, value: V, customTtlMs?: number): void {
// Evict oldest entries if at capacity
if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
this.cache.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
/**
* Check if key exists and is not expired.
*/
@@ -98,36 +112,19 @@ export class TTLCache<K extends string, V> {
}
/**
* Delete a specific key from cache.
* Get all valid (non-expired) keys.
*/
delete(key: K): boolean {
const entry = this.cache.get(key);
keys(): K[] {
const now = Date.now();
const validKeys: K[] = [];
if (entry && this.onEvict) {
this.onEvict(key, entry.value);
}
return this.cache.delete(key);
}
/**
* Clear all entries from cache.
*/
clear(): void {
if (this.onEvict) {
for (const [key, entry] of this.cache) {
this.onEvict(key, entry.value);
for (const [key, entry] of this.cache) {
if (now <= entry.expiresAt) {
validKeys.push(key);
}
}
this.cache.clear();
}
/**
* Get the number of entries (including potentially expired ones).
*/
get size(): number {
return this.cache.size;
return validKeys;
}
/**
@@ -150,38 +147,22 @@ export class TTLCache<K extends string, V> {
}
/**
* Get all valid (non-expired) keys.
* Set a value in cache with TTL.
*/
keys(): K[] {
set(key: K, value: V, customTtlMs?: number): void {
// Evict oldest entries if at capacity
if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
const validKeys: K[] = [];
for (const [key, entry] of this.cache) {
if (now <= entry.expiresAt) {
validKeys.push(key);
}
}
return validKeys;
}
/**
* Evict the oldest (least recently accessed) entry.
*/
private evictOldest(): void {
let oldestKey: K | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache) {
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}
if (oldestKey !== null) {
this.delete(oldestKey);
}
this.cache.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
/**
@@ -205,6 +186,25 @@ export class TTLCache<K extends string, V> {
return true;
}
/**
* Evict the oldest (least recently accessed) entry.
*/
private evictOldest(): void {
let oldestKey: K | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache) {
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}
if (oldestKey !== null) {
this.delete(oldestKey);
}
}
}
/**
@@ -213,14 +213,26 @@ export class TTLCache<K extends string, V> {
*/
export class ReactiveTTLMap<K extends string, V> {
private entries = $state<Map<K, CacheEntry<V>>>(new Map());
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly ttlMs: number;
get size(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
constructor(options: TTLCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? CACHE.DEFAULT_TTL_MS;
this.maxEntries = options.maxEntries ?? CACHE.DEFAULT_MAX_ENTRIES;
}
delete(key: K): boolean {
return this.entries.delete(key);
}
get(key: K): V | null {
const entry = this.entries.get(key);
@@ -237,21 +249,6 @@ export class ReactiveTTLMap<K extends string, V> {
return entry.value;
}
set(key: K, value: V, customTtlMs?: number): void {
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
this.entries.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
has(key: K): boolean {
const entry = this.entries.get(key);
@@ -266,18 +263,6 @@ export class ReactiveTTLMap<K extends string, V> {
return true;
}
delete(key: K): boolean {
return this.entries.delete(key);
}
clear(): void {
this.entries.clear();
}
get size(): number {
return this.entries.size;
}
prune(): number {
const now = Date.now();
@@ -293,6 +278,21 @@ export class ReactiveTTLMap<K extends string, V> {
return pruned;
}
set(key: K, value: V, customTtlMs?: number): void {
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
this.entries.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
private evictOldest(): void {
let oldestKey: K | null = null;
let oldestTime = Infinity;
@@ -38,7 +38,7 @@ import {
SETTINGS_KEYS
} from '$lib/constants';
import { BooleanString, ChatFormInputRichTokenKind } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
@@ -4,8 +4,8 @@ import { isLikelyTextFile, readFileAsText } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { SETTINGS_KEYS } from '$lib/constants';
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { ChatUploadedFile, DatabaseMessageExtra, FileProcessingResult } from '$lib/types';
import { getFileTypeCategory } from '$lib/utils';
import { toast } from 'svelte-sonner';
@@ -112,7 +112,7 @@ export async function parseFilesToMessageExtras(
const currentConfig = settingsStore.config;
// Use per-model vision check for router mode
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
? modelsStore.props.modelSupportsVision(activeModelId)
: false;
// Force PDF-to-text for non-vision models
+5 -2
View File
@@ -130,7 +130,7 @@ export { getImageErrorFallbackHtml } from './image-error-fallback';
// SSE-with-JSON stream iterator (used by server tool streaming, decoupled
// from chat.service.ts which embeds its own SSE parser for resume support)
export { parseSseJsonStream } from './sse';
export { extractSseDataPayload, parseSseJsonStream, splitSseRecords } from './sse';
// Stream session identity (conversation-id based)
export { streamIdentity } from './stream-identity';
@@ -150,7 +150,10 @@ export {
getResourceIcon,
getResourceTextContent,
getResourceBlobContent,
downloadResourceContent
downloadResourceContent,
getMcpIconUrl,
getMcpServerFaviconFallback,
getMcpServerLabel
} from './mcp';
// URI Template utilities
+148 -2
View File
@@ -1,3 +1,4 @@
import { extractRootDomain } from './url';
import {
AlertTriangle,
Code,
@@ -12,8 +13,10 @@ import {
CODE_FILE_EXTENSION_REGEX,
DEFAULT_RESOURCE_FILENAME,
DISPLAY_NAME_SEPARATOR_REGEX,
EXPECTED_THEMED_ICON_PAIR_COUNT,
FILE_EXTENSION_REGEX,
IMAGE_FILE_EXTENSION_REGEX,
MCP_ALLOWED_ICON_MIME_TYPES,
MCP_SERVER_ID_PREFIX,
MCP_SSE,
MIME_TYPE_PREFIXES,
@@ -24,8 +27,22 @@ import {
TEXT_FILE_EXTENSION_REGEX,
URI_PATTERNS
} from '$lib/constants';
import { MCPLogLevel, MCPTransportType, MimeTypeText, UrlProtocol } from '$lib/enums';
import type { MCPResourceContent, MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import {
ColorMode,
HealthCheckStatus,
MCPLogLevel,
MCPTransportType,
MimeTypeText,
UrlProtocol
} from '$lib/enums';
import type {
HealthCheckState,
MCPResourceContent,
MCPResourceIcon,
MCPResourceInfo,
MCPServerDisplayInfo,
MCPServerSettingsEntry
} from '$lib/types';
import type { MimeTypeUnion } from '$lib/types/common';
import type { Component } from 'svelte';
@@ -316,3 +333,132 @@ export function downloadResourceContent(
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Validates that an icon URI uses a safe scheme (https: or data:).
*/
function isValidMcpIconUri(src: string): boolean {
try {
if (src.startsWith(UrlProtocol.DATA)) return true;
const url = new URL(src);
return url.protocol === UrlProtocol.HTTPS;
} catch {
return false;
}
}
/**
* Selects the best icon URL from an MCP icons array.
* Follows security guidelines from the MCP specification:
* - Only allows https: and data: URIs
* - Filters to supported MIME types
*
* Selection priority:
* 1. Icon matching the current color scheme (dark/light)
* 2. Universal icon (no theme specified); if exactly 2, assumes [0]=light, [1]=dark
* 3. First valid icon as last resort
*/
export function getMcpIconUrl(icons: MCPResourceIcon[] | undefined, isDark = false): string | null {
if (!icons?.length) return null;
const validIcons = icons.filter((icon) => {
if (!icon.src || !isValidMcpIconUri(icon.src)) return false;
if (icon.mimeType && !MCP_ALLOWED_ICON_MIME_TYPES.has(icon.mimeType)) return false;
return true;
});
if (validIcons.length === 0) return null;
const preferredTheme = isDark ? ColorMode.DARK : ColorMode.LIGHT;
// 1. Prefer icon explicitly matching the current color scheme
const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
if (themedIcon) return themedIcon.src;
// 2. Handle universal icons (no theme specified)
const universalIcons = validIcons.filter((icon) => !icon.theme);
if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) {
// Heuristic: two theme-less icons → assume [0] = light, [1] = dark
return universalIcons[isDark ? 1 : 0].src;
}
if (universalIcons.length > 0) {
return universalIcons[0].src;
}
// 3. Last resort: use opposite-theme icon
return validIcons[0].src;
}
/**
* Construct a fallback favicon URL from the MCP server URL.
* e.g. https://mcp.example.com/sse -> https://example.com/favicon.ico
*/
export function getMcpServerFaviconFallback(serverUrl: string): string | null {
try {
const url = new URL(serverUrl);
const rootDomain = extractRootDomain(url);
if (!rootDomain) return null;
const origin = `${url.protocol}//${rootDomain}`;
const candidates = ['favicon.ico', 'favicon.png'];
for (const path of candidates) {
const faviconUrl = `${origin}/${path}`;
if (isValidMcpIconUri(faviconUrl)) {
return faviconUrl;
}
}
} catch {
// Invalid URL, return null
}
return null;
}
/**
* Resolves the raw label for a server: user-defined display name first,
* then server-reported title or name when the health check succeeded,
* then the configured name (admin baseline or legacy data), then URL.
*/
function getMcpServerBaseLabel(
server: MCPServerDisplayInfo,
healthState?: HealthCheckState
): string {
if (server.displayName) return server.displayName;
if (healthState?.status === HealthCheckStatus.SUCCESS)
return (
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
);
return server.name || server.url;
}
/**
* Returns the display label for a server, suffixed with a positional
* counter when several configured servers resolve to the same base label
* (e.g. two endpoints of the same host reporting an identical name).
* Numbering follows config order, so it is stable across renders.
*/
export function getMcpServerLabel(
server: MCPServerDisplayInfo,
servers: MCPServerDisplayInfo[],
healthChecks: Record<string, HealthCheckState>
): string {
const label = getMcpServerBaseLabel(server, healthChecks[server.id]);
const twins = servers.filter((s) => getMcpServerBaseLabel(s, healthChecks[s.id]) === label);
if (twins.length < 2) return label;
const position = twins.findIndex((s) => s.id === server.id);
return position < 0 ? label : `${label} (${position + 1})`;
}
@@ -4,8 +4,8 @@ import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { SETTINGS_KEYS } from '$lib/constants';
import { FileTypeCategory } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { toast } from 'svelte-sonner';
@@ -108,7 +108,7 @@ export async function processFilesToChatUploaded(
// Show suggestion toast if vision model is available but PDF as image is disabled
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
? modelsStore.props.modelSupportsVision(activeModelId)
: false;
const currentConfig = settingsStore.config;
+13 -13
View File
@@ -12,9 +12,9 @@ export interface SourceHistoryEntry {
}
export class SourceHistory {
private undoStack: SourceHistoryEntry[] = [];
private redoStack: SourceHistoryEntry[] = [];
private lastPush = 0;
private redoStack: SourceHistoryEntry[] = [];
private undoStack: SourceHistoryEntry[] = [];
constructor(
private limit = 100,
@@ -32,17 +32,6 @@ export class SourceHistory {
this.redoStack = [];
}
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.undoStack.pop();
if (!entry) return null;
this.redoStack.push(current);
this.lastPush = 0; // the next edit after an undo starts a new group
return entry;
}
redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.redoStack.pop();
@@ -53,4 +42,15 @@ export class SourceHistory {
return entry;
}
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.undoStack.pop();
if (!entry) return null;
this.redoStack.push(current);
this.lastPush = 0; // the next edit after an undo starts a new group
return entry;
}
}
+26 -2
View File
@@ -25,6 +25,30 @@ export interface SseJsonEvent<T = unknown> {
data: T;
}
/**
* Splits a raw SSE byte buffer into complete records on the blank-line
* boundary, returning the leftover partial record separately. Shared by the
* record-based consumers (parseSseJsonStream, models.service).
*/
export function splitSseRecords(buffer: string): { records: string[]; rest: string } {
const parts = buffer.split(SSE_RECORD_SEPARATOR);
return { records: parts.slice(0, -1), rest: parts[parts.length - 1] ?? '' };
}
/**
* Extracts the joined `data:` payload from one SSE record (the data lines
* concatenated with a newline), or an empty string when the record carries
* no data lines. Used by models.service to parse status envelopes.
*/
export function extractSseDataPayload(record: string): string {
return record
.split(SSE_LINE_SEPARATOR)
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
.join(SSE_LINE_SEPARATOR);
}
export async function* parseSseJsonStream<T = unknown>(
response: Response,
signal?: AbortSignal
@@ -46,9 +70,9 @@ export async function* parseSseJsonStream<T = unknown>(
if (done) break;
buffer += decoder.decode(value, { stream: true });
const records = buffer.split(SSE_RECORD_SEPARATOR);
const { records, rest } = splitSseRecords(buffer);
buffer = records.pop() ?? '';
buffer = rest;
for (const record of records) {
if (!record) continue;
+3 -3
View File
@@ -47,8 +47,8 @@
serverStore.isRouterMode &&
!modelsStore.isModelLoaded(model.id)
) {
modelsStore
.loadModel(model.id)
modelsStore.status
.load(model.id)
.catch((error) => console.error('Failed to load model:', error));
}
} catch (error) {
@@ -77,7 +77,7 @@
onMount(async () => {
if (!conversationsStore.isInitialized) {
await conversationsStore.init();
await conversationsStore.initialize();
}
conversationsStore.clearActiveConversation();
+2 -2
View File
@@ -216,11 +216,11 @@
if (!serverStore.isRouterMode) return;
untrack(() => {
modelsStore.subscribeStatus();
modelsStore.status.subscribe();
});
return () => {
modelsStore.unsubscribeStatus();
modelsStore.status.unsubscribe();
};
});
+2 -2
View File
@@ -4,7 +4,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app';
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
import { ROUTES } from '$lib/constants';
let { children } = $props();
@@ -24,7 +24,7 @@
if (browser && window.history.length > 1 && !prevIsSettings) {
history.back();
} else {
goto(SETTINGS_FALLBACK_EXIT_ROUTE);
goto(ROUTES.SETTINGS_EXIT);
}
}
</script>